互斥和原子性
多线程情况下,如果有一个全局变量
,也就是多个线程都能访问的临界资源
,多个线程
对这个临界资源进行操作
,那么就可能会出现这个数据的不确定性
,互斥的作用
就是保证临界资源只能被一个线程操作
,当一个线程进入临界区,剩下的线程会被排斥,也就是让这个临界区不可能出现多个执行流同时访问的情况
。
原子性是指一步操作它要么完整的被执行,要么完全不执行。这种特性就叫原子性。
下面有一个抢票的代码,用四个线程同时抢10张票,票的总数是一个全局变量,也就是临界资源,对票数的判断和票数的减少,则为临界区:
#include
#include
#include
//总票数
int tickets = 10;
void* get_ticket(void* arg) {
while (1) {
if (tickets > 0) {
//模拟买票其他操作用时
usleep(1000);
printf("[thread %d] get ticket NO:%d\n", (long)arg, tickets);
tickets--;
}
else {
break;
}
}
}
int main() {
pthread_t tid[4];
int i = 0;
for (; i < 4; ++i) {
pthread_create(tid + i, NULL, get_ticket, (void*)i);
}
for (i = 0; i < 4; ++i) {
pthread_join(tid[i], NULL);
}
return 0;
}
出现了票数为负的情况,也就是说票被多卖了3张,有几张一样的票被同时卖给了不同的人
原因:
if判断票数为1的时候,进入循环后,usleep(1000)模拟其他业务逻辑的这段时间内,线程时间片轮转,切换到了其他线程,其他线程也在票数为1时进入了循环,进行了tickets–操作,这时就会在剩余一张票时进行了多次卖票操作,造成了这种情况。
还有一张可能,就是ticket–本身就不是一个原子操作
查看windows环境和Linux环境下tackets–的汇编代码
--
操作并不是原子操作,而是对应三条汇编指令:
既然--操作
有三行汇编代码,那么在一个线程执行第一行代码,也就是将ticket的值加载进了寄存器
,还没进行-1操作
,这时时间片轮转
到了其他线程,这时第一次执行–的线程会将tackets的值保存到上下文信息中,其他线程也执行第一句汇编,这时两个线程的tackets的值是一样的
,但是两个线程都会执行--操作
,进行了两次--
,tackets的值却只减少了1
,就会出现问题。
要解决以上问题,需要做到三点:
互斥行为
:当代码进入临界区执行时,不允许其他线程进入该临界区。多个线程同时要求执行临界区的代码
,并且临界区没有线程在执行,那么只能允许一个线进入该临界区
。要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
初始化互斥量有两种方式,
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
pthread_mutex_init接口
#include
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
返回值:
互斥量初始化成功返回0,失败返回错误码。
#include
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
返回值:
互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
pthread_mutex_trylock 功能与pthread_mutex_lock一样,只是当mutex已经是锁定的时候,pthread_mutex_trylock直接返回错误码EBUSY
,而不是阻塞进程。
参数:
返回值:
互斥量加锁成功返回0,失败返回错误码。
调用pthread_ lock 时,可能会遇到以下情况:
自身线程已经锁定互斥量
,那么线程会直接阻塞
,但是互斥量是被自身线程锁定的,这时线程没法解锁线程,就会造成**死锁
**int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
返回值:
互斥量解锁成功返回0,失败返回错误码。
既然多个线程同时访问临界区会出现问题,那么就在进入临界区之前对临界区进行加锁操作
,在有可能退出临界区的所有出口
进行解锁
操作
#include
#include
#include
//定义互斥量
pthread_mutex_t lock;
int tickets = 10;
void* get_ticket(void* arg) {
while (1) {
//在一个线程解锁时,要稍等一会,不然还没等别的线程开始抢锁,刚刚解锁的线程就又会立刻加锁,造成全部票被一个线程全部抢走的情况
usleep(1000);
//在进入临界区之前加锁
pthread_mutex_lock(&lock);
if (tickets > 0) {
usleep(1000);
printf("[thread %d] get ticket NO:%d\n", (long)arg, tickets);
tickets--;
//退出临界区的出口解锁
pthread_mutex_unlock(&lock);
}
else {
//退出临界区的出口解锁
pthread_mutex_unlock(&lock);
break;
}
}
}
int main() {
pthread_t tid[4];
//初始化互斥量
pthread_mutex_init(&lock,NULL);
int i = 0;
for (; i < 4; ++i) {
pthread_create(tid + i, NULL, get_ticket, (void*)i);
}
for (i = 0; i < 4; ++i) {
pthread_join(tid[i], NULL);
}
pthread_mutex_destroy(&lock);
return 0;
}
注意:
互斥锁的本质其实是一个0/1计数器,用来标记资源状态,是否被加锁
加锁的本质是让这个计数器由1变为0
解锁的本质是让这个计数器由0变为1
一个线程申请锁资源
,其实就是读取这个计数器的值
,判断临界区是否加锁,如果锁被别的线程占用
,那线程就会进入等待队列阻塞等待
,等到锁被重新释放,也就是计数器由0变为1,就会唤醒等待队列队头的线程,申请锁
。
所有线程在进入临界区时都会申请锁,那么锁也是临界资源
,那么锁本身也要被保护
,其实锁的申请是一个原子操作
,只有两种状态,申请成功或者申请失败。
为了实现互斥锁操作,大多数体系结构都提供了swap
或exchange
指令,该指令的作用
是把寄存器和内存单元的数据相交换
,由于只有一条指令,保证了原子性
,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
看看lock和unlock的伪代码
我们认为mutex就是这个计数器的值,mutex的值默认为1,al是一个寄存器
move
指令,把al寄存器里的值初始化为0,交换
一个线程在申请锁资源的时候,执行完第一步初始化,这时如果时间片发生轮转
,轮到其他线程申请,其他线程也是将al初始化为0,并不会对结果造成影响
若是执行完第二步交换指令,把mutex的值交换到了al寄存器,这时时间片切换到其他线程,那么在切换前,会保存上下文数据
,1这个值会被线程保存起来
其他进程进来,把al初始化为0,再去交换mutex的值,此时这个1已经交换到第一个线程中了
,新线程拿不到1就会阻塞,原来线程拿到时间片就会恢复上下文数据,这个1也会被恢复到al寄存器,继续执行下面的指令 。
从始至终,这个1只有一份,线程要不拿到1也就是申请到锁,要不拿不到1,只有申请到和没申请到两种状态,所以说申请锁的过程是原子操作。
解锁的时候只有1条指令,就是把1放回mutex里,虽然al里还是1,但是申请锁的时候无论如何都会将al重新初始化为0,也就保证了1的唯一性。
一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
联系:
区别:
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态
这四个条件是必要条件,也就是说必须同时满足以上四个条件才会造成死锁
如果一个线程在申请到锁的情况下再次申请锁,那这个线程也会造成死锁
避免死锁就是要破坏死锁产生的必要条件
除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。
同步:在保证数据安全
的前提下(加锁保护),让线程能够按照某种特定的顺序访问临界资源
,从而有效避免饥饿问题,叫做同步
竞态条件:竞态条件 (race condition) 又名竞争危害 (race hazard)。旨在描述一个系统或者进程的输出展现无法预测的、对事件间相对时间的排列顺序的致命相依性。
互斥量不是万能的,比如某个线程正在等待共享数据内某个条件出现,可能需要重复对数据对象加锁和解锁(轮询),但是这样轮询非常耗费时间和资源,而且效率非常低
例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取。
这时我们就要引入一种方法,当一个线程申请到锁资源,但是没有满足这个线程的运行条件
,那么就让这个线程挂起等待
,一旦条件满足
,就立即唤醒这个线程
,这样这个线程就不会一直申请锁又释放锁,却不做任何事情,提高效率。这种方法就叫做同步。
条件变量(condition variable)是利用线程间共享的全局变量进行同步的一种机制
主要包括两个动作:
等待某个条件成立
,而将自己挂起等待
;使条件成立
,并通知等待的线程继续
。pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
使用 PTHREAD_COND_INITIALIZER 初始化的条件变量不需要销毁
pthread_cond_init接口
#include
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:
返回值:
条件变量初始化成功返回0,失败返回错误码。
pthread_cond_destroy接口
int pthread_cond_destroy(pthread_cond_t *cond);
参数:
返回值:
条件变量销毁成功返回0,失败返回错误码。
注意:
使用PTHREAD_COND_INITIALIZER
初始化的互斥量不需要销毁
pthread_cond_wait接口
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条件变量等待的线程
返回值:
函数调用成功返回0,失败返回错误码
创建两个线程,一个线程等待唤醒,唤醒就打印字符串,一个线程唤醒,每隔一秒唤醒一次
#include
#include
#include
using namespace std;
//创建条件变量
pthread_cond_t cond;
//创建互斥量
pthread_mutex_t lock;
//线程1等待被唤醒
void* pthread1_run(void* arg) {
while (1) {
pthread_cond_wait(&cond, &lock);
cout << "唤醒成功,活动!" << endl;
}
}
//线程2每隔一秒唤醒线程2
void* pthread2_run(void* arg) {
while (1) {
pthread_cond_signal(&cond);
sleep(1);
}
}
int main() {
//初始化条件变量和互斥量
pthread_cond_init(&cond, nullptr);
pthread_mutex_init(&lock, nullptr);
//创建两个线程
pthread_t tid1, tid2;
pthread_create(&tid1, nullptr, pthread1_run, nullptr);
pthread_create(&tid2, nullptr, pthread2_run, nullptr);
//等待两个线程
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
//摧毁互斥量和条件变量
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
return 0;
}
条件等待
是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。条件不会无缘无故的突然变得满足了
,必然会牵扯到共享数据的变化
。所以一定要用互斥锁来保护
。没有互斥锁就无法安全的获取和修改共享数据。要判断是否满足条件
,就一定要先进入临界区
,而进入临界区就一定要先获得锁资源
,一旦条件不满足,就要等待,但是当前线程是占有锁
资源的,如果直接进行阻塞等待
,那么这个锁就永远不会释放了
,就会造成死锁
pthread_cond_wait
进行等待时,会先释放锁资源
,而要释放锁,就得需要传入互斥量,pthread_cond_wait会在调用时自动释放锁资源
。被唤醒时
,程序会接着从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);