目录
一、线程互斥
1.1 相关概念介绍
1.2 互斥量mutex
1.3 互斥量接口
1.4 互斥量实现原理
二、可重入与线程安全
2.1 概念
2.2 常见线程不安全的情况
2.3 常见线程安全的情况
2.4 常见不可重入的情况
2.5 常见可重入的情况
2.6 可重入与线程安全的关系
三、死锁
四、线程同步
4.1 同步概念与竞态条件
4.2 条件变量
4.2.1 概念
4.2.2 接口
4.2.3 为什么pthread_cond_wait需要互斥量
4.2.4 使用规范
下面模拟实现一个抢票系统,将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程进行抢票,当票被抢完后这四个线程自动退出
#include
#include
#include
#include
using namespace std;
const int thread_num = 4;
int tickets = 1000;
void* GetTickets(void* args) {
while (true) {
if (tickets > 0) {
usleep(10000);//抢票所耗费的时间
printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
}
else {
break;
}
}
printf("%s quit!\n", (char*)args);
pthread_exit((void*)0);
}
int main()
{
pthread_t tids[thread_num];
pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");
for(int i = 0;i < thread_num; ++i) {
pthread_join(tids[i], nullptr);
}
return 0;
}
运行结果显然不符合预期,最终票数变为了负数
票数为负原因:
--tickets操作
对一个变量进行--,实际需要三个步骤:
-- 操作对应的汇编代码如下:
-- 操作需要三个步骤才能完成,有可能当thread1刚把tickets的值读进CPU寄存器就被切走了,假设此时thread1读取到的值是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文数据,因此需要被保存起来,之后thread1就被挂起了
假设此时thread2被调度,由于thread1只执行了 -- 操作的第一步,因此thread2此时在内存中看到tickets的值仍是1000,假设系统给thread2的时间片可能较多,thread2一次性执行了100次 -- 操作才被切走,最终tickets由1000减到了900
此时系统再把thread1恢复上来,继续执行thread1的代码并且将thread1曾经的硬件上下文信息恢复,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行 -- 操作的第二步和第三步,最终将999写回内存
此时,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。 -- 操作并不是原子的,虽然--tickets看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编;相反,对一个变量进行++也需要对应的三个步骤,即++操作也不是原子操作
若线程使用的数据是局部变量,变量的地址空间在线程栈空间内,变量归属单个线程,其他线程无法获得这种变量;但有些变量需要在线程间共享(共享变量),可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量就会带来一些问题
要解决上述抢票系统的问题,需要做到三点:
这时就需要一把锁,Linux中提供的这把锁被称为互斥量
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
返回值:互斥量初始化成功返回0,失败返回错误码
使用pthread_mutex_init()函数初始化互斥量的方式被称为动态分配,还可以使用静态分配进行初始化,即下面这种方式:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数mutex:需要销毁的互斥量的地址
返回值:互斥量销毁成功返回0,失败返回错误码
注意:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数mutex:需要加锁的互斥量的地址
返回值:互斥量加锁成功返回0,失败返回错误码
注意:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数mutex:需要解锁的互斥量的地址
返回值:互斥量解锁成功返回0,失败返回错误码
在上述的抢票系统中引入互斥量,以解决打印错乱和票数为负的问题:
#include
#include
#include
#include
using namespace std;
const int thread_num = 4;
int tickets = 1000;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void* GetTickets(void* args) {
while (true) {
pthread_mutex_lock(&mtx);
if (tickets > 0) {
usleep(1000);//抢票所耗费的时间
printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
pthread_mutex_unlock(&mtx);
usleep(10);//避免全部为同一线程抢占锁
}
else {
pthread_mutex_unlock(&mtx);
break;
}
}
printf("%s quit!\n", (char*)args);
pthread_exit((void*)0);
}
int main()
{
pthread_t tids[thread_num];
pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");
for(int i = 0;i < thread_num; ++i) {
pthread_join(tids[i], nullptr);
}
return 0;
}
加锁后的原子性如何体现?
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态(线程1持有锁)时也就被阻塞了。此时对于线程2、3、4而言,线程1的整个操作过程是原子的
临界区内的线程可能被切换吗?
临界区内的线程是可能进行线程切换。但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
互斥锁是否需要被保护?
多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,只需要保证申请锁的过程是原子的,那么锁就是安全的
如何保证申请锁是原子的?
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性。
lock和unlock的伪代码:
可以认为mutex的初始值为1,al是计算机中的一个寄存器
当线程申请锁时,需要执行以下步骤:
例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
注意:
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入
不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态
单执行流产生死锁
单执行流也可能产生死锁,若某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁时是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有办法释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态
#include
#include
using namespace std;
void *Routine(void *pmtx)
{
pthread_mutex_lock((pthread_mutex_t*)pmtx);
pthread_mutex_lock((pthread_mutex_t*)pmtx);
pthread_mutex_unlock((pthread_mutex_t*)pmtx);//无法执行
pthread_exit(nullptr);
}
int main()
{
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, (void *)&mtx);
pthread_join(tid, NULL);//等待不到
pthread_mutex_destroy(&mtx);
return 0;
}
此时主线程阻塞等待新线程退出,但是线程被阻塞进入死锁状态
该进程当前的状态是 sl+ ,其中 l 就是lock的意思,表示该进程当前处于一种死锁的状态
多执行流产生死锁
线程A申请锁资源的顺序为:锁1、锁2;线程B申请锁资源的顺序为:锁2、锁1
当线程A申请到锁1准备申请锁2时,线程B已申请到锁2准备申请锁1,这时两个线程都会因为申请锁失败而陷入阻塞,并且无法释放锁,进入死锁状态
产生死锁的条件
避免死锁
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步
竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件
譬如,现在有两个线程访问一块临界资源,一个线程往临界资源写入数据,另一个线程从临界资源读取数据。但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界资源被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取。引入同步后该问题就能很好的解决
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述
条件变量主要包括两个动作:
条件变量通常需要配合互斥锁一起使用
初始化条件变量
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数:
返回值:条件变量初始化成功返回0,失败返回错误码
使用pthread_cond_init()函数初始化条件的方式被称为动态分配,还可以使用静态分配进行初始化,即下面这种方式:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
参数cond:需要销毁的条件变量的地址
返回值:条件变量销毁成功返回0,失败返回错误码
注意:使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁
等待条件变量满足
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数:
返回值:函数调用成功返回0,失败返回错误码
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
参数cond:唤醒在cond条件变量下等待的线程
返回值:函数调用成功返回0,失败返回错误码
错误的设计
当进入临界区上锁后,若发现条件不满足,先解锁,然后在该条件变量下进行等待
//错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false){
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond, &mutex);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
不可行。解锁后、调用pthread_cond_wait()函数前,若此时有其他线程获取到互斥量,发现此时条件满足,于是发送了信号,那么此时pthread_cond_wait函数将错过这个信号(已经释放锁,无法在拿到锁了),最终可能会导致线程永远不会被唤醒。调用pthread_cond_wait()的线程必须是持有锁的
等待条件变量的代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
唤醒等待线程的代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);