
目录
一、线程的概念
在教材上官方一点的对于线程的概念是:线程是在进程内部运行的一个执行流;线程比进程粒度更细,调度成本更低;线程是CPU调度的基本单位。下面我们从Linux操作系统内核的角度更加详细地介绍一下线程的概念。
介绍多进程的时候,我们说每一个进程都有属于自己的进程地址空间和页表,页表实现了进程地址空间与物理内存之间的映射关系。当一个进程创建子进程时,子进程虽然会拷贝父进程的代码和数据,但子进程并不是和父进程共用一块进程地址空间,也不是和父进程共用一张页表,而是操作系统也为子进程开辟并维护一块进程地址空间和一张页表。
但当我们创建线程的时候,操作系统并不会为每一个线程都开辟一块进程地址空间和一张页表 ,而是只创建进程PCB,让其PCB指向父进程的进程地址空间和父进程的页表,线程与父进程共用同一块进程地址空间和同一张页表。当一个进程创建多个线程的时候,这些线程都指向同一块进程地址空间,它们可以访问到同一份共享资源。如下图

操作系统要管理进程,为进程创建了PCB,同样的操作系统也要管理线程,在很多操作系统中会为线程创建并维护一个结构叫 TCB(Thread Control Block)。但在Linux操作系统中,并没有为线程设计独立的数据结构 ,其它的操作系统设计者设计了TCB结构是因为他们认为进程和线程在执行流层面是不一样的,而Linux操作系统并没有设计TCB结构,因为Linux操作系统的设计者认为进程和线程并没有概念上的区分,它们都是一个个的执行流。Linux操作系统中的线程是用PCB结构来模拟的。
以前我们说 进程 = 内核数据结构 + 进程对应的代码和数据,今天我们引入了线程的概念,从内核视角再来看待进程时,进程是承担分配系统资源的基本实体,是向系统申请资源的基本单位。
在没有引入多线程概念的时候,我们以前所说的进程就是内部只有一个执行流的,包括进程的内核数据结构和数据代码的进程,叫作单执行流进程。

