二、进程 VS 线程
线程共用同一个地址空间,因此代码段(Text Segment)、数据段(Data Segment)等都是共享的:
若定义一个函数,在各线程中都可以调用
若定义一个全局变量,在各线程中都可以访问到
除此之外,各线程还共享以下进程资源和环境:
文件描述符表(进程打开一个文件后,其他线程也能够看到)
每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
当前工作目录(cwd)
用户ID和组ID
进程是承担分配系统资源的基本实体,线程是CPU调度的基本单位。线程共享进程数据,但也拥有自己的一部分数据:
线程ID
一组寄存器(存储每个线程的上下文信息)
栈(每个线程都有临时的数据,需要压栈出栈)
errno(C语言提供的全局变量,但每个线程都有自己的)
信号屏蔽字
调度优先级
三、Linux线程控制
3.1 POSIX线程库
在Linux中,站在内核角度上看并没有真正意义上线程相关的接口。但站在用户角度,当用户想创建一个线程时更期望使用thread_create这样类似的接口,而不是vfork函数,因此系统在应用层提供了原生线程库pthread。原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关的接口
应用层指的是这个线程库并不是操作系统直接提供的,而是由第三方使用系统接口编写的
原生指的是大部分Linux系统都会默认带上该线程库
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"开头
要使用pthread库,要引入头文件<pthreaad.h>
链接pthread库时,要在编译时要使用"-lpthread"选项
注意:
传统的函数是,成功返回0,失败返回-1,并且对全局变量errno设置以指示错误。pthread函数出错时并不会设置全局变量errno(而大部分POSIX函数会这样做),而是将错误信息通过返回值返回
pthread同样也提供了线程内的errno变量,以支持其他使用errno的代码。但对于pthread函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小,且线程的errno是各线程独占的
3.1 线程创建
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
参数:
thread:获取创建成功的线程ID,该参数是一个输出型参数
attr:用于设置创建线程的属性,传入NULL表示使用默认属性
start_routine:该参数是一个函数地址,表示线程例程,即线程启动后要执行的函数
arg:传给线程例程的参数(即传给start_routine的形参)
返回值:
线程创建成功返回0,失败返回错误码
使用案例
让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程,而主线程则继续执行后续代码
#include <iostream> #include <pthread.h> #include <unistd.h> using namespace std; void* Routine(void* args) { while (1) { cout << "I am " << (char*)args << endl; sleep(1); } } int main() { pthread_t tid; pthread_create(&tid, NULL, Routine, (void*)"thread 1"); while (1) { cout << "I am main thread!" << endl; sleep(2); } return 0; }
使用 ps -aL 命令,可以显示当前的轻量级进程,不带 -L 选项默认显示进程
LWP(Light Weight Process)就是轻量级进程的ID,可以看到显示的两个轻量级进程的PID是相同的,因为它们属于同一个进程。
在Linux中,应用层的线程与内核的LWP是对应的,实际上操作系统调度时使用的是LWP,而并非PID。单线程进程时PID和LWP是相等的,所以对于单线程进程而言,调度时采用PID和LWP是一样的;多线程进程时PID与主线程LWP相同。
3.3 线程等待
线程如同进程一般,也是需要被等待的。若主线程不对新线程进行等待,那么新线程的资源不会被回收,会发生类似于"僵尸进程"的问题,即内存泄漏。
使用pthread_join()可以进行线程等待
int pthread_join(pthread_t thread, void **retval);
参数:
thread:被等待线程的ID
retval:线程退出时的退出码信息
返回值:
线程等待成功返回0,失败返回错误码
调用该函数的线程将阻塞到ID为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的
若thread线程通过return返回,retval所指向的单元里存放的是线程的返回值
若thread线程被别的线程调用pthread_cancel()异常终止掉,retval所指向的单元里存放的是宏PTHREAD_CANCELED,即(void*)-1)
若thread线程是自行调用pthread_exit()终止的,retval所指向的单元存放的是传给pthread_exit的参数
若对thread线程的终止状态不感兴趣,可传NULL给retval参数
使用案例
#include <iostream> #include <pthread.h> #include <unistd.h> using namespace std; void* Routine(void *args) { cout << (char*)args << endl; sleep(3); return (void*)0; } int main() { pthread_t tid; pthread_create(&tid, nullptr, Routine, (void*)"new thread"); void* ret = nullptr; int n = pthread_join(tid,&ret); if(n == 0) { cout << "等待成功" << endl; cout << "返回信息为: " <<(long long)ret << endl; } else { cout << "等待失败" << endl; } return 0; }
3.4 线程终止
3.4.1 return退出
在创建线程时指定的例程中使用return代表当前线程退出,但在main函数中使用return代表整个进程退出,即主线程退出了那么整个进程就退出了。
3.4.2 pthread_exit()
void pthread_exit(void *retval);
参数retval:线程退出时的退出信息
注意:
pthread_exit()或者return返回的指针所指向的内存单元必须是全局的或者堆区开辟的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程已经退出了
线程退出不能使用exit()函数,其作用是退出整个进程,任何一个线程调用都是如此
3.4.3 pthread_cancel()
int pthread_cancel(pthread_t thread);
参数thread:被取消线程的ID
返回值:线程取消成功返回0,失败返回错误码
线程是可以取消自己的(使用pthread_self()函数)。也可以让新线程取消主线程,但不建议这么使用,一般都是使用主线程去控制新线程的。
取消成功的线程的退出码一般是宏PTHREAD_CANCELED,即(void*)-1)
3.5 线程分离
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成内存泄漏。但若本身并不关心线程的返回值,那么join也是一种负担,此时可将该线程进行分离,后续当线程退出时就会自动释放线程资源
线程若被分离了,这个线程依旧使用该进程的资源,且依旧在该进程内运行,甚至这个线程崩溃了一定会影响整个进程,只不过这个线程退出时不再需要主线程去join了,当这个线程退出时系统会自动回收该线程所对应的资源
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离
joinable和分离是冲突的,一个线程不能既是joinable又是分离的
使用pthread_detach()函数进程分离线程
int pthread_detach(pthread_t thread);
参数thread:被分离线程的ID
返回值:线程分离成功返回0,失败返回错误码
3.6 线程ID与进程地址空间布局
pthread_create函数会产生一个线程ID,存放在第一个参数指向的地址中,但该线程ID和内核中的LWP并不是一回事
内核中的LWP属于CPU调度的范畴,因为线程其实就是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程
pthread_create()函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,这个ID属于NPTL线程库的范畴,线程库的后续操作就是根据该线程ID来操作线程的
线程库NPTL提供的pthread_self()函数,获取的线程ID和pthread_create()函数第一个参数获取的线程ID是一样的
线程ID到底是什么?
可以将线程ID打印出来看看
#include <iostream> #include <pthread.h> #include <unistd.h> using namespace std; void* Routine(void* args) { cout << (char*)args << " : " << pthread_self() << endl; return (void*)0; } int main() { pthread_t tid; pthread_create(&tid,nullptr,Routine,(void*)"new thread"); sleep(1); cout << "main thread : " << pthread_self() << endl; pthread_join(tid,nullptr); return 0; }
可以发现,这个线程ID数值特别大,并不是LWP,那么这个线程ID到底是什么呢?
Linux系统中不提供真正的线程ID,只提供LWP,即操作系统只需通过LWP对轻量级进程进行管理,而供用户使用的线程接口等其他数据,由线程库来管理,因此管理线程时的"先描述,再组织"就应该在线程库中完成
使用 lld 命令可以看到,线程库实际上是一个动态库(默认使用动态库)
进程运行时动态库被加载到内存,然后通过页表映射到进程地址空间中的共享区,此时进程内的所有线程是共享这个动态库的
之前提到每个线程都有独占的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有各自的struct pthread,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。
每一个新线程在共享区都有一个struct pthread对其进行描述,因此要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息
上面讲述的各种线程函数,本质上都是在库内部对线程属性进行的各种操作,即线程数据的管理本质是在共享区的进行的
至于pthread_t到底是什么类型取决于实现,但对于Linux目前实现的NPTL线程库来说,线程ID本质就是进程地址空间共享区上的一个虚拟地址,同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程