💖作者:小树苗渴望变成参天大树🎈
🎉作者宣言:认真写好每一篇博客💤
🎊作者gitee:gitee✨
💞作者专栏:C语言,数据结构初阶,Linux,C++ 动态规划算法🎄
如 果 你 喜 欢 作 者 的 文 章 ,就 给 作 者 点 点 关 注 吧!
文章目录
- 前言
- 一、信号处理的流程图
- 二、3个小知识点
- 2.2.1可重入函数
- 2.2.2volatile
- 2.2.3SIGCHLD
- 三、线程的概念
- 3.1线程的优缺点
- 3.2线程的异常和用途
- 四、线程的操作
- 4.1 线程的创建(pthread_create)
- 4.2 线程的等待(pthread_join)
- 4.3 线程退出(pthread_exit)
- 4.4 C++的多线程
- 4.5 线程分离(pthread_detach)
- 五、验证独立栈和局部存储
- 六、线程互斥
- 6.1互斥量
- 6.2 互斥量的原理
- 6.3 RAII风格使用锁
- 6.4可重入VS线程安全
- 六、总结
前言
这篇一开始讲解的对于信号处理的的图解补充,在信号那节博主还要补充三个小的知识点,因为这里面有一个小知识点和多线程会有点关系,所以拿出来放在一起去讲,对于线程部分讲到很多硬件的知识,和我们当初学习进程一样,也是一块比较难啃的骨头,所以博主选择另写一篇博客,给大家吧前期知识补充后,后面才可以更好的学习线程。话不多说,我们一起来看正文
一、信号处理的流程图
对于信号处理,博主在给大家看一幅比较好的图解:
转自:信号执行流程,这位大佬画的图解非常到位,大家先通过我的上一篇博客,把信号的整个过程都看一遍,看这幅的前提还有一个是要了解用户态和内核态,这幅图完美的展示了信号处理的流程,以及一些注解,希望大家可以明白。不懂的再在评论区发出你的问题。
二、3个小知识点
我们在信号部分还有三个小知识点要补充,一个是信号引起的多执行流,二是volatile关键字的理解,三是父子进程之间的信号
2.2.1可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重(Reentrant) 函数
上面的案例造成的结果就是有可能内存泄漏,但是目前我们学到大部分都是不可重入函数,因为有多个执行流才导致这样的问题,让一个函数在计算的时候只有一个执行流执行不就行了,这个大家到时候等博主讲解到多线程的时候就知道了,因为大家对于执行流的概念还不是特别的清楚。学到线程大家就知道了
如果一个函数符合以下条件之一则是不可重入的:
(1) 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
(2) 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
2.2.2volatile
这个关键字在我们c语言就出现过,但是几乎没有使用过,这个关键字的作用保持内存的可见性,我将通过例子给大家介绍,来看代码:
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
int flag=0;
void myhandler(int sig)
{
cout<<"flag:1->0"<<"signal:"<<sig<<endl;
flag=1;
}
int main() {
signal(2,myhandler);
while(!flag)
{}
return 0;
}
这个程序就是通过自定义捕捉方法将全局变量修改导致循环终止,上面的结果符合我们的预期
大家还记得我们在学习右值引用的时候,就说过编译器会进行优化,那我们的gcc/g++也是编译器,所以它也会对一些操作进行优化,我们的gcc/g++不带任何选项的时候只会默认优化,它是有优化等级的,我们通过man g++
来查看文档
我们来看看默认的优化和最高级优化运行的效果:
通过O0和O1我们发现g++的默认优化等级是O0,我们看到一个奇怪的现象,我们使用优化程度较高的等级后,程序居然不退出了,而我们的flag确实变成了1,循环终止,进程退出,那为什么结果和我们看到的不一样呢?我们的CPU会做两种计算,一种是算术运算,一种是逻辑运算,我们的主函数里面只有一个flag检测,编译器没有发现这个执行流中没有操作可以修改这个变量,博主特地写的是!flag这是逻辑运算,就要意味检测真假要加载到CPU里面,数据会放在寄存器上面,造成内存不可见,当我们修改flag,其实是在内存层面修改了,而cpu的寄存器里面的数据还没有变,编译器没有检测到执行流修改这个变量,就导致我们每次检测都在寄存器上面去数据,因为寄存器上面的数据一直没有变过,所以程序就一直不退出
那我们怎么是的内存可见性呢,此时就需要使用volatile:
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
2.2.3SIGCHLD
父进程回收子进程退出的退出信息会遇到下面的问题,进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
就好比上一节说的os是怎么知道键盘上面有数据的,不是通过轮询去时不时去检查的,而是硬件中断。信号就是模拟硬件中断的,所以我们的子进程如果提前退出了,就会给父进程发送一个信号,告诉父进程我已经终止了,这样就解决上面说到问题,解决了父进程阻塞等待和轮询带来的损耗,接下来我们一起来看看再子进程提前退出后,父进程是不是收到对应的SIGCHLD的信号,来看案例:
#include<iostream>
#include<sys/wait.h>
#include<unistd.h>
using namespace std;
void myhandler(int signo)
{
cout<<"i get a signal"<<signo<<endl;//这里只是打印,没有干其他的事
}
int main()
{
signal(SIGCHLD,myhandler);
pid_t pid;
pid = fork();
if(pid < 0)
{
cout<<"fork error"<<endl;
return -1;
}
else if(pid == 0)
{
int cnt=0;
while(1)
{
cout<<"i am a chld:"<<getpid()<<endl;
if(cnt==5)
{
break;
}
sleep(1);
cnt++;
}
cout<<" chld quit!!!"<<endl;
exit(1);
}
while (true)
{
cout << "I am father process: " << getpid() << endl;
sleep(1);
}
waitpid(pid,NULL,0);
sleep(3);
return 0;
}
通过结果来看,我们的父进程确实收到了子进程发过来的信号。
- 一个子进程
我们的自定义捕捉函数刚才只做了打印,那我们直接将等待函数放到这个自定义捕捉函数里面不就行了
void myhandler(int signo)
{
sleep(3);//三秒后再等待
cout<<"i get a signal"<<signo<<endl;
waitpid(-1,NULL,0);//可以等待任意进程
}
- 多个子进程
我们看到了上面的结果,也是可以了,如果同时有10个子进程同时退出呢?退出一半呢,此时上面的代码也实现不了,我们可以循环的办法去实现10个进程同时退出的场景:
#include<iostream>
#include<sys/wait.h>
#include<unistd.h>
using namespace std;
void myhandler(int signo)
{
//sleep(3);//三秒后再等待
cout<<"i get a signal"<<signo<<endl;//这里只是打印,没有干其他的事
pid_t rid;
while((rid = waitpid(-1,nullptr,0))>0)
{
cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;
//sleep(1);
}
}
int main()
{
signal(SIGCHLD,myhandler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
sleep(10);
break;
}
cout << "child quit!!!" << endl;
exit(0);
}
}
// father
while (true)
{
cout << "I am father process: " << getpid() << endl;
sleep(1);
}
return 0;
}
退出一半呢?这时候就会出现自定义捕捉里面的循环是阻塞等待,一直退出不了自定义捕捉函数,所以使用非阻塞等待的方式:WNOHANG
void myhandler(int signo)
{
//sleep(3);//三秒后再等待
cout<<"i get a signal"<<signo<<endl;//这里只是打印,没有干其他的事
pid_t rid;
while((rid = waitpid(-1,nullptr,WNOHANG))>0)
{
cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;
//sleep(1);
}
}
每个退出的子进程退出的时候都会给父进程发送一个信号,因为不会阻塞等待了。
三、线程的概念
博主将通过一张图解带大家了解线程的概念
3.1线程的优缺点
(1)优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
(2)缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
3.2线程的异常和用途
(1)异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出(一会有代码演示)
(2)
3. 合理的使用多线程,能提高CPU密集型程序的执行效率
4. 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
四、线程的操作
在我们的Linux上,我们的内核中其实没有真正线程的概念,它只是使用进程来模拟的线程,所以也叫轻量化进程,这样就导致我们关于线程是没有直接的系统调用接口(其实是有一个的叫做clone,后面介绍),而是有轻量化进程的系统调用接口(函数太多,一会到后面介绍一下,现在不用关心),我们的用户不需要知道这些接口,那我们用户想用线程怎么办,这时候就有人帮我们对底层的轻量化进程的系统函数接口进行了封装,我们只需要包含pthread.h这个线程头文件就可以,这是一个第三方库,不是我们语言和编译器自带的,但是线程对于我们很重要,所以在Linux的任何一款os下,都默认安装了这个库,但是这是一个第三方库,所以学了动静态库,我们编译的时候一定不要忘记链接哪个库文件,这个一会用代码让大家看看。所以Linux下想使用多线程,就要使用pthread第三方库
博主先给大家直接写一份代码,来看看效果:
mythread.cc
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void* thread_func(void* arg)
{
while(1)
{
cout<<"new thread:"<<getpid()<<endl;
sleep(2);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, thread_func, NULL);
while(1)
{
cout<<"main thread:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
makefile:
mythread:mythread.cc
g++ -o $@ $^ -g -std=c++11 -lpthread
.PHONY:clean
clean:
- 通过结果来看,我们的两个死循环都跑起来了,在单执行流下,不可能两个死循环都可以执行,上面的结果表明,我们的执行流是两个。
- 我们看到两个执行流打出来的pid都是同一个,所以他们是一个进程内部的不同执行流
- 其次我们在编译的时候,在makefile中要加-lpthread选项,因为是第三方库,就算Linux默认下载好了,但是要链接还是要指定是哪个库文件。动静态库
4.1 线程的创建(pthread_create)
通过上面的例子,我们确实有办法去使用线程,接下来博主会一个个介绍关于线程的接口
pthread_create:
- 第一个参数:是一个输出型参数,在外面定义变量,返回线程的ID,在一开始的大图解里面讲过要唯一标识一下线程,让CPU知道调度是线程的调度还是进程的调度
- 第二个参数:设置线程的属性,为空就是默认属性,我们今天都设置为空,这个后面会说线程哪些属性必须有,现在不用关心
- 第三个参数:这是一个函数指针参数,就是我们线程要执行的启动函数,在大图解里面说过,一个进程内部的多个线程都是共享资源的,其中正文代码是划分的,目的是执行不同的任务,我们函数就是天然的划分任务的方式,所以将你要完成的任务写在一个函数里面,将函数通过参数传给线程就可以执行了
- 对于这个函数,它的返回值是void*,参数也是void*,为什么使用void*,目的是接受和返回任意类型的指针,因为线程也不知道你将来传什么返回什么,对于void的理解。
- 我们来看一下void和void*在Linux中的大小是多少?
因为博主的平台是64位的,所以指针大小为8字节,这个在后面我们强转会用到,我们的void不能创建变量,但是也是有大小的做为占位符的,就好比空类一样。- 因为我们的c语言没有模板,所以它要想办法来解决,就使用void去解决。大家可以想象qsort函数,也是这样实现的。
- 我们的第三个参数在我们之前学过的知识里面叫叫做回调函数,让线程执行不同的函数,达到分配的目的 。
- 第四个参数:这个参数就是第三个参数的参数,第三个参数是一个函数,第四个参数就是这个函数的参数,为什么要这么设计,因为你的第三个参数是一个函数指针,没有办法将参数带进去,所以需要第四个参数将线程的启动函数的参数给带进去。如果为空就不传参数,如上面的例子。
- 返回值:创建成功就返回0,创建失败就返回错误码,不像以前的有些函数失败就返回-1,它返回错误码,就能及时知道为什么创建失败了
- 对于一开始的例子,我们使用了pthread_create创建了一个线程,main函数是主线程的入口函数,thread_func是新线程的入口函数。
- 我们运行程序的时候,需要加-lpthread,我们来看看是不是链接上了:
ldd mythread 或者 ls /lib64/libpthread.so.0 -l
我们使用了软链接,这不就是我们之前下载第三方库然后使用软链接的方式找到这个库的方式,这样就不需要使用-I -L
选项去找到库了
- 我们再次运行这个进程,来通过ps -aL查看线程,-L叫做查看轻量化进程。
右图的查看出来是博主电脑上所有轻量化线程,我们看到我们自己写的程序有两个线程,一个是主线程一个是新线程。
- 第一列就是进程的pid,第二列是轻量化进程的ID,也就是我们创建线程的第一个参数返回出来的结果,第三列是对于的终端,不用管,其余两列不用说了
- PID=LWP就是主线程,其余的为新线程
我们来验证一下:
我们使用两种方式打印tid,发现都对不上我们想要的结果,这是为什么,因为线程的ID我们用户不需要知道,底层知道就好了,通过第二个打印地址我们看到,它返回的仅仅是一个数字(这个地址其实就是管理这个线程结构体的起始地址,就可以找到里面的属性,属性里面有这个线程的ID,后面会介绍的)。所以大家下去验证的时候发现对不上也不要奇怪。它不像创建子进程,需要靠返回的id去进行分流操作。这个大家理解一下。
- 我们把线程给干掉或者出异常终止了看看会有什么效果:
干掉:
异常:除0
上面两种一个是中断线程,一个是出异常,都是非正常退出,在进程退出的时候大家看案例会理解,因为有正常退出的场景,通过结果来看,我们把线程干掉相当于把整个进程给干掉了,信号相当于给进程发的,大家认真想想也明白,我们创建一个进程,CPU给我们的时间片是固定的,创建线程的时候,也是进程给线程分配资源,包括时间片也是固定的,所有线程的时间片加一起等于进程,才不会影响到其他进程,当一个线程收到信号或者异常终止了,说明进程的时间会变短,就有可能导致CPU上没有内容被调度的窗口,显然不行,所以我们的线程非正常终止,那么进程也会终止。
这也说明了线程的健壮性不好,一个出问题,整体就没了,编码的难度变大了,这些都是线程的缺点
- 理解线程之间共享资源
(1)我们在单独写一个show函数,两个线程都调用看:
我们看到两个线程都可以调用这个函数,大家还记得上面的2.2.1说的可重入函数吗,我们的show函数可以同时被多个执行流同时执行,show函数被重入了,这个函数里面的资源被两个线程共享了。
(2)我们定义一个全局变量,主线程进行修改,新线程负责打印,看看效果:
我们看到结果,发现地址是一样的,所以两个看到是同一个g_val.
通过上面两个例子,我们的线程共享资源,而且在大图解里面也说过,线程之间共享地址空间大部分资源,只是有一部分不共享(但是想访问到也是可以的,后面说),这个后面验证,说到共享资源,大家想到了什么,是不是通信,这里想说的是同一个进程内,线程的通信很容易
4.2 线程的等待(pthread_join)
我们的线程居然也有等待,大家想想我们创建子进程是为了干嘛,是不是帮助我们父进程做事的,那我们创建线程是干嘛,是不是也是帮助主线程做事的,所以我们新线程将事情做的咋样,我主线程是不是应该知道,所以我们的线程也有等待。
线程等待的原因:
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内部,新创建线程又不会复用刚才退出线程的地址空间,所以需要等待回收退出的线程
- 要告诉主线程事情完成怎么样了,类似于子进程,但是新线程不像子进程退出时候回变成僵尸,它直接退出,虽然不会对整个进程有啥影响,但是会占有地址空间的资源。
让我们一起来认识一下等待接口函数:
第一个参数是:线程的id,这个在创建的时候就有
第二个参数:是一个二级函数指针,也是一个输出型参数,接受线程启动函数的返回值的,就是来看结果的,要是不关心就设置为null,大家不明白二级函数指针,可以看看这篇博客函数指针,这个参数,博主一会会有案例展示的。
来看线程等待的案例:
我们看到线程五秒后退出了,然后监视窗口新线程直接没了,不会像子进程一样会变成僵尸进程,两秒后主线程回收了新线程的资源,大家把sleep(7)注释掉,在运行,会发现主线程会阻塞式等待新线程的退出。这个大家下去去测试一下。
- 线程等待的第二个参数,到底怎么使用的。
(1)我们简单将线程创建的第四个参数使用一下吧,我们写一个函数给线程执行,函数内部是实现1-5的加法,然后将结果返回出来
通过结果来看我们的主线程确实看到返回出来的结果了。并且大家也看到线程创建的第四个参数是怎么使用的。
(2)接下来再来看一个例子,更好的理解线程创建的第四个参数和线程等待的第二个参数。
我们实现的任务是1-100的相加,只不过使用类去完成:
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
class Request
{
public:
Request(int start, int end, const string &threadname)
: start_(start), end_(end), threadname_(threadname)
{}
int sum()
{
int sum=0;
for(int i = start_; i <= end_; i++)
{
cout << threadname_ << " is runing, caling..., " << i << endl;
sum+=i;
usleep(100000);
}
return sum;
}
public:
int start_;
int end_;
string threadname_;
};
class Response
{
public:
Response(int result, int exitcode):result_(result),exitcode_(exitcode)
{}
int result()
{
return result_;
}
int exitcode()
{
return exitcode_;
}
public:
int result_; // 计算结果
int exitcode_; // 计算结果是否可靠
};
void *sumCount(void *args) // 线程的参数和返回值,不仅仅可以用来进行传递一般参数,也可以传递对象!!
{
Request *rq = static_cast<Request*>(args); // Request *rq = (Request*)args
Response *rsp = new Response(0,0);
rsp->result_ = rq->sum();
delete rq;
return rsp;
}
int main()
{
pthread_t tid;
Request *rq = new Request(1, 100, "thread 1");
pthread_create(&tid, nullptr, sumCount, rq);
void *ret;
pthread_join(tid, &ret);
Response *rsp = static_cast<Response *>(ret);
cout << "rsp->result: " << rsp->result() << ", exitcode: " << rsp->exitcode() << endl;
delete rsp;
return 0;
}
通过上面这个例子,我们在SumCount函数里面开辟的Response *rsp,在主线程函数里面也可以使用,说明堆也是共享的。通过上面例子,希望大家对线程创建的第三个和第四个参数以及进程等待的第二个参数更好的理解。
为什么线程等待不像进程等待有退出码或者信号呢?
原因是做不到,因为线程非正常退出,整个进程就退出了,所以信号和退出码給进程就可以了,我线程不需要这些,我只能正确,不能就全部崩了。
4.3 线程退出(pthread_exit)
关于线程退出,如果不是非正常的退出,这会影响到整个进程,这个其实在线程创建那里简单看到效果了,所以我们将一个线程正常退出,无非就两种,第一,线程将事情解决完成返回,第二。线程事情做一半不想让他继续执行了,要提前提出,这样我们应该怎么去实现。
- 我们使用return来实现看看:
我们看到我们的主线程等待回收子进程后,还在继续运行,因为还打印了最好一句话,所以使用return也可以让一个线程正常退出。
- 使用一下exit()来看看行不行
我们通过监视窗口就可以看到新线程一退出,整个进程就退出了,而且也可以查看进程最近一次的退出码,所以说exit()是进程的退出函数,不管在哪个位置,调用就退出。所以这个函数不能让线程自己好好的退出。
- pthread_exit这是一个线程退出的函数,我们来看看怎么使用的:
我们让一个新线程先退出:
我们在让主进程先退出:
这里面的参数和return的返回值是一样的,返回结果,让主线程拿到。所以这个函数也可以让线程正常退出。如果主线程先退出,前提不是中断和异常的退出,那么其他新线程不受干扰,主线程变成僵尸
- pthread_cancel函数
这是一个取消正在执行的线程,这个函数因为不是在线程的启动函数内部调用的,所以线程执行到那一块是不好控制的,但是它确实可以让一个线程达到退出的效果
大家看到效果了线程取消执行,主线程没有受到影响
== 总结:我们的线程退出的方式其实还是很多了,不管哪种方法,都会有可能返回一个结果,需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了,要返回一个共享的资源数据,局部变量是在栈区开辟,栈区不是线程共享的,后面在说。大家下来测试一样,主线程非正常退出后,不管新线程在不在执行,都是直接退出的==
4.4 C++的多线程
我们的c++11在语言层面已经支持了多线程,我们语言已经给我们设计好了库,我们导入对应的头文件就可以使用了,写给大家写一段代码展示一下:
#include <iostream>
#include <string>
#include <pthread.h>
#include <thread>
#include <cstdlib>
#include <unistd.h>
using namespace std;
void* thread_function()
{
while(1)
{
cout<<"new thread:"<<getpid()<<endl;
sleep(1);
}
}
int main()
{
thread t1(thread_function);
t1.join();
return 0;
}
我们看到使用C++的线程接口也可以实现多线程的功能,我们上面的说的都是我们的原生线程,pthread库也叫原生线程库,但是它通过makefile的选项来看,我们c++能够实现多线程的根本原因,其实也是对原生线程库进行了封装了,所以我们其他语言如果要支持语言层面的多线程,那么实现的效果都是对底层的线程库进行封装。
我们的c/c++可以很好的跨平台,那么对于对不同平台的底层进行封装形成语言层面的库就是我们可跨平台性的根据,我们的Windows是真的具有线程的概念,有对应的线程数据结构,而Linux是模拟的,所以两者对底层实现就是不一样的,所以语言层面封装的方式肯定是不一样的,我们在不同的平台下进行写代码之前,需要下载编译器,会帮助我们下载对应的语言层面的库,我们语言里面的库实现了针对不同平台的封装方法,不同平台的库会帮助我们去屏蔽不是这个平台的封装方法,这个我们用户不需要去关心,跨平台性不是把我们上面说到一系列轻量化进程操作的接口的代码拿到Windows上跑就可以运行,而是语言层面进行封装了库,调用语言层面库里面的方法才具有可跨平台性。
在博主这一段话里面,给大家梳理一下逻辑,我们语言层面的库,就是类似于#include< thread >,
(1)#include<pthread.h>这是轻量化进程所对应的库,不属于哪个语言自身的,是第三方库,这个第三方库是间接表示系统调用,
(2)原来学习到的fork这种都是直接表示系统调用,而语言层面的库可以对着两种方式在进行封装,设计出语言层面特有的库。
(3)在不同平台的语言层面的库中。虽然名字是相同的,但你要知道这些库里面对方法的实现方法和平台有关系,只不过名字是一样,所以说是库给我们屏蔽了不是我们此平台的方法,这个大家要理解一下
线程ID及进程地址空间布局
#include <iostream>
#include <string>
#include <pthread.h>
#include <thread>
#include <cstdlib>
#include<functional>
#include <unistd.h>
using namespace std;
std::string toHex(pthread_t tid)//将获得的ID转成16进制的地址,方便看
{
char hex[64];
snprintf(hex, sizeof(hex), "%p", tid);
return hex;
}
void *threadRoutine(void *args)
{
while(true)
{
cout << "thread id: " << toHex(pthread_self()) << endl;//pthread_self是线程获得自己的ID
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"thread 1");
cout << "main thread create thead done, new thread id : " << toHex(tid) << endl;
pthread_join(tid, nullptr);
return 0;
}
通过结果发现,我们的tid和系统里面看到的不是一样的,这个现象也之前也看到过了,我们打印出来了地址,这个地址到底是什么?
我们知道使用多线程是调用原生线程库里面的方法,那原生线程库又是怎么实现的,其实在底层我们有一个创建轻量化进程的接口:
这种函数非常的复杂,使用成本非常高,里面有我标识参数,其余的参数不用管,因为我们的线程有自己独立的栈,栈是不共享的(一会验证),我们的设计者也知道这个函数对于用户的使成本太高了,所以使用了原生线程库对这个函数进行了封装,才让我们用户可以使用相对来说简单的函数,既然我们的线程库对这个函数进行封装了,那就必然要对这个函数进行传参,所以线程库内部必然要为线程开辟栈空间,启动函数等来维护我们的线程,所以线程库是维护我们线程的概念,底层去调度线程,不需要线程库去为维护,所以线程库只需要维护线程概念,不需要维护线程的执行流,而一个进程里面不止一个线程,注定了我们线程库要维护多份线程,要管理,所以要先描述在组织,因为底层没有线程的概念,所以没有办法进行管理,但是又不得不管理,此时就有了线程库去做这样的事,所以在维护线程的结构体里面,一定有时间片,id,独立栈, 线程的局部存储(一会介绍) == 等一些属性,来看一幅图:
通过上图我们看到线程库内部给我们维护了线程,上面仅仅演示了一部分属性,后面都会给大家验证的,我们的线程库是一个动态库,就意味着在运行的时候就要加载到内存中,那我们的线程的代码加载到哪里??答案是地址空间的共享区,通过页表映射到物理内存,除了主线程,所有其他线程的独立栈都在共享区,具体来说是在pthread库中的,而我们刚才通过代码打印出来的代码不是系统层面的线程ID,是因为这个打印出来的是地址是维护线程这个结构体的起始地址(这个地址是虚拟地址,用户可以直接拿到这个虚拟地址去访问里面的字段,看大小也知道在堆栈之间,所以线程库是加载到地址空间的共享区的),线程真的ID在这个结构体属性里面==。用这个起始地址叫做线程的ID,因为线程库其实是在用户层帮助我们维护线程(也叫用户级线程,由用户去维护,这是我们想想之前pthread_create的第二个参数,就是传线程的属性,虽然今天我们都设置为空,但是他是可以做到让用户去设置的。),所以用起始位置来标识线程ID有利于让用户看到线程的所有属性,也方便用户去设置线程的所有属性。简单理解,我们的进程是操作系统给我们维护结构体,我们使用系统调用接口去使用就可以,但是线程在操作系统层面是没有概念的,因为是模拟的,所以os层面没有办法给线程建模,所以需要第三方库进行去维护结构体,通过clone去和os打交道。
线程栈:也叫独立栈,不是共享的,因为我们每个线程都有自己的调用链,我们是通过函数天然的特点实现让线程去执行任务,就意味着每个线程的启动函数是每个线程的入口,也有可能这个启动函数又被其他进程当成了启动函数,因为这个函数暂时还是可重入的。所以就意味着每个线程要有自己调用链的栈帧结构来保存这个线程执行流在整个执行期间的临时变量,返回值,传参时候的形参,这样就不会受到影响,所以线程的栈是不共享的,但我们主线程的独立栈使用地址空间的栈结构就可以,而新线程需要线程库给他开辟属于自己的独立栈,所以创建一个线程,线程库先开辟一个结构体对象,然后再开辟一个栈空间,给这个新创建的线程去使用。后面给大家验证每个线程的独立栈不是共享的,但不做保护,也不是安全的。
4.5 线程分离(pthread_detach)
我们再线程等待说过一个线程如果不被等待,可能造成资源没有被回收的后果,默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源,但是pthread_join是阻塞等待的,但是现在我不想让他阻塞等待,而是去干一些其他的事情,这时候应该怎么办,使用线程分离,因为join able和分离是冲突的,所以设置一个另一个自动没有了。
我们来看看pthread_detach:
这个函数非常的简单,我们进行线程分离的时候,有两种方式
- 主线程去分离线程
void *threadRoutine(void *args)
{
char* name = (char*)args;
int i = 0;
while (i < 10)
{
cout << "threadname: " << name<<endl;
i++;
}
return nullptr;
}
int main()
{
// 创建多线程!
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)"new thread");
sleep(1); // 确保复制成功
pthread_detach(tid);
int n = pthread_join(tid, nullptr);
printf("n = %d why: %s\n", n,strerror(n));
return 0;
}
但我们的新线程已经被分离了,在join就会出现错误,而pthread_join的返回值是成功返回0,错误返回错误码,这和错误码是内部设置的一个标识。
- 新线程自己分离
五、验证独立栈和局部存储
博主使用一种方式创建多个线程,让这个线程同时执行同一个启动函数,我们通过打印里面的局部变量地址和变量,就可以验证这个临时变量是不是线程特有的。
#include <iostream>
#include <string>
#include <pthread.h>
#include <thread>
#include <cstdlib>
#include <vector>
#include <unistd.h>
using namespace std;
#define NUM 3//创建三个新线程
struct threadData//这样做的目的是为了添加新的属性,也可以再pthread_create的第四个参数传字符串,也可以传类
{
string threadname;
};
string toHex(pthread_t tid)//将tid转换程16进制的地址,方便查看
{
char buffer[128];
snprintf(buffer, sizeof(buffer), "%x", tid);
return buffer;
}
void InitThreadData(threadData *td, int number)//为了标识每个线程的名字,
{
td->threadname = "thread-" + to_string(number); // thread-0
}
void *threadRoutine(void *args)
{
threadData *td = static_cast<threadData *>(args);
string tid = toHex(pthread_self());//获取自己的线程ID
int i = 0;
while (i < 10)
{
cout << "pid: " << getpid() << ", tid : "
<< tid << ", threadname: " << td->threadname
<< ", i: " <<i<< " ,&i: " << &i <<endl;
i++;
}
sleep(100);//先不让线程退出
delete td;
return nullptr;
}
int main()
{
vector<pthread_t> tids;//保存线程的tid,方便退出等待的。
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
threadData *td = new threadData;//堆上开辟的,是所有进程共享的,每个线程在堆上开辟一块空间,拿走自己那一块空间
//threadData td;//这样写不行,原因是这个局部,传进去新线程看不到
InitThreadData(td, i);
pthread_create(&tid, nullptr, threadRoutine, td);
tids.push_back(tid);
sleep(1);//一秒创建一个线程,是效果打印在一起看的方便。
}
for (int i = 0; i < tids.size(); i++)
{
pthread_join(tids[i], nullptr);
}
return 0;
}
- 验证独立栈
通过上面的程序,我们的i就是局部变量,开辟在栈区,每个线程都执行这个函数,如果访问都是同一个i就意味i会加到27,其余的连个进程循环都进不去,如果i不是同一个,就意味每个线程的会有不同的i地址,而且每个线程都会使i加到9,我们来看效果:
通过地址来看我们的i确实使每个执行链都有一个,都放在自己的独立栈中,我们看到打印出来的tid和&i的地址使非常接近的,这也验证了我们刚才展示的线程结构体里面的属性的分布情况。
我们也说了独立栈不一定是安全的,不是共享的而已,我们想办法把这个局部地址给取出来看看又什么效果:
定义一个全局指针p,if(td->threadname == "thread-2") p = &i;
在启动函数的int i=0;
后面添加,看看能不能获取到地址:
我们获得了这个地址,而且这个地址是虚拟地址,我们可以访问修改的,在一个进程里,线程之间无秘密
- 认识局部存储
我们创建一个全局变量g_val=0,在启动函数的循环加加10次,因为变量i是局部的,所以每个线程都会将这个循环跑10遍,因为g_val是全局变量,所以是所有线程共享的,所以最后结果应该加到30.我们在循环里面顺便把全局变量的值和地址都打印出来看效果:
结果和我们分析的是一样的,而且看打印出来的地址也确实是在全局数据区的位置,那我们怎么做到一个线程拥有一个这个的全局变量呢??这时候就要使用__thread 这个编译选项来修改全局变量,来看效果:
我们看到结果,打印的地址也是靠近tid的,每个线程都拥有一个全局变量,虽然独占但是别人想访问还是可以访问到了。
- Linux中使用__thread定义的变量是线程局部存储变量,只能用于修饰POD类型,不能修饰class类型,因为无法自动调用构造函数和析构函数。这是因为__thread变量的初始化只能用编译期常量,而类的构造函数和析构函数是运行时才能确定的,所以无法在编译期确定。另外,__thread变量的生命周期是线程的生命周期,当线程结束时,__thread变量会被自动销毁,但是类的析构函数是在对象被销毁时才会被调用,所以无法自动调用类的析构函数。因此,如果要在__thread变量中使用类,需要手动调用构造函数和析构函数来管理对象的生命周期。
- 为什么我们不定义局部变量去呢,刚才的效果和定义局部变量的效果是一样的,为什么还要搞局部存储呢?我们上面的程序中,我们的每个线程都有需要打印进程pid,又在循环中,所以意味着会循环调用系统函数,使用一个局部存储,在循环前就可以将系统调用的结果保存起来不就行了,这样减少损耗,大家可能感觉鸡肋,我们的线程不是有独立栈吗,用临时变量保存不也一样,大家在好好想想,万一这个启动函数里面又调用其他函数呢,也需要你保存的数据呢,那按照原来的方法就需要传参,使用局部存储就不需要了,减少形参拷贝的损耗。
六、线程互斥
我们之前讲过线程哪些是共享的,哪些是不共享的,都给大家介绍了,因为存在共享资源,就会有可能出现一个线程在修改这个数据,此时另一个线程过来读取数据,就会出现数据不一致问题,导致数据不一致的根本原因在于不是原子性的,在管道哪里博主提过,不知道大家还记不记得,但是不重要,一会博主根据代码和图解给大家介绍。
我们来写一个抢票程序,电影院的电影假设有100张票,三个线程去循环抢,三个线程都是抢着100张票,所以是全局变量。不能重复抢:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 4
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 1000; // 用多线程,模拟一轮抢票
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
}
else
break;
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
大家应该看到数据不一致性问题,那接下来博主就通过一幅图带大家来看看怎么什么是数据不一致问题:
通过博主的画图,大家差不多理解了数据不一致问题的原因,本质是线程切换导致的,因为我们的ticket–不是原子性操作,所以导致在执行一半的时候会被打断,那么什么是原子性问题:原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。这只是概念,大家可以简单理解一下。造成上面的主要原因是:共享资源并且操作不是原子性的。一会针对原因去解决
解决疑惑:
- 像上图说的情况,刚好执行完判断语句后,就被打断了,这种事情应该是偶然的,但是大家多执行一下上面代码会发现每次都是这个结果,这是为什么??
因为我们加了休眠,使得一轮线程都被调度了,第一次的休眠才开始结束,这样就导致所有线程刚好都执行完判断,并且还有打印函数,使得效率更低,如果把休眠去掉,数据少点的话,就会出现一个线程把票全部抢完的情况,这个大家下去可以试试,大家可以想想一个进程的时间片就要已经很短了,在分到每个线程上就更少了。
6.1互斥量
通过上面的例子,我们发现在多线程的应用中,我们可能会出现上面的问题,大家还记得我们在进程间通信的时候提到过,共享资源就是临界资源,访问临界资源的代码就是临界区,所以我们要解决上面的问题就要解决三个问题:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
所以我们需要通过一把锁将临界区加锁,在任意时刻只允许一个线程来访问这段区域,中间不能被其他线程进来。这个锁就叫互斥量,加锁之后我们想要达成的效果如下图:
接下来我们来看一下怎么完成对临界区加锁的过程:
互斥量的接口
- 申请一个锁:有两种方式
(1)静态分配(全局锁)
//这个宏宏只能在全局的时候才可以用
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER//全局的
static pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER//静态的(其实就是在全局区)
(2)动态分配
pthread_mutex_t lock;//定义一个锁
pthread_mutex_init(&lock,nullptr);//给锁进行初始化
第二个参数大家不需要掌握,pthread_mutex_t 这是我们库定义的一个数据类型,和pthread一样都是库给我们定义出来的。上面的操作就相当于申请锁并且初始化
- 销毁一个锁:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_destroy(&lock);
销毁锁要注意三点:
1.使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
2.不要销毁一个已经加锁的互斥量
3.已经销毁的互斥量,要确保后面不会有线程再尝试加锁
- 加锁和解锁:
加锁有两种:
int pthread_mutex_lock(pthread_mutex_t *mutex);//阻塞等待加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);//非阻塞等待加锁
解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
加锁解锁很简单,传互斥量的指针就可以了
pthread_mutex_lock(&lock);
//临界区
pthread_mutex_unlock(&lock);
因为关于线程互斥的接口使用起来非常简单,所以接口参数博主就没有具体介绍,也不需要了解,大家会用就可以了。但是对于加锁和解锁博主要强调一下,我们一个线程后,一定要记得解锁,因为加锁的区域肯定是有多个线程同时访问的区域,你不解锁,就意味你一直拿着锁,别人过来如果是阻塞式申请锁,那么就会导致所有线程都会卡在申请锁的位置,这样就会出现死锁的情况,这个一会用例子给大家看看
改写一开始的代码:
我们上面造成原因就是因为ticket这个共享资源被好几个线程同时访问,导致数据不一致问题,此时我们访问ticket这个代码就是临界区,也就是我们加锁的区域。就是下面一段代码:
while (true)
{
if(tickets > 0)
{
//usleep(1);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
}
else
break;
}
加锁的区域越小越好,因为加锁就是用时间换安全,所以不止有用空间换时间的说法了,这个一会再说。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 4
class threadData
{
public:
threadData(int number,pthread_mutex_t* lock)
:lock_(lock)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
pthread_mutex_t* lock_;
};
int tickets = 100; // 用多线程,模拟一轮抢票
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
//1. pthread_mutex_lock(td->lock_);
//2.pthread_mutex_lock(td->lock_);
while (true)
{
//3.pthread_mutex_lock(td->lock_);
pthread_mutex_lock(td->lock_);//4
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
pthread_mutex_unlock(td->lock_);//4
}
else
{
pthread_mutex_unlock(td->lock_);//4
break;
}
//2.pthread_mutex_unlock(td->lock_);
//3.pthread_mutex_unlock(td->lock_);
//usleep(13);//5
}
//1. pthread_mutex_unlock(td->lock_);
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
pthread_mutex_t lock;//这也是在主线程的栈区开辟的变量所以不是共享的,新线程拿不到这个锁的,所以我们需要将这个锁传进去
//此时我们写的类就发挥作用了,我们在类中在i当一个属性,就可以传进去了。
pthread_mutex_init(&lock, nullptr);//对锁进行初始化
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i,&lock);//把锁通过类传到线程的启动函数
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
tids.push_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
pthread_mutex_destroy(&lock);//记得销毁锁。
return 0;
}
对于申请锁和销毁锁这个博主认为读者都理解,和新建线程一下,操作的都差不多,但是在博主刚才标识的临界区范围,博主写了四种加锁解锁的方式,首先加锁解锁是由程序员定的,你怎么弄都行,但是有bug就不是系统的锅,是程序员的锅,所以按照写代码上面的四种都可以,但是前两种会有问题。(下面测试将数据量改到3000,防止线程执行太快看到都是同一个线程执行完的效果)
- 在第一个位置加锁解锁,因为我们的线程是会被切换的,当我们的第一个线程拿到锁,他会等到循环结束才会进行解锁,那么在第一个线程抢票的时候,其余的线程会一直阻塞在申请锁的位置,不会往下面执行,在这种情况我们被阻塞的线程不会因为等时间片结束才会被切走,而是立马就被切走,这样就导致我们第一个拿到锁的线程又开始被调度,执行抢票,最后出现的效果就是票被第一个线程抢完了,来看效果:(放不放开5的代码都可以,数据量大不大无所谓)
- 我们的第二种加锁解锁的方式就达不到保护的作用,因为我们的加锁就在进入这个函数的时候会加锁一次,以后都在循环里面执行,当我们每个线程执行第一次循环之后,解锁了,就没有办法进行加锁了,还是之前并发的执行,访问同一份共享资源,所以这样是不行的,来看效果:(放不放开5的代码都可以,数据量大不大无所谓)
- 在我们的第三个位置加锁解锁,确实可以,但是我们这里有break,但我们通过break退出循环,那么这个锁就无法解锁了,这样就会出现死锁,其他线程阻塞等待,此时自己在循环又要申请,因为你申请的是一个没有解锁的锁,所以也会阻塞等待,我们测试一下,抢到还有50张的时候,退出循环:。(放开5的代码)
- 我们的第四种情况才是最好的,我们循环一次就解锁,然后再申请,我们这次测试分两种:
(1)(不放开5的代码,并且数据量只有100)
(2)(放开5的代码,并且数据量只有100)
解答:usleep(5)在本程序的作用
通过上面的四种分析,读者肯定有疑惑,博主每次再后面加的条件是什么,为什么要这么设计,原因是让大家效果看的更加明显,对于前两种方式那是逻辑性错误,所以跟数据都没有关系,博主为什么要将第四个设计成两种测试结果,原因是再一个线程的时间片内,如果不加5处的代码,一个线程要是立马解锁,他会立马去加锁,因为再时间片内的线程离锁最近,其他线程还要唤醒,所以我们的第一种测试大家看到还是同一个线程将票抢完,大家这时候或许会像,把数据量搞大,等这个线程时间片到,那也不一定,因为再临界区被切换的概率比刚解锁完切换的概率大,再临界区切换,因为还没有解锁,其他线程还是得不到锁访问临界区(一会解释),或许这样测试大家可能偶尔看到不是同一个线程再抢票,那第二种测试放开5处的代码,发现数据量很小的时候也是很公平的抢票,在现实生活中,我们抢到票不是立马就去抢下一张,而是还有后续工作,将票和你的信息绑定等等,这里用5处的代码代替 ,这样就给其他线程拿到的锁的概率变大,才会出现公平的现象,上面的测试需要通过大家下来自己去测试才能体会到。
结论:
- 加锁和解锁的位置一定要弄好,不然就会出现死锁的可能。
- 一个线程拿到锁,没有解锁,其他线程申请锁就会出现阻塞,不会往后面执行代码,同理自己申请锁之后,在申请一次也是属性在申请没有释放的锁,出现阻塞,形成死锁。
- 加锁的本质就是用时间换安全,使得临界区的代码是串行执行,所以尽量保证加锁的范围越少越好。
- 一个线程在时间片内会立马将解过的锁有加上,这是就近原则,使得其他线程长时间获取不得访问资源,这就是线程的饥饿问题。所以在纯互斥环境这也是锁分配不合理问题。像博主刚才使用5处代码去干预了一下,就不是纯互斥环境,所以说不是有互斥就有饥饿,这是一个必要关系
- 我们一个线程进行解锁,就会导致有一堆线程被唤醒抢锁,但是最后只会有一个线程拿到锁,导致其他被唤醒的线程无效,所以等待的一批线程必须排队,为了解决一解锁就加锁的情况,解锁的必须要到排队的后面进行等待。这样所有线程在获取锁会按照一定的顺序加锁,获取资源有一定的顺序性-----同步性
- .告诉大家一个结论,上面是访问这段临界区的线程都会进行加锁,万一我只想使用一半的线程加锁解锁,其他线程正常并发执行,在代码上很简单,加一个判断就可以,但是这样不好,在访问同一个共享资源加锁的情况,让所有线程都要遵守这个规则,不能有例外,不然有bug。
小故事带大家理解上面的案例
假设你的学校有一个自习室,但是这个自习室一次只允许一个人学习,并且在门外的墙上有一个钥匙,要自习的人进去把钥匙拿进去,关门就被反锁了,这样外面的人就进不来了,有一天你早上很早就来学校自习,你拿到钥匙进去把里面反锁,过了一会有人过来了想要进去进不去,因为没有钥匙,所以只能在外面阻塞等待,然后你又学了一会发现太累了,想走,当你出去把钥匙放在墙上,别人还没有反应过来,你发现门口好多人,这钥匙给出去不知道多久才能拿回来,所以你立马又把钥匙拿走了,反锁学习去,这就是你解锁立马加锁的场景,这次你真的不想学习了,出去了,但是钥匙忘记自习室,你出来关门了,外面的人没有钥匙进不去,你过来也进不去,钥匙在自习室里面,所以所有人都在外面苏泽等待了,在解答哪里有一段话加粗了,这里先用小故事讲解,你在自习中,突然肚子疼,想去上厕所,但你怕出去钥匙被别人拿走,所以你出去的时候就把钥匙也带走了,这就是在临界区的时候别切换了,等到上完厕所回来,在进去自习室自习,在你上厕所的一段时间中,别人还是访问不了自习室。这样就导致拿到锁的人才能访问自习室,因为加锁解锁是由原子性的(一会解释),这个故事后面还有后续,但是上面的故事已经可以帮助大家理解我写的案例了。
6.2 互斥量的原理
通过上面案例,我们发现锁确实可以解决我们的问题,但是大家有没有想过我们的传进去的锁都是同一个,所以锁也是共享的,在小故事里面也能体现,大家拿的都是同意把锁,锁就是保证共享资源的安全,所以锁本身的安全就要保证,所以加锁和解锁本身就是原子性操作,原子性操作也就是两种状态,没做和做完,之前说的原子性操作时一条汇编语句就是原子性的,那么临界区的代码可不止一条汇编,到底是怎么做到原子操作的。
在讲解原理之前我要补充一句一个线程在执行到临界区代码的时候是绝对有可能被切走的,但是不被别的进程进来访问资源,所以当临界区代码越少,在临界区切换走的概率就小,就会导致其他线程等待的时候变少,使得串行比例低,使得损耗的时间就越少,效率相对会高一些。
原理:
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下:
这就是我们一条加锁解锁的代码,因为我们调用也是函数,内部的伪代码是这样的,这个图大家不懂,来看博主的图解:
通过上面的图解相信大家应该明白锁是怎么实现原子性的吧,主要利用了硬件的上下文数据是线程独有的,调度会进行恢复的特点实现的。希望大家可以理解。
6.3 RAII风格使用锁
上面的接口博主也带大家认识了,所以博主打算对接口进行封装一下,这种写法通过对类对象的创建加锁,和生命周期到了调用析构函数进行解锁的方式去实现,我们先对类进行封装:
class mylock
{
public:
mylock(pthread_mutex_t*lock)
:lock_(lock)
{}
void lock()
{
pthread_mutex_lock(lock_);
}
void unlock()
{
pthread_mutex_unlock(lock_);
}
~mylock()
{
pthread_mutex_destroy(lock_);
}
private:
pthread_mutex_t* lock_;
};
class lockguard
{
public:
lockguard(pthread_mutex_t*lock)
:mutex_(lock)
{
mutex_.lock();
}
~lockguard()
{
mutex_.unlock();
}
private:
mylock mutex_;
};
定义一个全局锁,然后去对临界区进行加锁:
void *getTicket(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while (true)
{
{
lockguard lockg(&lock);
if(tickets > 0)
{
usleep(100);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
}
else
break;
}
usleep(13);
}
printf("%s ... quit\n", name);
return nullptr;
}
我们通过代码块来代表临时对象lockg的生命周期,并且把不是临界资源的休眠也隔开了,等离开这个代码块之后我们的lockg自动调用析构函数进行解锁,这样写使代码变得非常简洁。
6.4可重入VS线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
这两个是不同的概念,一个是线程并发引起来的问题,一个是函数的特性,**结论:**一个不可重入函数可能会造成线程不安全,但是可重入函数一定没有线程不安全
- 常见的线程不安全的情况
不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
- 常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
- 常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
- 常见可重入的情况
不使用全局变量或静态变量。
不使用用malloc或者new开辟出的空间。
不调用不可重入函数。
不返回静态或全局数据,所有数据都有函数的调用者提供。
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
- 可重入与线程安全联系
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
- 可重入与线程安全区别
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
六、总结
这篇博客花了很长时间进行总结,重要介绍多线程的知识,里面有许多和前面相近的知识,但是都有区别,大家一定要好好理解,接下来就是带大家来进行一些模型的介绍,cp模型,线程池等一些问题,希望大家后面来多支持支持博主。