文章目录
- 线程安全问题的引入
- 线程互斥
- 互斥概念
- 互斥锁
- 互斥锁的计数器当中如何保证原子性
- 互斥锁基础API
- 初始化互斥锁变量函数
- 动态初始化
- 静态初始化
- 加锁函数
- 阻塞加锁
- 非阻塞加锁
- 带有超时时间的加锁
- 解锁函数
- 销毁互斥锁函数
- 线程同步
- 线程同步的必要性
- 条件变量
- 条件变量的使用原理
- 条件变量的原理
- 条件变量基础API
- 初始化条件变量函数
- 动态初始化
- 静态初始化
- 销毁条件变量函数
- 等待条件变量函数
- 唤醒条件变量函数
- 单个唤醒
- 广播唤醒
- 条件变量常见问题
线程安全问题的引入
使用一个 抢票程序 演示线程安全的概念及重要性:
代码如下:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 int g_tickets = 100;
W> 5 void* mythread_start(void* arg){
6 while(1){
7 if(g_tickets <= 0)
8 break;
W> 9 printf("I am %p, I have ticket %d\n", pthread_self(),g_tickets--);
10 //usleep(100);
11 //--g_tickets;
12 }
W> 13 }
14 int main(){
W> 15 int i = 0;
16 pthread_t tid[2];
17 for(int i=0; i<2; ++i){
18 int ret = pthread_create(&tid[i], NULL, mythread_start, NULL);
19 if(ret < 0){
20 perror("pthread_create");
21 return 0;
22 }
23 for(int i=0; i<2; ++i){
24 pthread_join(tid[i],NULL);//设置为阻塞
25 }
26 }
27 return 0;
28 }
执行结果:
结果分析:
我们代码的预期目标是创造两个黄牛(线程),让这两个线程去抢票,我们观察执行可以不难看出,抢票活动只有一个黄牛在参与,两外一个线程并没有参与,这是怎么回事呢?这是正常现象吗?
首先这是正常的现象和结果:我们称两个线程分贝为A和B,A首先被创建出来,A被创建出来后是不会等B创建出来再一起去抢票的,我们之前强调过,线程是被操作系统独立调度的,所以,先被创建出来的A线程就先执行了抢票程序,拿到了全部的票。
那什么样的结果算是有问题的呢?A和B抢到了同一张票,或者抢到了不合法的票(负数)
为了让两个黄牛都能抢到票,我们每次抢票结束后都让黄牛休息一下
代码如下:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 int g_tickets = 100;
W> 5 void* mythread_start(void* arg){
6 while(1){
7 if(g_tickets <= 0)
8 break;
W> 9 printf("I am %p, I have ticket %d\n", pthread_self(),g_tickets);
10 usleep(100);
11 --g_tickets;
12 }
W> 13 }
14 int main(){
W> 15 int i = 0;
16 pthread_t tid[2];
17 for(int i=0; i<2; ++i){
18 int ret = pthread_create(&tid[i], NULL, mythread_start, NULL);
19 if(ret < 0){
20 perror("pthread_create");
21 return 0;
22 }
23 for(int i=0; i<2; ++i){
24 pthread_join(tid[i],NULL);//设置为阻塞
25 }
26 }
27 return 0;
28 }
执行结果:
出现了段错误,这是什么原因呢?因为我们出现了二义性,在同一时刻两个线程对同一个数据进行修改。
错误分析:
1、假设同一个程序中有两个线程:A和B,A和B同时对一个int类型的全局变量n=10在其各自的线程入口函数中对这样一个全局变量进行加加操作
2、当A拥有CPU之后,对n进行++操作是非原子性的操作,也就是说,这个操作随时可能会被打断,假设A被调度,刚把全局变量的数值10读取到CPU的寄存器中时就被切换出去了(程序计数器中保存着线程A下一步要执行的指令,上下文信息中保存寄存器的值,这两个东西是为了当线程A再次拥有CPU使用权的时候,还原当时线程A切换出去的线程现场使用的)
3、当线程A被切换出去后,这会儿,可能线程B获取了CPU时间片,被调度,B对全局变量进行了++操作,此时全局变量从10变成了11,并且回写到内存中去
4、当线程A再次拥有CPU时间片之后,恢复当时切换出去的现场,继续往下执行,由于上下文信息中保存的全局变量仍然是10,执行完++操作变成11,然后再回写到内存中去,全局变量的值仍任是11
5、虽然两个线程都对这个全局变量进行了++操作,但是从值上面来看,全局变量仅仅进行了一次++操作,这就是线程的不安全
线程互斥
互斥概念
互斥要做的事情:控制线程的访问时序,保证各个线程对共享资源的独占式访问
。当多个线程能够同时访问到临界资源的时候,有可能会导致线程执行的结果产生二义性。而互斥就是要保证多个线程在访问同一个临界资源,执行临界区代码的时候 (非原子性性操作(线程可以被打断) ),控制访问时序。让一个线程独占临界资源执行完,再让另外一个独占执行。
临界资源
:能被多个线程同时访问到的资源
临界区代码
:访问临界资源的代码
互斥锁
互斥锁的本质就是0/1计数器,计数器的取值只能为0/1
- 0:表示当前线程不可以获取到互斥锁,也就不能访问临界区资源
- 1:表示当前线程可以获取到互斥锁,可以访问临界资源
需要注意的是:并不是说线程不获取互斥锁就不能访问临界资源,而是程序员需要在代码中用同一个互斥锁去约束多个线程,意思就是说,在加锁时,必须加的是同一把锁,当线程A拿到这把锁将这把锁的信号量改成0其他线程就无法访问了,但是当线程A被切换出去之后,其他线程加锁加的也是这把被A置为0的锁,只有当A访问结束将这把锁置为1其他线程才可以访问临界区资源,否则A加锁访问,B访问临界资源之前不加锁,这样也不能约束B,这就叫防君子不防小人,也就是说在每一个线程代码中要访问一个临界区资源之前要先获取锁,也就是加锁。
为了避免出现两个线程可能同时加锁成功,我们需要互斥锁本身就是原子性的
。
互斥锁的计数器当中如何保证原子性
为什么计数器(锁)当中的值从0变成1,或者从1变成0是原子性的呢?
为了保证互斥锁操作,大多数体系结构都提供了swap和exchange指令,该指令作用是把寄存器和内存单元的数据相交换,由于只有一条指令,只会出现执行成功和未执行两种情况,所以是原子性的
加锁的时候(将寄存器当中的值设置为0)
- 第一种情况:计数器的值为1,说明锁空闲,没有被线程加锁
- 交换情况:加锁成功
- 第二种情况:计数器的值为0,说明锁忙碌,被其他线程加锁拿走(此时当前线程进入等待状态:等待其他线程访问完毕将锁打开)
- 交换情况:加锁失败
解锁的时候(将寄存器当中的值设置为1)
- 计数器的值为0,需要解锁, 进行一步交换
- 交换成功:解锁成功
- 交换失败:解锁失败
互斥锁基础API
互斥锁的相关函数主要有以下5个:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 这些函数的第一个参数mutex都是一个
pthread_mutex_t结构体指针变量
,指向要操作的目标互斥锁
结构体 - 参数attr是一个
pthread_mutexattr_t结构指针变量
,指向互斥锁属性
结构体,如果填入NULL,表示使用默认属性
初始化互斥锁变量函数
动态初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
功能:初始化一个互斥锁结构体的属性
参数:
- pthread_mutex_t* : 一个指向pthread_mutex_t结构体的指针变量
- pthread_mutexattr_t* : 一个指向pthread_mutexattr_t结构的指针变量
返回值:成功返回0,失败返回errno
静态初始化
使用宏PTHREAD_MUTEX_INITIALIZER
来初始化一个互斥锁结构体,实际上该宏是将互斥锁的各个字段初始化为0
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//初始化一个mutex锁
宏定义源码:
#define PTHREAD_MUTEX_INITIALIZER \
{ { 0, 0, 0, 0, 0, __PTHREAD_SPINS, { 0, 0 } } }
加锁函数
阻塞加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
功能:给临界区代码阻塞等待加锁
参数:pthread_mutex_t* : 一个指向pthread_mutex_t结构体的指针变量
返回值:返回0表示成功;加锁失败返回errno
阻塞加锁解释:
- 如果mutex中互斥量的值为1,则pthread_mutex_lock函数就返回0表示加锁成功
- 如果mutex中互斥量的值为0,则线程阻塞在pthread_mutex_lock函数中,直到加锁成功
非阻塞加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);
功能:给临界区代码非阻塞加锁
参数:pthread_mutex_t* : 一个指向pthread_mutex_t结构体的指针变量
返回值:返回0表示成功;加锁失败返回errno
非阻塞加锁解释:
- 如果mutex中互斥量的值为1,则pthread_mutex_trylock函数就返回0表示加锁成功
- 如果mutex中互斥量的值为0,则pthread_mutex_trylock函数就返回错误码EBUSY
非阻塞加锁,拿锁的时候如果拿不到锁就直接返回了,也不等待,需要搭配循环使用,一直调用,当其返回值为zero时循环结束,否则使用这个接口的后果就和我们上面说的,当一个进程对一块被占用的资源访问时,如果不拿到这个锁,也是可以访问的,这样就达不到互斥访问的目的了
带有超时时间的加锁
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, const struct timespec *restrict abs_timeout);
功能:如果不能立即获得目标互斥锁,则等待abs_timeout时间,如果在等待时间内加锁成功则直接返回,如果超过等待时间则直接返回表示加锁失败
解锁函数
int pthread_mutex_unlock(pthread_mutex_t *mutex);
功能:将互斥锁中的互斥量从0变成1,表示其他线程可以获取该互斥锁了。以原子操作
的方式给一个互斥锁解锁,如果此时有其他线程正在等待这个互斥锁,则其他线程会获得该互斥锁
参数:pthread_mutex_t* : 一个指向pthread_mutex_t结构体的指针变量
返回值:返回0表示解锁成功;解锁失败返回errno
销毁互斥锁函数
int pthread_mutex_destroy(pthread_mutex_t *mutex);
功能:将目标互斥锁销毁
参数:pthread_mutex_t* : 一个指向pthread_mutex_t结构体的指针变量
返回值:销毁成功返回0;销毁失败返回errno
如果是动态初始化互斥锁,就需要销毁,如果是静态初始化就不用销毁。
在所有线程可能退出的地方进行解锁,防止产生死锁
代码演示:
我们对上面的黄牛抢票的程序进行修改,代码如下:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 int g_tickets = 100;
5 pthread_mutex_t lock;
W> 6 void* mythread_start(void* arg){
7 pthread_mutex_lock(&lock);//加锁
8 while(g_tickets--){
W> 9 printf("I am %p, I have ticket %d\n", pthread_self(),g_tickets);
10 }
W> 11 }
12 int main(){
13 pthread_mutex_init(&lock, NULL);//动态初始化
14 pthread_t tid[2];
15 for(int i=0; i<2; ++i){
16 int ret = pthread_create(&tid[i], NULL, mythread_start, NULL);
17 if(ret < 0){
18 perror("pthread_create");
19 return 0;
20 }
21 }
22 while(1){
23 sleep(1);
24 }
25 pthread_mutex_destroy(&lock);//退出时销毁互斥锁
26 return 0;
27 }
执行结果:
结果分析:
我们查看进程的调用栈,发现除了主线程外,只有一个线程,这个工作线程一直在等待锁,但是加锁的线程已经将锁锁上,而且执行完代码后退出了,现在这把锁就被永远的锁上,这种情况叫死锁
上述导致死锁的原因是线程退出时没有进行解锁,那防止死锁的产生也就需要在线程所有可能退出的地方进行解锁。
我们对代码进行改进,前五十张票让第一个线程拿,也就是全局变量小于50时,第一个线程进行解锁,让另一个线程可以接手,修改代码如下:
执行结果:
这下子并没有再出现死锁的情况了
结论:在线程所有有可能退出的地方都进行解锁,防止产生死锁,不要让线程退出的时候把锁带走
线程同步
线程同步的必要性
多个线程保证了互斥, 也就是保证了线程能够合理的访问临界资源了。但并不是说, 各个线程在访问临界资源的时候都是合理的。同步是为了保证多个线程对临界资源的访问的合理性
,这个合理性建立在多个线程保证互斥的情况下
。就比如说一个吃面的场景,当一个只有一个碗,吃面和做面的人都可以对这个碗进行访问操作,而对这个碗同时只能有一个人访问,不可以同时做面的人在往碗里做面吃面的人也在碗中吃面,要防止这样的情况发生就要采用互斥的原理,但是当有了互斥之后保证线程可以独自访问资源了,就如吃面的人可以独自吃面了而不会有做面的人来干扰,而做面的人也可以独自做面了,也不会有吃面的人同时和它抢生意,但是还有一个问题,就是你吃面的人不可以在碗里没有面的情况下去吃面,甚至把碗吃掉,你做面的人不可以在碗里有面的情况下,再去做面,那碗里都盛不下面了,所以此时要有同步的概念来保证访问临界资源的合理性。
同步:保证各个线程对于
共享资源的访问具有合理性
模拟一下上面的场景,代码如下:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 pthread_mutex_t g_bowl;//互斥锁,碗
5 int bowl = 1;
W> 6 void* eat_thread_start(void* arg){
7 while(1){
8 pthread_mutex_lock(&g_bowl);
9 printf("I am eatman eat%d\n",bowl--);
10 pthread_mutex_unlock(&g_bowl);
11 }
12 }
W> 13 void* make_thread_start(void* arg){
14 while(1){
15 pthread_mutex_lock(&g_bowl);
16 printf("I am makeman make%d\n",++bowl);
17 pthread_mutex_unlock(&g_bowl);
18 }
19 }
20 int main(){
21 pthread_mutex_init(&g_bowl, NULL);
22 pthread_t eat_tid;
23 pthread_t make_tid;
24 int ret = pthread_create(&eat_tid, NULL, eat_thread_start, NULL);
25 if(ret < 0){
26 perror("pthread_create");
27 }
28 ret = pthread_create(&make_tid, NULL, make_thread_start, NULL);
29 if(ret < 0){
30 perror("pthread_create");
31 }
32 pthread_join(eat_tid, NULL);
33 pthread_join(make_tid, NULL);
34 pthread_mutex_destroy(&g_bowl);
35 return 0;
36 }
执行结果:
做面做出了负数,出现了不合理的访问。
为了控制线程对共享资源的独占式访问,并且访问次序具有合理性,有以下方式实现线程同步
- POSIX信号量
- 条件变量
什么是对共享资源的独占式访问呢?
同学们在上厕所的时候,坑位就是一个共享资源,同学们在上厕所时,将厕所门关上,就是对共享资源的独占式访问,在你上厕所的时候,别人进不来(无法访问),引申到代码中,就是相当:将对于一个变量的操作变成原子性操作,要么操作成功,要么没操作,不存在操作一半被打断的情况
条件变量
条件变量的使用原理
- 线程在
加锁后,判断下临界资源是否可用
- 如果可用,则直接访问临界资源
- 如果不可用,则调用等待接口,让该线程进行等待
改进代码:
执行结果:可以看到这里就正常了。做面人不会因为碗里有面再做面,吃面人不会因为碗里没有面而把碗吃掉
但是上述代码有很严重的效率缺陷,十分耗费CPU资源,还不拿CPU资源干正事儿,假设此时一个线程拿到的时间片是200ms,该线程在该时间片内判断,吃面人要吃面但是如果碗里没面他就continue退出这一次循环,但是由于时间片没有结束,就会再次进行循环,加锁判断是否有面,但是还是没有面,就再次退出循环,如此反复,假设一次他加锁后,还没进行解锁,时间片就用完了,下一个线程要执行,要拿到互斥锁,但是此时互斥锁没有被释放,这个线程又要在所有的时间片内重复判断,但是这些举动是毫无意义的,程序效率就很低下
条件变量的原理
条件变量提供了一种线程间的通知机制:当某个共享数据达到某个值的时候,唤醒等待这个共享数据的线程
条件变量本质是一个PCB等待队列(存放在等待的线程的PCB)
条件变量基础API
条件变量的相关函数主要以下几个:
#include <pthread.h>
int pthread_cond_init(pthread_cond_t * cond,pthread_condattr_t * attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_timedwait(pthread_cond_t * cond, pthread_mutex_t * mutex, struct timespec * abstime);
int pthread_cond_wait(pthread_cond_t * cond, pthread_mutex_t * mutex);
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
- pthread_cond_t是条件变量结构体
- pthread_condattr_t是条件变量属性结构体
- PTHREAD_COND_INITIALIZER是一个宏,用来初始化条件变量结构体的,本质是将条件变量各个字段设置为0
初始化条件变量函数
动态初始化
int pthread_cond_init(pthread_cond_t* cond, pthread_condattr_t* attr);
功能:初始化条件变量结构体
参数:
- pthread_cond_t : 条件变量结构体指针
- pthread_condattr_t : 条件变量属性结构体指针,常传入NULL,使用默认的属性
pthread_cond_t cond;
pthread_cond_init(&cond,NULL);
静态初始化
使用宏PTHREAD_COND_INITIALIZER初始化条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量函数
int pthread_cond_destroy(pthread_cond_t *cond);
功能:销毁一个条件变量
参数:pthread_cond_t : 条件变量结构体指针
pthread_cond_destroy(&cond)
等待条件变量函数
int pthread_cond_wait(pthread_cond_t * cond, pthread_mutex_t * mutex);
功能:将调用该函数的线程放入PCB等待队列中
参数:
-
pthread_cond_t : 条件变量结构体指针
-
mutex : 该线程等待的互斥锁
唤醒条件变量函数
单个唤醒
int pthread_cond_signal(pthread_cond_t *cond);
功能:唤醒一个等待目标条件变量的线程,至于唤醒的是PCB等待队列当中的哪个线程,取决于线程的优先级和调度策略,(有可能唤醒两个或者三个或者全部都唤醒)
参数:pthread_cond_t : 条件变量结构体指针
广播唤醒
int pthread_cond_broadcast(pthread_cond_t *cond);
功能:以广播的方式唤醒所有等待目标条件变量的线程
参数:pthread_cond_t : 条件变量结构体指针
代码演示:
执行结果:吃面人吃面,做面人做面,很和谐
将上述程序的进程数量增加,比如说有两个做面人和两个吃面人,此时又会出现新的问题:当wait结束后,执行的是printf,也就是吃面/做面,再之后,我们的if判断就不起作用了,就会出现疯狂的做面和吃面,所以我们在wait结束后,printf之前,也就是吃面/做面前,再次判断是否能吃面/做面
代码如下:
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#define countman 2
pthread_mutex_t g_bowl;//互斥锁,碗
pthread_cond_t g_cond;//条件变量
int bowl = 1;
void* eat_thread_start(void* arg){
while(1){
pthread_mutex_lock(&g_bowl);
if(bowl==0){
//如果碗里没有面了,就不能吃面了
pthread_mutex_unlock(&g_bowl);
pthread_cond_wait(&g_cond, &g_bowl);//没面了等做面人做面
}
printf("I am eatman eat%d\n",bowl--);
pthread_mutex_unlock(&g_bowl);
pthread_cond_signal(&g_cond);//面吃完了通知做面人来做面
}
}
void* make_thread_start(void* arg){
while(1){
pthread_mutex_lock(&g_bowl);
if(bowl==1){
//如果碗里有面就不再做面了
pthread_mutex_unlock(&g_bowl);
pthread_cond_wait(&g_cond, &g_bowl);//等待吃面人吃面,没碗装面了
}
printf("I am makeman make%d\n",++bowl);
pthread_mutex_unlock(&g_bowl);
pthread_cond_signal(&g_cond);//面做好了通知吃面人吃面
}
}
int main(){
pthread_mutex_init(&g_bowl, NULL);
pthread_t eat_tid[countman];
pthread_t make_tid[countman];
for(int i=0; i<countman; ++i){
int ret = pthread_create(&eat_tid[i], NULL, eat_thread_start, NULL);
if(ret < 0){
perror("pthread_create");
}
ret = pthread_create(&make_tid[i], NULL, make_thread_start, NULL);
if(ret < 0){
perror("pthread_create");
}
}
for(int i=0; i<countman; ++i){
pthread_join(eat_tid[i], NULL);
pthread_join(make_tid[i], NULL);
}
pthread_mutex_destroy(&g_bowl);
pthread_cond_destroy(&g_cond);
return 0;
}
执行结果:
改进如下:
执行结果:我们虽然解决了刚刚多个吃面线程乱吃的问题,但是这里发现运行到最后程序不跑了
查看一下调用栈:
这样的一个场景就是所有线程都进入了等待队列中,但是没有人通知。说到底产生这样的问题是因为所有做面线程已经进入等待队列当中去了,但是此时吃面进程欲要通知等待队列中的做面线程但是它有可能将吃面线程通知出来,导致所有线程进入等待状态。
解决程序卡死方式:我们想要解决这个问题就要让做面线程每次唤醒的都是吃面线程,而吃面线程每次唤醒的都是做面线程。(当然也可以用int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒PCB等待队列中所有的线程,但是种不推荐使用,因为太浪费CPU资源)
改进如下:
执行结果:
发现不会再卡死或者疯狂吃面或者疯狂做面的情况,针对不同的资源,可以设置不同的条件变量
条件变量常见问题
条件变量的等待接口第二个参数为什么会有互斥锁?
当线程被放入等待队列后,就不会再执行后续代码了,也就不会再进行解锁了,锁将永远被锁上,其他线程就不会获得这把锁来进行操作,所以我们要将互斥锁传入条件变量等待函数,在该函数内部进行解锁操作,让其他线程可以拿到锁
pthread_cond_wait的内部是针对互斥锁做了上什么操作?先释放互斥锁还是先将线程放入到PCB等待队列?
pthread_cond_wait在调用的时候先要将线程放入等待队列当中。然后再释放互斥锁。
我们假设是先解锁然后再让线程入等待队列,这样的话,有可能这个线程刚解锁,就有其他线程拿到锁,并且修改了临界区资源,而此时的临界区资源被修改后恰好满足条件,所以这个线程就唤醒等待队列当中等待资源的线程,但是现在等待队列中还没有线程,它唤醒之后也进入了等待队列进行等待,此时当此线程进入等待队列当中时,之前的线程也刚刚到等待队列,那么此时两个线程就同时进到等待队列中了,谁也出不去
线程被唤醒之后会执行什么代码,为什么需要获取互斥锁?
pthread_cond_wait在返回之前一定会在其内部进行加锁操作,就是当一个线程在调用pthread_cond_wait函数进入等待队列中后,然后被唤醒时一定会在pthread_cond_wait函数中执行加锁操作。
而在加锁操作时:
- 抢到了:pthread_cond_wait函数就真正的执行完毕,函数返回。
- 没抢到:pthread_cond_wait函数就没有被真正的执行完成,还处于函数内部抢锁的逻辑,然后一直来进行抢锁操作。