线程详解
- 前言
- 正式开始
- 啥是线程
- 理解线程
- Windows和Linux下的线程
- Windows下的线程
- Linux下的线程
- 对比
- 重新理解进程
- 理解曾经写的代码
- Linux的线程
- pthread库
- 验证多线程在同一个进程中跑
- ps -aL
- 线程资源
- 线程切换成本低
- 线程缺点
- 线程异常
- 线程等待
- pthread_create的第三个参数——回调函数的返回值
- 终止线程
- 线程取消
- 线程id
- 证明全局数据所有线程共享
- 线程调用进程替换函数
- 线程分离
- C++中的thread
前言
本篇重点讲解:
- 了解线程概念,理解线程与进程区别与联系。
- 学会线程控制,线程创建,线程终止,线程等待。
- 了解线程分离与线程安全概念。
- 学会线程同步。
- 学会使用互斥量,条件变量,posix信号量,以及读写锁。
- 理解基于读写锁的读者写者问题。
正式开始
啥是线程
先说一个第一次听到线程的同学不太理解的概念:
线程在进程内部执行,是CPU调度的基本单位。
点进来同学可能大部分都是第一次接触线程,或者在课堂上听过老师念过ppt,上面这个概念要在线程的概念大概讲一点之后再给大家阐述。
理解线程
如果说我创建两个不相关的进程,二者的PCB、虚拟地址空间、用户级页表等等数据结构都是不相同的,CPU看到的是两份不一样的东西。也就是下图:
如果说我现在通过一定的手段,在同一个进程中再产生一个PCB,这个新的PCB所维护的数据结构和原先进程的PCB一样,而且原先进程的PCB不会消失,是否可行?如下图:
答案是可行的,因为这里就涉及到了多线程。
这里将原本一个PCB(黑色的)的进程中的“资源”,比如说当前进程的代码、堆区、栈区等以一定的方式划分给不同的task_struct。
我再多画几个:
假如说进程代码中有五个函数,让这里的五个不同的PCB来执行不同的函数,即多个PCB去执行同一份代码中的不同函数,通过一定的手段,让不同的PCB去申请不同的堆空间和占空间,那么这样就可以将原进程所申请的资源分配给这几个PCB(包括本来那个黑色的PCB)。
这种方式创建PCB与创建进程最大的区别就在于这里不用再去申请虚拟地址空间、页表等等内核数据结构,只创建一第个PCB时,像os申请资源(代码/数据之类的),后续再创建PCB就直接向原进程伸手要资源就行。那么这里就可以吧每一个task_struct都叫做线程。
最开始说的线程在进程内部执行,是CPU调度的基本单位,这里就可以大概解释一下了。
-
线程在进程内部执行
意思就是线程在进程的地址空间内运行。 -
调度的基本单位
CPU其实不关心当前跑的执行流是线程还是整个进程,其只关心PCB。
在未接触线程之前,我们一直认为的是一个task_struct就能代表一个进程,但是这个进程的概念是在用户层说的,不太准确。CPU看不出进程和线程的区别,它只管PCB,根据PCB找到对应虚拟地址空间,再根据页表找到特定执行流想要执行的代码和数据。
而上述这种一整个进程中搞多个task_struct的实现方案是Linux特有的方案。
Windows和Linux下的线程
操作系统的书上都会写线程比进程调度更轻量化、创建 / 维护的成本更低、执行粒度更细腻、资源占用更少等等优点,进程内绝大部分资源线程共享。而不同的os下线程的底层实现方式是不同的,但是都能满足os书上写的线程的优点。
Linux没有为线程设置专门的数据结构,直接复用了进程中的task_struct。但是Windows下专门为线程搞了一套与进程无关的数据结构。
-
不管是进程还是线程,都是要被CPU调度的。
-
进程和线程在同一时刻下可以存在很多,所以都要有各自的标识符以区分。
-
进程和线程都要被创建,所以都还要有父子关系和主从关系。
多个进程可能被同一个父进程创建,要维护进程间的父子关系;多个线程可能被同一个主线程创建,所以也要维护主从关系,线程也要有优先级 / 状态 / 上下文等数据,和进程高度相似,所以可以发现,线程和进程在概念上是高度重合的。
未接触线程之前,我们可以把进程看作是一个执行流,而接触了线程之后,也可以把一个线程看作是一个执行流,一个进程内可以创建多个线程,那么当存在大量进程的时候也就有可能存在大量线程,所以os也是要管理线程的。
如果要管理线程,那么os就要为线程创建出大量的数据结构来维护线程,因为要先用特定结构对线程做描述,再用数据结构将各个线程结构组织起来。比如说能够标识出不同线程的字段、维护主从关系的字段等等,而这些字段和进程是非常类似的。
Windows下的线程
前面说了,Windows下位线程单独又设计了一套数据方案,如线程的调度、上下文的切换、上下文保存、代码的执行、数据结构的创建、初始化和释放等。而这些工作绝大部分和进程相关的工作是重复的。
Linux下的线程
但是Linux经过其社区内部讨论,统一认为根本没有必要在内核层面上去区分进程或线程,所有的进程和线程都统一用task_struct表示,只不过进程又独立的虚拟地址空间,而线程和进程共享这个虚拟地址空间就行了,故创建线程就创建PCB就行。所以Linux对于线程的设计方案就将进程的task_struct进行了复用,这样的话,对进程的调度 / 上下文切换 等工作就都能复用到线程上了,当然肯定会有点差别,但是很多核心的代码 / 内部属性是完全重叠的,以最少的代码来实现同样的效果。
对比
所以再来看Windows,创建一个进程,又在内部创建了一大批线程,进程数据结构于线程的不同,但是还要维护线程间的关系,所以Windows下的各种数据结构一定是非常复杂的,一旦复杂就容易出问题,这也就是为啥Windows系统的电脑打开几天就会非常卡(我之前有电脑两天没关机,然后开了一把lol,直接卡成ppt了),但若是Linux系统,开几年都没问题。
重新理解进程
说了这么多,可能有的同学都糊涂了,跟前面学的进程完全是两个东西,到底啥是进程呢?
我前面的博客中讲的是 独立PCB、虚拟地址空间、页表 + 代码 + 数据等。这个概念是比较肤浅的,但是也没有错。进程还是内核数据结构 + 该进程的代码 + 数据,和前面的区别不大,但是一个真正的进程可能不止一个PCB,而是很多个PCB,也就是下面这张图用红色框起来的整体:
不过这是站在用户视角来看的,也就是使用os的人来看。
当仅有一个PCB被创建的时候,会像我前面进程中讲的那样,申请虚拟地址空间、页表、加载代码、数据等等,但是再创建 n 个PCB时不会再这么做了,而是用第一个创建好的资源,后续PCB创建时是向第一个PCB申请资源的。
- 在内核视角来看,进程是承担分配系统资源的基本实体
第一次申请空间是进程向os申请,后续申请资源是线程向进程申请。
理解曾经写的代码
我曾经的博客中写的所有代码都是一个进程中只有一个执行流,也就是只有一个PCB,即使fork创建了子进程也是父进程一个执行流,子进程一个执行流,都是单执行流的场景。而本篇讲的是内部具有多个执行流的进程,也就是一个进程有多个PCB,而多执行流的特殊情况不就是只有一个执行流吗?
所以从这里开始,各位就可以改变一下原来学习进程时的观念了。
进程就是一大批的执行流(至少一个)+ 虚拟地址空间 + 页表等内核数据结构 + 该进程所对应的代码和数据等,整体为基本单位向os申请对应的资源。
学习进程时的观念中,一个PCB仅仅称为一个进程内的一个执行流,当一个进程内有多个执行流时就称之为单进程多执行流的程序。
在Linux下,可认为task_struct就是进程内部的一个执行流。
在CPU视角下,CPU进行调度的时候,运行队列中全都是task_struct,所以排队的时候可以是一个进程中的多个task_struct在排队,也可以是不同进程的多个task_struct在排队。所以CPU其实不关心当进程和线程的概念,只认task_struct就行。
我前面有关进程的博客看多了容易产生误区,因为前面的博客中的代码写的都是单进程单执行流的,实际上CPU调度的是进程内部的task_struct,而非整个进程。但这里的概念和前面也是不冲突的,之前博客中的概念是这里的概念的子集。所以CPU调度的基本单位是线程。
Linux的线程
在CPU的视角下,Linux中的PCB <= 其他os的PCB。因为Linux下没有为线程设置专门的数据结构,所以当多执行流进程运行时,Linux下全都是task_struct,而其他os下只有一个PCB,剩下的都是和线程相关的数据结构,所以是小于。等于就是单进程单执行流的情况,此时既可称之为进程,有可称之为线程。故Linux下的进程和线程又被统一称为轻量级进程。
从严格意义上讲,Linux没有真正意义上的线程结构,因为其没有为线程设立相应的数据结构,都是以进程PCB来模拟线程的,而Windows是有真正的线程的。
但虽然没有真正意义上的线程结构,Linux还是得要给用户提供线程相关操作的接口的,但是Linux不能直接提供系统层面上的接口,这些接口都不是严格意义上的线程接口,只能说是轻量级进程的接口,但是如果还要让用户学一学轻量级进程的接口就有些麻烦了。所以Linux的设计者们也是想到了这一点的,所以他们在用户层提供了一套多线程的方案,以库的方式提供给用户进行使用,这个库就是pthread线程库,也叫做原生线程库。其底层就是对轻量级进程接口的封装,虽然是第三方库,但我们可以把它当做系统调用函数来看。
pthread库
- 这个库可以查到:
.so结尾的就是动态库,.a结尾的就是静态库。还有一个软链接。
去其前缀lib,去其后缀.so / .a,剩下的就是文件名pthread。
-
对应的头文件:
-
我们可以用其中的pthread_create来创建线程:
其中第一个参数thread就是线程的id,其类型就是一个unsingned long:
这里的线程id等会再详细讲。
第二个参数是线程属性,本篇博客不做讨论,我们直接给nullptr,默认的就行。
第三个参数start_routine是一个函数指针,其实就和我前面信号的那篇博客中一样,是一个回调函数,当线程被创建之后就会去执行这个函数。函数指针所指向的函数返回值和参数都是void*类型的。
这里第四个参数的类型是void*,第三个参数所指向的函数的参数也为void*,第四个参数arg是用来给第三参数所指向的函数提供参数的。
pthread_create函数返回值:
下面就来带大家见见猪跑,创建一个线程:
#include <iostream>
using namespace std;
#include <pthread.h>
#include <unistd.h>
// 回调函数的方法
void* threadRoutine(void* arg)
{
// thread1 不断运行
while(1)
{
cout << (char*)arg << " is running" << endl;
sleep(1);
}
}
int main()
{
// 线程的id
pthread_t tid;
// 创建线程 回调函数 回调函数的参数,直接强转成(void*)就行
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread_1");
// 主线程不断运行
while(1)
{
cout << "mainThread is running" << endl;
sleep(1);
}
return 0;
}
编译链接:
出错了,其中error后面是一个ld,就是链接错误,因为这里所使用的线程库在严格意义上来讲是一个用户层的线程库,也就是说这是一个第三方库(各位可认为第一方的库就是语言(c/c++等)所带的库,第二方就是os自带的库,第三方就是其他人写的库),虽然前面说了这个库中的接口可以当做系统接口来用,但毕竟不是语言/os的库,是别人写的库,库放到系统库路径 lib64 下,但该路径中库非常多,gcc不知道要链接 /lib64 下的哪一个库,所以要手动指明。我们这里只要添加上 -lpthread 选项就行了(如果有同学听不懂可以看看我这篇博客:看里面的动静态库制作就行):
- 运行:
上面出现同时打印在一行是因为cout语句不是原子操作,至于不懂原子性的同学先不要着急,后面会讲的。
验证多线程在同一个进程中跑
前面说了两个线程都是在同一个进程中的,代码中加上打印pid再看看:
#include <iostream>
using namespace std;
#include <pthread.h>
#include <unistd.h>
// 回调函数的方法
void* threadRoutine(void* arg)
{
// thread1 不断运行
while(1)
{
cout << (char*)arg << " is running, pid:: " << getpid() << endl;
sleep(1);
}
}
int main()
{
// 线程的id
pthread_t tid;
// 创建线程 回调函数 回调函数的参数,直接强转成(void*)就行
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread_1");
// 主线程不断运行
while(1)
{
cout << "mainThread is running, pid:: " << getpid() << endl;
// 这里让主线程睡3s再打印
sleep(3);
}
return 0;
}
运行:
可以看到主线程和从线程都打印的是28674,同一个进程。
ps -aL
我现在创建5个线程,运行起来,用ps -aL来查看一下线程相关属性:
#include <iostream>
#include <string>
#include <cstdio>
#include <unistd.h>
#include <pthread.h>
using namespace std;
// 一个全局数据x
int x = 100;
// 用来让每个进程打印
void show(const string &name)
{
cout << name << ", pid: " << getpid() << " " << x << "\n"
<< endl;
}
// 回调方法
void *threadRun(void *args)
{
const string name = (char *)args;
while (true)
{
show(name);
sleep(1);
}
}
int main()
{
// 5个线程的id
pthread_t tid[5];
// 每个线程的名字
char name[64];
for (int i = 0; i < 5; i++)
{
snprintf(name, sizeof name, "%s-%d", "thread", i);
pthread_create(tid + i, nullptr, threadRun, (void *)name);
sleep(1); // 缓解传参的bug
}
while (true)
{
cout << "main thread, pid: " << getpid() << endl;
sleep(3);
}
}
先用ajx查看pid:
上面打印的有点问题,不过问题不大,可以看到是一个进程在跑。
再用ps -aL:
这里我用的循环查看,命令是:
while :; do ps -aL | head -1 && ps -aL | grep myThread; sleep 1; done
可以看到,有五个线程在跑,而且五个线程pid都一样,但是有一个东西需要各位注意,就是LWP,LWP就是light weight process的意思,也就是前面提到的轻量级进程。其中有一个线程的pid和lwp一样,这个线程就是主线程。前面说了,线程是cpu调度的基本单位,而cpu调度的时候看的就是线程的LWP。这里可以说PID : LWP = 1 : n。以前我博客中的代码,cpu调度的时候都是一个进程一个执行流,这样的话就一个主线程,自然PID和LWP就相同了,此时cpu调度的话,看PID/LWP没有什么差别。
如果我此时kill -9 PID,会杀掉一个进程,也就是说此时进程中的所有线程都会消失:
因为所有线程资源是进程给的,而信号是进程的信号,进程接收到信号后进行后续的处理动作,进程就会被干掉,即进程资源就回收了,就会导致几个执行流所依赖的资源都消失,这样几个线程就都随之消失了。
线程资源
下面说说线程间共享的资源和线程独立的资源。
- 文件描述符
一个线程打开一个文件,假如该文件描述符是3,其他线程也能通过该描述符看到同一个文件。此时另一个线程再打开其他文件,分配的描述符就是4,因为3已经被刚刚的文件描述符占用了。
如果不懂文件描述符,还是我刚刚给的那篇博客链接中有讲。
-
每种信号的处理方式(三种,SIG_DEF,SIG_IGN,自定义)
如果不懂可以看我这一篇:这个要从头开始看 -
当前工作目录
这个没啥好说的。 -
虚拟地址空间中的某些区域。
先给张图:
总共这么些,理论上讲,都是可共享的,但是一般某些区域不会共享。这里共享的有:
a. 代码区共享,比如我刚刚写的代码中,每个线程用同一份show函数。
b. 静态区共享,还是刚刚的代码,show函数中打印全局变量x。
c. 堆区可以共享(能做到,但是一般不共享,一般是自己线程用自己的,用完就释放掉自己开的堆区空间)。
d. 共享区共享(动态库、共享内存啥的线程共享)
e. 环境变量和命令行参数共享。
线程私有的:
- 一组寄存器(线程的上下文数据)
- 栈(也可做到共享,但是不这么做,一般认为线程独占栈空间,等会会详细讲)
这里ab两点能够体现出线程的动态属性,线程被调度要有上下文,线程要执行得用栈保存其数据 - 错误码errno
- 信号屏蔽字(在刚刚信号的那篇博客中)
- 调度优先级
线程切换成本低
CPU切换时,有可能是进程切换,也有可能是线程切换,为什么说线程切换的成本更低?
线程切换时地址空间和页表不需要切换,当进程切换时要把这两个都切换掉。
线程调度时要将虚拟地址空间、页表、PCB地址加载到寄存器中,切换进程时看起来也没有那么高的成本,因为就切换几个寄存器不就是随手的事,但是重点不在这寄存器上,而是在CPU的缓存上。
成本高的核心原因是CPU内部有缓存(cache),这里讲的偏硬件一点,缓存分一级到三级L1~L3,CPU在进行寻址时,先在内存中找到相应的指令,然后需要把这条指令load到寄存器中,如果是每次load指令都要去内存中的话,效率会大大降低,因为CPU的速度比内存高不少,所以CPU中搞了缓存,比如要加载地址为1000处的指令时,会把1000位置往后的很多条指令的地址都先放到CPU的cache中,此过程就叫做预读,根据局部性原理,即加载某一处的地址并进行访问时,很有可能下一步就走的是该地址的下一个位置,所以把从该位置起的后面的一部分地址也全部先加载到缓存中,下一次读指令的时候就可以直接从CPU内部的缓存找地址,然后将地址对应的指令加载到寄存器中,这样效率就唰一下的就上来了。
多线程下,缓存一块区域的地址,切换到下一个线程,可能还会执行后续区域的地址,这种情况下就不会清空缓存。但是进程切换的话缓存中的内容就立即失效,新进程过来,只能重新缓存,而cache缓存要比寄存器大得多,这样成本就高在这里。
线程讲了不少了,这里再详细的提一嘴线程的优点:
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
说一下倒数第二条。
比如说现在要对一个10G的文件进行加密工作,这就是一个计算密集型应用,
我们可以将这10G拆分成10个1G的,然后搞10个线程来各自进行加密工作,但是要注意一点,线程不能搞太多,不然执行任务的效率反而会下降。因为如果你的cpu是一个单核的cpu,每次只能跑一个线程,此时若开10个线程,那么线程的切换反而会导致效率下降,这种情况下,最优解是让一个执行流去进行整个文件的加密工作,也就说不要再多开线程了,一个就够了,从头到尾没有线程切换的成本,效率更高。如果线程开多了,执行任务的主要耗时就变成线程切换,而任务本身的解决效率就下降了。
一般来说,创建线程的个数等于CPU的核数。
线程缺点
这里的缺点是相对于进程来说的,但是如果你写的代码逻辑非常严密,这些缺点也就不存在的。如果你写的代码不太行,那就得多多注意下面这几点了。
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
代码写很完美的话,这些问题就不是问题。
说一个我们生活中的线程的例子,比如我们看电影,用迅雷下载时有一个边下边播的功能,这就相当于是两个线程,一个负责播放,一个负责下载,当我们看完时也就差不多下完了。
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
这里来演示一下。
代码:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void* threadRoutine(void* arg)
{
cout << (char*)arg << "start to running" << endl;
// 5s后进行除零
sleep(5);
int a = 10;
a /= 0;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
// 主线程一直跑
while(1)
{
cout << "main thread running" << endl;
sleep(1);
}
return 0;
}
运行:
当新线程出现除零错误时整个进程都会退出。
这里再让新线程多睡一会,然后再除零,配合着ps -aL来看:
可以看到,当异常出现时,所有的线程都没了。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
注意两点:
- 多个线程谁先执行与调度器相关。
- 一个线程一旦异常,就会导致整个进程整体退出。
线程等待
线程在创建并执行时,也要让主线程对其进行等待的,不然也会出现类似僵尸进程导致内存泄漏的问题,进程中的等待是wait,线程这里的等待是join:
这里第一个参数是线程id,第二个参数先不说,先给空,写份代码看看:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#include <unordered_map>
void* threadRoutine(void* arg)
{
int count = 0;
while(1)
{
cout << (char*)arg << "start to running" << endl;
sleep(1);
++count;
if(count == 5) break;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
// 主线程等待从线程
cout << "main thread waiting" << endl;
pthread_join(tid, nullptr);
return 0;
}
就和进程中的用法一样。
运行:
没啥好说的。
pthread_create的第三个参数——回调函数的返回值
再拿出来pthread_create来看看:
这里的返回值给了谁?
其实是给了pthread_join的第二个参数。
可以看到,回调函数的返回值类型是void*的,pthread_join的第二个参数的类型是void**的。
我们可以对pthread_join第二个参数传一个一级指针的地址。这样就可以拿到这个参数了。相当于是输出型参数。
看代码:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#include <unordered_map>
void* threadRoutine(void* arg)
{
int count = 0;
while(1)
{
cout << (char*)arg << "start to running" << endl;
sleep(1);
++count;
if(count == 2) break;
}
// 返回值
return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
// 主线程等待从线程
cout << "main thread waiting" << endl;
void* ret;
// 返回值放到ret中
pthread_join(tid, &ret);
// linux下是64位的,这里ret大小为8,所以得用8字节的long或long long来强转
// 用int报错
cout << "threadRoutine's return val ::" << (long)ret << endl;
return 0;
}
运行:
只要是谁调用的pthread_join,谁就获得返回值,但一般给main线程。
线程不但可以返回数字,还可以返回其他的,比如说一个数组:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void* threadRoutine(void* arg)
{
int count = 0;
while(1)
{
cout << (char*)arg << "start to running" << endl;
sleep(1);
++count;
if(count == 2) break;
}
// 返回值
//return (void*)10;
int* arr = new int[10];
for(int i = 0; i < 10; ++i)
{
arr[i] = i;
}
return (void*)arr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
// 主线程等待从线程
cout << "main thread waiting" << endl;
void* ret;
// 返回值放到ret中
pthread_join(tid, &ret);
// linux下是64位的,这里ret大小为8,所以得用8字节的long或long long来强转
// 用int报错
cout << "threadRoutine's return val ::";
for(int i = 0; i < 10; ++i)
{
cout << " " << *((int*)ret + i);
}
cout << endl;
delete[] (int*)ret;
return 0;
}
运行:
所以说,主从线程之间可以随意来回传递信息。
如果说线程异常了,还会接收返回值吗?
比如除零错误:
答案是不会接收:
因为没有意义了,如果线程异常导致崩溃,整个进程就崩溃了,此时接收返回值没啥用,所以说线程等待不需要关心线程退出是否异常。
我前面博客中讲的信号叫做进程信号,CPU中的状态寄存器属于进程,同样所有的线程也是共享的线程异常了就是整个进程异常。
终止线程
不要在线程中调用exit,exit是会让整个进程终止的:
void* threadRoutine(void* arg)
{
cout << (char*)arg << " start to running" << endl;
// 新线程调用exit,会导致整个进程终止
exit(1);
return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
// 主线程等待从线程
cout << "main thread waiting" << endl;
void* ret;
// 返回值放到ret中
pthread_join(tid, &ret);
// linux下是64位的,这里ret大小为8,所以得用8字节的long或long long来强转
// 用int报错
cout << "threadRoutine's return val ::" << (int*)ret << endl;
return 0;
}
运行:
可以看到,主线程中的内容一点都没打印。整个进程都没了。退出码是1,也就是exit(1)。
多执行流中的某一执行流执行某一方法时,如果该方法中有exit,就会导致整个执行流都退出。如果想要退出,应该用pthread_exit:
其中这个参数,就是刚讲的回调函数的返回值。
代码:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void* threadRoutine(void* arg)
{
cout << (char*)arg << " start to running" << endl;
pthread_exit((void*)20);
return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
// 主线程等待从线程
cout << "main thread waiting" << endl;
void* ret;
// 返回值放到ret中
pthread_join(tid, &ret);
// linux下是64位的,这里ret大小为8,所以得用8字节的long或long long来强转
// 用int报错
cout << "threadRoutine's return val ::" << (long)ret << endl;
return 0;
}
运行:
可以看到,返回值是20,走的是pthread_exit。而不是return。
线程取消
线程取消就是让某一个直接终止,pthread_cancel:
想取消谁就直接传那个线程的id就行了。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void* threadRoutine(void* arg)
{
int count = 0;
while(1)
{
++count;
if(count == 5) break;
cout << (char*)arg << " is running" << endl;
sleep(1);
}
return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
// 主线程等待从线程
cout << "main thread waiting" << endl;
sleep(2);
// 主线程2s后直接将新线程取消
pthread_cancel(tid);
void* ret;
// 返回值放到ret中
pthread_join(tid, &ret);
// linux下是64位的,这里ret大小为8,所以得用8字节的long或long long来强转
// 用int报错
cout << "threadRoutine's return val ::" << (long)ret << endl;
return 0;
}
运行:
可以看到回调函数的返回值是-1。我们再来看看join的介绍:
上面说了如果等待的线程被取消了,就会将返回值设置为PTHREAD_CANCELED:
就是-1。
不要在create线程后直接cancel掉,没有意义,至少要让新线程跑一会。
还有不要让新线程cancel掉主线程,也没有意义,没这种场景,取消主线程会让整个进程退出,此时的新线程也就没了。
线程退出就用pthread_exit或者return就行,然后让主线程join一下新线程。
线程id
先来段代码打印一下:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void* threadRoutine(void* arg)
{
int count = 0;
cout << (char*)arg << " is running" << endl;
sleep(2);
return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
cout << "thread 1 id ::" << tid << endl;
// 主线程等待从线程
cout << "main thread waiting" << endl;
void* ret;
// 返回值放到ret中
pthread_join(tid, &ret);
// linux下是64位的,这里ret大小为8,所以得用8字节的long或long long来强转
// 用int报错
cout << "threadRoutine's return val ::" << (long)ret << endl;
return 0;
}
运行:
可以看到这个数字非常大。为啥线程的id会这么大呢?
我再来换个格式打印一下:
printf("thread 1 id, lu:: %lu , p :: %p\n", tid, tid);
是不是很像地址?
因为tid本身就是一个地址。我们目前用的线程的接口都不是Linux自带的接口,用的是pthread的接口。
线程要有自己的属性集合,也是要被管理起来的,这里的管理,os占一部分,pthread库占一部分,os负责对轻量级进程的调度以及内核数据结构的管理,而库负责提供线程相关的属性字段,包括线程id、线程栈的大小等等,线程再怎么复用进程的代码,也得要有自己的一部分属性,进程相关的属性
由os来提供,而线程想要和进程有不一样的地方,就得不光让os来提供属性,还要让库来提供一部分。
看这张图:
这里搞5个线程:
前面说了,线程要有自己独立的栈空间,但如果这里5个都用虚拟地址空间中的栈的话,整个栈空间就乱了,这样会导致不同的线程之间相互影响。那么如何保证每个栈区有独立栈空间呢?
首先os仅能提供上图中的虚拟地址空间,不会再开别的了,而这里的空间是系统层的,既然系统层提供不了,我们还有用户层,由phread库来提供:
其中,动态库地址加载到共享区中,而库的内部还为各个线程开辟了独立的空间,其中就包括了线程的栈空间,线程的局部存储等会给例子说。
所以说库是不仅可以提供某些方法的,还可以提供某些数据。
而每个栈的独立空间对应的起始地址就是线程的tid,所以上面打印出来的看起来就像个地址。
图中还有一个主线程栈,其实就是虚拟地址空间中的那个栈,这个栈是专门提供给主线程用的,这样也不会和我前面博客中的单执行流冲突,因为单执行流就是一个主线程,直接用的就是虚拟地址空间中的那个栈。
我们可以用pthread_self来获取当前线程的id,哪个线程调用的就返回哪个线程的id:
这里让新线程调用,返回的就是新线程自己的id:
不要用pthread_cancel(pthread_self())。
证明全局数据所有线程共享
代码:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
using namespace std;
int g_val = 0;
void* threadRoutine(void* arg)
{
while(1)
{
printf("%s -> g_val :: %d, p ::%p\n", (char*)arg, g_val, &g_val);
printf("========================================\n");
++g_val;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread1");
sleep(1);
while(1)
{
printf("main thread -> g_val :: %d, p ::%p\n", g_val, &g_val);
sleep(1);
}
return 0;
}
运行:
地址都相同,而且一个新线程中修改,主线程中的g_val也会被修改。不像进程那样,创建一个子进程,会有写时拷贝,父子进程互不影响。
如果我在g_val前面加上__thread,就变了:
运行:
__thread修饰全局变量,带来的结果就是让每一个线程各自拥有一个全局遍历,此即线程的局部存储。
线程调用进程替换函数
进程可以调用exec系列的函数来进行进程替换,线程这里调用会怎么样?
代码如下:
void* threadRoutine(void* arg)
{
// 新线程这里直接调用替换函数
execl("/bin/ls", "ls", nullptr);
printf("thread1 running\n");
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, nullptr);
while(1)
{
sleep(1);
printf("main thread running\n");
}
return 0;
}
运行:
会将整个进程都替换掉,主线程啥都没打印。
如果我让新线程睡5秒再替换呢?
运行:
发现,并不会因为主线程在执行某些任务而出问题,该替换时还是会替换。
同样的,也适用于fork。
这里写一个简单点的:
void* threadRoutine(void* arg)
{
(void)arg;
pid_t pid = fork();
if(pid == 0)
{
// 子进程
int count = 0;
while(1)
{
printf("child process %d is running\n", getpid());
printf("===================================\n");
sleep(1);
++count;
if(count == 5) break;
}
exit(0);
}
// 父进程
printf("father process %d is waiting child process %d\n", getpid(), pid);
waitpid(pid, nullptr, 0);
printf("wait success\n");
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, nullptr);
while(1)
{
sleep(1);
printf("main thread running\n");
}
return 0;
}
这里是新线程调用fork,等于是相对于整个进程创建了一个子进程。
新线程创建完子进程后直接对其进行等待,主线程保持原状态不变。
运行:
线程分离
进程等待时,如果父进程不想等子进程,可以直接对子进程发送的SIGCHLD进行忽略,直接将处理方式改为SIG_IGN就行,这样子进程退出后空间就自动释放了,不会出现僵尸进程的问题。
这里涉及到了信号的知识,听不懂的同学可以看我这篇:【Linux】三万字详解信号❤️❤️隔壁小孩看了都说学会了❤️❤️
父进程如果想等子进程的话,可以有两种方式来等待,一种是阻塞等待,一种是非阻塞等待,非阻塞的办事效率更高一点。但是主线程等待新线程的话,只能阻塞join等待,不能非阻塞join,这一点就很烦,但是主线程可以选择不等新线程,直接线程分离即可,新线程用pthread_detach,就可将其与主线程进行分离,主线程就不需要join等新线程了,此时新线程结束后会由os和库自动回收其相关的资源。
来段代码:
void* threadRoutine(void* arg)
{
// 线程分离
pthread_detach(pthread_self());
printf("thread1 [%p] is running\n", pthread_self());
return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, nullptr);
printf("main thread [%p] running\n", pthread_self());
void* res;
// 线程分离但还强制等待
int ret = pthread_join(tid, &res);
printf("join return val ::%d, strerror ::%s, res ::%d\n", ret, strerror(ret), (long)res);
return 0;
}
线程已经分离了,但是还强制等待,返回错误码22。
也可以在主线程中用pthread_detach(tid)来使新线程分离,这里就不演示了。
我再注释掉线程分离:
运行:
成功。
- 线程分离后,若新线程异常,则整个进程退出。
代码:
void* threadRoutine(void* arg)
{
// 线程分离
pthread_detach(pthread_self());
printf("thread1 [%p] is running\n", pthread_self());
// 线程分离并除零
int a = 10;
a /= 0;
return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, nullptr);
printf("main thread [%p] running\n", pthread_self());
// 主线程睡3s
sleep(3);
void* res;
// 线程分离但还强制等待
int ret = pthread_join(tid, &res);
printf("join return val ::%d, strerror ::%s, res ::%d\n", ret, strerror(ret), (long)res);
return 0;
}
运行:
整个进程退出。
C++中的thread
C++中也提供了线程相关的库,库名就叫thread:
这里就简单给个例子,之后专门开一个C++的博客再详细讲。
代码:
void fun()
{
while(1)
{
cout << "thread is running" << endl;
sleep(1);
}
}
int main()
{
thread t1(fun);
thread t2(fun);
thread t3(fun);
thread t4(fun);
while(1)
{
cout << "main thread is running" << endl;
sleep(1);
}
t1.join();
t2.join();
t3.join();
t4.join();
return 0;
}
运行:
但是注意,这里编译的时候也需要加上-lpthread链接系统中的pthread库,不然过不了:
因为这里C++虽然提供了thread库,但是其底层也是基于原生的phread线程库的,所以链接不加上-lpthread也无法使用。
到此结束。。。