线程安全
线程不安全现象
黄牛抢票程序
- 直接上代码,创建了4个线程分别表示4个抢票的,我们知道抢票,肯定是一人一票,不可能存在两个人买的是同一张票,接下来的代码的结果就是线程不安全的现象
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADCOUNT 4
int g_tickets = 100;
void* threadStart(void* arg)
{
(void)arg; // 防止报错的
while(1)
{
if(g_tickets > 0)
{
printf("i am thread %p, i get tickets:%d\n", pthread_self(), g_tickets);
g_tickets--;
}
else
{
printf("no tickets\n");
break;
}
}
}
int main()
{
pthread_t tid[THREADCOUNT];
for(int i = 0; i < THREADCOUNT; i++)
{
int ret = pthread_create(&tid[i], NULL, threadStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
}
for(int i = 0; i < THREADCOUNT; i++)
{
pthread_join(tid[i], NULL);
}
return 0;
}
- 观察前两行,两个不同的线程f700和e700拿到的是同一张票,显然这是不合理的
线程不安全原理
结论:线程不安全会导致程序结果出现二义性
假设我们现在只有一个cpu,程序中有一个全局变量g_val,初值为10,现在有两个线程A和B要对g_val进行++操作;
线程A获得cpu资源,对全局变量进行++操作,而++操作并非是原子性操作,也就意味着线程A在执行加的过程中有可能会被打断,假设,线程A刚刚从内存中读取g_val保存到寄存器中,就被切换出去了,那么此时,线程A的程序计数器中保存的是下一条指令,上下文信息中(寄存器中)保存的值为10;
切换到线程B,线程B对全局变量g_val执行了++操作,并将结果11回写到内存中,线程B被切换出去;
再次切换到线程A时,线程A恢复现场,继续往下执行,通过上下文信息从寄存器中读到的g_val的值还是10,然后根据程序计数器执行下一条指令,对g_val进行++操作后,也将结果11回写到内存中;
总结:理论上,线程A和线程B都对g_val进行了++操作,g_val的值应该被修改成12,但是上述场景中g_val的值被修改为11。这就是线程不安全
线程不安全怎么解决
互斥
互斥是指同一时间,有且只有一个执行流访问临界资源
保证互斥的方式:互斥锁
同步
同步是在互斥的基础上,解决资源分配不平衡的问题
线程安全相关概念
临界资源
多个线程都能访问到的资源,称之为临界资源
临界区
访问临界资源的代码区域,称之为临界区
互斥
想要保证互斥,我们需要用到互斥锁
互斥锁
互斥锁本身也是一种资源,也就是说,在代码中获取互斥锁的时候也要保证多个线程互斥
本质:
在互斥锁内部有一个计数器,也就是互斥量;计数器的值只能为1或0;
当线程获取互斥锁的时候,如果计数器当中的值为0,表示当前线程获取不到互斥锁,即加锁失败,此时一定不能去访问临界资源;
当线程获取互斥锁的时候,如果计数器当中的值为1,表示当前线程获取到互斥锁,即加锁成功,此时就可以执行代码中临界区的代码了
互斥锁中的计数器是如何保证原子性的?
在获取锁资源的时候(加锁的时候):
1.将寄存器当中的值赋值为0
2.将寄存器当中的值和内存中计数器的值交换
3.判断交换后寄存器当中的值,得出加锁结果
3.1当寄存器当中的值为1时,则表示可以加锁
3.2当寄存器当中的值为0时,则表示不可以加锁
初始化互斥锁变量
动态初始化
锁变量
互斥锁变量的类型为pthread_mutex_t,该类型是一个结构体
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
// mutex:传入要进行初始化的锁变量的地址
// attr:互斥锁属性,一般传递NULL,表示采用默认属性
静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
PTHREAD_MUTEX_INITIALIZER这个宏定义了一个结构体的值
加锁
pthread_mutex_lock
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
如果mutex当中计数器当中的值为1,则pthread_mutex_lock接口就返回了,表示加锁成功,同时将计数器当中的值改为0;
如果mutex当中计数器当中的值为0,则pthread_mutex_lock接口就阻塞了,函数不会返回,直到加锁成功
pthread_mutex_trylock
#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);
该接口是非阻塞接口
如果mutex当中计数器当中的值为1,则加锁成功,函数返回;
如果mutex当中计数器当中的值为0,也会返回,但是加锁失败,一定不能去访问临界资源
注意:一般非阻塞接口都要搭配循环去使用
pthread_mutex_timedlock
#include <pthread.h>
#include <time.h>
int pthread_mutex_timedlock(pthread_mutex_t *mutex, const struct timespec *abs_timeout);
带有超时时间的加锁接口
带有超时时间的加锁接口,意味着当不能立刻获取到锁资源的时候,会等待abs_timeout时间;
如果在abs_timeout时间内加锁成功,立即返回;
如果超过abs_timeout时间,也会返回,但是表示加锁失败;
需要循环加锁
解锁
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
不管是用哪个加锁接口加锁成功的,都可以使用该接口进行解锁;
解锁的时候,会将互斥锁中的计数器的值从0变为1,表示其他线程可以获取该互斥锁
销毁锁
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
针对动态初始化互斥锁(调用pthread_mutex_init函数进行初始化的互斥锁)
修改黄牛抢票程序
代码中需要关注以下四点:
1.在什么地方对锁进行初始化?
要保证程序中所有线程获取的是同一个互斥锁,所以可以将互斥锁定义为一个全局变量,然后再main函数中进行初始化
2.在哪里加锁?
要访问临界资源时加锁
3.在哪里解锁?
在任何可能导致线程退出的地方都要进行解锁,否则线程带着互斥锁退出了,其他线程会阻塞在获取锁的地方
4.在哪里释放锁?
对临界资源的全部操作结束后(线程全部返回)即可释放
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADCOUNT 4
int g_tickets = 100;
pthread_mutex_t lock_;
void* threadStart(void* arg)
{
(void)arg; // 防止报错的
while(1)
{
pthread_mutex_lock(&lock_);
if(g_tickets > 0)
{
printf("i am thread %p, i get tickets:%d\n", pthread_self(), g_tickets);
g_tickets--;
pthread_mutex_unlock(&lock_);
}
else
{
printf("no tickets\n");
pthread_mutex_unlock(&lock_);
break;
}
}
pthread_mutex_unlock(&lock_);
return NULL;
}
int main()
{
pthread_mutex_init(&lock_, NULL);
pthread_t tid[THREADCOUNT];
for(int i = 0; i < THREADCOUNT; i++)
{
int ret = pthread_create(&tid[i], NULL, threadStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
}
for(int i = 0; i < THREADCOUNT; i++)
{
pthread_join(tid[i], NULL);
}
pthread_mutex_destroy(&lock_);
return 0;
}
- 修改之后,虽然保证了互斥,程序运行结果没有二义性,但是,可以看到可能存在一个线程买好几张票的现象,资源不平衡
基于上边代码的问题,引入下面的同步
同步
同步是为了保证各个线程对临界资源访问的合理性
同步会使用到条件变量
条件变量
本质是一个TCB等待队列+一堆接口
条件变量的接口
初始化 pthread_cond_init
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
// cond:传入条件变量的地址 pthread_cond_t 是条件变量的类型
// attr:条件变量的属性,一般传递NULL,表示采用默认属性
等待 pthread_cond_wait
将调用该接口的线程放到PCB等待队列中
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
Q:为什么条件变量的等待接口中会有互斥锁?
1.因为同步并不能保证互斥,而保证互斥要用到互斥锁
2.pthread_cond_wait函数中会解锁,并且是先放到PCB等待队列中,然后再解锁
等待接口的内部实现原理
1.将调用pthread_cond_wait函数的线程放到PCB等待队列中
2.解互斥锁
3.等待被唤醒
唤醒 pthread_cond_signal和pthread_cond_broadcast
通知PCB等待队列当中的线程,将其从PCB队列中出队,唤醒该线程
pthread_cond_signal函数是至少唤醒一个线程
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_broadcast函数是将PCB队列当中的所有线程全部唤醒
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
Q:被唤醒后会做什么?
情况一:拿到互斥锁,pthread_cond_wait函数就返回了
情况二:没有抢到互斥锁,阻塞在pthread_cond_wait函数内部抢锁逻辑的执行流,一旦时间片耗尽,意味着当前线程被切换出来,程序计数器中保存的就是抢锁的指令,上下文信息当中保存的就是寄存器当中的值;当再次拥有CPU时间片之后,从程序计数器和上下文信息当中恢复抢锁逻辑;直到抢锁成功,pthread_cond_wait函数才返回
释放 pthread_cond_destroy
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
- 代码示例(吃面程序):有两个消费者线程,两个生产者线程;消费者只吃面,生产者只做面
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADCOUNT 2
int g_bowl = 0;
pthread_mutex_t mutex_;
pthread_cond_t cond_;
void* ConStart(void* arg)
{
while(1)
{
pthread_mutex_lock(&mutex_);
while(g_bowl <= 0)
{
// 放到PCB等待队列
pthread_cond_wait(&cond_, &mutex_);
}
printf("i am consumer %p, i eat %d\n", pthread_self(), g_bowl);
g_bowl--;
pthread_mutex_unlock(&mutex_);
pthread_cond_signal(&cond_);
}
return NULL;
}
void* ProStart(void* arg)
{
while(1)
{
pthread_mutex_lock(&mutex_);
while(g_bowl > 0)
{
// 放到PCB等待队列
pthread_cond_wait(&cond_, &mutex_);
}
g_bowl++;
printf("i am producer %p, i make %d\n", pthread_self(), g_bowl);
pthread_mutex_unlock(&mutex_);
pthread_cond_signal(&cond_);
}
return NULL;
}
int main()
{
pthread_mutex_init(&mutex_, NULL);
pthread_cond_init(&cond_, NULL);
pthread_t consumer[THREADCOUNT];
pthread_t producer[THREADCOUNT];
for(int i = 0; i < THREADCOUNT; i++)
{
int ret = pthread_create(&consumer[i], NULL, ConStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
ret = pthread_create(&producer[i], NULL, ProStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
}
for(int i = 0; i < THREADCOUNT; i++)
{
pthread_join(consumer[i], NULL);
pthread_join(producer[i], NULL);
}
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
return 0;
}
-
第一个现象:进程会卡住
-
第二个现象:卡的地方不一定相同
-
查看函数调用栈可以看到从Thread2到Thread5都卡在pthread_cond_wait函数中
上面代码产生的卡住的现象,是因为只有一个条件变量,而通过条件变量唤醒接口出队的线程可能有吃面的线程,也有可能有做面的线程,也有可能全是吃面的,也有可能全是做面的;假设一种极端情况,做面的线程中做好面之后(g_val==1),通知PCB等待队列出队的全部依然是做面的线程,那么做面的线程被唤醒之后就会进入到抢锁逻辑中,但是每次while循环判断都是g_val>0,然后又将做面线程放到PCB等待队列;也就是说线程会卡死在while循环中的pthread_cond_wait函数中
-
基于上述问题,对吃面程序做修改,就只需给吃面的线程和做面的线程各自一个条件变量(有两个PCB队列)
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADCOUNT 2
int g_bowl = 0;
pthread_mutex_t mutex_;
pthread_cond_t cond_; // 吃面的条件变量
pthread_cond_t makecond_; // 做面的条件变量
void* ConStart(void* arg)
{
while(1)
{
pthread_mutex_lock(&mutex_);
while(g_bowl <= 0)
{
// 放到PCB等待队列
pthread_cond_wait(&cond_, &mutex_);
}
printf("i am consumer %p, i eat %d\n", pthread_self(), g_bowl);
g_bowl--;
pthread_mutex_unlock(&mutex_);
pthread_cond_signal(&makecond_);
}
return NULL;
}
void* ProStart(void* arg)
{
while(1)
{
pthread_mutex_lock(&mutex_);
while(g_bowl > 0)
{
// 放到PCB等待队列
pthread_cond_wait(&makecond_, &mutex_);
}
g_bowl++;
printf("i am producer %p, i make %d\n", pthread_self(), g_bowl);
pthread_mutex_unlock(&mutex_);
pthread_cond_signal(&cond_);
}
return NULL;
}
int main()
{
pthread_mutex_init(&mutex_, NULL);
pthread_cond_init(&cond_, NULL);
pthread_cond_init(&makecond_, NULL);
pthread_t consumer[THREADCOUNT];
pthread_t producer[THREADCOUNT];
for(int i = 0; i < THREADCOUNT; i++)
{
int ret = pthread_create(&consumer[i], NULL, ConStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
ret = pthread_create(&producer[i], NULL, ProStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
}
for(int i = 0; i < THREADCOUNT; i++)
{
pthread_join(consumer[i], NULL);
pthread_join(producer[i], NULL);
}
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
pthread_cond_destroy(&makecond_);
return 0;
}
死锁
死锁概念及现象
1.当多个执行流使用同一个互斥锁的时候,有一个执行流获取到了互斥锁之后,但是没有释放互斥锁,导致其他执行流都卡死在加锁的接口当中,我们将这种现象称之为死锁
2.多个执行流,多个互斥锁的情况下,每一个执行流都占有一把互斥锁,但是还想申请对方的互斥锁,这种情况下,就会导致各个执行流都阻塞掉,我们将这种现象称之为死锁
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREADCOUNT 1
pthread_mutex_t lock1_;
pthread_mutex_t lock2_;
void* threadStart1(void* arg)
{
pthread_mutex_lock(&lock1_);
sleep(2);
pthread_mutex_lock(&lock2_);
}
void* threadStart2(void* arg)
{
pthread_mutex_lock(&lock2_);
sleep(2);
pthread_mutex_lock(&lock1_);
}
int main()
{
pthread_mutex_init(&lock1_, NULL);
pthread_mutex_init(&lock2_, NULL);
pthread_t tid1[THREADCOUNT], tid2[THREADCOUNT];
for(int i = 0; i < THREADCOUNT; i++)
{
int ret = pthread_create(&tid1[i], NULL, threadStart1, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
ret = pthread_create(&tid2[i], NULL, threadStart2, NULL);
if(ret < 0)
{
perror("pthread_create");
return -1;
}
}
sleep(3);
for(int i = 0; i < THREADCOUNT; i++)
{
pthread_join(tid1[i], NULL);
pthread_join(tid2[i], NULL);
}
pthread_mutex_destroy(&lock1_);
pthread_mutex_destroy(&lock2_);
return 0;
}
-
运行结果如下,发现进程卡死不动
调试上边的代码
调试方法一:不运行
1.想要调试不要忘了加-g
2.gdb [可执行程序]
2.1 l:可以查看代码行号
2.2 b [行号]:在该行下断点
2.3 r:让代码运行
2.4 thread apply all bt:查看各个线程调用堆栈
2.5 t [线程编号]:跳转到指定编号的线程的调用堆栈
2.6 f [堆栈编号]:跳转到指定堆栈中
2.7 p [变量]:打印变量的值
2.8 q:退出gdb
-
首先,我在49行下了一个断点
-
r让其运行到49行停止,此时一个线程一定拿到了lock1_,现在想要获取lock2_;一个线程一定拿到了lock2_,现在想要获取lock1_
-
thread apply all bt:可以看到各个线程的调用堆栈,Thread 1是主线程,Thread 2和Thread 3是工作线程,可以看到工作线程都阻塞在pthread_mutex_lock函数
-
我打印了lock1_的值,从_owner可以看到lock1_现在被线程号为27917的线程占用,从现场调用堆栈中可以找到线程号为27917的线程是Thread 2(这里的2我们后边就叫它线程编号);lock2_被线程号为27918的线程(Thread 3)占用
-
t 2进入到Thread 2的调用堆栈,然后f 3可以看到代码是在调用获取lock2的加锁中
-
t 3进入到Thread 2的调用堆栈,然后f 3可以看到代码是在调用获取lock1的加锁中
调试方法二:运行时调试
gdb attach [pid]:将进程附加上gdb
三种调试:
1.事前调试:gdb [可执行程序]
2.事中调试:gdb attach [pid]
3.事后调试:gdb [可执行程序] [coredump]
死锁的四个必要条件
1.互斥条件
2.请求与保持条件
3.不可剥夺条件
4.循环等待
预防死锁
1.破坏必要条件:破坏请求与保持条件或者循环等待
2.加锁顺序一致
3.不要忘记解锁,在所有可能导致执行流退出的地方都进行解锁