线程互斥
进程线程间的互斥相关背景概念
- 共享资源: 在多线程执行流中,能被多线程看到的资源就称为共享资源。
- 临界资源:多线程执行流中,被保护的共享资源就称为临界资源。
- 临界区:每个线程内部,访问临界资源的代码段,称为临界区。
- 互斥:在任何时刻,互斥确保有且只有一个执行流进入临界区,访问临界资源,从而对临界资源起到保护作用。
- 原子性(后续讨论如何实现):指不会被任何调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成。
通过一段代码认识一下上面的概念。
简单模拟抢票机制:
#include<iostream>
#include<pthread.h>
#include<string>
#include<cstdio>
#include<unistd.h>
int tickets=1000;
void* route(void* args)
{
std::string name=static_cast<const char*>(args);
while(true)
{
if(tickets>0)
{
usleep(1000);
printf("%s 抢到了 %d 号票\n",name.c_str(),tickets);
tickets--;
}
else
break;
}
return (void*)0;
}
int main()
{
pthread_t p1,p2,p3,p4;
pthread_create(&p1,nullptr,route,(void*)"thread_1");
pthread_create(&p2,nullptr,route,(void*)"thread_2");
pthread_create(&p3,nullptr,route,(void*)"thread_3");
pthread_create(&p4,nullptr,route,(void*)"thread_4");
pthread_join(p1,nullptr);
pthread_join(p2,nullptr);
pthread_join(p3,nullptr);
pthread_join(p4,nullptr);
return 0;
}
运行结果:

