线程概念与控制
- 一.Linux线程概念
- 1.什么是线程?
- 2.分页式存储管理
- 1.虚拟地址和页表的由来
- 2.物理内存管理
- 3.页表
- 4.页目录结构
- 5.两级页表的地址转换
- 6.缺页中断(异常)
- 3.线程的优点(面试题)
- 4.线程的缺点
- 5.线程异常
- 6.线程用途
- 二.Linux进程VS线程
- 1.进程和线程
- 2.进程的多个线程共享
- 三.Linux线程控制
- 1.POSIX标准:线程库
- 2.线程创建
- 3.线程退出
- 4.线程等待
- 5.线程分离
- 四.线程ID、进程地址空间布局
- 五.线程局部存储
- 六.线程栈
- 七.线程封装
本节重点:
- 深刻理解线程。
- 深刻理解虚拟地址空间。
- 了解线程概念,理解线程与进程区别与联系。
- 学会线程控制,线程创建,线程终止,线程等待。
- 了解线程分离与线程安全概念。
- 掌握线程与进程地址空间布局。
- 理解LWP和原生线程库封装关系。
一.Linux线程概念
1.什么是线程?
进程:一个执行起来的程序,进程 = 内核数据结构 + 代码和数据。是分配系统资源的基本单位。
线程:是进程内部的一个执行分支(执行流),执行力度比进程要更细。是操作系统调度的基本单位。
- 线程在进程内部运行,本质是线程在进程地址空间内运行。
- 之前学的进程内部只有一个执行分支(执行流),即内部只存在一个线程,当然一个进程可以存在多个线程。
- Linux线程统一称为:轻量级进程(LWP)
- Linux中没有真正意义上的线程,是用轻量级进程模拟实现的。
- Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
- 从操作系统调度的角度来看,实际上CPU调度的对象是轻量级进程LWP。
2.分页式存储管理
1.虚拟地址和页表的由来
如果在没有虚拟内存和分页机制的情况下,每一个用户程序在物理内存上所对应的空间必须是连续的,如下图:
因为每一个程序的代码、数据长度都是不一样的,按照这样的映射方式,物理内存将会被分割成各种
离散的、大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。
怎么办呢?我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚
拟内存和分页便出现了,如下图所示:
把物理内存按照固定的长度的页框(物理页)进行分割。大多数 32位 体系结构支持 4KB 的页框,而 64位 体系结构一般会支持 8KB 的页框。
区分页和页框是很重要的:
- 页是虚拟内存中的概念,是进程虚拟地址空间划分的固定大小的连续块。
- 页框是物理内存中的概念,是物理内存被划分成的固定大小的连续块,其大小通常与页的大小相同。
- 有了这种机制,CPU 便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个虚拟地址,在32位机上,其范围从0~4G-1。
- 操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每一对页和页框的映射关系,能让CPU间接的访问物理内存地址。
- 总结一下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若框干页,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。
2.物理内存管理
- 假设一个可用的物理内存有 4GB 的空间。按照一个页框的大小 4KB 进行划分, 4GB 的空间就是4GB/4KB = 1048576 个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统需要知道哪些页正在被使用,哪些页空闲等等。
- 内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使用了大量的联合体union
- 要注意的是 struct page 与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体,让我们来算算对所有这些物理页都这么做,到底要消耗掉多少内存。
- 算 struct page 占40个字节的内存,假定系统的物理页为 4KB 大小,系统有 4GB 物理内存。那么系统中共有 1MB 物理页,所以描述这么多物理页的 page 结构体消耗的内存只不过40MB ,相对系统 4GB 内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页,这个代价并不算太大。
- 要知道的是,页的大小对于内存利用和系统开销来说非常重要,页太大,页必然会剩余较大不能利用的空间(页内碎片)。页太小,虽然可以减小页内碎片的大小,但是页太多,会使得页表太长而占用内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常为 512B - 8KB,windows系统的页框大小为4KB。
- struct page mem_map[N]:管理这些page,每一个page有了下标,就可以转换为物理内存。物理内存 = 下标 * 4KB + 偏移量(mem_map);下标 = 物理内存 - 偏移量(mem_map) / 4KB。
- 文件内核缓冲区就是一个个页框page。
3.页表
页表中的每一个表项,指向一个物理页的开始地址。在 32 位系统中,虚拟内存的最大空间是 4GB,这是每一个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中就需要能够表示这所有的 4GB 空间,那么就一共需要 4GB/4KB = 1048576 个表项。如下图所示:
虚拟内存看上去被虚线“分割”成一个个单元,其实并不是真的分割,虚拟内存仍然是连续的。这个虚线的单元仅仅表示它与页表中每一个表项的映射关系,并最终映射到相同大小的一个物理页上。
页表中的虚拟地址与物理内存之间是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要虚拟地址是连续的就可以了,最终都能够通过页表找到实际的物理地址。
在 32 位系统中,地址的成度是 4 个字节,那么页表中的每一个页表项就是占用 4 个字节。所以页表占据的总空间大小就是:1048576*4 = 4MB 的大小。也就是说页表需要占用 4MB / 4KB = 1024 个物理页。这会存在哪些问题呢?
- 回想一下,当初为什么使用页表,就是要将进程划分为一个个物理页可以不用连续的存放在物理内存中,但是此时页表就需要1024个连续的物理页,似乎和当时的目标有点背道而驰了…
- 此外,根据局部性原理可知,很多时候进程在一段时间内只需要访问某几个物理页就可以正常运行了。因此也没有必要一次让所有的物理页都常驻内存。
解决需要大容量页表的最好方法是:把页表看成普通的文件,对它进行离散分配,即对页表再分页,由此形成多级页表的思想。为了解决这个问题,可以把这个单一页表拆分成 1024 个体积更小的映射表。如下图所示。这样一来,1024(每个表中的表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。
这里的每一个表,就是真正的页表,所以一共有 1024 个页表。一个页表自身占用 4KB,那么1024 页表一共就占用了 4MB 的物理内存空间,和之前没差别啊?从总数上看是这样,但是一个应用程序是不可能完全使用全部的 4GB 空间的,也许只要几十个页表就可以了。例如:一个用户程序的代码段、数据段、栈段,一共就需要 10 MB 的空间,那么使用 3 个页表就足够了。
计算过程:每一个页表项指向一个 4KB 的物理页,那么一个页表中 1024 个页表项,一共能覆盖 4MB 的物理内存。那么 10MB 的程序,向上对齐取整之后(4MB的倍数,就是12MB),就需要3个页表就可以了。
4.页目录结构
目前为止,每一个页框都被一个页表中的一个页表项来指向了,那么这 1024 个页表也需要被管理起来。管理页表的表称之为页目录表,形成二级页表。如下图所示:
- 所有页表的物理地址被页目录表项指向。
- 页目录表的物理地址被 CR3 寄存器 指向,这个寄存器中,保存了当前正在执行任务的页目录地址。
所以操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为用来保存程
序的页目录和页表分配物理内存。
5.两级页表的地址转换
下面将虚拟地址(0000000000, 0000000001, 11111111111)转换为物理地址的过程:
- 在32位处理器中,采用4KB的页大小,则虚拟地址中低12位为页偏移,剩下高20位分成两级,每个级别占10个bit,分给页目录表和页表。
- CR3 寄存器 读取页目录起始地址,再根据一级页号查找页目录表,找到页表在物理内存中存放位置。
- 根据⼆级页号查表,找到最终想要访问的物理页地址。
- 结合偏移量得到物理地址。
- 注:一个物理页的地址一定是 4KB 对齐的(最后的 12 位全部为 0),所以其实只需要记录物理页地址的高 20 位即可。
- 以上其实就是 MMU 的工作流程。MMU(Memory Manage Unit)是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之⼀。
- 所以页表中不需要存储虚拟地址。
到这里其实还有个问题,MMU要先进行两次页表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当页表变为N级时,就变成了N次检索+1次读写。可见,页表级数越多查询的步骤越多,对于CPU来说等待时间越长,效率越低。
让我们现在总结⼀下:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加一个中间层来解决。MMU 引入了新武器,江湖人称快表的 TLB(缓存)
当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存。但 TLB 容量比较小,难免发生 Cache Miss,这时候 MMU 还有保底的老武器页表,在页表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录一下刷新缓存。
6.缺页中断(异常)
设想,CPU 给 MMU 的虚拟地址,在 TLB 和页表都没有找到对应的物理页,该怎么办呢?其实这就是缺页异常 Page Fault,它是一个由硬件中断触发的可以由软件逻辑纠正的错误。
假如目标内存页在物理内存中没有对应的物理页或者存在但无对应权限,CPU 就无法获取数据,这种情况下CPU就会报告一个缺页错误。
由于 CPU 没有数据就无法进行计算,CPU罢工了用户进程也就出现了缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler 处理。
缺页中断会交给 PageFault Handler 处理,其根据缺页中断的不同类型会进行不同的处理:
- Hard Page Fault 也被称为 Major Page Fault,翻译为硬缺页错误/主要缺页错误,这时物理内存中没有对应的物理页,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立虚拟地址和物理地址的映射。
- Soft Page Fault 也被称为 Minor Page Fault,翻译为软缺页错误/次要缺页错误,这时物理内存中是存在对应物理页的,只不过可能是其它进程调入的,发出缺页异常的进程不知道而已,此时MMU只需要建立映射即可,无需从磁盘读取写入内存,一般出现在多进程共享内存区域。
- Invalid Page Fault 翻译为无效缺页错误,比如进程访问的内存地址越界访问,又比如对空指针解引用内核就会报 segment fault 错误中断进程直接挂掉。
- 问题:已知记录页框的地址只需要高20位bit位就行,那么低12位bit位是什么?
- 答案:标志位(是否命中,RWX权限、U/K权限)
当运行某一个程序时,不需要将全部的代码和数据加载到内存中,而是加载一部分,然后填充页表构建映射关系。当这些代码和数据访问完了,剩下的代码和数据不再内存中,此时再查页表发现不命中,MMU虚拟到物理内存转化失败,此时触发 “缺页中断”,操作系统会暂停当前程序的执行,将所需的页面从磁盘加载到物理内存中,填写页表构建映射关系,然后再恢复程序的执行。
- 如何理解 new 和 malloc:只需要申请堆区空间,填写页表(是否命中为0,物理地址为全0),当使用该空间时,触发缺页中断!
- 如何理解写时拷贝:父子进程的代码和数据是共享的,因为页表是共享的,只需要将数据修改为只读,当子进程写数据时,操作系统按照4KB为单位申请内存,拷贝数据。
- 申请内存的本质:申请虚拟地址空间 && 填充页表。此时进程管理和内存管理就解耦了,用户不需要关系物理内存,只需要交给操作系统就行!
如何区分缺页中断,还是越界(野指针)?
- 页号合法性检查:操作系统在处理中断或异常时,首先检查触发事件的虚拟地址的页号是否合法。如果页号合法但页面不在内存中,则缺页中断;如果页号非法,则越界访问。
- 内存映射检查:操作系统还可以检查触发事件的虚拟地址是否在当前进程的内存映射范围内。如果地址映射在范围内但是页面不在内存中,则缺页中断;地址映射不在范围内,则越界访问。
线程资源划分的真相:只需要将虚拟地址空间进行划分,进程资源就天然被划分好了。
3.线程的优点(面试题)
线程的优势:
- 创建一个新线程的代价要比创建一个新进程小得多。
- 线程占用的资源要比进程少。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多:
- 线程不需要切换虚拟内存空间和页表,而进程需要。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
- 线程切换不会扰乱处理器的缓存机制,而进程会。进程切换 TLB(快表) 中缓存的虚拟地址和物理地址的映射关系全都作废,需要从零加载,降低切换的效率。线程切换 TLB(快表) 不需要跟新。进程切换 cache 缓存的物理地址和代码、数据(4KB)的映射关系全部作废,需要从零加载,降低切换的效率。
进程和线程的优势:
- 能充分利用多处理器的可并行数量。
- 在等待慢速I/O操作结束的同时,程序可执行其它的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现(CPU个数 * 核数)
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
# 查看CPU信息
xzy@hcss-ecs-b3aa:~$ cat /proc/cpuinfo
4.线程的缺点
- 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高:由于资源共享,需要添加保护机制。编写与调试一个多线程程序比单线程程序困难得多。
5.线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发进程信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
6.线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率。
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
二.Linux进程VS线程
1.进程和线程
进程:进程是执行起来的程序,是系统进行资源分配的基本单位。每个进程都有自己独立的内存空间、系统资源(虚拟地址空间、文件描述符表、信号处理表等),操作系统会为每个进程分配独立的进程控制块(task_struct)来管理进程的资源和状态。
线程:线程是进程中的一个执行分支,是操作系统调度的基本单位。一个进程可以包含多个线程,这些线程共享进程的大部分资源,但也拥有自己的一部分数据:线程ID、一组寄存器(线程的上下文数据)、栈、errno、信号屏蔽字、调度优先级等。
2.进程的多个线程共享
同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表。
- 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
- 当前工作目录。
- 用户id和组id
进程和线程的关系如下图:
三.Linux线程控制
1.POSIX标准:线程库
在Linux内核中没有线程的概念,只有轻量级进程(LWP)的概念,线程是LWP模拟实现的。Linux操作系统不会给我们提供线程接口,只会提供轻量级进程的接口。
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
/* pid_t *parent_tid, void *tls, pid_t *child_tid */ )
其中的flag的不同取值决定是创建子进程,还是轻量级进程(LWP),创建线程的库函数 pthread_create 就是封装了 clone。
POSIX:可移植操作系统接口
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以 “pthread_” 打头的。
- 该系列是库函数而非系统调用,要使用这些库函数,要通过引入头文件 <pthread.h>
- 链接这些线程库(Linux系统自带的原生线程库,不属于C/C++标准库,而是第三方库)时要使用编译器命令的 “-lpthread” 选项。
2.线程创建
#include <pthread.h>
功能: 创建一个新的线程
原型: int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine) (void *),
void *arg);
参数:
thread: 返回线程ID
attr: 设置线程的属性, attr为nullptr表示使⽤默认属性
start_routine: 是个函数地址, 线程启动后要执行的函数
arg: 传给线程启动函数的参数
返回值: 成功返回0; 失败返回错误码
错误检查:
- 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量 errno 赋值以指示错误。
- pthread 函数出错时不会设置全局变量 errno(而大部分其它 POSIX 函数会这样做),而是将错误代码通过返回值返回。
- pthread 同样也提供了线程内的errno变量,以支持其它使用 errno 的代码。对于 pthread 函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的 errno 变量的开销更小。
// 获取线程ID
pthread_t pthread_self();
#include <iostream>
#include <string>
#include <cstring>
#include <thread>
#include <pthread.h>
#include <unistd.h>
void* run(void* args)
{
std::string name = static_cast<const char*>(args);
while(true)
{
std::cout << "new thread, name: " << name << ", pid: " << getpid() << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, run, (void*)"thread-1");
if(n != 0)
{
std::cout << "create thread error" << strerror(n) << std::endl;
return 1;
}
while(true)
{
std::cout << "main thread, pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
// C++11的线程库在头文件< thread >中,本质是对不同操作系统的线程库进行了封装,
// 在不同的操作系统中使用#include<thread>保证C++11线程库的跨平台性!
int main()
{
std::string name = "thread-1";
std::thread t([&](){
while(true)
{
std::cout << "new thread, name: " << name << ", pid: " << getpid() << std::endl;
sleep(1);
}
});
while(true)
{
std::cout << "main thread, pid: " << getpid() << std::endl;
sleep(1);
}
}
xzy@hcss-ecs-b3aa:~$ ./mythread
new thread, name: thread-1, pid: 627150, tid: 140363396011584
main thread, pid: 627150, tid: 140363396027328
new thread, name: thread-1, pid: 627150, tid: 140363396011584
main thread, pid: 627150, tid: 140363396027328
new thread, name: thread-1, pid: 627150, tid: 140363396011584
main thread, pid: 627150, tid: 140363396027328
- 打印出来的 tid 是通过 pthread 库中有函数 pthread_self 得到的,它返回一个 pthread_t 类型的变量,指代的是调用 pthread_self 函数的线程的“ID”。怎么理解这个“ID”呢?这个“ID”是 pthread 库给每个线程定义的进程内唯一标识,是 pthread 库维持的。
- 由于每个进程有自己独立的内存空间,故此“ID”的作用域是进程级而非系统级(内核不认识)。其实pthread库也是通过内核提供的系统调用(例如clone)来创建线程的,而内核会为每个线程创建系统全局唯一的“ID”来唯一标识这个线程。
使用 PS 命令查看线程信息:
# ps -aL: 查看轻量级进程(LWP)
xzy@hcss-ecs-b3aa:~$ ps -aL | head -1 && ps -aL | grep mythread
PID LWP TTY TIME CMD
627150 627150 pts/0 00:00:00 mythread
627150 627151 pts/0 00:00:00 mythread
- LWP 是什么呢?LWP 得到的是真正的线程ID。之前使用 pthread_self 得到的这个数实际上是一个地址,在虚拟地址空间上的一个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线程ID,线程栈,寄存器等属性。
- 在 ps -aL 得到的线程ID,有一个线程ID和进程ID相同,这个线程就是主线程,主线程的栈在虚拟地址空间的栈上,而其它线程的栈在是在共享区(堆栈之间),因为 pthread 系列函数都是 pthread 库提供给我们的。而 pthread 库是在共享区的。所以除了主线程之外的其它线程的栈都在共享区。
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
int gval = 100;
std::string toHex(pthread_t tid)
{
char buffer[64];
snprintf(buffer, sizeof(buffer), "0x%lx", tid);
return buffer;
}
// 不可重入函数: 被多个执行流执行
void *routine1(void *args)
{
std::string name = static_cast<const char *>(args);
while (true)
{
std::cout << name << " tid: " << toHex(pthread_self()) << "全局变量(修改): " << gval << std::endl;
gval++;
sleep(1);
int *p = nullptr;
*p = 10;
}
}
void *routine2(void *args)
{
std::string name = static_cast<const char *>(args);
while (true)
{
std::cout << name << " tid: " << toHex(pthread_self()) << "全局变量(检测): " << gval << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_create(&tid1, nullptr, routine1, (void *)"thread-1");
pthread_t tid2;
pthread_create(&tid2, nullptr, routine2, (void *)"thread-2");
while (true)
{
std::cout << "main thread pid: " << toHex(pthread_self()) << std::endl;
sleep(1);
}
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./mythread
main thread pid: thread-1 tid: 0x7f4cdcdb96400x7f4cdcdbd3c0全局变量(修改):
100
thread-2 tid: 0x7f4cdc5b8640, 全局变量(检测): 101
thread-2 tid: 0x7f4cdc5b8640, 全局变量(检测): 101
Segmentation fault (core dumped)
结论:
- 新线程和主线程谁先运行,不确定;父进程和子进程谁先运行,也不确定。
- 线程创建出来,要对进程的时间片进行瓜分。
- 不加保护的情况下,显示器文件就是线程的共享资源,打印到显示器中的数据是乱序的。
- 栈区、共享区、堆区、数据区和代码区,线程都是共享的,但是需要获取地址,才能访问/修改该空间。因为虚拟地址空间是共享的。
- 线程一但出现异常,可能会导致其它线程全面崩溃:任何一个线程都是进程的一个执行分支,线程出错,等同进程出错。例如:线程出现野指针,就是页表查失败了,MMU报错,CPU内部触发软中断,根据中断号,查中断向量表,进行异常处理,给目标进程发信号,信号的处理方式每一个线程是共享的,所以每个线程都退出了。
- 线程创建之后,也是要被等待和回收的:类似僵尸进程的问题,为了知道线程的执行结果。
3.线程退出
如果需要只终止某个线程而不终止整个进程,有三种方法:
- 线程函数 return。但是主线程 return 相当于调用 exit,进程退出。
- 线程可以调用 pthread_exit 终止自己。
- 一个线程可以调用 pthread_cancel 终止同一进程中的另一个线程(不推荐使用)
功能: 线程退出
原型: void pthread_exit(void *retval);
参数retval: 线程退出时的返回值
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
void *start(void *args)
{
while (true)
{
sleep(1);
break;
}
pthread_exit((void *)10);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start, (void *)"thread-1");
void *ret = nullptr;
pthread_join(tid, &ret);
std::cout << "new thread exit code: " << (long long int)ret << std::endl;
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./mythread
new thread exit code: 10
注意:
- pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc/new 分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了,导致野指针问题。
- 只要某一个线程调用 exit,进程直接退出,导致所有的线程全部退出。
功能: 线程退出
原型: int pthread_cancel(pthread_t thread);
参数thread: 线程id
返回值: 成功返回0; 失败返回错误码
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
void *start(void *args)
{
while (true)
{
std::cout << "I am a new thread" << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start, (void *)"thread-1");
sleep(5);
pthread_cancel(tid);
std::cout << "取消线程: " << tid << std::endl;
sleep(5);
void *ret = nullptr;
pthread_join(tid, &ret);
std::cout << "new thread exit code: " << (long long int)ret << std::endl;
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./mythread
I am a new thread
I am a new thread
I am a new thread
I am a new thread
I am a new thread
取消线程: 140085988091456
new thread exit code: -1
注意:线程被取消时的返回值是 -1,#define PTHREAD_CANCELED ((void *) -1)
,是一个宏。
4.线程等待
为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
功能: 等待线程结束
原型: int pthread_join(pthread_t thread, void **retval);
参数thread: 线程ID
参数value_ptr: 输出型参数, 指向线程的返回值
返回值: 成功返回0; 失败返回错误码
调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止。thread线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:
- 如果 thread 线程通过 return 返回,value_ptr 所指向的单元里存放的是 thread 线程函数的返回值。
- 如果 thread 线程被别的线程调用 pthread_cancel 异常终掉,value_ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED,该宏为 -1。
- 如果 thread 线程是自己调用 pthread_exit 终止的,value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
- 如果对 thread 线程的终止状态不感兴趣,可以传 nullptr 给 value_ptr 参数。
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
class ThreadData
{
public:
ThreadData(const std::string name, int a, int b)
: _name(name), _a(a), _b(b)
{}
~ThreadData()
{}
void Excute() { _result = _a + _b; }
int Result() { return _result; }
std::string Name() { return _name; }
private:
std::string _name;
int _a;
int _b;
int _result;
};
void *routine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
std::cout << "我是新线程, 我的名字是: " << td->Name() << ", my tid is: " << pthread_self() << std::endl;
td->Excute();
sleep(1);
break;
}
return (void *)td;
}
int main()
{
ThreadData *td = new ThreadData("thread-1", 10, 20);
pthread_t tid;
pthread_create(&tid, nullptr, routine, (void *)td);
ThreadData *rtd = nullptr;
int n = pthread_join(tid, (void **)&rtd); // 保证执行完毕, 任务一定处理完了, 结果变量一定写入了
if (n != 0)
{
std::cout << "join error: " << n << ", " << strerror(n) << std::endl;
return 1;
}
std::cout << "join success, ret: " << rtd->Result() << std::endl;
delete td;
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./mythread
我是新线程, 我的名字是: thread-1, my tid is: 139716701619776
join success, ret: 30
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine) (void *),
void *arg);
int pthread_join(pthread_t thread, void **retval);
总结:
- 参数 arg 和 回调函数 start_routine 的返回值可以是:变量,数值,对象(全局区/堆区,不能是栈区,会导致野指针问题)
- 参数 retval 指向回调函数 start_routine 的返回值。
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
class ThreadData
{
public:
ThreadData() {}
~ThreadData() {}
void Init(const std::string name, int a, int b)
{
_name = name;
_a = a;
_b = b;
}
void Excute() { _result = _a + _b; }
int Result() { return _result; }
std::string Name() { return _name; }
void SetTid(pthread_t tid) { _tid = tid; }
pthread_t GetTid() { return _tid; }
int GetA() { return _a; }
int GetB() { return _b; }
private:
std::string _name;
int _a;
int _b;
int _result;
pthread_t _tid;
};
void *routine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
std::cout << "我是新线程, 我的名字是: " << td->Name() << ", my tid is: " << pthread_self() << std::endl;
td->Excute();
sleep(1);
break;
}
return (void *)td;
}
#define NUM 10
int main()
{
ThreadData td[NUM];
// 1.初始化数据
for (int i = 0; i < NUM; i++)
{
char buffer[64];
snprintf(buffer, sizeof(buffer), "thread-%d", i);
td[i].Init(buffer, i * 10, i * 20);
}
// 2.创建多个线程
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, routine, (void *)&td[i]);
td[i].SetTid(tid);
}
// 3.等待多个线程
for (int i = 0; i < NUM; i++)
{
ThreadData *rtd = nullptr;
pthread_join(td[i].GetTid(), (void **)&rtd); // 本质: rtd等价于routine函数的返回值(rtd = &td[i])
}
// 4.获取结果
for (int i = 0; i < NUM; i++)
{
printf("td[%d]: %d + %d = %d, tid = %ld\n", i, td[i].GetA(), td[i].GetB(), td[i].Result(), td[i].GetTid());
}
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./mythread
我是新线程, 我的名字是: thread-0, my tid is: 139732258440768
我是新线程, 我的名字是: thread-2, my tid is: 139732241655360
我是新线程, 我的名字是: thread-3, my tid is: 139732233262656
我是新线程, 我的名字是: thread-5, my tid is: 139732216477248
我是新线程, 我的名字是: thread-8, my tid is: 139732122068544
我是新线程, 我的名字是: thread-7, my tid is: 139732130461248
我是新线程, 我的名字是: thread-1, my tid is: 139732250048064
我是新线程, 我的名字是: thread-4, my tid is: 139732224869952
我是新线程, 我的名字是: thread-9, my tid is: 139732113675840
我是新线程, 我的名字是: thread-6, my tid is: 139732208084544
td[0]: 0 + 0 = 0, tid = 139732258440768
td[1]: 10 + 20 = 30, tid = 139732250048064
td[2]: 20 + 40 = 60, tid = 139732241655360
td[3]: 30 + 60 = 90, tid = 139732233262656
td[4]: 40 + 80 = 120, tid = 139732224869952
td[5]: 50 + 100 = 150, tid = 139732216477248
td[6]: 60 + 120 = 180, tid = 139732208084544
td[7]: 70 + 140 = 210, tid = 139732130461248
td[8]: 80 + 160 = 240, tid = 139732122068544
td[9]: 90 + 180 = 270, tid = 139732113675840
5.线程分离
- 问题:如果新线程不退出,主线程想要做自己的事情?而线程没有非阻塞轮询方式,那岂不是主线程需要一直等待新线程?
- 答案:可以不等待新线程 -> 将目标线程设置为分离状态。线程被等待状态有两种:1. joinable:线程需要被等待;2. detach:线程不需要被等待(线程分离)
注意:在多执行流情况下,主执行流是最后退的!
- 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,pthread_join 是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
功能: 分离线程
原型: int pthread_detach(pthread_t thread);
参数thread: 线程ID
返回值: 成功返回0; 失败返回错误码
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是 joinable 又是分离的。
#include <iostream>
#include <pthread.h>
#include <cstring>
#include <unistd.h>
void* start(void* args)
{
while(true)
{
std::cout << "I am a new thread" << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start, (void*)"thread-1");
pthread_detach(tid);
sleep(5);
// 分离后的线程等待失败, 返回错误码
int n = pthread_join(tid, nullptr);
if(n!=0)
{
std::cout << "join error, n: " << n << ", " << strerror(n) << std::endl;
}
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
I am a new thread
I am a new thread
I am a new thread
I am a new thread
I am a new thread
join error, n: 22, Invalid argument
注意:线程不能进行程序替换(exec*),但是可以创建子进程进行程序替换。
四.线程ID、进程地址空间布局
- pthread_create 函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和轻量级进程(LWP)ID不是一回事。
- 轻量级进程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
- pthread_create 函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
#include <iostream>
#include <string>
#include <pthread.h>
#include <cstring>
#include <unistd.h>
std::string toHex(pthread_t tid)
{
char buffer[64];
snprintf(buffer, sizeof(buffer), "0x%lx", tid);
return buffer;
}
void* start(void* args)
{
while(true)
{
std::cout << "I am a new thread, my thread id: " << toHex(pthread_self()) << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start, (void*)"thread-1");
std::cout << "I am a main thread, my thread id: " << toHex(pthread_self()) << std::endl;
pthread_join(tid, nullptr);
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
I am a main thread, my thread id: 0x7f67c2b473c0
I am a new thread, my thread id: 0x7f67c2b43640
I am a new thread, my thread id: 0x7f67c2b43640
I am a new thread, my thread id: 0x7f67c2b43640
I am a new thread, my thread id: 0x7f67c2b43640
...
- Linux中没有线程,只有轻量级进程(LWP),用户要使用线程,只能创建轻量级进程,如果我想要线程的属性?例如:id、优先级、状态、栈大小。这些属性在内核LWP中能获取,即便如此,也要在用户级线程库中维护起来,无需用户调用系统调用。实现软件上的解耦!
- 线程库要对线程的属性进行管理,如何管理?先描述,在组织!
- pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t 类型的线程ID,本质就是一个进程地址空间上的一个地址。
五.线程局部存储
当每个线程需要维护自己的状态信息,而这些信息不应该被其他线程访问或修改时,可以使用线程局部存储。例如,每个线程可能需要记录自己的执行次数、错误信息等。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
// 线程局部存储: 编译时, 每一个线程都开辟自己的share_value
// 注意: 只能修饰内置类型
__thread int share_value = 100;
void *start(void *args)
{
while (true)
{
printf("I am a new thread, share_value: %d, &share_value: %p\n", share_value, &share_value);
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start, (void *)"thread-1");
while (true)
{
printf("I am a main thread, share_value: %d, &share_value: %p\n", share_value, &share_value);
share_value += 10;
sleep(1);
}
pthread_join(tid, nullptr);
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
I am a main thread, share_value: 100, &share_value: 0x7f8388b503bc
I am a new thread, share_value: 100, &share_value: 0x7f8388b4c63c
I am a new thread, share_value: 100, &share_value: 0x7f8388b4c63c
I am a main thread, share_value: 110, &share_value: 0x7f8388b503bc
I am a new thread, share_value: 100, &share_value: 0x7f8388b4c63c
I am a main thread, share_value: 120, &share_value: 0x7f8388b503bc
...
六.线程栈
虽然 Linux 将线程和进程不加区分的统一到了 task_struct,但是对待其地址空间的 stack 还是有些区别的:
- 对于 Linux 进程或者说主线程,简单理解就是main函数的栈空间,在fork的时候,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝(cow)以及动态增长。如果扩充超出该上限则栈溢出会报段错误(发送段错误信号给该进程)。进程栈是唯一可以访问未映射页而不一定会发生段错误⸺超出扩充上限才报。
- 然而对于主线程生成的子线程而言,其 stack 将不再是向下生长的,而是事先固定下来的。线程栈一般是调用线程库接口 pthread_create 创建的线程,在文件映射区(共享区),其中使用 mmap 系统调用获得 stack。这种 stack 不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。
因此,对于子线程的 stack,它其实是在共享区中 map 出来的一块内存区域,原则上是线程私有的,但是同一个进程的所有线程生成的时候,是会浅拷贝生成者的 task_struct 的很多字段,如果愿意,其它线程也还是可以访问到的。
七.线程封装
目标:封装原生线程库
// Makefile
bin=testThread
cc=g++
src=$(wildcard *.cc)
obj=$(src:.cc=.o)
$(bin):$(obj)
$(cc) -o $@ $^ -lpthread
%.o:%.cc
$(cc) -c $< -std=c++17
.PHONY:clean
clean:
rm -rf $(bin) $(obj)
// Pthread.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
namespace ThreadModule
{
using func_t = std::function<void()>;
static int number = 1;
// 强类型枚举: 枚举的成员名称被限定在枚举类型的作用域内
enum class TSTATUS
{
NEW,
RUNNING,
STOP
};
class Thread
{
private:
// 成员方法: 需要加上static表示不需要this指针, 否则回调函数报错
// 而要执行_func()函数又需要由this指针, 所以Routine函数传this指针
static void *Routine(void *args)
{
Thread *t = static_cast<Thread *>(args);
t->_func();
return nullptr;
}
void EnableDetach()
{
_joinable = false;
}
public:
Thread(func_t func)
: _func(func), _status(TSTATUS::NEW), _joinable(true)
{
_name = "Thread-" + std::to_string(number++);
_pid = getpid();
}
~Thread() {}
// 线程创建
bool Start()
{
if (_status != TSTATUS::RUNNING)
{
int n = pthread_create(&_tid, nullptr, Routine, this);
if (n != 0)
return false;
_status = TSTATUS::RUNNING;
return true;
}
return false;
}
// 线程退出
bool Stop()
{
if (_status == TSTATUS::RUNNING)
{
int n = ::pthread_cancel(_tid);
if (n != 0)
return false;
_status = TSTATUS::STOP;
return true;
}
return false;
}
// 线程等待
bool Join()
{
if (_joinable)
{
int n = ::pthread_join(_tid, nullptr);
if (n != 0)
return false;
_status = TSTATUS::STOP;
return true;
}
return false;
}
// 线程分离
bool Detach()
{
EnableDetach();
int n = ::pthread_detach(_tid);
if (n != 0)
return false;
return true;
}
// 线程是否分离
bool IsJoinable()
{
return _joinable;
}
std::string Name()
{
return _name;
}
private:
std::string _name;
pthread_t _tid;
pid_t _pid;
bool _joinable; // 线程是否是分离的, 默认不是
func_t _func;
TSTATUS _status;
};
}
// Main.cc
#include <iostream>
#include <unordered_map>
#include <memory>
#include "Pthread.hpp"
#define NUM 10
using thread_ptr = std::shared_ptr<ThreadModule::Thread>;
int main()
{
std::unordered_map<std::string, thread_ptr> threads;
// 创建多线程
for (int i = 0; i < NUM; i++)
{
thread_ptr t = std::make_shared<ThreadModule::Thread>([](){
while (true)
{
std::cout << "Hello World" << std::endl;
sleep(1);
}
});
threads[t->Name()] = t;
}
for (auto &thread : threads)
{
thread.second->Start();
}
for (auto &thread : threads)
{
thread.second->Join();
}
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./testThread
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
Hello World
...
模版: 传参数的版本
// Pthread.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
namespace ThreadModule
{
static int number = 1;
// 强类型枚举: 枚举的成员名称被限定在枚举类型的作用域内
enum class TSTATUS
{
NEW,
RUNNING,
STOP
};
template<class T>
class Thread
{
using func_t = std::function<void(T)>;
private:
// 成员方法: 需要加上static表示不需要this指针, 否则回调函数报错
// 而要执行_func()函数又需要由this指针, 所以Routine函数传this指针
static void *Routine(void *args)
{
Thread<T> *t = static_cast<Thread<T> *>(args);
t->_func(t->_data);
return nullptr;
}
void EnableDetach()
{
_joinable = false;
}
public:
Thread(func_t func, T data)
: _func(func), _data(data), _status(TSTATUS::NEW), _joinable(true)
{
_name = "Thread-" + std::to_string(number++);
_pid = getpid();
}
~Thread() {}
// 线程创建
bool Start()
{
if (_status != TSTATUS::RUNNING)
{
int n = pthread_create(&_tid, nullptr, Routine, this); // 传入this指针
if (n != 0)
return false;
_status = TSTATUS::RUNNING;
return true;
}
return false;
}
// 线程退出
bool Stop()
{
if (_status == TSTATUS::RUNNING)
{
int n = ::pthread_cancel(_tid);
if (n != 0)
return false;
_status = TSTATUS::STOP;
return true;
}
return false;
}
// 线程等待
bool Join()
{
if (_joinable)
{
int n = ::pthread_join(_tid, nullptr);
if (n != 0)
return false;
_status = TSTATUS::STOP;
return true;
}
return false;
}
// 线程分离
bool Detach()
{
EnableDetach();
int n = ::pthread_detach(_tid);
if (n != 0)
return false;
return true;
}
// 线程是否分离
bool IsJoinable()
{
return _joinable;
}
std::string Name()
{
return _name;
}
private:
std::string _name;
pthread_t _tid;
pid_t _pid;
bool _joinable; // 线程是否是分离的, 默认不是
func_t _func;
TSTATUS _status;
T _data;
};
}
// Main.cc
#include <iostream>
#include <unordered_map>
#include <memory>
#include "Pthread.hpp"
class ThreadData
{
public:
int start;
int end;
};
void count(ThreadData td)
{
for(int i = td.start; i < td.end; i++)
{
std::cout << "i == " << i << std::endl;
sleep(1);
}
}
int main()
{
ThreadData td;
td.start = 10;
td.end = 20;
ThreadModule::Thread<ThreadData> t(count, td);
t.Start();
t.Join();
return 0;
}
xzy@hcss-ecs-b3aa:~$ ./testThread
i == 10
i == 11
i == 12
i == 13
i == 14
i == 15
i == 16
i == 17
i == 18
i == 19