Linux:线程概念和线程控制
1.线程概念
1.1 什么是线程
我们之前所谈论的进程,本质是内核数据结构+代码和数据,在操作系统视角下的定义,是承担分配系统资源的基本实体。而我们从现在起谈论的线程,本质也是依靠进程产生的。
线程(Thread),也叫轻量级进程,是操作系统CPU 调度执行的最小单位。 一个进程内部可以包含一条或多条线程,所有线程共享所属进程的全部系统资源,可以说,线程是进程内部的执行分支。我们以前学习的进程本质是:内部只有一个线程的进程。
线程仅有私有寄存器、程序计数器、线程栈与线程局部存储,会共享所属进程的虚拟地址空间、全局数据、文件描述符等全部进程资源,内核依靠简易 TCB 管理线程,创建和切换的系统开销很低,依靠线程可以实现单个程序内部的并发运行。
进程和线程最本质的区别在于定位不同,进程是系统分配资源、实现资源隔离的基本实体,拥有独立完整的一套系统资源,进程间内存相互隔离,切换时需要更换页表、刷新缓存,开销巨大,单个进程崩溃不会影响其他进程,而线程不具备独立资源,仅作为执行载体依附于进程,同进程线程可直接访问共享内存,线程异常崩溃会造成整个进程终止。
在了解了线程之后,我们可以这样去说,进程的本质是多个线程+地址空间+页表+代码和数据。因此 多个线程+地址空间+页表 = 内核数据结构。
用通俗易懂的话来描述进程就是这样的:一个家庭里面有爷爷奶奶、爸爸妈妈、还有我们自己,爷爷奶奶退休了之后每天的事情就是到广场上跳广场舞,爸爸妈妈的任务就是好好工作,我们的任务就是好好学习。虽然我们都在干各自不同的事情,但我们有一个共同的目标就是让这个家庭的日子过的越来越好。如果爸爸妈妈不认真工作或者我们不好好学习整天混日子,这个家里的日子就过得不得安生。对于这个场景,整个家庭就相当于进程,爷爷奶奶、爸爸妈妈还有我们自己就相当于线程。
1.2 线程控制块TCB
TCB (Thread Control Block) 是操作系统内核专门用来描述、管理单条线程的内核数据结构,只要系统中存在一条线程,内核就会为它生成一块 TCB。
TCB主要存储这些内容:
1. 线程上下文:通用寄存器、程序计数器 PC、栈指针,线程被 CPU 切走时,现场数据保存在 TCB,下次调度到该线程时从 TCB 恢复现场;
2. 线程私有资源信息:线程栈地址、线程局部存储 TLS 地址;
3. 调度相关参数:线程优先级、线程状态(就绪、阻塞、运行)、调度队列指针;
4. 归属标识:记录该线程隶属于哪一个进程 PCB,以此关联进程共享资源。
它和进程控制块PCB不同的是:PCB 保管整个进程的资源信息(虚拟地址空间、文件描述符、内存映射等),一个进程仅一张 PCB; TCB 只保存线程自身执行、调度信息,不存储资源描述,同一进程内多条线程共用进程 PCB 记录的全部资源,各自持有独立 TCB。
内核依靠 TCB 识别线程、完成 CPU 调度切换、管控线程生命周期;TCB 数据体量远小于 PCB,所以新建线程的内存开销远低于新建进程。
1.3 分页式存储管理
1.3.1 虚拟地址和页表的由来
思考一下,如果在没有虚拟内存和分页机制的情况下,每一个用户程序在物理内存上所对应的空间必须是连续的,如下图:
因为每一个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割成各种离散的、大小不同的块。经过一段时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。 怎么办呢?我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了,如下图所示:
把物理内存按照一个固定的长度的页框进行分割,有时叫做物理页。每个页框包含一个物理页(page)。一个页的大小等于页框的大小。大多数 32 位体系结构支持 4KB 的页,而 64 位体系结构一般会支持 8KB 的页。区分一页和一个页框是很重要的。
页,也称作逻辑页或虚拟页,是虚拟内存的管理单位,操作系统会将单个进程的虚拟地址空间划分为若干固定大小的内存块,每一块即为一页,它隶属于进程独有的虚拟逻辑地址,每个应用程序都拥有专属的页面集合;页面只是逻辑层面的概念,对应的数据不一定存放在物理内存中,闲置页面的数据可被置换到磁盘交换分区保存。
而页框又称物理页、页帧,是物理内存条的管理单位,整机的物理内存会被切割成与页面尺寸完全一致的大小为 4KB 的固定存储块内存区块,每一块就是页框,归系统全局所有,由计算机内全部进程共享;页框代表真实存在的硬件内存空间,只有已经调入内存的虚拟页面,才会占用对应的物理页框。所有页帧尺寸统一,内核通过页帧号管理全部物理内存。如果有空闲页帧,会被操作系统维护在空闲链表,供程序动态分配。当物理内存不足时,OS 会把部分页帧的数据置换到 swap 交换分区,释放页帧给其他进程使用。
在操作系统内部有一个全局数组struct page *mem_map(页描述符数组),所有物理页框的信息,统一存放在全局数组 mem_map中,其中数组下标对应页帧号 PFN,一个下标对应一个物理页框。数组元素类型是struct page(页描述符),每个struct page完整记录单个页框的全部属性:
1. 页框状态:空闲、进程私有页、文件缓存页、锁定、脏页等;
2. 引用计数:多少个虚拟页映射了这个页框;
3. 归属信息:属于哪个进程、对应哪个磁盘文件块、Swap 位置等;
简单说:mem_map 是所有页框的总花名册,每个页框对应一条 struct page 记录。
有了这种机制,CPU 便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个逻辑地址,在 32 位机上,其范围从 0 ~ 4G-1。
操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每一对页和页框的映射关系,能让 CPU 间接的访问物理内存地址。
总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。
1.3.2 页表
页表中的每一个表项,指向一个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是 4GB,这是每一个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就一共需要 4GB/4KB = 1048576 个表项。如下图所示:
虚拟内存看上去被虚线 “分割” 成一个个单元,其实并不是真的分割,虚拟内存仍然是连续的。这个虚线的单元仅仅表示它与页表中每一个表项的映射关系,并最终映射到相同大小的一个物理内存页上。
页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用就指向哪里 (物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。
假设,在 32 位系统中,地址的长度是 4 个字节,那么页表中的每一个表项就是占用 4 个字节。所以页表占据的总空间大小就是:1048576*4 = 4MB 的大小。也就是说映射表自己本身,就要占用 4MB / 4KB = 1024 个物理页。这会存在哪些问题呢?
我们一开始引入分页机制的核心目的,是允许进程的虚拟页面分散、不连续地存放在物理内存中,以此充分利用内存里零散的空闲页框,减少内存碎片。 但单层完整页表自身需要1024 个连续的物理页框来完整存放,等于又要求操作系统为页表分配一大块连续物理内存,和分页 “离散存储、消除连续内存需求” 的核心目标相互冲突。
另外,计算机程序具备明显的局部性原理:进程运行的任意一段时间内,只会频繁访问自身 4GB 虚拟地址空间里很小一部分页面,绝大多数虚拟页长期不会被读写。 单层页表会一次性为整个 4GB 虚拟内存创建全部表项,无论对应页面是否使用,整张 4MB 的页表。
因此,解决需要大容量页表的最好方法是:把页表看成普通的文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想,即把原本完整的大页表再次切分成多个小型子页表,新增一层顶层索引(也就是 32 位系统里的页目录),只有进程实际用到的虚拟内存区域,才分配对应的下层子页表。
1.3.3 页目录
页目录是多级页表机制里的最高层级页表,是操作系统实现虚拟地址到物理地址转换的顶层索引结构。
32 位虚拟地址会被拆分三段:页目录索引、页表索引、页内偏移:
CPU 内控制寄存器 CR3 专门存放当前进程页目录物理起始地址。CPU 进行地址翻译时,第一步就通过 CR3 找到页目录,再通过虚拟地址的前十位映射的页目录索引,顺着页目录项找到下层页表,最终定位物理页框。
其中页目录数组内一共有 2^10 = 1024 个页目录项 (PDE),也就是 1024 个存储位置,因为虚拟地址最高 10 位,作为下标,去页目录里找到对应的页目录项,当虚拟地址的前10都为 1 时,对应的页目录项就是 2^10 = 1024。因此,1024 张子页表 × 每张子页表 1024 个页表项 = 1024 x 1024 = 2 ^ 20 个页面,每个页面 4KB(2¹² 字节) ,总寻址空间:2^20 x 2^12 = 2 ^ 32 = 4GB,刚好覆盖 32 位系统完整虚拟地址空间。
另外,中间 10 位:用这个下标去下层页表找页表项,拿到物理页框号;低 12 位页内偏移直接拼接物理页框号,得到完整物理地址。
而操作系统为每一个用户进程、内核进程单独分配一份专属页目录,不同进程的页目录在物理内存中是相互独立、互不干扰的两块内存。 32 位二级分页下,一份页目录大小固定 4KB,刚好占用1 个物理页框。
1.4 线程的优点
1. 上下文切换开销远小于进程
创建普通进程会完整复制虚拟地址空间、页目录、文件描述符、信号上下文等大量资源,切换时 MMU 刷新 TLB、更换 CR3 寄存器,内存映射全部重建,耗时很高。 同一进程内的线程共享虚拟地址空间、文件表、信号处理器等资源,线程切换仅需要保存 / 恢复寄存器、线程私有栈,无需修改页表相关硬件寄存器,不用刷新 TLB,切换速度远快于进程,CPU 损耗更低。
2. 创建、销毁成本极低
新建进程需要内核分配页目录、大量页表,拷贝父进程内存(写时复制也存在初始化开销); 创建线程仅需分配一小块私有栈,调用 clone 时传入资源共享标识,直接复用进程已有的全部内存资源,内核分配资源极少,创建和回收速度远快于进程,适合大量并发执行流场景。
3. 线程间数据通信简单高效
同一进程内所有线程共享全局变量、堆内存、静态存储区,线程之间传递数据只需要读写共享内存,无需调用管道、消息队列、共享内存段等进程间通信(IPC)接口,省去系统调用、内核缓冲区拷贝的开销。 仅需搭配互斥锁、条件变量控制访问冲突,数据交换的效率远超进程通信。
4. 充分利用多核 CPU,提升程序并发性能
现代 CPU 普遍为多核架构,操作系统调度器可以将同一个进程的多个线程分配到不同 CPU 核心上并行执行。 如果只用单进程单线程,同一时刻只能占用一个 CPU 核心;多线程能够把计算任务拆分,同时跑在多个核心,大幅缩短计算耗时,适合图像运算、数据解析、批量计算等 CPU 密集型程序。
5. 优化 IO 阻塞场景,提升程序吞吐量
程序执行文件读写、网络收发时,IO 操作会造成执行流阻塞等待外设数据。 单线程程序阻塞期间整个程序无法处理其他任务;多线程架构可以拆分任务:一部分线程负责等待网络 / 文件 IO,另一部分线程持续处理已有数据,CPU 资源不会因 IO 空闲浪费,大幅提升网络服务、文件处理类 IO 密集型程序的处理能力。
6. 资源占用更少,内存利用率更高
多个进程会各自拥有独立的代码段、全局内存、库映射,多进程并发会造成大量内存冗余; 同一进程内的全部线程共用一份代码段、堆、动态链接库、文件描述符集合,不会重复占用内存,大量并发场景下整体内存占用远低于多进程方案。
7. 编程模型简单,适配通用业务逻辑
借助 pthread 库提供的标准 POSIX 线程接口,开发者可以轻松拆分业务任务,比如分离数据接收、数据解析、结果存储等逻辑为不同线程,代码结构清晰;同步工具(互斥锁、条件变量)使用便捷,相比复杂的进程间通信,开发与维护成本更低。
1.5 线程的缺点
1. 共享内存带来数据竞争与同步开销
同一进程内线程共享全局变量、堆、静态区,多个线程同时读写同一块共享数据时,极易产生数据竞争,出现脏数据、逻辑错乱。
2. 一处线程崩溃会导致整个进程直接退出
进程内所有线程共用同一个地址空间、文件描述符、信号上下文。只要任意一条线程发生段错误、除零错误等内存异常,操作系统会向整个进程发送终止信号,全部线程都会随进程一同销毁。 多进程则相互隔离,单个进程崩溃不会影响其他进程,稳定性更好。
3. 线程设计存在原生资源限制
每个线程拥有独立私有栈,默认栈大小固定(Linux 下通常 8MB)。创建大量线程会快速耗尽虚拟地址空间;递归过深还会发生栈溢出。自定义调整线程栈大小又会带来内存管理的额外工作量。操作系统会限制单个进程能创建的最大线程数量,海量并发场景下单纯依靠多线程会受此约束。
4. 信号处理逻辑复杂混乱
信号是以进程为单位接收,而非单一线程。当信号触发时,系统无法精准控制由哪一条线程处理信号,极易打断正常线程逻辑;想要精细化管控信号处理,需要额外编写复杂的信号屏蔽逻辑,增加开发难度。
5. 线程切换存在隐性损耗
虽然线程切换远快于进程切换,但频繁的线程切换依旧会消耗 CPU 资源:需要保存恢复通用寄存器、浮点寄存器;大量线程并发时,操作系统调度器频繁轮换线程,会出现 CPU 软中断升高、有效算力下降的问题。
1.6 线程本质
实际上,Linux 内核本身不存在专门的线程内核对象,系统内所有可调度执行流都统一用task_struct结构体描述,内核只会区分普通进程与轻量级进程(LWP),不会单独识别线程。
创建执行流依靠clone()系统调用,通过传入不同共享标记决定资源隔离程度:普通进程几乎不与父进程共享资源;而轻量级进程会共享虚拟地址空间、文件描述符、信号处理上下文等资源,仅保留独立栈、寄存器与内核 TID,这就是内核层面支撑线程的底层载体。内核调度器平等调度所有task_struct,不会区分主线程、子线程或普通进程。
Linux 内核仅提供clone()、futex()这类底层系统调用,没有实现 POSIX 标准的线程接口。用户程序使用的线程功能,全部由用户态的pthread 库封装实现:pthread 会自动携带全套资源共享参数调用clone()生成对应的 LWP,同时维护用户态线程管理信息,封装互斥锁、条件变量等同步工具,屏蔽轻量级进程的底层细节,对外提供统一、跨平台的标准线程 API。
既然是依靠用户态的 phread 库,在进行编译链接的时候,按理来说就应该指定链接库,而我们之所以在上面的创建中没有指定库,是因为当前所使用的操作系统版本较新,已经把 pthread 库自动加载到操作系统中,无需指定,但对于低版本的操作系统,不指定库的话,就无法使用线程内容。
1.7 线程 VS 进程
讲解到现在,我们会发现线程和进程的相似性真的很高,都可以用来处理多并发的问题,但是在处理并发时,我们一般优先选用多线程而非多进程。
核心原因是线程创建、销毁与上下文切换的资源开销远低于进程,线程间共享同一进程的虚拟地址空间,能够直接读写共享内存完成数据交互,省去管道、共享内存等复杂进程间通信方式带来的系统调用与数据拷贝损耗,同时多线程代码架构更简洁,能轻量化地拆分业务任务并充分利用多核 CPU 实现并行运算。
而多进程虽具备资源隔离、单个进程崩溃不会波及其他进程的优势,但进程创建时需分配独立页目录、完整虚拟内存空间,资源占用量大,进程间数据交互依赖繁琐的 IPC 机制,开发与运行成本更高,仅对程序稳定性、故障隔离有极高要求的场景才适合采用多进程方案。
因此可以这样总结:在需要并发执行、多核加速、频繁通信的场景下,优先使用多线程,因为线程创建切换开销小、通信高效、资源占用低、编程模型简单。多进程虽然拥有更好的稳定性与隔离性,但其创建成本高、通信复杂,仅适用于需要强安全隔离的场景。
2. 线程控制
2.1 创建线程
2.1.1 pthread_create 函数
了解了线程的基础概念,我们来创建一个线程亲眼看看它,在此之前我们要学习一个函数:pthread_create ,用于创建一个新的子线程,让程序实现多任务并发执行。
#include <pthread.h> // 必须包含的头文件 int pthread_create( pthread_t *thread, // 输出参数:新线程ID const pthread_attr_t *attr, // 线程属性(NULL表示默认属性) void *(*start_routine)(void *), // 线程入口函数 void *arg // 传递给线程函数的参数 );这个函数的第二个参数表示线程属性,通常包含栈大小、优先级、分离状态等,不过我们一般传参传为nullptr/NULL表示按照系统默认属性。其中第四个参数如果不需要的话,也直接传nullptr/NULL即可。
其中线程入口函数的格式一定要按照这样:
// 固定格式:返回值void*,参数void* void *线程函数名(void *参数) { // 线程要执行的代码 }另外,当创建失败时,返回值为非 0 错误码 ,创建成功时返回值为 0 ,并且该值由主线程返回。
接下来我们来看代码:
#include <iostream> #include <pthread.h> using namespace std; #include <unistd.h> void *threadRun(void *args) { while(true) { cout << "新线程正在运行" << endl; sleep(1); } } int main() { pthread_t tid; pthread_create(&tid,nullptr,threadRun,nullptr); while(true) { cout << "主函数线程正在运行" << endl; sleep(1); } }我们在主函数中调用 pthread_create 函数创建了一个子线程,它的线程入口函数是 threadRun 。大家要注意的是,当系统运行我们的主函数时,会自动创建一个父进程,系统自动给它配一个主线程,当父进程中代码执行调用 pthread_create 函数才创建了一个子线程。如果父进程创建了一个子进程,子进程创建好的同时,里面也会自动存在一个主线程。
2.1.2 验证同属一个进程
因此,现在主线程和子线程都在执行循环打印操作,并且当我们给代码里面加上pid显示的时候,打印结果是这样的:
说明这两个线程同属于一个进程。
ps -aL是用来查看Linux系统当中所有的轻量级进程,我们会发现有两个,名字都是testThread。其中轻量级进程的英文是:Light weight Process,缩写取首字母就是:LWP,所以上图里的LWP就是指轻量级进程的编号。其中主线程就是PID和LWP相等的那一个。
接下来我们需要探讨一个细节,当主线程创建子线程时,传入的值是 tid ,对应着新创建的子线程的 LWP ,那么也就意味着 tid 的值就是 LWP 吗?我们来看一下:
我们调用 printf 函数打印新线程的 tid ,发现打印的数字是一个巨大无比的数字,和我们查看的线程的LWP完全不一样,这是怎么回事儿?其实转念一想也不奇怪,我们在前面讲解线程本质的时候提到过,线程的概念其实被 pthread 库封装了,因此对于用户态来说,确实应该看不到线程真正的 LWP,我们看到的这个奇怪的数字也就具有合理性了。那这串数字到底是什么呢?
我们再用 16 进制打印一次这串数字:
大家再次看到这一串十六进制的数字,不免会想到,这其实是一串地址。但具体是什么地址?我们后续会讲解。
2.1.3 验证多线程之间的关系
那么我们创建出新线程之后,怎么确定这个线程就是由我们的主线程创建的呢?我们可以使用这个函数:pthread_self
#include <pthread.h> pthread_t pthread_self(void);它的核心作用是:在线程函数内部获取自身的线程 ID。类似进程里getpid()获取自身进程号。
我们在子线程中调用 pthread_self 函数,发现获取的 tid 和在主线程中获取的 tid 一样。
这里的static_cast是C++ 标准的静态类型转换运算符,用来做编译期安全的类型强制转换,替代 C 语言粗暴的(类型)变量强转,编译器会在编译阶段做基础类型校验,非法转换会直接告警 / 报错。
2.1.4 验证多线程共享资源
我们也可以同时调用多个线程:
void *threadRun(void *args) { string name = static_cast<const char *>(args); printf("-------------------------------------------\n"); while (true) { // cout << "新线程正在运行,名字是:" << name << endl; printf("新线程正在运行,名字是:%s, tid : 0x%lx\n", name.c_str(), pthread_self()); sleep(1); } // return nullptr; } int main() { const int num = 10; for (int i = 0; i < num; i++) { pthread_t tid; // 构建线程名字 char threadname[64]; snprintf(threadname, sizeof(threadname), "thread-%d", i + 1); int n = pthread_create(&tid, nullptr, threadRun, threadname); (void)n; sleep(1); } while (true) { cout << "主函数线程正在运行, pid:" << getpid() << endl; sleep(1); } return 0; }另外,我们在全局创建一个变量 g_val ,让子线程的入口函数里面对 g_val 进行操作,然后在主线程和子线程中同时打印该变量:
会发现即使只有子线程将 g_val 的值进行改动,主线程依旧可以访问到同一个 g_val 值,说明同进程的多线程是共享同一块资源的。
2.1.5 线程参数地址复用问题
另外大家要注意我们的执行结果 ,会发现我们竟然打印了两次相同的 thread-10 ,这其实是因为我们这里直接使用一个 char 类型的数组去存储字符串导致的原因,因为:
char threadname[64];定义在 for 循环内部,属于main 函数栈上的局部变量:
- 栈内存特性:每次进入循环体,复用同一块栈内存存放这个数组;
- 循环执行流程:
- 第 1 轮 i=0:栈数组写入
thread-1,调用pthread_create把数组首地址传给子线程; - 主线程
sleep(1)停顿 1 秒; - 第 2 轮 i=1:还是同一块栈数组,
snprintf直接覆盖原有内容,写成thread-2,再创建第二个线程;
- 第 1 轮 i=0:栈数组写入
pthread_create只是把数组地址传给子线程、唤醒子线程,主线程和新子线程是并发执行的:
- 主线程刚创建完线程,立刻进入下一轮循环,用
snprintf覆盖栈数组; - 子线程拿到的只是栈数组的地址,它不会立刻读取数组内容,可能被操作系统调度挂起;
- 等子线程被调度、准备读取名字时,主线程早已覆盖了数组里的字符串,所有子线程读到的都是最新一轮循环的名字,出现大量重复
thread-10之类的错乱。
由于我们调用sleep(1)让主线程阻塞 1 秒,给足子线程执行、读取字符串的时间,因此打印错乱的情况才不明显:
- 主线程创建线程后主动休眠,子线程拿到 CPU,第一时间读取栈数组里的名字;
- 等 1 秒后主线程唤醒,才会进入下一轮覆盖数组; 这起到了临时规避的作用,但是没有修复底层逻辑漏洞,系统负载极高、线程调度延迟时,依旧会出现名字错乱。
因此我们的解决办法是,对于每次循环,我们都独立开辟一个新的数组,让每个线程使用的数组都属于自己,而不是共享同一个内存:
这样再打印的时候就不会出现重复打印的问题,不过顺序还是不同的,这是因为 Linux 采用抢占式线程调度机制,pthread_create 创建线程时只会将线程加入内核就绪队列,不会强制新线程马上执行,主线程循环创建完所有线程后,全部子线程都会处于就绪状态等待 CPU 时间片,系统调度器会随机挑选就绪线程分配执行时间片,不存在先创建先运行的规则,因此各个线程执行打印输出的顺序不会按照 thread-1 到 thread-10 的递增顺序排列。
要让线程严格按照指定顺序执行,不能依赖操作系统的随机调度,必须通过线程同步机制控制执行顺序,最常用的方法是使用互斥锁 + 条件变量,这个知识会在下篇文章中讲解。
2.1.6 验证单线程错误导致进程崩溃
现在我们在子线程函数当中添加一个判断语句,来观察某一个线程发生错误的时候,对整个进程的影响:
int g_val = 100; void *threadRun(void *args) { string name = static_cast<const char *>(args); printf("-------------------------------------------\n"); while (true) { // cout << "新线程正在运行,名字是:" << name << endl; printf("新线程正在运行,名字是:%s, tid : 0x%lx,g_val:%d, &g_val: %p\n", name.c_str(), pthread_self(),g_val,&g_val); sleep(1); g_val++; if(name == "thread-8") { cout << "thread-8 : 我要异常了" << endl; int a = 10; a /= 0; } } // return nullptr; }我们可以看到,当 thread-8 这个线程发生除零错误的时候,整个进程都会崩溃停止运行。这也印证了线程的一个缺点,因为关联度太高,当单线程出问题时,会导致整个进程出错。
2.2 线程终止
线程终止就是指线程执行完自己的任务、主动退出,或者被强制结束,从而结束生命周期、停止运行的过程。线程终止后,它不再占用 CPU 资源,私有栈、寄存器等资源会被系统回收,但不会影响同进程内的其他线程,也不会销毁进程本身。
线程终止一共有 3 种常见方式:
1. 正常终止:线程函数执行完毕,return返回,线程自动结束。但这种方式对主线程不适用,从main函数中return相当于调用exit。
2. 主动终止:线程内部自己调用pthread_exit(),强制退出当前线程。
3. 被动终止:被其他线程使用pthread_cancel()取消执行。即一个线程可以通过调用 pthread_cancel 的方式终止同一进程中的另一个线程。
要注意的是,不能在任何单独线程中调用 exit ,这表示让整个进程退出。
我们来介绍一下 pthread_cancel 函数:
#include <pthread.h> int pthread_cancel(pthread_t thread);pthread_cancel用于向指定线程发送取消请求,让目标线程提前终止。 它只是发送信号,不会阻塞等待,目标线程不一定会立刻停止。线程最终能否停止,完全取决于线程内部是否执行到「取消点」。没有取消点,线程永远不会响应取消。取消点是系统标准规定的安全退出点,保证线程安全,不用我们手动定义。这个概念会在后面讲解线程同步与互斥的时候会再次提到,大家只要知道我们刚刚写的代码里面有取消点即可。
其中的参数thread代表要取消的线程 ID(tid)。
我们来使用一下:
int main() { srand(time(nullptr) ^ getpid()); const int num = 10; vector<pthread_t> tids; for (int i = 0; i < num; i++) { pthread_t tid; // 构建线程名字 // char *threadname = new char[64]; // sprintf(threadname,"thread-%d", i + 1); int x = rand()% 10 + 1; usleep(500); int y = rand()% 7 + 1; Task *t = new Task(x,y); int n = pthread_create(&tid, nullptr, threadRun, t); (void)n; //存储所有线程ID tids.push_back(tid); // sleep(1); } for(auto &tid: tids) { printf("tid:0x%lx\n",tid); } while (true) { sleep(1); // cout << "主函数线程正在运行, pid:" << getpid() << endl; printf("我是主线程,tid:0x%lx, g_val:%d, &g_val:%p\n",pthread_self(),g_val,&g_val); int who = rand() % tids.size(); if(tids[who] == -1) continue; else { pthread_cancel(tids[who]); tids[who] = -1; } } return 0; }我们用 vector 容器存储所有线程的 ID ,再在循环当中随机选取一个,调用 pthread_cancel,代码执行的结果是这样的:
大家可以看到当我们查看系统中的线程的时候,呈现的是数量递减的情况,说明我们删除成功了。
2.3 线程等待
我们之前谈论过进程等待,其实线程等待和它差不太多,如果主线程不等待子线程,也会引发僵尸问题。在绝大多数情况下,主线程都是要等待子线程的,我既然这样说,那就意味着线程等待并不是 100% 必须的。
线程等待就是用pthread_join(tid, NULL)函数,让主线程阻塞,一直等到指定的子线程结束、退出,主线程才继续往下运行。
#include <pthread.h> int pthread_join(pthread_t thread, void **retval);主线程调用它,阻塞等待指定的子线程退出,并回收该线程的资源(内核栈、TCB 等),防止资源泄漏。它的两个参数:thread代表要等待的线程 ID。retval是输出型参数,它的参数类型之所以是 void ** ,是因为该参数用来接收子线程的返回值,而子线程本质上就是void *(*start_routine)(void *) 这个让入口函数,它的类型是 void* ,既然是获取类型为 void* 类型的返回值,所以 retval 的类型才为 void** ;不需要返回值传NULL。
我们来测试一下这个函数:
void *Routine(void *args) { string name = static_cast<const char*>(args); while(true) { cout << "new thread:" << name << endl; sleep(5); break; } return (void*)10; } int main() { pthread_t tid; pthread_create(&tid,nullptr,Routine,(void*)"thread-1"); //线程等待 void *retval = nullptr; int n = pthread_join(tid,&retval); if(n == 0) { cout << "等待成功:" << (long long)retval << endl; } return 0; }我们在子线程执行函数 Routine 中,return 将数值 10 强制转换为 void类型作为线程退出返回值;在主函数中定义void类型变量 retval 用于接收子线程的返回结果,调用 pthread_join 阻塞主线程,主线程会在此处暂停全部执行逻辑,持续等待 tid 对应的子线程运行终止,同时回收子线程运行后遗留的内核资源,子线程运行满 5 秒结束后,pthread_join 函数会将子线程 return 传递的返回值存入 retval 变量,函数执行成功返回 0,主线程通过判断返回值确认等待完成,打印输出子线程的返回数值 10,最后主线程执行 return 结束整个进程。
结果正如我们所料。
不过既然子线程函数的返回值固定是 void* ,这就意味着我们除了可以返回一个 int 类型的常量,也可以返回一个字符串,一个类对象等等:
class Res { public: string name; string info; int code; }; void *Routine(void *args) { string name = static_cast<const char*>(args); while(true) { cout << "new thread:" << name << endl; sleep(3); break; } Res *res = new Res(); res->code = 10; res -> info = "这个进程已经完蛋了"; res->name = "张三"; return (void*)res; } int main() { pthread_t tid; pthread_create(&tid,nullptr,Routine,(void*)"thread-1"); //线程等待 Res *retval = nullptr; int n = pthread_join(tid,(void**)&retval); if(n == 0) { cout << retval->name <<"说 : " << retval->info << endl; } delete retval; return 0; }就像这样,我们直接创建一个类,声明好之后在子线程函数中对其定义,最后用主函数中的 retval 去接收,打印的结果也是正常的。
当我们先调用 pthread_cancel 函数对线程发出取消信号,再调用 pthread_join 函数去等待线程,来观察一下它的 retval 接收到的线程退出状态:
void *Routine(void *args) { // string name = static_cast<const char*>(args); while(true) { // cout << "new thread:" << name << endl; sleep(3); // break; } // Res *res = new Res(); // res->code = 10; // res -> info = "这个进程已经完蛋了"; // res->name = "张三"; // return (void*)res; } int main() { pthread_t tid; pthread_create(&tid,nullptr,Routine,(void*)"thread-1"); cout << "正在取消目标线程" << endl; sleep(3); pthread_cancel(tid); //线程等待 Res *retval = nullptr; int n = pthread_join(tid,(void**)&retval); if(n == 0) { // cout << retval->name <<"说 : " << retval->info << endl; cout << "等待成功:" << (long long)retval << endl; } // delete retval; return 0; }大家会发现直接返回 -1 ,这其实是在源代码中的一个宏定义:
2.4 分离线程
分离线程是指线程被设置为脱离主线程管理的状态,线程退出后系统会自动回收其内核资源,无需主线程调用pthread_join等待。因此我在前面提到的线程等待并不是 100% 必须的,指的就是分离线程的情况。
线程被设置为分离线程后,当它执行完任务、正常退出,或是被pthread_cancel取消终止时,操作系统内核会自动回收该线程所占用的全部系统资源,包括线程的内核栈、线程控制块 TCB、线程描述符等,不会残留任何占用的系统资源,不会变成无法被回收的僵尸线程,也不会随着程序长时间运行而产生资源泄漏问题,资源的释放工作由系统自动完成,无需程序员手动处理。
将线程设置为分离状态需要用到这个函数: pthread_detach
#include <pthread.h> int pthread_detach(pthread_t thread);void *Routine(void *args) { // string name = static_cast<const char*>(args); int cnt = 5; while(cnt--) { // cout << "new thread:" << name << endl; cout << "新线程正在运行: " << cnt << endl; sleep(1); // break; } return nullptr; // Res *res = new Res(); // res->code = 10; // res -> info = "这个进程已经完蛋了"; // res->name = "张三"; // return (void*)res; } int main() { pthread_t tid; pthread_create(&tid,nullptr,Routine,(void*)"thread-1"); cout << "主线程正在分离新线程" << endl; sleep(3); pthread_detach(tid); // cout << "正在取消目标线程" << endl; // sleep(3); // pthread_cancel(tid); //线程等待 Res *retval = nullptr; int n = pthread_join(tid,(void**)&retval); if(n == 0) { // cout << retval->name <<"说 : " << retval->info << endl; cout << "等待成功:" << (long long)retval << endl; } else { cout << "分离成功, n 的值为: " << n << endl; } // delete retval; return 0; }不过要注意的是:线程分离之后,如果线程出现段错误、除零错误、野指针等严重错误,整个进程照样直接崩溃!因为分离线程 ≠ 独立进程,它仍然属于原来的进程,共享同一片地址空间。
3. 线程ID和进程地址空间布局
大家首先要明确一件事情,我们前面说的对于线程的操作其实是依赖于一个封装的库:pthread 库,而这个库也是要被映射到进程的虚拟地址空间当中,以支持线程控制的操作,从进程整体虚拟地址空间布局来看,程序代码段、全局数据段、堆区为所有线程共享资源,主线程栈固定处在地址空间高地址的原生栈区,而 libpthread.so 这类动态链接库统一加载在进程中间位置的 mmap 共享映射区:
后续所有子线程相关私有内存也都由 pthread 库在这片共享区中统一分配管理。
另外,我们前面保留了一个问题,当创建线程成功之后会获得一个线程 ID ,这个线程 ID 用十六进制打印出来的时候,是一个很大的数字,其实它的本质就是一个地址:
在调用 pthread_create创建子线程时,pthread 库会通过 mmap 一次性在共享区申请一整块连续内存,整块内存由栈保护页、子线程私有栈空间、TLS 线程局部存储、TCB 线程控制块(struct pthread)自上而下排布,其中 struct pthread 结构体存储着线程栈地址、调度属性、分离状态、线程退出信息等全部用户态管控数据,这块 TCB 结构体所处内存的起始虚拟地址,最终就被赋值给到传出的 tid 变量。
图中 tid、tid2 直接指向每一块内存最顶部的 struct pthread 结构体首地址,这个结构体也就是用户态 TCB,内部记录了对应线程栈起始地址、线程属性、分离标记、退出返回值等全部用户层管理信息,因此我们代码中打印出的 tid,本质就是用户态 TCB 结构体(struct pthread)在进程虚拟地址空间的内存起始地址,该地址仅在当前进程地址空间内有效,和内核通过 gettid 获取的系统全局 LWP 线程 ID 分属两套标识体系。
而 LWP 的产生,是 pthread_create 先内存映射开好 TCB (struct pthread)、线程栈内存后,pthread 库继续调用clone系统调用,此时切换到内核态;
内核copy_process新建 task_struct,从系统全局 PID 编号池中自动分配一个全新整型 ID,就是 LWP,全系统唯一,主线程 LWP = 进程 PID。
因此我们总结一下 TCB、LWP、tid的关系就是:tid 存储的是管理某个线程的TCB的结构体地址,也就意味着,每一个结构体只对应一个LWP,这个结构体中是LWP的属性信息等等。这就相当于,LWP是身份证号,TCB是个人简历,tid是家庭住址。
4. 线程局部存储
线程局部存储(Thread Local Storage,简称 TLS) 是多线程编程里一个非常实用的技术,核心一句话:为每个线程创建独立的变量副本,线程之间互不干扰、互不共享。
在我们之前所写的代码当中,因为线程之间是共享资源的,所以当一个线程修改变量时,另一个线程如果想要使用这个变量,就会收到影响:
pid_t id; void *routine(void *args) { string name = static_cast<const char*>(args); while(true) { cout << "new thread id :" << id << endl; id++; sleep(1); } return nullptr; } int main() { pthread_t tid; pthread_create(&tid,nullptr,routine,(void*)"thread-1"); while(true) { cout << "main thread id: " << id << endl; sleep(1); } pthread_join(tid,nullptr); return 0; }如果想要子线程修改 id 值,但是主线程不受到影响,我们可以给 pid_t id 加一个属性 __thread 。__thread是 GCC 编译器提供的线程局部存储(TLS)属性,用来声明每个线程独立拥有一份副本的全局 / 静态变量。
当我们为 id 这个变量加上这个属性之后,会发现此时子线程中 id 不断增大,但是主线程中 id 一直保持不变。
并且当我们调用系统调用 syscall(SYS_gettid) ,并打印其 id 地址时, syscall(SYS_gettid) 是用于获取线程ID的系统调用,会发现主线程和子线程隶属于不同的两个地址:
__thread pid_t id; pid_t GetTid() { return syscall(SYS_gettid); } void *routine(void *args) { id = GetTid(); string name = static_cast<const char*>(args); while(true) { cout << "new thread id :" << id << endl; printf("new thread id: %p\n",&id); sleep(1); id++; } return nullptr; } int main() { id = GetTid(); pthread_t tid; pthread_create(&tid,nullptr,routine,(void*)"thread-1"); while(true) { cout << "main thread id: " << id << endl; printf("main thread id: %p\n",&id); sleep(1); } pthread_join(tid,nullptr); return 0; }这是因为id被声明成了__thread pid_t id;,所以每个线程都有自己独立的一份变量,相当于是在各自的TCB中的线程局部存储中各自开辟了一块新的地址。
不过要注意的是,线程局部存储只能用来局部存储内置类型,常见的是整型,对于局部函数中的变量无法进行线程局部存储。
本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位读者批评或指正。