在上面代码中tickets是个全局变量,可以被所有线程看到,所以该变量是个共享资源。
当我们运行结束后,我们发现,票被抢到了负数,为什么会出现这种现象呢?
在CPU中,只有一组寄存器,寄存器中只能保存当前执行流的上下文。在这个抢票的代码中,票的数据保存在物理内存中,当某个线程被唤醒,那么CPU中的寄存器就会拷贝票的数据,但是无论是线程还是进程,都有时间片,当时间片的时间到了,寄存器就要切换下一个线程的上下文,当线程再次切换回来的时候,线程就直接从被切走的地方开始执行。在上面这段代码中,如果线程在后面还有几张票的时候,都在if判断完后,因为时间片的时间到了,然后被切走了,线程再次被唤醒时,就可以直接打印,让票的数量再减减,抢到的票就是负数了。
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要⼀把锁。Linux上提供的这把锁叫互斥量。
互斥量mutex
互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
- 方法1:静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 方法2:动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, constpthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL
注:动态初始化的条件变量必须调用销毁函数!
销毁互斥量
销毁互斥量注意事项
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
//加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
//解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用pthread_mutex_lock时可能遇到的情况
- 互斥量处于未锁状态:该函数会将互斥量锁定,同时返回成功
- 互斥量已被锁定:发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量但没有竞争到互斥量,那么pthread_mutex_t mutex调用会陷入阻塞(执行流被挂起),等待互斥量解锁
改进上面的抢票代码:
#include<iostream>
#include<pthread.h>
#include<string>
#include<cstdio>
#include<unistd.h>
int tickets=1000;
//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex;
void* route(void* args)
{
std::string name=static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mutex);
if(tickets>0)
{
usleep(1000);
printf("%s 抢到了 %d 号票\n",name.c_str(),tickets);
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return (void*)0;
}
int main()
{
pthread_t p1,p2,p3,p4;
pthread_mutex_init(&mutex,nullptr);
pthread_create(&p1,nullptr,route,(void*)"thread_1");
pthread_create(&p2,nullptr,route,(void*)"thread_2");
pthread_create(&p3,nullptr,route,(void*)"thread_3");
pthread_create(&p4,nullptr,route,(void*)"thread_4");
pthread_join(p1,nullptr);
pthread_join(p2,nullptr);
pthread_join(p3,nullptr);
pthread_join(p4,nullptr);
pthread_mutex_destroy(&mutex);
return 0;
}
在上面代码中,因为对tickets变量进行了保护,所以tickets变量就是临界资源;被加锁和解锁代码的部分就是临界区。
互斥量实现原理探究
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性。即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
现在我们把lock 和unlock的伪代码改写为基于这种硬件原语的实现:
mutex值为0表示该锁正在被使用,还未归还,1表示该锁空闲,可使用。
lock:
movb $0, %al # 将0值移动到AL寄存器
xchgb %al, mutex # 原子交换AL寄存器和mutex变量的值
if (al > 0) { # 检查交换后AL寄存器的值
return 0; # 等于0表示加锁成功
} else {
挂起等待; # 大于0表示锁已被占用,挂起当前线程
}
goto lock; # 被唤醒后重新尝试获取锁unlock:
movb $1, mutex #将1值移交给mutex变量
唤醒等待Mutex的线程
return 0;
理解
临界区就好比是只能容纳一个人的图书馆自习室,假设自习室是有锁的,锁要拿钥匙开的,那么钥匙就相当于互斥量,而自己拥有钥匙,在自习室里面学习,那么这种行为就是加锁;把钥匙归还给图书馆,留给别人用,那么这种行为就是解锁。如果拥有自习室钥匙的同学(小明)总想离开房间一会,但小明还想待会回来学习,小明就把钥匙拿走了,其他同学得不到这个钥匙,只能在外面一直等小明归还钥匙。这里面的小明就相当于正在执行临界区的线程。但是如果小明一直不归还钥匙,那么其他同学就无法进入自习室,其他同学只能等待小明归还钥匙,这就会导致“饥饿”问题。
要解决以上的问题,那么就需要做到以下三点:
- 互斥进入
- 凡是从自习室内出来的人,归还钥匙后,不能立即申请
- 外部的人必须排好队,出来的人必须排在队伍的尾部
要做到以上三点,本质上就是需要线程同步。
线程同步
条件变量
续上面的例子:
主动轮询(没有条件变量):
同学们在自习室外不停地敲门:"钥匙有了吗?钥匙有了吗?"
每个同学都频繁尝试申请钥匙
图书馆管理员(操作系统)忙于处理这些请求
同学们自己也很累,还制造了很多噪音(消耗CPU)
条件变量等待(高效方式):
同学们在门外安静地休息(线程阻塞)
小明归还钥匙时按门铃
同学们被唤醒后才去申请钥匙
大家都不累,系统也很安静
条件变量让线程在条件不满足时被动等待,而不是主动轮询。主动轮询就是一直申请互斥量,这样会让CPU一直去检测执行条件是否满足,会耗费CPU。
线程同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
条件变量函数
初始化
静态初始化:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
动态初始化:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t*restrict attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
唤醒等待
//唤醒全部等待线程,让全部等待线程争一个互斥量
int pthread_cond_broadcast(pthread_cond_t *cond);
//唤醒一个等待线程
int pthread_cond_signal(pthread_cond_t *cond);
实例:
#include<iostream>
#include<string.h>
#include<pthread.h>
#include<unistd.h>
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
void* route(void* args)
{
std::string name=static_cast<const char*>(args);
while(1)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);//阻塞等待,其他线程等待通知才能进入临界区
std::cout<<name.c_str()<<"进入临界区"<<std::endl;
pthread_mutex_unlock(&mutex);
}
return (void*)0;
}
int main()
{
pthread_t t1,t2;
pthread_create(&t1,nullptr,route,(void*)"thread_1");
pthread_create(&t2,nullptr,route,(void*)"thread_2");
sleep(2);
while(1)
{
pthread_cond_broadcast(&cond);
sleep(1);
}
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
return 0;
}
为什么 pthread_cond_wait 需要互斥量?
- 当线程A进入临界区时,会先给进程A加锁,但是如果线程A没有满足条件变量,那么线程A就要加入到等待队列,但是线程A手里还拿着锁,没有把锁释放,其他进程就拿到不这个锁,就会造成死锁现象。
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有另一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好地通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
// 错误实现:非原子操作
pthread_mutex_lock(&mutex); // 1. 加锁
while (condition == false) { // 2. 检查条件
pthread_mutex_unlock(&mutex); // 3. 解锁
// 🚨 危险区域:可能错过通知!
pthread_cond_wait(&cond); // 4. 等待(没有互斥量保护)
pthread_mutex_lock(&mutex); // 5. 重新加锁
}
// 处理工作...
pthread_mutex_unlock(&mutex);
在上面的代码中,如果线程A去检查临界区,去临界区前会对该线程进行加锁,但加了锁后发现不满足条件变量,线程A进行解锁,但是在解锁后正在要加入到阻塞队列的过程中,其他进程可能申请得到了互斥量,那么线程A就会进入永久等待中。
当线程解锁后,内核就会发出信号,让其他线程去申请,但是信号递达后,就会消失,所以线程还没有进入阻塞队列中,信号就已经消失了,那么线程就会进入永久等待中。
所以等待必须是一个原子操作,pthread_cond_wait的原子性保证:先入队,后释放锁,发出通知。
正确原子操作
// 正确实现:原子操作
pthread_mutex_lock(&mutex); // 1. 加锁
while (condition == false) { // 2. 检查条件
pthread_cond_wait(&cond, &mutex); // 3. 原子操作:
// - 释放锁
// - 等待通知
// - 重新获取锁
}
// 处理工作...
pthread_mutex_unlock(&mutex); // 4. 解锁
条件变量使用规范
等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假) //if??
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
线程安全与重入问题
概念
线程安全:就是多个线程在访问共享资源时,能够正确地执行,不会相互干扰或破坏彼此的执行结果。一般而言,多个线程并发同一段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进行操作,并且没有锁保护的情况下,容易出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
学到现在,其实我们已经能理解重入其实可以分为两种情况:
- 多线程重入函数
- 信号导致一个执行流重复进入函数(在进程间信号讲过)
常见的线程不安全的情况
- 不保护共享变量的函数;
- 函数状态随着被调用,状态发生变化的函数;
- 返回指向静态变量指针的函数;
- 调用线程不安全函数的函数。
常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的;
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构;
- 可重入函数体内使用了静态的数据结构。
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的;
- 类或者接口对于线程来说都是原子操作;
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的可重入的情况
- 不使用全局变量或静态变量;
- 不使用malloc或者new开辟出的空间;
- 不调用不可重入函数;
- 不返回静态或全局数据,所有数据都由函数的调用者提供;
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
总结
- 函数是可重入的,线程就是安全的。
- 线程安全不一定是可重入的。因为如果这个重入函数带锁,而且上完锁之后被信号中断了,执行流就把锁带走了,等到解决完信号问题回来的时候,又申请锁,但系统不能识别锁,所以这把锁就不见了,就会导致死锁问题。
注意:
- 如果不考虑信号导致一个执行流重复进入函数这种重入情况,线程安全和重入在安全角度不做区别。
- 线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
- 可重入描述的是一个函数是否能被重复进入,表示的是函数的特点
常见锁
死锁
死锁是指在一组进程(或线程)中,每个进程都持有至少一个资源,并且都在等待获取另一个被该组中其他进程所占用的资源。由于没有一个进程能够主动释放自己已占有的资源,这组进程将永远处于互相等待的状态。
为了方便表述,假设现在线程A,线程B必须同时持有锁1和锁2,才能进行后续资源的访问。
申请一把锁的时候,是具有原子性的,但是多把锁就不是了。

