目录
1.线程控制
(1)pthread和thread库
(2)线程的创建、等待和分离
①线程创建
②线程等待
③线程分离
④线程替换(不可行)
(3)线程的终止和取消
①线程终止
②线程取消
2.Linux下线程的底层
(1)线程ID
(2)资源划分和维护
①新线程栈的动态创建和销毁
②struct pthread的存储
③线性局部存储
④线性栈和mmap
(3)clone
(4)创建线程过程
1.线程控制
(1)pthread和thread库
线程的使用需要pthread库,这是用户级别的、通用的、基于 POSIX 标准的线程库。这个库提供C语言接口,并且具备跨平台性。很明显,它基于操作系统底层的线程机制实现并进一步封装,向上提供了统一的接口。
pthread库的实现是glibc的一部分,所以pthread库是Linux系统自带的线程库。但由于线程库可能不是项目中必需的,而且有可能和其它库引发冲突,因此Linux有可能不会默认链接pthread动态库。如果要使用线程,必须编译时选择-lpthread(不需要选择查找目录、头文件位置,系统能找到)。
除此之外,C++11引入了<thread>,这是C++风格的线程实现,同样具有跨平台性。Linux下使用<thread>需要链接原生线程库-lpthread
(2)线程的创建、等待和分离
①线程创建
当创建成功时返回0,失败时返回错误码。
参数我们不关注第二个,默认填nullptr。第一个参数pthread_t是线程的id,用于唯一标识线程;第三个是让线程线程执行的函数(回调函数),该函数返回值为void*,参数为void*;第四个参数是传入该回调函数的参数。
下面是简单的使用
我们可以看到引用线程库后,就算还没有创建线程,主程序的代码执行可被认为是主线程,可以获取它的唯一标识pthread_t。虽然Linux下都是LWP,但在C语言上封装后就出现了线程的概念。
我们可以认为,线程的存在就是为了执行某一个函数,主线程的存在就是为了执行main函数(执行main函数的线程就是主线程),当main函数结束后代表主线程执行完毕,进而表示整个进程的结束。其余线程也都可以这么理解,它们的生命周期从开始执行函数为始,结束执行函数为终。
主线程也可以新建一个线程并让它运行代码。当主线程结束运行后,程序退出。
但这里有个问题:万一主线程比创建的线程更早结束,就会导致创建的线程没有完成任务。那应该怎么保证主线程在最后退出呢?sleep?这太难控制了,而且效率很低。
②线程等待
成功返回0,错误返回错误码。
假如thread1调用该join函数,其第一个参数是thread2,这就意味着thread1会阻塞在这个函数直到thread2结束,第二个参数是thread1接收thread2的返回值所用。
除此之外,join之后线程的资源(如线程的退出状态、栈等)将会被释放。如果不及时join,这些未释放的资源会一直保留在系统中,直到整个进程结束,就像僵尸进程占用系统资源一样。
下面是对刚才代码的修改
这样主线程就会在等待的线程退出后才退出,并且能够获取到子线程的函数返回值。要特别注意子线程的返回值要求为void*,参数为void*,必须完全对应才能编译通过。如果不对子线程的返回值感兴趣,可以传nullptr作为join函数的第二个参数。
线程等待类似回收僵尸进程,只要join对应线程的id,就算该线程已经结束了,也都能获取到该线程的返回值,并且这个返回值需要通过堆区创建空间。对于所有线程来说,堆区是共享的,谁拿着堆的入口地址,谁就能访问空间。
在线程中虽然名义上进程地址空间被划分给了不同线程,但线程之间可以互相访问,只要有地址。
join成功了,代表此刻数据一定处理完了,这样就可以放心执行后面的代码了。
③线程分离
join有个坏处,就是join的线程必须等待被join的线程执行完后才会继续执行代码,但有的时候线程要做自己的事情,比如主线程承担分配任务的使命。想要不阻塞在join函数,则要将目标线程设置为分离状态,即detach状态。
线程分离出对应的线程后join会失败,join的返回值不是0。之后该线程执行完毕后会自动释放空间,而不需要join。同时,除非极端情况,就算主线程走完了,分离的线程还能执行,进程会在分离线程执行完毕后回收,但要注意一些共享资源的访问问题,有可能分离线程会去访问已被释放的线程的资源。
④线程替换(不可行)
线程能不能程序替换?不可以。只有进程可以程序替换,进程替换会把代码数据全部替换,线程没这个能力,它不能对进程做出操作。但我们可以在线程里面fork,进一步实现程序替换。
(3)线程的终止和取消
①线程终止
主线程return表示整个进程结束,此刻所有线程也都会退出,不管是否执行完毕。新线程return表示该线程退出,其余线程不受影响。
但注意,任何线程在任何地方调用exit均表示进程退出,即所有线程都会终止。相对的,例如pthread_exit((void*)10)这种exit退出方式就相对温和,只是结束相应调用的线程并且交出返回值给等待它的线程,pthread_exit和return等价,平时就用return就好。
②线程取消
线程可以被取消,主线程可以在任何时候取消任何线程的执行,这是被动式退出,不由其它线程决定。
其参数就是被取消的线程id。取消成功返回0,失败返回错误码。
取消一个线程的前提是目标线程已经被启动了,即在create函数之后。但一般不推荐取消线程,因为对应线程的状态不清楚,处理的数据状态也不清楚,因此盲目取消会导致程序混乱。
注意,取消的线程依旧需要被join,这就相当于回收僵尸进程,被取消的线程会返回-1,该返回值其实是宏PTHREAD_CANCELED。
2.Linux下线程的底层
(1)线程ID
线程id(即pthread_t)是一个地址,这个地址本身也有唯一性。
pthread.so库加载到内存中,这个库被经过页表映射到共享区。创建线程的其实是在调用库方法,由于进程内的所有线程共享进程地址空间,因此它们都能执行库的其它方法。
Linux中严格上只有LWP,但用户要用线程,想要用线程接口(create、join等),想要线程的属性(线程的id、优先级、状态、栈大小等),怎么办呢?
用户不关心LWP的属性,他们只关心线程的属性,这就是pthread.so库单独提供的。pthread.so库要维护相关属性,要存储相应的结构体来存储相关属性。这和glibc维护FILE一样,所有文件的属性信息都是由glibc库来进行维护的,当要对某个文件进行操作时,都是调用glibc的函数对其维护下的结构体进行修改。注意,这些结构体由动态库维护(调用动态库的函数对这些结构体进行修改),其结构体的物理内存也在库的里面,下面的pthread.so也是如此。
pthread.so会维护该进程下所有线程的属性。对于一个线程来说,其属性的集合就相当于结构体TCB,线程的id、优先级、状态、栈大小、返回值都在里面保存着的。一般来说,所有线程的属性的结构体整体形成的是一个数组。既然线程属性保存在一个物理内存中,那么理所应当的,其线程id的值就是相应结构体的地址,也就是pthread_t类型,pthread_create的第一个参数获取的值。
我们后续就可以用TCB来描述个由pthread.so库维护的结构体,毕竟它是上层封装,和LWP有区别了。这个时候我们就能理解,为什么对线程操作,都需要传入线程id,本质上对线程的管理都是对维护线程属性的结构体的操作。
(2)资源划分和维护
①新线程栈的动态创建和销毁
当主线程开始执行时,它就会保证自己有一个栈,即主线程栈,这个栈通常是在程序启动时由操作系统预先分配好的,它的大小一般由操作系统和编译环境决定。当程序运行完毕后,这个栈才会销毁。这个栈的位置就在进程地址空间的栈区。
除此之外,其它所有子线程的栈都是动态创建的。为什么叫动态创建?只有当调用create函数,创建子线程时,这个栈才会被创建。同理,只要这个线程被join(或分离线程执行完毕),那么它的栈就会被销毁。因此,子线程栈的动态性在于它可以在程序运行途中创建和销毁,而主线程栈是一进程序就创建,结束才销毁。
②struct pthread的存储
struct pthread(TCB)被pthread.so库维护,就意味着从物理内存的角度上看,一个系统的所有线程的属性都存在pthread.so库的内部。当pthread.so被映射到虚拟内存中后,就存放有所有线程的TCB,当我们对某个线程进行操作时,都是对这个库中某个线程属性的修改。同理,FILE也是如此,glibc这个库中维护了整个系统所有的FILE结构体。
struct pthread里面主要含有两个属性,线性局部存储和线性栈的属性,除此之外还一定包括LWP的属性task_struct,就像FILE里面一定有文件描述符fd。
③线性局部存储
对于代码全局区的变量来说,每个线程都可修改它们的值,并且修改之后其它线程都能同时感受到这个变化。但有的时候我们不希望这样,这就要用到编译性关键字__thread,用它修饰变量后(如__thread int a = 10),每个线程都会独立地得到该数据,不会互相影响,这就叫线程局部存储。
线性局部存储的数据是线程私有的数据,错误码error的私有性质就是用线程局部存储实现的,这样就能正确标识每个线程的错误了,而不会相互影响。
__thread只能修饰内置类型,结构体不行。局部存储的变量会单独存到TCB的一个属性里,因此对一些高频访问效率较高,不用到线性栈里面去找。
④线性栈和mmap
后面会提到的mmap区域就是指的共享区。
前面已经说过主线程栈在栈区,可以动态增长。新线程的栈在共享区,也就是mmap区域被开辟,并且它有一个最大容量值,即最多开辟8MB(随系统,用完就没了,不像主线程栈可以向下增长),这些栈的起始地址、容量信息都会被存入线程的属性结构体TCB中。我们还应知道,mmap还是一个系统调用,充当开辟空间的作用,存在mmap区域的新线程栈就是由mmap系统调用申请的。malloc的底层也是mmap。
线程的栈区原则上是独立的,每个线程各自用各自的,但实际上是共享的,因为没有权限限制,加上所有线程看到的都是同一个进程地址空间,所以只要拿到别的线程的栈空间地址,就能访问。堆区、数据段同理。
到此我们可以总结一下,线性栈和线性局部存储都由struct pthread(TCB)管理,都存在共享区,其中栈是由mmap系统调用单独在mmap(共享)区开辟空间。这些TCB最终都由pthread.so维护,事实上,pthread.so维护了整个系统的线程属性struct pthread。
(3)clone
这个系统调用的几个参数需要声明
fn是函数入口地址,arg是函数的参数包,轻量级进程就会根据这两个参数执行对应函数。stack是LWP占用的独立的栈的地址,LWP会在指定栈保存变量。flags是标志位,区分不同的操作。
我们从参数就能理解,调用pthread_create就需要先后调用mmap、clone。先是由mmap开辟空间,更新TCB,再由clone来执行。在系统层这是新建了一个LWP并执行代码,而在用户层看来,这就是创建了一个新的线程。
我们还可以了解一下clone,它也是vfork和fork的底层调用,clone是更底层的系统调用,我们用户无法直接调用。
(4)创建线程过程
当程序开始运行时,主线程栈在栈区创建并向下增长。当要创建新线程时,会创建struct pthread,会调用系统调用mmap在mmap(共享)区域动态创建栈且栈的大小不可增长,之后由pthread.so维护的线程的属性struct pthread会进行更新,之后通过clone系统调用创建好一个LWP,并将pthread.so维护的struct pthread的地址返回给用户作为id。之后用户的所有操作本质都是调用pthread.so的函数对struct pthread的属性进行修改。