一、线程的基础概念
1.1. 线程是什么
1.在一个程序里的一个执行路线就叫做线程,更准确的定义是线程是“一个进程内部的控制序列或执行流”
2.一切进程至少都是一个执行线程(进程:线程=1:n)
3.线程在进程内部运行,本质是在进程地址空间运行(一个进程的多个线程共用一块地址空间)
4.在Linux系统中,看到的PCB都是轻量级进程(线程的本质是轻量级进程)
5.通过进程的虚拟地址空间,可以看到进程的大部分资源,选择将进程合理分配给每个执行流,就形成了线程执行流(进程是申请资源,线程是分配资源)
创建进程,我们从无到有创建了很多东西,申请了很多资源,比如进程控制块、地址空间、页表、将磁盘中的代码和数据加载到内存中。而线程,就是该进程中的一个执行路线。更准确的定义是:线程是“一个进程内部的控制序列(执行流)”
举一个很简单的例子,比如QQ可以一边视频聊天,一边文字聊天,一边视频传输。这里的进程就是整个QQ,而线程则是该进程中的三个执行路线。
综上,我们可以总结进程和线程的关系:
进程是承担分配系统资源的基本实体。 这里要注意,光一个task_struct并不是进程,进程创建的一整套资源(进程控制块、地址空间、页表等)才称之为进程。
线程是CPU调度和分配的基本单位,是进程里面的执行流(线程在进程的地址空间内运行)。 也就是说,一个进程中的所有线程,用的都是该进程的地址空间,进程和线程是1:n的关系。有进程必有线程。
在Linux中没有真正意义上的线程,线程是用进程模拟的,数据结构也是用的task_struct
Linux线程本质上就是进程,只是线程间共享所有资源。如上图所示。 每个线程都有自己task_struct,因为每个线程可被CPU调度。多线程间又共享同一进程资源。这两点刚好满足线程的定义。Linux就是这样用进程实现了线程,所以线程又称为轻量级进程。
1.2. 线程的优点
1.创建一个新线程的代价要比创建一个新进程小得多
进程是承担资源分配的基本实体,需要去申请各种资源,而线程是CPU调度的基本单位,承担资源分配的角色,创建线程的代价要比进程小得多。
2.与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
每个进程都有自己独立的代码和数据空间,当切换进程时,需要保存/恢复进程运行环境,还需要切换内存地址空间(更新快表、更新缓存),因此进程之间的切换会有较大的开销。
线程在进程的地址空间内部运行,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,因此同一进程内的各个线程间不需要切换进程运行环境和内存地址空间,线程之间的切换开销小。
3.线程占用的资源要比进程少很多
线程只是占一个进程资源的一部分
4.能充分利用多处理器的可并行数量
当然进程也可以并行。
5.在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
比如:
I/O比较慢,我们可以一个线程执行I/O,另外一个线程执行计算。
计算密集型应用(加密,解密等等,占用CPU),为了能在多处理器系统上运行,将计算分解到多个线程中实现。
I/O密集型应用(访问数据库,打印内容等等,占用内存、带宽),为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
6.线程间通信的开销要比进程间通信少
各个进程的内存地址空间相互独立,只能通过请求操作系统内核的帮助来完成进程间通信,开销大。
同一进程下的各个线程间共享内存地址空间,可以直接通过读/写内存空间进行通信。
1.3. 线程的缺点
1.性能的损失
由于线程之间资源师共享的,因此多线程的临界资源一定会变多,而临界资源我们需要保证它的安全性,因此需要进行一系列的加锁、解锁、互斥等动作。
而这些动作都会带来副作用的,即导致性能的降低。
2.健壮性(鲁棒性)降低
健壮性就是程序在异常情况下是否能正常工作。
因为线程是共享资源的,所以线程之间是会互相影响的,如果遇到了不确定的情况,访问了原本不该访问的资源,会导致出现一些未知的错误,而进程具有独立性,所以进程的独立性是要强于线程。
3.缺乏访问的控制
在创建一个全局变量,多进程中只要有一方发生了写入,那么这个变量就会发生写时拷贝,因为进程之间是具有独立性的。
而在多线程中,大家共享地址空间,如果一方在写入,另外一方在读取,那么就有可能会发生问题。
4.编程难度提高
在调式方面,一个线程出现错误会影响整体,因此调试难度会增大。
1.4. 线程的用途
合理的使用多线程,能提高CPU密集型程序的执行效率。
合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
1.5. 线程私有的数据
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到。,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器
- 栈
- 上下文
- errno
- 信号屏蔽字
- 调度优先级
二、线程的操作
Linux没有真正意义上的线程,而是用进程模拟的。Linux虽然没有暴露创建线程的接口,但是暴露了创建轻量级进程的接口,这个接口就是一个非常接近底层的多线程库,叫做pthread线程库。这个库采用的标准是POSIX。
2.1. 创建线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
thread:输出型参数,线程的ID,操作系统不知道它的存在,这个线程ID是库提供的
因为操作系统没有线程的概念,用户又得使用线程,那么只能用一个库来实现
因此线程管理的动作,比如:线程创建、线程终止,线程等待,线程分离等都由这个库实现
attr:线程的属性,一般默认为NULL,交给库管理
start_routine:函数指针,返回值为void*,参数为void*,
这个指针指向的函数是线程的入口,
多线程就是把进程的代码拆成很多块,我们的一个线程执行的就是多个代码块之中的某一块
arg:给线程入口函数传递的参数
返回值:成功返回0,出错返回错误码
Compile and link with -pthread.//在链接的时候需要链接pthread库
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
//新线程的代码
void* thread_run(void*arg){
while(1){
printf("I am %s,my pid:%d\n",(char*)arg,getpid());
sleep(1);
}
}
int main(){
pthread_t tid;
pthread_create(&tid,NULL,thread_run,(void*)"thread 1");//创建新线程,执行thread_run函数
//主线程的代码
while(1){
printf("I am main thread,my pid:%d\n",getpid());
sleep(2);
}
return 0;
}
可以看到它们的pid是相同的,说明它们是同一个进程的不同执行流。
2.2. 线程的异常
如果一个线程发生了错误,比如除零或者野指针:
那么整个进程的所有线程都会终止:
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,操作系统释放进程的资源,该进程内的所有线程也就随即退出。这说明线程的鲁棒性不强。
2.3. ps -aL查看轻量级进程
可以看到它们的PID相同,但是LWP不同,这是因为LWP是轻量级进程的标识,PID是进程的标识。
在单进程单线程之中,PID和LWP是一样的,而单进程多线程之中,linux引入了线程组的概念。
线程组中每一个线程(轻量级进程)都存在一个进程描述符LWP,这个轻量级进程描述符就是用户级进程ID,是操作系统调度的做小单位。
操作系统真正调度的时候调度的是LWP。
2.4. 获取当前线程的线程ID
#include <pthread.h>
pthread_t pthread_self(void);
Compile and link with -pthread.
pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面的线程PID和LWP不是一回事,PID和LWP属于进程调度的范畴。
因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
2.5. 线程的终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
(1)从线程函数return
这种方法对主线程不适用,从main函数return相当于调用exit(),exit() 会让整个进程终止。主线程结束,整个进程也会随之结束,因为进程是承担系统分配资源的基本实体,所有的线程都是基于这个资源进行分配的,当进程都不在后,自然要进行资源的回收。
(2) 调用pthread_exit() 终止自己
当线程调用这个函数以后,线程会终止,主线程调用这个函数后主线程终止,新线程不受影响。
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
//新线程的代码
void* thread_run(void*arg){
while(1){
printf("I am %s,my pthread id:%lu, my pid:%d\n",(char*)arg,pthread_self(),getpid());
sleep(1);
pthread_exit(NULL);
}
}
int main(){
pthread_t tid;
pthread_create(&tid,NULL,thread_run,(void*)"thread 1");//创建新线程
//主线程的代码
printf("I am main thread,my thread id:%lu ,my pid:%d\n",pthread_self(), getpid());
sleep(2);
//获取新线程的退出码
void*ret=NULL;
pthread_join(tid,&ret);
printf("pthread quit code:%d\n",(long long)ret);//在64位下指针为8字节
return 0;
}
(3) 调用pthread_cancel终止同一进程的另外一个线程
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
//新线程的代码
void* thread_run(void*arg){
while(1){
printf("I am %s,my pthread id:%lu, my pid:%d\n",(char*)arg,pthread_self(),getpid());
sleep(1);
}
}
int main(){
pthread_t tid;
pthread_create(&tid,NULL,thread_run,(void*)"thread 1");//创建新线程
//获取新线程的退出码
void* ret=NULL;
pthread_cancel(tid);//新线程退出
printf("new thread %lu be cancled!\n",tid);
pthread_join(tid,&ret);
printf("pthread quit code:%d\n",(long long)ret);//在64位下指针为8字节
return 0;
}
通过这个函数终止的线程,其退出码为-1,退出码是一个在头文件中定义的宏:#define PTHREAD_CANCELED (void*) -1
2.6. 线程的等待
2.6.1. 为什么需要线程等待?
新线程退出时,必须被等待,因为已经退出的线程,它的空间不会被释放,仍然在进程的地址空间内,这样创建的线程不会复用刚才退出线程的地址空间,有点类似僵尸进程的问题。
2.6.2. 线程等待的操作函数
线程的底层是轻量级进程,线程退出的时候,会将退出码写入进程的PCB之中。因此,我们是可以获取它的退出码的。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
thread:要等待线程的ID
retval:输出型参数,获取线程退出的退出码。
返回值:成功返回0,失败返回错误码
Compile and link with -pthread.
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
//新线程的代码
void* thread_run(void*arg){
printf("I am %s,my pthread id:%lu, my pid:%d\n",(char*)arg,pthread_self(),getpid());
sleep(5);
return (void*)10;
}
int main(){
pthread_t tid;
pthread_create(&tid,NULL,thread_run,(void*)"thread 1");//创建新线程
//主线程的代码
printf("I am main thread,my thread id:%lu ,mypid:%d\n",pthread_self(),getpid());
sleep(2);
void*ret=NULL;
pthread_join(tid,&ret);
printf("pthread quit code:%d\n",(long long)ret);//在64位下指针为8字节
return 0;
}
调用该函数的线程将阻塞等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED(-1)。
- 如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。
如果线程出异常,那整个进程都会终止,因此无法获取退出码。
2.7. 线程的分离
默认情况下,新创建的线程是需要被等待退出的,否则会无法释放资源,如果不关心线程的返回值,这时线程等待就是一种负担,这个时候我们可以告诉操作系统进,当线程退出时,自动释放线程资源。这就是线程分离。
当新线程分离之后,主线程就不会再关注新线程的情况,新线程的资源就独立了,线程退出之后,自己就释放了自己的资源,不再往自己的PCB之中写入退出码,主线程也不再需要进行等待。
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
//新线程的代码
void* thread_run(void*arg){
pthread_detach(pthread_self());//新线程自己分离自己
while(1){
printf("I am %s,my pthread id:%lu, my pid:%d\n",(char*)arg,pthread_self(),getpid());
sleep(1);
}
}
int main(){
pthread_t tid;
pthread_create(&tid,NULL,thread_run,(void*)"thread 1");//创建新线程
//主线程的代码
printf("I am main thread,my thread id:%lu ,my pid:%d\n",pthread_self(), getpid());
sleep(2);
void*ret=NULL;
pthread_join(tid,&ret); //主线程等待新线程
printf("pthread quit code:%d\n",(long long)ret);//在64位下指针为8字节
pthread_exit((void*)10); //主线程退出
}
可以看到新线程分离以后,主线程并没有等待新线程,而是直接执行主线程退出的代码。
补充一点:即使线程分离以后,其中任何一个线程出现了异常,整个进程还是会被终止。
三、 进程和线程的区别汇总
3.1. 进程和线程的概念
进程: 进程是操作系统资源分配的基本实体
线程: 线程是CPU调度和分配的基本单位
线程共享进程数据,但也拥有自己的一部分数据:线程ID、寄存器、栈、errno、信号屏蔽字、调度优先级
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到。除此之外,各线程还共享以下进程资源和环境:文件描述符表、每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)、当前工作目录、用户id和组id
进程和线程的关系:
1.一个线程只能属于一个进程,但是一个进程可以有多个线程(至少一个线程),只有一个线程的叫单线程,一个进程至少有一个线程。进程:线程=1:n
2.资源分配给进程之后,进程内部的线程都可以共享该进程的资源
3.在处理机上运行的是线程
4.线程在执行的过程中需要协作同步,不同进程的线程需要利用消息通信来实现同步
3.5. 为什么要引入线程
1.更加易于调度
2.提高并发性,因为可以创建多个线程去执行同一个进程的不同部分
3.开销少,因为创建进程的话要创建PCB,存放上下文信息,文件信息等等,开销比较大,创建线程的话开销就会比较少
4.充分发挥多处理器的功能,如果创建出多线程进程,那么可以让线程在不同的处理器上运行,这样不仅可以提高效率,同时也发挥了每个处理器的作用。
3.6. 进程和线程的区别
根本区别: 进程是操作系统分配资源的基本实体,线程是CPU调度的基本单位
开销方面: 每个进程都有自己独立的代码和数据空间,因此进程之间的切换会有较大的开销。但是线程在进程的地址空间内部运行,因此同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,因此线程之间的切换开销小。
所处环境: 在操作系统中能同时运行多个进程,在同一个进程中有多个线程同时执行
内存分配: 系统在运行的时候会给每个进程分配不同的内存空间,但是不会给线程分配,线程使用的资源均来自于进程
包含关系: 线程是进程的一部分,没有线程的进程叫做单线程进程,有多个线程的进程叫做多线程进程
四、 线程互斥
首先补充几个概念:
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完 成
比如我们的线程都去访问同一个全局变量,这个全局变量就是临界资源。而我们的main函数之中的资源,其它的线程也能看到,但是不会去进行访问,因此不是临界资源。
下面的代码可以验证临界资源:
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int a=10;//全局变量,所有的线程都能访问
void *thread_run(void*arg){
while(1){
printf("%s,%lu,pid:%d,global a:%d,%p\n",(char*)arg,pthread_self(),getgid(),a,&a);
sleep(1);
}
return (void*)10;
}
int main(){
pthread_t tid1;
pthread_t tid2;
pthread_create(&tid1,NULL,thread_run,"thread 1");
pthread_create(&tid2,NULL,thread_run,"thread 2");
printf("before a:%d,%p\n",a,&a);
sleep(3);
a=100;
printf("after a:%d,%p\n",a,&a);
pthread_exit((void*)0);
}
可以看到它们共享的全局变量是同一个地址,因此是临界资源,当主线程将其修改后,其他线程访问的就是修改后的值。
另外,thread_run这个函数被两个执行流执行,因此该函数是重入函数。
4.1. 多个线程访问临界资源带来的问题
正常情况,假设我们定义一个变量 i ,这个变量 i 一定是保存在内存的栈当中的,我们要对这个变量 i 进行计算的时候,是CPU(两大核心功能:算术运算和逻辑运算)来计算的,假设要对变量 i = 10 进行 +1 操作,首先要将内存栈中的 i 的值为 10 告知给寄存器,此时,寄存器中就有一个值 10,让后让CPU对寄存器中的这个 10 进行 +1 操作,CPU +1 操作完毕后,将结果 11 回写到寄存器当中,此时寄存器中的值被改为 11,然后将寄存器中的值回写到内存当中,此时 i 的值为 11。
线程不安全:
而在多线程的情况下:假设有两个线程,线程A和线程B,线程A和线程B都想对全局变量 i 进行++。
假设全局变量 i 的值为 10,线程A从内存中把全局变量 i = 10 读到寄存器当中,此时,线程A的时间片到了,线程A被切换出来了,线程A的上下文信息中保存的是寄存器中的i = 10,程序计数器中保存的是下一条即将要执行的 ++ 指令,若此时线程B获取了CPU资源,也想对全局变量 i 进行 ++ 操作,因为此时线程A并未将运算结果返回到内存当中,所以线程B从内存当中读到的全局变量 i 的值还是10,然后将 i 的值读到寄存器中,然后再在CPU中进行 ++ 操作,然后将 ++ 后的结果 11,回写到寄存器,寄存器再回写到内存,此时内存当中 i 的值已经被线程B机型 ++ 后改为了 11,然后线程B将CPU资源让出来,此时线程A再切换回来的时候,它要执行的下一条指令是程序计数器中保存的对 i 进行 ++ 操作 ,而线程A此时 ++ 的 i 的值是从上下文信息中获取的,上下文信息中此时的 i = 10 ,此时线程A在CPU中完成对 i 的 ++ 操作,然后将结果 11 回写给寄存器,然后由寄存器再回写给内存,此时内存中的 i 被线程B改为了 11,虽然 ,线程A和线程B都对全局变量 i 进行了 ++ ,按理说最终全局变量 i 的值应该为12,而此时全局变量 i 的值却为11。
线程A对全局变量 i 加了一次,线程B也对全局变量 i 加了一次,而此时,全局变量的值为 11 而不是 12,由此就产生了多个线程同时操作临界资源的时候有可能产生二义性问题(线程不安全现象)
比如下面的四个新线程抢票程序:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100; //初始100张票,临界资源
void *route(void *arg) {
int ticket_sum=0;//记录抢票的个数
while (1) { //临界区
if ( ticket > 0 ) {
usleep(1000);
printf("%s sells ticket:%d\n", (char*)arg, ticket);
ticket_sum++:
ticket--;
}
else {
printf("I am %s,i get %d tickets!\n",(char*)arg,ticket_sum);
break;
}
}
}
int main( ) {
//创建四个新线程执行抢票代码
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
因为ticket为临界资源,而多个线程同时访问临界资源,导致信息不一致的问题
比如票数为1的时候,有三个线程进入if条件判断,一个线程对其进行了修改,临界资源ticket变为了0,而另外一个线程当它将ticket加载到寄存器进行自减操作时,ticket已经被修改成了0,这就出现ticket为负数的情况。
为了减少出现这种冲突的情况,我们可以让线程在进入if判断之前先休眠几秒:
当然这种方法并不好,最优解还是要加锁。
4.2. 锁
为了解决多个线程同时访问临界资源带来的问题,提出了锁的概念。linux之中将这把锁叫做互斥量
加锁的粒度越小越好,因为加锁的地方是串行的,加锁的地方越多,串行的地方也随之越多,对多线程的弱化作用也就越大。
4.2.1. 锁(互斥量)的接口
初始化锁和释放锁:
#include <pthread.h>
//销毁锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//初始化锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
mutex:要释放或初始化的锁
atrr:系统自动设置,不关心。
//也可以使用这个全局变量,但是太麻烦
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
初始化完锁以后还要加锁和解锁:
对上面的代码进行加锁:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100; //初始100张票,临界资源
pthread_mutex_t lock;//创建锁
void *route(void *arg) {
int ticket_sum=0;//记录抢票的个数
while (1) { //临界区
pthread_mutex_lock(&lock);//进入循环时加锁
if ( ticket > 0 ) {
usleep(1000);
printf("%s sells ticket:%d\n", (char*)arg, ticket);
ticket_sum++:
ticket--;
pthread_mutex_unlock(&lock);//出循环时解锁
//线程切换是在内核态转为用户态时,因此可以增加一些系统调用,防止快的进程把票全抢了
usleep(1000);
}
else {
printf("I am %s,i get %d tickets!\n",(char*)arg,ticket_sum);
pthread_mutex_unlock(&lock);//出循环时解锁
break;
}
}
}
int main( ) {
pthread_mutex_init(&lock,NULL);//初始化锁
//创建四个新线程执行抢票代码
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&lock);//释放锁
}
4.2.2. 互斥量(锁)的实现原理
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理 器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
互斥锁的底层是一个互斥量,而互斥量的本质就是一个计数器,计数器的取值只有两种情况,一种是 1 ,一种是 0 ;
1:表示当前临界资源可以被访问。
0:表示当前临界资源不可以被访问。
整个过程中,为1的mutex只有一份,exchange一条汇编就完成了寄存器和内存数据的交换。
假设A线程在申请锁,由于每个线程的寄存器是私有的,而锁是共享的,在交换的过程之中,A会将自身寄存器之中的0和互斥锁的1进行交换,
如果此时B抢占了A进程(发生了进程切换),由于上下文保护,A线程CPU寄存器中的1会被保存到TSS中。B与互斥锁交换,此时的互斥锁的内容为0,if判断不成立,B被挂起等待。
A线程回来之后,从TSS中恢复数据,由于TSS中保存了从互斥锁中交换的1(TSS中的1加载到CPU寄存器中),if判断成功,A申请锁成功。
解锁:将CPU寄存器中的1和互斥锁中的0进行交换。
4.3. 死锁
死锁是指在同一组进程中的各个进程均占有不会释放的资源,但因互相申请被其它进程所占有不会释放的资源而处于一种永久等待的状态。比如线程A获取到互斥锁1 ,线程B获取到互斥锁2的时候,线程A和线程B同时还想获取对方手里的锁(线程A还想获取互斥锁2,线程B还想获取互斥锁1),此时就会导致死锁。
4.3.1. 死锁的四个必要条件
-
互斥条件:
一个资源每次只能被一个执行流使用 -
请求与保持条件:
一个执行流引请求资源而阻塞时,对方已获得的资源保持不放
解决办法:允许进程只获得运行初期需要的资源,便开始运行,在运行过程中逐步释放掉分配到的已经使用完毕的资源,然后再去请求新的资源。
-
不剥夺条件:
一个执行流已获得的资源,在未使用完之前,不能强行剥离 -
循环等待条件:
若干执行流之间形成一种头尾相接的循环等待资源的关系
4.3.2. 避免死锁的方法
- 破坏死锁的四个必要条件(随便破坏一个即可)
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配(能不用锁就不用锁)
五、 可重入函数和线程安全
线程安全:
多个线程并发同一段代码时,出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题(多个线程,调用不可重入函数导致的)。STL和智能指针都不是线程安全的。STL极度追求效率、加锁会造成效率的影响。
可重入函数:
同一个函数,被不同的执行流调用,当前一个执行流还没执行完,就有其它的执行流再次进入,我们成之为重入函数。
一个函数在重入的情况下,运行结果不会出现任何问题,该函数被称为可重入函数,否则是不可重入函数(大部分的函数都是不可重入的)
常见线程不安全情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况:
- 每个线程对全局变量或者静态变量只有读取的权限,没有写入的权限,就不会改变共享资源,一般就是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况:
-
不使用全局变量或者静态变量
每个线程私有一个栈,而全局变量或者静态变量是共享的,因此不使用全局或者静态变量就能在一定程度上保证安全吸顶。 -
不使用malloc或者new开辟空间
malloc和new的空间都是在堆上的,因此STL的容器基本上是不可重入的,因为它们都会自动进行扩容。 -
不调用不可重入函数
-
不返回静态或者全局数据,所有数据都由函数的调用者提供
-
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
比如C语言提供的接口基本上都包括全局变量errno,在线程之中就将其拷贝了一份,保证每个线程是私有的
可重入与线程安全联系
- 函数是可重入的,那就是线程安全
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数一定是线程安全的
六、线程同步
在保证数据安全的前提下(一般使用加锁的方式),让多个线程能够按照某种特定的顺序访问临界资源,从而有效的避免饥饿问题,这种就叫做同步。 同步是为了协同高效完成某些事物。
比如有两个线程,线程A负责往队列之中添加数据(队列为空就添加),B负责从队列之中读取数据(队列不为空)。
如果线程A的优先级高于线程B,竞争力比B强, 假设1万次中,A连续成功的竞争申请到了9千次锁,但是只在第一次放入了数据,后面因为队列不为空,因此只是重复的进行申请锁和释放锁的过程。
这样虽然不会出错,成功的保护了临界资源,但是A做了很多没有意义的事情,这样效率就会非常低下,非常不合理。
合理的方式是:
A申请锁,放数据,释放锁、当A条件不满足(队列不为空)后就不再进行申请了,直接通知B来申请;
B再来申请锁,读数据,释放锁、当B线程的条件不满足后(队列为空),也不再继续了,然后通知A。
线程同步的编码方式:
- 当线程访问临界资源,如果条件不满足,就挂起等待,释放锁
- 发现条件不满足,通知对方
6.1. 条件变量及其接口
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
#include <pthread.h>
//释放条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
//初始化条件变量
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
cond:要释放或初始化的条件变量
attr:系统自动设置,不关心
//全局变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
条件等待:
#include <pthread.h>
//不阻塞等待,
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
//阻塞等待,直到被唤醒
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
和锁类似,条件变量需要唤醒等待的线程:
以下面的代码为例:
#include <stdio.h>
#include <pthread.h>
#include<unistd.h>
pthread_mutex_t lock;//创建一个锁
pthread_cond_t eat_cond;//创建吃饭的条件变量
pthread_cond_t cook_cond;//创建做饭的条件变量
int rice_bowl=0;//一开始饭的碗数为0
void* Eat(void*arg){
while(1){
sleep(1);
pthread_mutex_lock(&lock);//加锁
if(rice_bowl<=0){
pthread_cond_wait(&eat_cond,&lock);//当没饭时,吃饭的线程等待
}
rice_bowl--;//吃饭,饭碗-1
printf("I am %s,i am eating!\n",(char*)arg);
pthread_mutex_unlock(&lock);
if(rice_bowl<=0){
pthread_cond_signal(&cook_cond);//通知做饭的线程做饭
}
}
}
void*Cook(void*arg){
while(1){
sleep(1);
pthread_mutex_lock(&lock);
if(rice_bowl>0){
pthread_cond_wait(&cook_cond,&lock);//当饭的数量多于0,做饭的线程等待
}
rice_bowl++;//做饭,饭碗数量+1
printf("I am %s,i am cooking!\n",(char*)arg);
pthread_mutex_unlock(&lock);
pthread_cond_signal(&eat_cond);//通知吃饭的进程吃饭
}
}
int main(){
pthread_mutex_init(&lock,NULL);//初始化锁
pthread_cond_init(&eat_cond,NULL);//初始化条件变量
pthread_cond_init(&cook_cond,NULL);//初始化条件变量
pthread_t t1,t2;//创建吃饭和做饭的进程
pthread_create(&t2,NULL,Eat,"Diner");
pthread_create(&t1,NULL,Cook,"Cook");
while(1){}
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_mutex_destroy(&lock);//释放锁
pthread_cond_destroy(&eat_cond);//释放条件变量
pthread_cond_destroy(&cook_cond);//释放条件变量
return 0;
}
如果是一个Cook做饭,两个Diner吃的情况:
#include <stdio.h>
#include <pthread.h>
#include<unistd.h>
pthread_mutex_t lock;//创建一个锁
pthread_cond_t eat_cond;//创建吃饭的条件变量
pthread_cond_t cook_cond;//创建做饭的条件变量
int rice_bowl=0;//一开始饭的碗数为0
void* Eat(void*arg){
while(1){
sleep(2);//为了防止出现一个进程每次都抢到锁的情况,可以增加一些系统调用,休眠时间由1秒增加到2秒
pthread_mutex_lock(&lock);//加锁
while(rice_bowl<=0){
pthread_cond_wait(&eat_cond,&lock);//当没饭时,吃饭的线程等待
}
rice_bowl--;//吃饭,饭碗-1
printf("I am %s,i am eating!The number of bowl:%d\n",(char*)arg,rice_bowl);
pthread_mutex_unlock(&lock);
if(rice_bowl<=0){
pthread_cond_signal(&cook_cond);//通知做饭的线程做饭
}
}
}
void*Cook(void*arg){
while(1){
sleep(1);
pthread_mutex_lock(&lock);
while(rice_bowl>0){
pthread_cond_wait(&cook_cond,&lock);//当饭的数量多于0,做饭的线程等待
}
rice_bowl++;//做饭,饭碗数量+1
printf("I am %s,i am cooking!The number of bowl:%d\n",(char*)arg,rice_bowl);
pthread_mutex_unlock(&lock);
pthread_cond_signal(&eat_cond);//通知吃饭的进程吃饭
}
}
int main(){
pthread_mutex_init(&lock,NULL);//初始化锁
pthread_cond_init(&eat_cond,NULL);//初始化条件变量
pthread_cond_init(&cook_cond,NULL);//初始化条件变量
pthread_t t1,t2,t3,t4;//创建吃饭和做饭的进程
pthread_create(&t2,NULL,Eat,"Diner");
pthread_create(&t1,NULL,Cook,"Cook");
pthread_create(&t3,NULL,Eat,"Diner");
while(1){}
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_join(t3,NULL);
pthread_mutex_destroy(&lock);//释放锁
pthread_cond_destroy(&eat_cond);//释放条件变量
pthread_cond_destroy(&cook_cond);//释放条件变量
return 0;
}
如果是多对多的情况,继续使用if
判断,一开始没有饭,此时Diner1拿到了锁,则Diner1判断碗里没有饭后将自己放入PCB等待队列中进行等待,然后释放互斥锁,假设此时,Cook1拿到了互斥锁,然后Cook1做了一碗饭,然后释放锁并通知PCB等待队列,此时Diner1已经出队,假设此时Diner2拿到了锁,并吃了一碗饭,然后释放锁,然后Diner1又拿到了锁,而此时Diner1将要执行的是跳过pthread_cond_wait函数,则Diner1跳过了判断碗里是否有饭,直接吃饭,此时rice_bowl的值就由0变成-1。为了防止这种情况要把if
判断改成while
循环。
6.2. 为什么等待的时候需要传入互斥锁
pthread_cond_wait函数会在内部对互斥锁进行解锁,当有线程进去之后要把锁释放别人才能用。解锁之后,其他的执行流才能获取到这把互斥锁,所以,需要传入互斥锁,否则,如果在调用pthread_cond_wait线程在进行等待的时候,不释放互斥锁,其他线程就不能访问临界资源。
当pthread_cond_wait函数返回时,返回到了临界区内,所以该函数会让线程重新拥有锁。
因此,这个函数的功能可以总结如下:
- 等待条件变量满足;
- 把获得的锁释放掉;(注意:1,2两步是一个原子操作)
当然如果条件满足了,那么就不需要释放锁。所以释放锁这一步和等待条件满足一定是一起执行(指原子操作)。 - pthread_cond_wait()被唤醒时,它解除阻塞,并且尝试获取锁(不一定拿到锁)。因此,一般在使用的时候都是在一个循环里使用pthread_cond_wait()函数,因为它在返回的时候不一定能拿到锁(这可能会发生饿死情形,当然这取决于操作系统的调度策略)。
七、 生产者消费者模型
生产者消费者模型是生产者将产品放在交易场所,消费者将东西拿回去。
在计算机中本质是有一段内存空间,有多个线程进行生产,有多个线程进行消费
7.1. 生产者消费者遵循的规则(321规则)
7.2. 生产者消费者模型的优点
- 解耦
- 支持并发
- 支持忙闲不均
耦合性:是一种软件度量,是指一程序中,模块及模块之间信息或参数依赖的程度。内聚性则是耦合性的相对概念。低耦合性是结构良好程序的特性,低耦合性程序的可读性及可维护性会比较好。
7.3. 基于BlockingQueue(阻塞队列)的生产者消费者模型
BlockingQueue 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。
下面是两个生产者和两个消费者的模型:
BlockQueue.hpp:
#ifndef _QUEUE_BLOCK_H_ //防止头文件重复包含
#define _QUEUE_BLOCK_H_
#include<iostream>
#include<pthread.h>
#include <unistd.h>
#include<queue>
class BlockQueue{
private:
std:: queue<int> q;
size_t cap;
pthread_mutex_t lock; //生产者和消费者共同竞争的锁
pthread_mutex_t c_lock;//消费者的锁
pthread_mutex_t p_lock;//生产者的锁
pthread_cond_t c_cond;//消费者在该条件变量下等
pthread_cond_t p_cond;//生产者在该条件变量下等
public:
BlockQueue(size_t _cap):cap(_cap){//构造函数,初始化锁和条件变量
pthread_mutex_init(&lock,nullptr);
pthread_mutex_init(&c_lock,nullptr);
pthread_mutex_init(&p_lock,nullptr);
pthread_cond_init(&c_cond,nullptr);
pthread_cond_init(&p_cond,nullptr);
}
bool IsFull(){//判断队列是否为满
return q.size()>=cap;
}
bool IsEmpty(){//判断队列是否为空
return q.empty();
}
void LockQueue(){//实现加锁
pthread_mutex_lock(&lock);
}
void UnlockQueue(){//实现解锁
pthread_mutex_unlock(&lock);
}
void LockQueueComsumer(){//实现消费者加锁
pthread_mutex_lock(&c_lock);
}
void UnlockQueueComsumer(){//实现消费者解锁
pthread_mutex_unlock(&c_lock);
}
void LockQueueProducer(){//实现生产者加锁
pthread_mutex_lock(&p_lock);
}
void UnlockQueueProducer(){//实现生产者解锁
pthread_mutex_unlock(&p_lock);
}
void WakeUpComsumer(){//唤醒消费者
std::cout<<"wake up consumer!"<<std::endl;
pthread_cond_signal(&c_cond);
}
void WakeUpProducer(){//唤醒生产者
std::cout<<"wake up producer!"<<std::endl;
pthread_cond_signal(&p_cond);
}
void ComsumerWait(){//消费者等待
std::cout<<"consumer wait!"<<std::endl;
pthread_cond_wait(&c_cond,&lock);
}
void ProducerWait(){//生产者等待
std::cout<<"producer wait!"<<std::endl;
pthread_cond_wait(&p_cond,&lock);
}
void Put(int in){//生产者进行写入
LockQueue();//生产者和消费者会竞争同一把锁
while(IsFull()){//判断是否已满,如果已满,生产者进行等待并唤醒消费者进行读数据
WakeUpComsumer();//唤醒消费者进行读数据
std::cout<<"queue full,notify consumer,producer stop!"<<std::endl;
ProducerWait();//生产者等待消费者进行读数据
}
q.push(in);
UnlockQueue();//释放掉锁
}
void Get(int& out){//消费者进行读取
LockQueue();//生产者和消费者会竞争同一把锁
while(IsEmpty()){//如果为空,消费者进行等待,通知生产者写入数据
WakeUpProducer();//唤醒生产者进行写入
std::cout<<"queue empty,notify producer,consumer stop"<<std::endl;
ComsumerWait();//消费者等待生产者进行写入
}
out=q.front();
q.pop();
UnlockQueue();//释放掉锁
}
~BlockQueue(){
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&c_cond);
pthread_cond_destroy(&p_cond);
}
};
main.cpp:
#include"BlockQueue.hpp"
using namespace std;
void*consumer_run(void*arg){
BlockQueue*bq=(BlockQueue*)arg;
while(true){
int n=0;
bq->LockQueueComsumer();//两个消费者先竞争一个锁
bq->Get(n);
cout<<"consumer "<<pthread_self()<<" data is:"<<n<<endl;
bq->UnlockQueueComsumer();//释放掉消费者的锁
sleep(1);
}
}
void*producer_run(void*arg){
sleep(1); //为了让消费者先抢到锁,让生产者先休眠1秒
BlockQueue*bq=(BlockQueue*)arg;
int count=0;
while(true){
bq->LockQueueProducer();//两个生产者先竞争一个锁
count=count%5+1;
bq->Put(count);
cout<<"producer "<<pthread_self()<<" data is:"<<count<<endl;
bq->UnlockQueueProducer();//释放生产者的锁
sleep(1);
}
}
int main(){
BlockQueue*bq=new BlockQueue(5);
pthread_t c1;//创建生产者和消费者的线程
pthread_t c2;//创建生产者和消费者的线程
pthread_t p1;//创建生产者和消费者的线程
pthread_t p2;//创建生产者和消费者的线程
pthread_create(&c1,nullptr,consumer_run,(void*)bq);
pthread_create(&c2,nullptr,consumer_run,(void*)bq);
pthread_create(&p1,nullptr,producer_run,(void*)bq);
pthread_create(&p2,nullptr,producer_run,(void*)bq);
//等待线程
pthread_join(c1,nullptr);
pthread_join(p1,nullptr);
pthread_join(c2,nullptr);
pthread_join(p2,nullptr);
//释放new出来的队列
delete bq;
return 0;
}