形成死锁的四个充分必要条件
互斥条件:一个资源每次只能被一个执行流使用。
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 就好比两个人都只有五毛钱的情况下,想吃棒棒糖(一块钱一个),两个人都想吃,所以两个人都不愿把自己身上的五毛钱给对方,但是同时又想要对方的五毛钱。这里的两个人就好比是两个线程,棒棒糖就好比是临界资源,钱就好比是锁。
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 和上面情景一样,虽然我想吃,但是我不会去抢别人的钱。
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
- 两个人就在互相等待对方能把钱给自己,这就是循环等待。多线程之间也存在。

避免死锁
只要破环形成死锁的四个条件中的一个就可以避免。
对于条件1:不用锁就不会产生死锁。
对于条件2:
核心思想:让线程在开始执行前就一次性申请它所需要的所有资源,而不是在运行过程中逐步申请。把两个锁“绑定”在一起。
方法:在 C++ 中,可以用是std::lock(lock1,lock2,……)来同时锁定多个锁,避免死锁。
#include <mutex>
#include <thread>
std::mutex mtx1, mtx2;
void function_with_two_locks() {
// 同时锁定 mtx1 和 mtx2,避免死锁
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
std::lock(lock1, lock2); // 同时锁定
// ... 临界区操作两个资源 ...
}
对于条件3:
核心思想:如果一个线程已经持有了一些资源,但无法立即获得新的资源,那么它必须释放已经获得的所有资源,以后需要时再重新申请。
方法:使用带超时机制的锁(如pthread_mutex_timelock)。如果一个线程在等待新锁时超时,它就主动释放自己持有的所有锁,然后等待一段时间后重试。
对于条件4:
核心思想:对系统中的所有资源进行统一编号,强制线程按照严格的顺序(升序或降序)来申请资源。
STL,智能指针和线程安全
STL中的容器是否是线程安全的?
不是。STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响,而且对于 不同容器,加锁的方式不同,性能可能也不同(例如hash表中的锁表和锁桶),因此STL默认不是线程安全的,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
智能指针是否是线程安全的?
对于unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题,但是标准库实现的时候考虑到这个问题,基于原子操作的方式保证了shared_ptr能够高效,原子的操作引用计数。
注:shared_ptr 保证的是引用计数的线程安全,不是指向对象的线程安全,也不是同一个 shared_ptr 实例的线程安全。
3035

被折叠的 条评论
为什么被折叠?



