本篇博客整理了Linux下线程的概念、线程控制的相关接口,旨在让读者初步认识线程,并为下一篇多线程作铺垫。
目录
一、线程是什么
1.线程是进程的执行流
2.线程的执行、调度、切换
3.页表分级与线程资源分配
4.线程的优缺点
二、线程控制
1.创建线程
2.获取线程TID
3.等待线程
4.分离线程
5.终止线程
6.取消线程
补.全局函数能被多个线程同时调用
补.全局变量在线程间共享
补.线程的局部存储
补.简要说明 C++11 的线程库
一、线程是什么
1.线程是进程的执行流
线程是什么?简单来说,一个线程是一个进程的一个执行流。
读起来是绕口的文字,但如果换个描述方式,以生活为鉴,就会更容易理解:
在这个世界上有许许多多个人类,他们有着许许多多种命运,无论悲欢,无论离合。人们会组建家庭,以家庭为单位在这世界上生活。每个家庭有每个家庭的命运,每个家庭成员也有每个家庭成员的命运。也许当下正在发生着的,有的家庭正在迎接一个新生命的到来,尽管身为家庭成员,新上任的父亲因柴米油盐而有些紧张,新上任的母亲因生产而有些疲惫,这个新生命还在因面对陌生的事物而哇哇啼哭,但此时此刻,这个家庭是温馨喜悦的。
在以生活为鉴后,又回到进程与线程上来,其实一个进程就类似于一个家庭,进程要经历的类似于家庭的命运;而一个线程就类似于一个家庭成员,线程要经历的就类似于家庭成员的命运,且每个线程要经历的未必相同,就类似于每个家庭成员都有各自的命运。
如果从纯概念出发,线程又该作何解呢?
进程之间是互相独立的,在运行时互不干扰,这都要归功于,每个进程都有自己独立的进程控制块、进程地址空间、页表、文件描述符表、pending表、block表、hanlder表、上下文结构等。那么,在 Linux 中,一个进程就是由一个进程控制块(task_struct)来指代的,这样理解是可以的吗?
其实不然,其实是多个进程控制块(task_struct)在共同指代一个进程。但如果说一个进程地址空间、或一个页表可以指代一个进程,这样理解是没问题的。
实际上,在 Linux 中,一个进程只拥有一个进程地址空间、一张页表、一份代码和数据,但拥有多个 task_struct 对象,这多个 task_struct 对象指向了同一个进程地址空间。而这多个 task_struct 对象,就可以理解为是多个线程。
线程在进程内部运行,是进程的一个执行分支,本质是说,线程在进程地址空间中运行,一个进程的所有资源是被它的所有线程共享的,它的每一个线程都是它当中的一个执行流。
在操作系统中会存在大量的进程,而一个进程内又存在一个或多个线程,因此,线程的数量一定比进程的数量更多,线程的执行粒度总体也要比进程更精细。
按理来说,如果要支持线程,就需要操作系统对这些线程进行管理,例如创建线程、终止线程、调度线程、切换线程、给线程分配资源、释放资源等等......而这一套管理体系需要另起炉灶,需要有一套与进程平行的线程管理模块,因此,操作系统的设计会相当复杂。
但在 Linux 中,并没有为线程单独再设计数据结构,而是直接复用了进程的进程控制块,使描述线程的控制块和描述进程的控制块是类似的,因此在Linux中,所有执行流也被叫作轻量级进程。
从技术的发展角度来看,进程理应比线程诞生得更早,相关的接口也完善得更早,那么,重写线程的接口,会花费更高的成本去开发、测试、维护,还会伴随许多不稳定因素。而线程与进程之间有着极大的相似程度,Linux 由此复用了进程的接口,节省了大量成本。但这样也就提高了线程与进程之间的耦合度,所以,为了让用户更方便地理解和使用,Linux 还封装了一套用户特供的原生线程库。
【Tips】线程是进程的执行流
- 线程在进程地址空间中运行,是进程的一个执行分支。
- 一个进程的所有资源是被它的所有线程共享的(但每个线程也拥有自己的数据,如
线程 ID、线程上下文、栈、错误码、信号屏蔽子、调度优先级等),进程的每一个线程都是它当中的一个执行流。
- 进程可以分为单执行流进程和多执行流进程,单执行流进程内部只有一个线程,多执行流进程内部有多个线程。
进程至少有一个线程,其中,main() 里的执行流,叫做主线程,其余线程叫做子线程或副线程。
- 由于线程的数量一定比进程的数量更多,因此线程的执行粒度总体要比进程更精细。
- 一个进程PCB可以指代一个线程或一个单执行流进程,多个进程PCB可以明确指代一个多执行流进程。
- 一个进程地址空间或一个页表可以指代一个进程,因为每个进程只有一个进程地址空间和一个页表。
- 站在内核的角度,进程是分配资源的基本实体,线程之间以浅拷贝的方式共享进程资源。
- CPU无法区分它所调度的进程PCB是进程还是线程,CPU仅关注一个个独立的执行流,且无论进程内部只有一个执行流还是有多个执行流,CPU都以进程PCB为单位进行调度,也就是说,线程是CPU调度的基本单位。
合理使用多线程,可以提高 CPU 密集型程序的执行效率,可以提高 I/O 密集型程序的用户体验。
单个线程中,如果出现除零错误、野指针问题就会导致线程崩溃,进而导致进程崩溃。
线程是进程的执行分支,线程出异常,进程也会出异常,进而触发信号机制,终止进程和退出进程内的所有线程。
2.线程的执行、调度、切换
- 线程如何执行
线程是进程的执行流,而执行流本质就是函数栈帧。每个线程都维护着自己的栈结构,执行流在栈上运行,执行流的执行就是栈帧不断创建与销毁的过程。其中,主线程 main() 的栈帧创建于栈区,其余子线程的栈帧创建于共享区。
多个线程在执行,就是多个执行流在执行,例如在一个软件应用打开了一个进程,在上面同时听音乐和读文字,这“听音乐”和“读文字”就是两个执行流,它们同时执行又互不影响,这就是多线程的作用。
- 线程如何调度
进程与进程之间可以通过PID来区分,每个进程有且仅有一个独属于它的PID,封装在进程控制块 task_struct 中。尽管同一个进程可以拥有多个 task_struct ,但同一个进程的 task_struct 封装的PID是相同的。
线程与线程之间可以通过TID来区分,每个线程有且仅有一个独属于它的TID,也封装在进程控制块 task_struct 中。对于一个进程中的多个线程,由于进程的每一个 task_struct 指代了进程中的每一个线程,所以同一个进程中的线程拥有相同的PID,但它们的TID是不同的。
CPU都以 task_struct 为单位进行调度,而通过被调度的 task_struct 既可以找到进程,也可以找到轻量级进程,即线程,既然如此,在多个拥有相同PID的 task_struct 中,派出一个在运行队列或等待队列中排队即可,而剩余的 task_struct 用双向链表管理起来。
- 线程如何切换
要了解线程如何切换,首先要了解进程是如何切换的。
进程是在CPU上进行切换的,切换的是进程的上下文,例如进程PID、进程地址空间与页表、进程的代码和数据、寄存器信息、文件相关信息,信号相关信息、内存资源等。
进程上下文的作用是,保存进程执行任务的进度。一个进程在CPU上的运行时间是有限的,由时间片决定,当进程分到的时间片到期,哪怕进程的任务还未完成,操作系统都会将其从CPU上剥离,因此,为了保证进程下一次还能继续执行未完的任务,就需要保存执行任务的进度,以便之后进程再被加载时恢复任务进度,于是就有了进程上下文。
进程切换的流程分为三步:
- 保存当前进程的上下文;
- 加载下一个进程的上下文;
- 加载下一个进程的资源,之后就是新加载到CPU上的进程在执行任务了。
线程的切换就是执行流的切换,也发生在CPU上,切换的是线程的上下文,例如线程TID、栈帧相关的寄存器信息(eax、ebx、ecx、pc指针等)、当前线程的权限(用户态或内核态)、线程的局部存储(存储了线程自己的数据)等。
线程切换的流程也分为三步:
- 保存当前线程的上下文;
- 加载下一个线程的上下文;
- 加载下一个线程的资源,之后就是新加载到CPU上的线程在执行了。
【Tips】线程切换的资源与进程切换的资源
- 线程除了拥有共享的进程资源,还拥有属于自己的独立资源。
- 进程切换需要切换进程地址空间,线程切换无须切换进程地址空间,仅需切换与自己执行相关的资源即可。
- 属于同一进程的线程,它们的资源是在相同的进程地址空间中,也就是说,线程可以访问彼此间的资源,独立性较弱,且天然具备通信功能。
3.页表分级与线程资源分配
页表的作用是,建立和存储进程地址空间中的虚拟地址与真实内存中的物理地址之间的映射关系,以使数据和资源可以正常被访问和修改。
事实上,所谓的页表并不纯粹是一张“表”,而是多张“表”的组合。在 Linux 中,32 位平台所用的是二级页表,而 64 位平台所用的是多级页表。
以 32 位平台为例:
32 位平台中总共有 2^32 个地址,也就是说,有 2^32 个地址需要被映射。
页表的每一个表项中,除了要存虚拟地址与其映射的物理地址之外,实际还存有一些权限信息,以区分用户级页表和内核级页表。
每个表项中,存储一个物理地址和一个虚拟地址需要 8 个字节的空间,再加上权限等信息,就算每个表项所需的空间大小仅按 10 个字节计算,一张页表总共也需要 2^32 乘以 10 个字节的空间,也就是 40 GB的空间,然而,32 位平台下的内存可能总共才 4 GB,根本无法将这样一张页表存储起来。因此,所谓的页表肯定不纯粹是一张“表”。
那么,所谓的页表是多张“表”的组合,以及页表真实的映射方式,又该如何理解呢?
为了方便读写数据,物理内存实际被划分为一个个 4 KB大小的页框,以对应磁盘上被划分为一个个4KB大小的页帧,使内存和磁盘时以 4 KB大小为单位进行数据交换。这里的 4 KB,是相关领域的科学家经过大量实验,从而得出的一个各方面均优秀的数值。
4 KB经过换算,是 2^12 个字节,也就是说,内存中一个页框有 2^12 个字节的空间大小。访问物理地址的基本单位是 1 字节,因此,一个页框就能存 2^12 个地址。
在 Linux 的 32 位平台下的页表,由页目录和二级页表组成,二级页表可能有多个,其中每一个表项都映射了物理内存中的一个页框(映射的是起始地址,因为一个二级页表最多有 1 KB个条目,只能指向 1 KB个页框),而页目录只有一个,其中每一个表项都指向一个二级页表的起始地址。
32 位平台下,一个虚拟地址有 32 个比特位,其中——
- 地址的第 1 位到第 10 位(共 10 个比特位)作为页目录的下标,通过下标可以获取页目录中一个二级页表的起始地址,从而找到相应的二级页表;
- 第 11 位到第 20 位(共 10 个比特位)作为二级页表的下标,通过下标可以获取二级页表中一个页框的起始地址,从而找到物理内存中相应的页框;
- 第 21 位到第 32 位(共 12 个比特位)作为偏移量,通过在页框的起始地址上进行偏移,就可以得到数据在内存中具体的物理地址(偏移量有 12 个比特位,最大有 2^12 = 4KB 的空间,这也是页框和页帧设计为4KB的原因之一)。
上述虚拟地址到物理地址的映射过程,物理地址实际是通过“多级页表 + 起始地址 + 偏移量”来标识的,同时也采用了软硬件结合的映射方式,其中,软件映射由页表完成,硬件映射由集成在 CPU 中的 MMU(Memory Management Unit)完成。
【Tips】进程地址空间与页表
- 进程地址空间是进程的资源窗口,使每个进程都认为自己有 4 GB的资源。
- 页表决定进程真正拥有的资源。
- 对进程地址空间和页表进行了合理的资源划分,就能实现对一个进程所有资源的分类。
- 页目录和二级页表也是由操作系统管理的资源,一个进程的创建会伴随着一个页目录的创建,但一个二级页表只有在页目录中存在才会被创建,且被创建的二级页表大部分时候是不全的。
【Tips】站在进程地址空间的角度,分配线程资源本质是在分配地址空间的范围。
4.线程的优缺点
【Tips】线程的优点:
- 创建一个新线程的代价要比创建一个新进程更小。
- 与进程切换相比,操作系统对于线程切换的工作量更少。
- 线程占用的资源要比进程更少。
- 线程能充分利用多处理器的可并行数量。
- 在等待慢速 I/O 结束期间,线程可以使程序执行其他的计算任务,提升效率。
- 计算密集型是指,执行流的任务以计算为主,如加密解密、大数据查找等。为了能在多处理器系统上运行计算密集型应用,会将计算任务分解到多个线程中实现。
- IO密集型是指,执行流的任务以IO为主,如刷新磁盘、访问数据库、访问网络等。为了提高I/O 密集型应用的性能,会让线程同时等待不同的 I/O 操作,以使 I/O 操作重叠。
【Tips】线程的缺点:
- 可能导致性能损失:一个很少被外部事件阻塞的计算密集型线程,往往无法与其它线程共享同一个处理器,如果计算密集型线程的数量比可用的处理器多,那么可能会造成较大的性能损失,这里的性能损失主要是指,在可用资源不变的基础上,增加了额外的同步和调度的开销。
- 可能降低程序的健壮性:编写多线程需要更全面深入的考虑,在一个线程程序里,因时间分配上的细微偏差,或共享了不该共享的变量,而造成不良影响的可能性是很大的,换句话说,就是线程之间缺乏安全保护。
- 缺乏访问控制:进程是访问控制的基本粒度,在进程的一个线程中调用某些 OS 的接口,可能会对整个进程造成影响。
- 提高编程难度:一个多线程程序的编写与调试,比单线程程序更加困难。
二、线程控制
线程与进程之间有着极大的相似程度,Linux 由此复用了进程的接口,节省了大量成本。但这样也就提高了线程与进程之间的耦合度,所以,为了让用户更方便地理解和使用,Linux 还封装了一套用户特供的原生线程库,库中是线程相关的系统调用接口。
pthread 线程库是应用层的原生线程库——
- “应用层”是指,这个线程库不是系统接口直接提供的,而是由第三方为用户提供的。
- “原生”指的是大部分 Linux 系统都默认携带 pthread 线程库。
- 与线程有关的接口构成了一个完整的系列,绝大部分的命名以“pthread_”打头。
- 要使用这些接口,需引入头文件<pthread.h>。
- 链接 pthread 线程库时,编译器指令要特别添加参数 -lpthread。
- pthread 系统接口有特别的错误检查——传统的接口是,调用成功返回0,失败返回-1且对全局变量errno赋值以指示错误,而 pthread 系列接口出错时,是将错误代码通过返回值返回,尽管这并不意味着 pthread 系列不提供线程内的 errno 变量,以支持其他涉及 errno 的代码,但由于读取返回值要比读取线程内 errno 变量的开销更小,因此更加建议通过返回值来判定 pthread 系列的错误。
1.创建线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
功能:在进程的主线程中创建一个新的子线程
参数:1.thread:输出型参数,是一个 pthread_t 类型变量的地址,用于获取创建成功的线程的TID。
2.attr:用于设置线程的属性,不关心设为 NULL(默认属性)即可。
3.stat_routine:一个返回类型为void* 、参数为 void*的函数指针,即线程启动后要执行的函数(线程例程)。
4.arg,是传给 start_routine (线程例程)的参数,不关心设置为 NULL(默认方法)即可。
返回值:创建成功则返回0;失败则返回错误码
【ps】主线程与子线程
- 当一个可执行程序被加载,就有一个进程被操作系统创建,同时也有一个线程立刻运行。这个在进程被创建时就立刻运行的线程,叫主线程。
- 主线程是产生其他子线程的线程,通常必须在程序最后完成某些执行操作(如各种关闭等)。
- 在主线程创建一个新的子线程后,新的子线程就会去执行自己的新例程,主线程则继续执行后续的代码。
为演示 pthread_create() 的用法,此处引入以下代码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg)
{
char* msg = (char*)arg;
while (1){
printf("I am %s\n", msg);
sleep(1);
}
}
int main()
{
pthread_t tid;//线程TID是一个 pthread_t 类型的变量
pthread_create(&tid, NULL, Routine, (void*)"thread 1");
while (1){
printf("I am main thread!\n");
sleep(2);
}
return 0;
}
由演示图,主线程和其创建的子线程同时都执行了相关的打印任务。通过指令 ps -aL,可以查看正在执行的线程的相关信息,其中,LWP(Light Weight Process:轻量级进程)就是线程的TID,主线程与子线程的PID相同(说明属于同一进程,且它们的PPID也相同)但TIP不同(说明是不同的线程),特别的,主线程的TID与进程的PID相同,子线程的TID是在主线程TID的基础上线性递增的。
此处,对以上代码进行修改,让主线程创建一批子线程:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
void* Routine(void* arg)
{
char* msg = (char*)arg;
while (1){
printf("I am %s\n", msg);
sleep(1);
}
}
int main()
{
pthread_t tid[5];
char name[64];
int i = 0;
for (i = 0; i < 5; i++){
sprintf(name, "thread %d", i); //将特定内容写到 name 中
pthread_create(&tid[i], NULL, Routine, (void *)name);
sleep(1);
}
while (1){
printf("I am main thread!\n");
sleep(1);
}
return 0;
}
由演示图,主线程和五个子线程的PID相同,子线程的TID与主线程的TID不同,且是在主线程TID的基础上线性递增的。
2.获取线程TID
获取线程的TID,有两种常见方法,一种是在用 pthread_create() 创建线程时,通过输出型参数 thread 获得,另一种是通过系统调用 pthread_self() 获取。
#include <pthread.h>
pthread_t pthread_self(void);
功能:获取调用该接口的用户级线程的TID
参数:无
返回值:一定会调用成功,返回的是调用该接口的线程的TID
为演示 pthread_self() 的用法,此处引入以下代码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg)
{
char* msg = (char*)arg;
while (1){
printf("I am %s , my tid is: %lu\n", msg , pthread_self());
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread-1");
while (1){
printf("I am main thread,my tid is: %lu\n", pthread_self());
sleep(2);
}
return 0;
}
由演示图,主线程和子线程都通过调用 pthread_self() 打印了自己的TID,但为什么调用 pthread_self() 获得的值,与上文中通过 ps -aL 指令显示的轻量级进程 LWP 的值不同呢?
这与 Linux 中的线程管理方案有关。
pthread_self() 获得的是原生线程库用户级线程的ID,而通过 ps -aL 指令显示的 LWP 是内核中轻量级进程的ID,尽管它们之间是一一对应的关系,但它们的值是不同的,pthread_self() 返回的这个很大的整数,其实是一个虚拟地址。
Linux内核中是没有明确的线程概念的,也没有相应的 TCB 结构(线程控制块),内核中只有轻量级进程的概念,线程的相关接口也是由经过封装的原生线程库所提供。
用户在代码中调用原生线程库提供 pthread_create() 来创建子线程时,实际会调用 pthread_create() 中封装的系统调用 clone(),在内核中创建线程复用的PCB结构,以创建一个轻量级进程。
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
系统中可能存在着大量子线程需要被管理,实际上,内核中只有轻量级进程的 PCB,而描述子线程的TCB 结构存在于线程库中。线程库中的 TCB 结构又被称为用户级线程,存放了每个子线程自己的属性。 也就是说,每一个子线程其实由两部分组成,一部分是 pthread 线程库中的用户级线程,另一部分是内核中的轻量级进程,它们的数据规模的比例大约是 1 : 1。
pthread 线程库本身是一个动态库,从磁盘上加载到内存中后,会通过页表建立物理内存与进程地址空间中的共享区的映射。pthread 线程库最终映射在共享区中的 mmap 区域,它所维护的 TCB 结构也在共享区中。 TCB 结构中封装了线程的TID,而子线程的 TID 其实就是子线程在线程库中的 TCB 结构在共享区内的起始地址。所以才说,pthread_self() 返回的一个很大的整数,其实是一个线程 TCB 的虚拟地址。而线程的栈帧之间是相互独立的,因为每一个线程 TCB 中都维护了一个栈帧结构。
此外,子线程的栈帧也在进程地址空间的共享区中,而不在栈区中。实际上,主线程的栈帧位于栈区,而子线程的栈帧位于共享区。
【Tips】主线程与子线程详解
- 在单执行流进程中,可以认为,主线程就是进程本身;在多执行流进程中,可以认为,主线程是进程中最大的那个执行流,其他子线程都是由它派生出来的小执行流。
- 主线程只有一个,本质是一个轻量级进程,但它既有轻量级进程 PCB 和自己的线程 TCB,且都在内核中由系统维护。
- 子线程可以有多个,根据它创建、撤消和调度的过程可以将它分为两部分,一部分是原生线程库中的用户级线程(语言层面),另一部分是内核中的轻量级进程(系统层面),其中,原生线程库负责维护线程 TCB,内核则负责通过轻量级进程 PCB 来调度线程。
- 主线程的 TID 与进程的 PID 相同,子线程的 TID 是其 TCB 结构在共享区中的起始地址。
- 主线程的栈帧位于栈区,子线程的栈帧位于共享区。
3.等待线程
一个子线程被主线程创建出来,它的退出信息也是需要被主线程等待回收的。如果主线程不进行等待,就可能造成内存泄漏,引起类似于“僵尸进程”的问题。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
功能:调用该接口的线程将阻塞等待另一个线程的终止
参数:1.thread:输出型参数,被等待终止的线程的TID
2.retval:输出型参数,用于设置被等待终止的线程的退出信息,传NULL表示不关心。
返回值:成功返回0,失败返回错误码。
【ps】参数 retval 的说明
- 如果被等待终止的线程是通过 return 返回而退出的,则参数 retval 所指的内容将被置为 return 返回的值。
- 如果被等待的线程,因为别的线程调用 pthread_cancel() 接口而使其异常终止,参数 retval 所指的内容会默认置为 PTHREAD_CANCELED(是头文件 pthread.h 中的一个宏定义,它的值本质是 -1)。
- 如果被等待的线程是因自己调用 pthread_exit() 接口而正常终止的,参数 retval 所指的内容默认置为 pthread_exit() 的参数 retval 。
- 如果无需关心被等待线程的退出情况,可以将参数 retval 置为 NULL 。
为演示 pthread_join() 的用法,此处引入以下代码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg) //子线程执行完5次打印后正常终止
{
char* msg = (char*)arg;
int count = 0;
while (count < 5){
printf("I am %s , my tid is: %lu\n", msg , pthread_self());
sleep(1);
count++;
}
return (void*)20240609;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread-1");
void* ret=NULL; //用于接收子线程的退出信息
pthread_join(tid, &ret); //默认阻塞等待子线程退出
printf("new thread quit:%lld\n",(long long)ret);//打印子线程的退出信息
//注:linux64位平台的指针是8个字节,故此处强转成long long
while (1){
printf("I am main thread,my tid is: %lu\n", pthread_self());
sleep(1);
}
return 0;
}
由演示图,代码的运行符合预期,子线程执行5次打印后正常退出,其间主线程一直阻塞等待,直到子线程退出后,打印了子线程的退出信息,才继续执行后续代码。
【补】为什么一个线程终止时,只能获取到这个线程的退出信息?
一个进程在终止时,能够获取到这个进程的退出码、退出信号等。
线程是进程的一个执行分支,如果线程正常终止则无须关心,如果线程异常终止,则整个进程都会终止,此时代码未必会执行到 pthread_join() 所在的一行,因此,类似进程信号的概念对线程来说意义并不大,只需关心线程终止时的退出信息即可。
pthread_join() 只获取线程正常退出时的退出信息,无须操心异常的情况,因为异常终止的情况已经有进程在操心了,
4.分离线程
一个子线程的退出信息在被主线程等待回收时,主线程只能干等而不能做其他事情。
其实,子线程可以在终止时自动释放退出信息,使主线程无须关心子线程的状态,只需用接口 pthread_detach() 将子线程设置为分离状态即可。
#include <pthread.h>
int pthread_detach(pthread_t thread);
功能:将一个线程设置为分离状态,使其在终止时自动释放退出信息
参数:待分离的线程的tid
返回值:分离成功则返回0,失败则返回错误码。
【ps】线程的 Joinable 和 Detachable
- 默认情况下,新创建的子线程是 Joinable(可连接的)的,此时,在子线程退出后,需要对其进行 pthread_join() 操作,否则其资源无法被释放,可能造成内存泄漏。
- 如果无须关心子线程的退出信息,回收操作对主线程而言就是一种负担,可以用 pthread_detach() 将子线程设为 Detachable(可分离的),此时,在子线程退出后,其资源会被系统自动释放,而主线程无须关心。
- 一个 Detachable 的子线程仍要使用进程的资源,仍会在进程内运行,如果崩溃了仍会影响其他线程。
- 设置 Detachable ,可以由线程组内其他线程对目标线程来完成,也可以是目标线程自己来完成。
- Joinable 和 Detachable 是互斥的,一个线程不能既是 Joinable又是 Detachable 的。
为演示 pthread_detach() 的用法,此处引入以下代码:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg) //子线程执行完5次打印后正常终止
{
pthread_detach(pthread_self());//让子线程自己分离自己
char* msg = (char*)arg;
int count = 0;
while (count < 5){
printf("I am %s , my tid is: %lu\n", msg , pthread_self());
sleep(1);
count++;
}
return NULL;
}
int main()
{
pthread_t tid;
char* buffer = (char*)malloc(64);
sprintf(buffer, "thread-1");
pthread_create(&tid, NULL, Routine, buffer);
printf("the tid of %s is: %lu\n", buffer, tid);
free(buffer);
while (1){
printf("I am main thread,my tid is: %lu\n", pthread_self());
sleep(1);
}
return 0;
}
由演示图,子线程执行5次打印期间,主线程也在执行打印,子线程退出后,主线程继续执行打印,说明主线程并没有阻塞等待子线程退出,而一直都有在做自己的事。
5.终止线程
【Tips】线程的终止方式
要终止进程中的一个线程,可以通过以下三种方式:
- 从线程例程(线程所执行的函数)中 return 返回。
- 在自己的线程例程中调用 pthread_exit() 以终止自己。
- 一个线程在自己的线程例程中调用 pthread_cancel() 终止了同一进程中的另一个线程。
【ps】主线程的退出细节
主线程 main() 中的 return 一旦执行,主线程和整个进程都将退出,进程的资源会被释放,因此,一旦主线程 return 退出,进程中的其他子线程也会退出。
但主线程用 pthread_exit() 退出,pthread_exit() 之后的主线程代码不再会执行,但进程的资源并不会释放,其余子线程仍会继续运行直到各自退出,此时可能引发内存泄漏。因此,最好让主线程最后退出,来给子线程“善后”。
#include <pthread.h>
void pthread_exit(void *retval);
功能:终止线程,并返回设置的退出信息
参数:输出型参数,用于设置线程的退出信息,传NULL表示不关心。
返回值:就是参数 retval
ps:线程例程(线程所执行的函数)会因 pthread_exit() 或 return
而返回一个指针(线程例程的返回类型是void*),
其所指向的内存单元必须是全局的或是由malloc分配的(总之在堆上),
而不能是由线程的栈帧分配的,否则其他线程如果访问了这个返回指针,
会因线程的栈帧已经被销毁而造成野指针问题
为演示 pthread_exit() 的用法,此处引入以下代码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg) //子线程执行完5次打印后正常终止
{
char* msg = (char*)arg;
int count = 0;
while (count < 5){
printf("I am %s , my tid is: %lu\n", msg , pthread_self());
sleep(1);
count++;
}
//return (void*)20240610;
pthread_exit((void*)20240610); //终止线程
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread-1");
void* ret=NULL; //用于接收子线程的退出信息
pthread_join(tid, &ret); //默认阻塞等待子线程退出
printf("new thread quit:%lld\n",(long long)ret);//打印子线程的退出信息
//注:linux64位平台的指针是8个字节,故此处强转成long long
while (1){
printf("I am main thread,my tid is: %lu\n", pthread_self());
sleep(1);
}
return 0;
}
6.取消线程
取消一个线程 pthread_cancel() 与退出一个线程 pthread_exit() 的效果大差不差, 虽然pthread_cancel() 也可以像 pthread_exit() 一样让线程自己终止自己,但更重要的是,它可以让一个线程来控制另一个的线程的终止,为线程控制提供了更加灵活的选项。
#include<pthread.h>
int pthread_cancel(pthread_t thread);
功能:取消一个线程
参数:待取消的线程的tid。
返回值:取消成功则返回0,失败则返回错误码。
ps:若线程例程中,pthread_exit() 或 return 未执行,因 pthread_cancel() 而退出,
则线程的退出码为PTHREAD_CANCELED。PTHREAD_CANCELED是一个宏定义:
#define PTHREAD_CANCELED ((void*)-1)
ps:pthread_cancel() 的调用,最好发生在主线程与子线程之间、子线程与子线程之间。
尽管子线程也可以调用 pthread_cancel() 来退出主线程,但极其不建议这样做。
为演示 pthread_cancel() 的用法,此处引入以下代码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg)
{
char* msg = (char*)arg;
while (1){
printf("I am %s , my tid is: %lu\n", msg , pthread_self());
sleep(1);
}
//return (void*)20240610;
pthread_exit((void*)20240610);
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread-1");
int i=3;
void* ret=NULL; //用于接收子线程的退出信息
while (i--){
printf("I am main thread,my tid is: %lu\n", pthread_self());
if(i==1)
{
pthread_cancel(tid); //主线程运行一段时间后,主线程终止子线程
pthread_join(tid, &ret); //主线程运行一段时间后,回收子线程的退出信息
}
sleep(1);
}
printf("new thread quit:%lld\n",(long long)ret);//打印子线程的退出信息
//注:linux64位平台的指针是8个字节,故此处强转成long long
return 0;
}
补.全局函数能被多个线程同时调用
由于一个进程地址空间是可以被多个线程共享的,因此位于代码区的全局函数能被多个线程同时调用。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void Print(const char* name)//全局函数
{
printf("%s is running, tid: %lu\n", name, pthread_self());
}
void* Routine(void* arg) //子线程执行完10次打印后正常终止
{
char* msg = (char*)arg;
int count = 10;
while (count--){
Print(msg);
sleep(1);
}
//return (void*)20240610;
pthread_exit((void*)20240610); //终止线程
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread-1");
void* ret=NULL; //用于接收子线程的退出信息
int count = 5;
while (count--){
Print("main thread");
sleep(1);
}
pthread_join(tid, &ret); //默认阻塞等待子线程退出
printf("new thread quit:%lld\n",(long long)ret);
return 0;
}
由演示图,主线程和子线程都能成功调用全局函数来完成打印任务,主线程执行5次打印后阻塞等待子线程执行完退出,最终成功获取了子线程的退出码。
补.全局变量在线程间共享
由于一个进程地址空间是可以被多个线程共享的,因此位于代码区的全局变量能被多个线程同时访问和修改。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int g_val = 100;//全局变量
void Print(const char* name)//全局函数
{
printf("%s is running, tid: %lu, g_val: %d, &g_val: 0X%p\n", name, pthread_self(),g_val, &g_val);
}
void* Routine(void* arg) //子线程执行完10次打印后正常终止
{
char* msg = (char*)arg;
int count = 10;
while (count--){
g_val++;
Print(msg);
sleep(1);
}
//return (void*)20240610;
pthread_exit((void*)20240610); //终止线程
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread-1 ");
void* ret=NULL; //用于接收子线程的退出信息
int count = 5;
while (count--){
g_val++;
Print("main thread");
sleep(1);
}
pthread_join(tid, &ret); //默认阻塞等待子线程退出
printf("new thread quit:%lld\n",(long long)ret);
return 0;
}
尽管每一个线程都有自己独立的栈帧,但如果线程之间共享的全局变量是一个指针,就可以使一个线程拿到另一个线程的栈帧中的数据。不过,出于数据安全的考虑,建议不要这样编写代码。
补.线程的局部存储
一般来说,全局变量是在所有线程之间共享的,但如果想让一个全局变量在每个线程内部各自私有一份,可以在全局变量的前面加上关键字 __thread 以修饰。
关键字 __thread 其实是原生线程库维护的 TCB 中,一个线程的局部存储属性,是一个介于全局变量和局部变量之间线程特有的属性,它只能用来修饰内置类型,不能用来修饰自定义类型,可以使每一个线程在其独立栈中为由 __thread 修饰的全局变量或静态变量单独开辟一块空间,使这个全局变量在每个线程内部各自私有一份。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
__thread int g_val = 100;//全局变量
void Print(const char* name)//全局函数
{
printf("%s is running, tid: %lu, g_val: %d, &g_val: 0X%p\n", name, pthread_self(),g_val, &g_val);
}
void* Routine(void* arg) //子线程执行完10次打印后正常终止
{
char* msg = (char*)arg;
int count = 10;
while (count--){
g_val++;
Print(msg);
sleep(1);
}
//return (void*)20240610;
pthread_exit((void*)20240610); //终止线程
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread-1 ");
void* ret=NULL; //用于接收子线程的退出信息
int count = 5;
while (count--){
g_val++;
Print("main thread");
sleep(1);
}
pthread_join(tid, &ret); //默认阻塞等待子线程退出
printf("new thread quit:%lld\n",(long long)ret);
return 0;
}
补.简要说明 C++11 的线程库
C++11 的线程库其实是对原生线程库的一层封装。
在 Linux 环境下,C++11 的线程库底层封装的是 Linux 中的系统调用;而在 Windows 环境下,C++11 的线程库底层封装的是 Windows 中的系统调用。这也使得 C++ 这门语言具有了跨平台性。