目录
线程安全
【问】什么是线程不安全?
-
线程之间抢占式执行;
-
单核CPU,线程只能并发执行;多核CPU,线程可并行执行;
-
由于线程操作是一个非原子操作(线程操作可以被打断),因此多线程并发或并行运行时,有可能会导致程序结果二义性;
原子性:某一操作只会有两种状态:要么没有执行,要么执行完毕。
下面是一个线程不安全的例子:两线程,单核系统,操作同一个全局变量
假设有一个单核CPU机器中运行中两个线程A、B,两线程想同时对全局变量
g_i = 10
进行+1
操作;
- 由于是单核系统,线程A、B独立且抢占执行;
- 每个线程对变量
g_i = 10
进行操作并不是一个原子操作:内存的g_i
—》线程寄存器 —》CPU处理;- 完全有可能当线程A先获取到
g_i = 10
的值至寄存器还未进行++操作,系统就线程切换至B,这时B取到的仍为g_i = 10
,进行CPU++操作后,g_i = 11
;- 但我们会以为B是在A对
g_i = 10
先++的基础上再++,应该是g_i = 12
,这就是结果二义性;
代码模拟:
线程执行很快,为了观察现象,我们将数值设定很大;
为了方便观察,我们将输出结果重定向至一个文件,而不是打印至屏幕:
结果:产生二义性(单核CPU这种情况很难观察,现象并不是每次都会出现)
【问】什么是线程安全?
一个安全的多线程进程一般有如下特征:
- 每次运行结果和单线程运行的结果无二义性,且其他的变量的值也和预期的是一样的;
- 每个线程中对全局变量、静态变量只有读操作,而无写操作(若多线程需写操作,一般都需要考虑线程同步);
- 一个类或者程序所提供的接口对于线程来说是原子操作;
- 多个线程之间的切换不会导致该接口的执行结果存在二义性,
【问】如何解决线程不安全?
- 线程安全问题都是因为访问临界资源(全局变量及静态变量)引起的;
- 多线程互斥访问临界资源:对于临界资源进行加锁处理,确保同一时间只由一个线程占用;
- 多线程同步使用临界资源:为每个线程添加同步变量,保证线程对临界资源的访问时机都是合理的;
互斥机制——互斥锁
互斥是什么
互斥锁原理
- 互斥锁的本质就是0/1计数器(取值只能是 1 / 0);
- 加锁是一个主动行为,加锁、解锁的本质上是操作计数器,且操作必须是一个原子操作;
- 注意:不是说一个线程不获取互斥锁就不能访问临界资源,而是程序员需要在多个线程代码中添加同一个互斥锁,以此约束多个线程对这个临界资源的访问;
互斥锁的计数器为何能够保证原子性?
-
加锁、解锁 直接使用寄存器中的值和计数器内存的值交换(交换是一条汇编指令即可完成);
-
加锁:不管锁有无被占用,先初始化一个值为0的寄存器;
锁空闲:
锁忙碌:
-
解锁:解锁时肯定只有一个线程,不存在像加锁一样的抢占;
直接初始化一个值为 1 的寄存器:
互斥锁计数器原子性的汇编伪码:
lock:
movb $0, %al
xchgb %al, mutex //交换指令
if(al 寄存器的内容 > 0)
{
return 0; //加锁成功
}
else
{
挂起等待;
}
goto lock;
unlock:
movb $1, mutex
唤醒等待Mutex的线程;
return 0;
互斥锁的接口
-
初始化锁:一般采用动态初始化锁(静态锁就是一个宏,而不是函数);
-
加锁:常用接口1;
-
解锁
-
销毁锁
互斥锁的应用
若为上述线程不安全的例子增加互斥锁机制,会是什么结果呢?
【错误示范】
【正确加锁】
// 两线程,单核系统,操作同一个全局变量
// 加入互斥锁
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int g_i = 10; //临界资源
// 互斥锁
// 一般一个临界资源一个互斥锁
// 互斥锁一般都定义为全局变量
pthread_mutex_t g_lock;
// 工作线程入口函数
void* thread_start(void* arg)
{
(void)arg;
// 修改全局变量
while(1)
{
// 加锁(进入临界区前)
pthread_mutex_lock(&g_lock);
if(g_i <= 0) //非法访问
{
// 1.先解锁
// 在任何有可能退出临界区的情况前一定要解锁!!!
pthread_mutex_unlock(&g_lock);
// 2.再退出
break;
}
printf("this is work:%p, g_i is %d\n",pthread_self(), g_i);
g_i--;
// 解锁(退出临界区前)
pthread_mutex_unlock(&g_lock);
sleep(1); //让程序结果更明显
}
return NULL;
}
int main()
{
// 初始化锁
pthread_mutex_init(&g_lock, NULL);
// 创建两个线程
pthread_t tid[2]; //线程标识符数组
int i = 0;
for(; i < 2; ++i)
{
int ret = pthread_create(&tid[i], NULL, thread_start, NULL);
if(ret < 0)
{
perror("pthread_create ERROE");
return 0;
}
}
// 主线程进行线程等待
for(i = 0; i < 2; ++i)
{
pthread_join(tid[i], NULL);
}
// 销毁互斥锁
pthread_mutex_destroy(&g_lock);
return 0;
}
【总结】:
- 一定要找访问互斥资源前加锁;
- 一定要在任何可能退出临界区的地方解锁;
同步机制——条件变量
【问】有了互斥,为什么还要有同步?
某些情况,不同线程对于临界资源的访问是收到其他线程影响,比如吃面-做面模型
;
在
吃面-做面模型
中,临界资源是g_bowl
碗,有且仅有一个;
对于吃面人来说,只有当g_bowl = 1
碗里有面时,才能执行(吃面);
对于做面人来说,只有当g_bowl = 0
碗里没面时,才能执行(做面);
对于这种情况,如果仅对临界资源
g_bowl
增加一个互斥锁,肯定达不到要求,因为做面线程有可能一次做好多碗面,超出g_bowl
;吃面线程有可能一次吃好多碗面,甚至吃到g_bowl
为负;
基于这种情况,如果只是简单的为每个工作线程增加一个条件判断,当线程A条件判断后,如果条件不满足,完全有可能出现线程A一直在抢到锁 -》条件不满足 -》继续强锁。。。的死循环中,直至时间片结束,让线程B执行,但线程B也有可能陷入这种循环;非常消耗CPU资源!
我们需要对多线程进程增加同步机制,让每个线程对临界资源的访问时机合理;
因此,同步机制的存在的意义是:
- 多个线程有了互斥机制,保证了各线程能够独占访问临界资源;但并不是说,各线程访问临界资源是时机都是合理的;
- 同步机制保证了每个线程对临界资源的访问都是合理的;
条件变量
-
原理:本质是一个PCB等待队列,存放着等待的线程的PCB;
-
同步机制:条件变量,使用在加锁之后,用于判断临界资源是否满足使用条件;
当某一线程获得锁后,先判断条件变量是否满足,若不满足,则将自己添加至这种条件的条件变量队列中,并通知另一个线程的条件变量队列;
条件变量接口
-
初始化接口
-
等待接口
-
唤醒接口
-
销毁接口
条件变量的原理
- 条件变量的本质就是一个线程PCB等待队列;
- 条件变量的等待接口就是将当前线程添加至PCB等待队列,等待条件满足时被唤醒;
- 条件变量的唤醒接口就是当条件满足时,唤醒对应条件下的PCB等待队列中正在等待的线程;
“吃面-做面模型”的初步代码:
根据条件变量原理分析结果:
【问】条件变量关于pthread_cond_wait
的夺命追问:
1、条件变量等待接口的第二个参数为什么有互斥锁?
- 在线程访问临界资源前,一定是加锁访问的,来保证互斥性;
- 为等待接口传递互斥锁参数,就是要让进程进入PCB等待队列时也要解锁;
- 如果不解锁,该条件变量无意义,且会发生死锁;
2、pthread_cond_wait
内部针对互斥锁做了什么操作?先释放互斥锁还是先将线程放入PCB等待队列?
- 第一步:将线程放入PCB等待队列;
- 第二步:释放互斥锁;
- 顺序反了可能出现死锁;
3、线程被唤醒后会执行什么代码?需要再次获取互斥锁吗?
pthread_cond_wait
函数在阻塞结束后,让PCB等待队列里的线程出队前,一定会先进行加锁操作;- 且加锁的权限和其他不在PCB等待队列中的线程是一样;
- 若抢锁成功:
pthread_cond_wait
执行完毕,函数返回; - 强锁失败:继续抢锁,直到成功后返回;
条件变量的应用
对于上面的“吃面-做面模型”,我们需注意:
1、使用while
循环判断条件:
pthread_cond_single()
接口有可能将多个PCB等待队列的线程均唤醒;- 而从PCB等待队列唤醒的线程,相当于
pthread_cond_wait
函数阻塞完毕,从该函数退出,会继续执行下面的代码; - 因此从
pthread_cond_wait
函数阻塞退出的线程,必须重新判断条件是否满足,因此我们需将进入该接口的条件判断改为while
循环判断;
2、模型有几个条件关系,就创建多少个条件变量;
- 若只有一个条件变量,在线程通知PCB等待队列中的线程的时候,可能会将同种类的线程通知出来;
- 然后刚出来的线程,由于条件不满足,会继续进入PCB等待队列,最终有可能引发死锁;
- 因此需要将吃面线程和做面线程的条件变量分开;
吃面-做面模型
:
// [吃面-做面模型]
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#define THREAD_COUNT 2
int g_bowl = 0; //临界资源,0:空 1:有面
pthread_mutex_t g_lock; //临界资源互斥锁
pthread_cond_t g_cond_eat; //吃面线程条件变量
pthread_cond_t g_cond_make; //做面线程条件变量
// 吃面线程入口函数
void* eat_thread_start(void* arg)
{
while(1)
{
// 加锁
pthread_mutex_lock(&g_lock);
while(g_bowl == 0)
{
printf("吃面人:没面,等待~\n");
// 吃面线程进入等待队列
pthread_cond_wait(&g_cond_eat, &g_lock);
}
printf("EAT: 有g_bowl:%d,吃一碗\n", g_bowl--);
// 解锁
pthread_mutex_unlock(&g_lock);
// 通知做面等待队列中的做面线程做面
pthread_cond_signal(&g_cond_make);
}
}
// 做面线程入口函数
void* make_thread_start(void* arg)
{
while(1)
{
// 加锁
pthread_mutex_lock(&g_lock);
while(g_bowl == 1)
{
printf("做面人:仍有面,等待~\n");
// 做面线程进入等待队列
pthread_cond_wait(&g_cond_make, &g_lock);
}
printf("MAKE: g_bowl:%d碗面\n", ++g_bowl);
// 解锁
pthread_mutex_unlock(&g_lock);
// 通知吃面等待队列中的做面线程做面
pthread_cond_signal(&g_cond_eat);
}
}
int main()
{
// 初始化互斥锁
pthread_mutex_init(&g_lock, NULL);
// 初始化条件变量
pthread_cond_init(&g_cond_eat, NULL);
pthread_cond_init(&g_cond_make, NULL);
pthread_t eat_thread[THREAD_COUNT];
pthread_t make_thread[THREAD_COUNT];
// 创建线程
int i = 0;
for(; i < THREAD_COUNT; ++i)
{
int ret = pthread_create(&eat_thread[i], NULL, eat_thread_start, NULL);
if(ret < 0)
{
perror("pthread_create_eat");
return 0;
}
ret = pthread_create(&make_thread[i], NULL, make_thread_start, NULL);
if(ret < 0)
{
perror("pthread_create_make");
return 0;
}
}
// 主线程等待所有工作线程
for(i = 0; i < THREAD_COUNT; ++i)
{
pthread_join(eat_thread[i], NULL);
pthread_join(make_thread[i], NULL);
}
// 销毁互斥锁、条件变量
pthread_mutex_destroy(&g_lock);
pthread_cond_destroy(&g_cond_eat);
pthread_cond_destroy(&g_cond_make);
return 0;
}