所以反过来,多个线程其实就是在进程地址空间内部运行的多个执行流,我们把这种内部有多个执行流的,包括进程的内核数据结构和数据代码的进程,叫作多执行流进程。
多进程和多线程的区别就在于,多进程的时候CPU调度起来的成本非常高,当我们从一个进程切换到另一个进程的时候,不只是切换了进程的PCB,进程的地址空间、进程的页表等都要进行切换,而多线程是在进程地址空间内运行的执行流,它们与进程共用一套进程地址空间和页表,当CPU进行切换的时候只需要切换PCB即可,调度成本低了很多,所以也就有了线程比进程的粒度更细这样的概念。
进程的优点
- 创建一个新线程的代价要比创建一个新进程的代价小得多,因为创建一个新线程操作系统不需要为其创建新的进程地址空间和页表。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。因为单个进程内的多线程是共用一张进程地址空间和页表的,线程之间的切换不需要切换进程地址空间和页表。另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB(快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。
- 线程占用的资源要比进程少很多,线程是轻量级进程。
- 能充分利用多处理器的可并行数量
- 在等待慢速I/0操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中
- 实现I/0密集型应用,为了提高性能,将I/0操作重叠。线程可以同时等待不同的0操作。
线程的缺点
- 可能会造成性能的损失,如果计算密集型线程的数量比可用的处理器的数量多,那么有可能会有较大的性能损失。因为这里增加了额外的同步和调度的开销,但可用的资源却只有那么多,性能就下降了。
- 健壮性降低 。编写多线程需要更全⾯更深⼊的考虑,在⼀个多线程程序⾥,因时间分配上的细微偏差或者 因共享了不该共享的变量⽽造成不良影响的可能性是很⼤的,换句话说线程之间是缺乏保护的。
- 线程是缺乏访问控制的,进程是访问控制的基本粒度,在一个线程中调用某些操作系统函数有可能会对整个进程造成影响。
- 编写与调试⼀个多线程程序比单线程程序困难得多
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
线程与进程
进程是资源分配的基本单位
线程是调度的基本单位
线程共享进程的数据和进程地址空间,如果定义一个函数,在各个线程中都可以调用,如果定义一个全局变量,在各个线程中都可以访问。除此之外,各线程还共享以下的进程资源和环境**:**
- 文件描述符表
- 各种信号的处理方式(默认处理方式、忽略或者自定义处理方式)
- 当前工作目录
- 用户ID和组ID
线程共享进程的数据,但线程也拥有一部分自己的数据是独立的:
- 线程ID
- 一组寄存器
- 栈
- errno错误码
- 信号屏蔽字
- 调度优先级
进程和线程的关系如下图:

二、Linux线程控制
POSIX线程库
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread "打头的
要使用这些函数库,要通过引入头文<pthread.h>
链接这些线程函数库时要使用编译器命令的"-lpthread"选项
1.pthread_create函数
pthread_create函数是用来创建线程的函数
形参:
(1)pthread_t *thread :这是一个输出型参数,用来获取线程的ID值
(2)const pthread_attr_t *attr :用来设置线程的属性,传入nullptr表示使用默认属性
(3)void * ( * start_routine ) (void * ) :函数指针,传入一个函数的地址,该函数就是线程启动以后要执行的函数
(4)void *arg :传给线程启动函数的参数
返回值:如果创建线程成功则返回0,否则返回错误码

2.pthread_join函数
线程与进程一样,在创建线程退出以后,都需要等待线程,pthread_join函数就是线程等待函数。
pthread_join:
形参:
(1)pthread_t thread:需要等待的线程的ID
(2)void **value_ptr:这是一个输出型参数,该参数指向一个指针,该指针指向的是线程的返回值,用来获取线程退出时的退出码。
返回值:如果等待线程成功则返回0,否则返回错误码

线程退出和进程退出的情况一样:代码跑完,结果正确;代码跑完,结果不正确;异常退出。但线程只考虑前两种情况退出的退出码,异常退出的退出码并不考虑,原因是线程异常退出时进程也会跟着异常退出,进程异常退出时发信号就足够了。
3.pthread_self函数
pthread_self函数可以获得线程的ID值,该函数不需要传递参数,哪一个线程调用该函数,就会返回该调用线程的ID值。

4.pthread_exit函数
pthread_exit函数是在线程运行函数内部调用的退出线程函数,参数只需要传递进线程退出码即可。

例如:
cpp
pthread_exit((void*)10);
exit函数与pthread_exit函数的区别是exit函数是进程退出,任何一个线程调用exit函数整个进程都会退出。
5.pthread_cancel函数
pthread_cancel函数是在线程运行函数外部调用的函数,可以向线程发送取消请求,从而在外部退出指定线程。参数只需要传递指定线程的ID值即可。当我们取消一个线程时,线程退出码是-1,其实是PTHREAD_CANCELED这个宏。
(可以主线程取消新创建的线程,但不推荐新创建的线程取消主线程)

三、页表
在以前我们介绍进程的时候,说操作系统会为进程创建并维护进程地址空间,该进程地址空间通过页表与物理内存建立映射关系,但页表不只是简单地建立进程地址空间与物理内存之间的关系,它还有很多条目可以控制我们的进程。
举个例子:我们用C语言写下 char *msg = "hello"; 这样的语句时,msg是字符串常量,它不可被修改。当我们对msg进行修改的时候,编译过程中不会出现编译报错,但是程序运行起来以后就出错误了。这是因为我们的页表中有 RWX权限 这一列,它规定了msg的权限是只可读不可写的,因此当我们对msg进行修改写入的时候,直接在页表处就给我们报错了,程序也就无法正常运行了。
除此之外,页表中还有 U/K权限 这一列,这里是规定页表是属于用户级页表(User)还是属于内核级页表(Kernel)。页表中的 是否命中 这一列,标定的是该内容是否在物理内存中,在物理内存中则命中,否则是不命中的。
在操作系统中会存在很多张页表,页表其实是分为两级的(类似于MySQL的索引结构),第一级是页目录,它里面存储的是每一张页表对应的地址,通过页目录可以找到每一张不同的页表。第二级是一张张的页表,通过页表再将进程地址空间和物理内存建立起映射关系。

- 所有页表的物理地址被页目录表项指向
- 页目录的物理地址被CR3 寄存器 指向,这个寄存器中,保存了当前正在执行任务的页目录地址。
页表建立起了进程地址空间与物理内存之间的映射关系,进程地址空间上的地址是虚拟地址,它通过页表的转化便可以找到物理内存上的物理地址。但虚拟地址在被转化的过程中不是一步到位直接转化完成的。假设在32位的机器下,32位的虚拟地址会被分为 10 + 10 + 12 三部分,前十位地址负责搜索页目录,通过它可以找到对应的那一张页表;中间十位地址搜索对应的页表,通过它可以找到对应的物理地址。 通过这两部分我们就可以将虚拟地址转换成物理地址,实际上物理内存的基本存储单位是页(page),一页的大小是4KB,所以对物理内存的管理就是对每一页进行管理。我们通过物理地址可以找到物理内存中对应的某一页,虚拟地址的后12位,对应的就是页内偏移量,用来搜索查找页内的数据。

四、线程独立栈
在Linux操作系统下并没有真正地实现线程,而是使用了轻量级线程。我们使用的操作线程的函数接口其实是属于libpthread.so这个动态库的,当我们写代码操作线程的时候,我们的代码和数据要从磁盘加载到内存,线程动态库也要从磁盘加载到内存,通过页表将其映射到进程地址空间上,动态库的代码就映射到了进程地址空间的共享区上。当我们的程序在代码区执行代码的时候,发现我们的代码使用了线程动态库的函数,于是就会到共享区去查找对应的代码并调用。
虽然操作系统只给我们提供了轻量级进程,也就是操作系统其实是有提供线程的,它为我们提供了具体的执行流。但操作系统并没有为我们提供具体的操作线程的函数以及描述线程的结构,这些都是由libpthread.so这个线程动态库帮我们实现的。线程动态库可以帮我们创建线程,控制线程,当然它也要管理这些创建出来的线程,管理的原则就是先描述再组织。线程动态库里不仅提供操作线程的函数接口,还提供线程的控制结构体用来保存每一个线程的具体信息,这些信息包括线程的ID值 pthread_t tid 和线程的私有栈 void * stack ,线程的ID值( pthread_t**)其实就是一个地址值,它是该线程对应的在线程动态库内控制结构体的起始地址。**
主线程的独立栈结构,用的是进程地址空间中的栈区;新线程用的栈结构,用的是线程动态库中提供的栈结构。
五、线程分离
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则 ⽆法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
pthread_detach函数可以实现线程的分离,这个函数的使用方法非常简单,只需要传递需要分离的线程的ID值即可

注意;如果新创建的线程被分离了,并且主线程都退出了新线程还没有退出,此时无论新线程是否运行结束,都会立马跟着一起退出。原因是主线程退出就意味着进程退出了,进程退出以后就会被其父进程回收,进程的地址空间等资源都会被操作系统回收,线程本来就是和主线程共用这些资源的,既然被回收了,线程当然也没得继续运行了。所以有分离线程的时候,主线程一般是不退出的,这样的程序被称为常驻内存的进程。

