Linux:线程概念和线程控制
1.线程概念1.1 什么是线程我们之前所谈论的进程本质是内核数据结构代码和数据在操作系统视角下的定义是承担分配系统资源的基本实体。而我们从现在起谈论的线程本质也是依靠进程产生的。线程Thread也叫轻量级进程是操作系统CPU 调度执行的最小单位。 一个进程内部可以包含一条或多条线程所有线程共享所属进程的全部系统资源可以说线程是进程内部的执行分支。我们以前学习的进程本质是内部只有一个线程的进程。线程仅有私有寄存器、程序计数器、线程栈与线程局部存储会共享所属进程的虚拟地址空间、全局数据、文件描述符等全部进程资源内核依靠简易 TCB 管理线程创建和切换的系统开销很低依靠线程可以实现单个程序内部的并发运行。进程和线程最本质的区别在于定位不同进程是系统分配资源、实现资源隔离的基本实体拥有独立完整的一套系统资源进程间内存相互隔离切换时需要更换页表、刷新缓存开销巨大单个进程崩溃不会影响其他进程而线程不具备独立资源仅作为执行载体依附于进程同进程线程可直接访问共享内存线程异常崩溃会造成整个进程终止。在了解了线程之后我们可以这样去说进程的本质是多个线程地址空间页表代码和数据。因此 多个线程地址空间页表 内核数据结构。用通俗易懂的话来描述进程就是这样的一个家庭里面有爷爷奶奶、爸爸妈妈、还有我们自己爷爷奶奶退休了之后每天的事情就是到广场上跳广场舞爸爸妈妈的任务就是好好工作我们的任务就是好好学习。虽然我们都在干各自不同的事情但我们有一个共同的目标就是让这个家庭的日子过的越来越好。如果爸爸妈妈不认真工作或者我们不好好学习整天混日子这个家里的日子就过得不得安生。对于这个场景整个家庭就相当于进程爷爷奶奶、爸爸妈妈还有我们自己就相当于线程。1.2 线程控制块TCBTCB (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 个页面每个页面 4KB2¹² 字节 总寻址空间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_castconst 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 轮 i0栈数组写入thread-1调用pthread_create把数组首地址传给子线程主线程sleep(1)停顿 1 秒第 2 轮 i1还是同一块栈数组snprintf直接覆盖原有内容写成thread-2再创建第二个线程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_castconst 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代表要取消的线程 IDtid。我们来使用一下int main() { srand(time(nullptr) ^ getpid()); const int num 10; vectorpthread_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_castconst 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_castconst 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_castconst 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_castconst 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_castconst 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_castconst 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中的线程局部存储中各自开辟了一块新的地址。不过要注意的是线程局部存储只能用来局部存储内置类型常见的是整型对于局部函数中的变量无法进行线程局部存储。本文到此结束感谢各位读者的阅读如果有讲解的不到位或者错误的地方欢迎各位读者批评或指正。