[linux仓库]多线程数据竞争?一文搞定互斥锁与原子操作[线程·伍]
本文深入探讨了Linux线程互斥机制及其底层实现原理。通过抢票Demo案例,分析了共享资源访问导致的数据不一致问题,提出临界资源与临界区的概念,解释了互斥锁如何通过原子性操作解决并发问题。文章详细介绍了互斥锁的初始化、加锁和解锁操作,阐述了RAII设计模式在锁封装中的应用,并对比了C++11原生mutex与pthread库的实现差异。最后,通过汇编层面分析++/--操作的非原子性,揭示了互斥锁的底
🌟 各位看官好,我是egoist2023!
🌍 Linux == Linux is not Unix !
🚀 今天来学习Linux的线程互斥、原子性的深入理解及锁操作的底层理解。
👍 如果觉得这篇文章有帮助,欢迎您一键三连,分享更多人哦!
目录
线程互斥
- 大部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来⼀些问题。
int tickets = 1000;
void *routel(void* args)
{
std::string name = static_cast<const char*>(args);
while (true)
{
if(tickets>0)
{
usleep(10000);
printf("%s 抢占票号: %d\n", name.c_str(), tickets--);
}
else
{
break;
}
}
return nullptr;
}
int main()
{
//创建一批线程,每个线程都去执行抢票逻辑
pthread_t t1,t2,t3,t4;
pthread_create(&t1,nullptr,routel,(void*)"thread-1");
pthread_create(&t2,nullptr,routel,(void*)"thread-2");
pthread_create(&t3,nullptr,routel,(void*)"thread-3");
pthread_create(&t4,nullptr,routel,(void*)"thread-4");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
pthread_join(t4,nullptr);
return 0;
}
上面这段程序中,tikets就是所谓的共享资源,而这个共享资源是没有被保护的.而我们创建了一批线程,让这几个线程都对这个共享资源进行--,当达到0的时候自动退出被回收.
可是,我们观察到两个现象:打印消息出现错乱是为什么?为什么我们的票被抢到了负数呢?

多执行流向同一个显示器进行写入时,而显示器本身就是一个共享资源.才导致了打印消息可能错乱 --> 多线程面临的问题,因为多线程大部分资源都是共享的(幸运的话,我们甚至可以看见两个线程打印出现在同一行)
为解决票被抢到负数的问题,需要深刻理解下面的概念:
进程线程间的互斥相关背景
临界资源:多线程执⾏流共享的资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥本质:是把多线程访问资源由并发执行转变成串行执行
- 正面意义:保护共享资源
- 负面意义:降低程序运行效率
在一个线程maloc一个资源的全局变量,这个资源可以被其他线程看到,但能看到不代表能访问,临界区访问临界资源造成异常并不是因为共享了资源,而是因为共享了资源的同时,多线程还进行了并发访问导致的,共享资源不一定会导致数据不一致问题,访问共享资源才可能会导致数据不一致问题
互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
- 互斥的出现才有了原子性:取钱的动作就是原子的:要么没人访问,要么上一个取完了,站在外人角度,你取钱动作就是原子的,对我就是有意义,在你访问期间,我对你产生不了影响

算逻运算

数据在内存当中,计算在CPU中,就注定了一次完整的运算.
为什么会出现票被抢到负数的原因呢?
当前正在执行线程A的代码,此时经过了逻辑运算进入了判断体内,但因为usleep或时间片到了被切走了,要保存硬件上下文,带走了数据1000;调度线程B,做了tikets--,把票数干到了0,退出.此时线程A被唤回来了,还认为票数为1000,执行printf .tikets--,重新获取内存的值0,进行算术运算,改为了-1,修改tikets本身的值,写回到物理内存.
互斥锁
我们出现问题的原因是对tikets进行了--操作,那么针对全局变量,进行--或者++操作,又是否是安全的呢?即是否保证了所谓的原子性呢?
我们清楚:--或++操作被汇编后会形成多条语句,说明并不是原子的啊!
取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax #
600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) #
600b34 <ticket>
多线程并发访问全局变量,因为汇编问题,导致不安全的,导致数据不一致问题.
操作并不是原子操作,而是对应三条汇编指令:
- load :将共享变量ticket从内存加载到寄存器中
- update : 更新寄存器⾥⾯的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
要解决以上问题,需要做到三点:
- 代码必须要有互斥⾏为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。
- 如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程进⼊该临界区。
- 如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。
本质就是要对:对代码进行保护,形成临界区! --> pthread库提出来一个互斥锁的概念(可编程的)
这里讲个小故事:
你是某位大学生,每天都抱着笔记本跑图书馆进行学习,而图书馆有一个条款,自习室只允许一个人进去且只有一把钥匙,当一个人进去的时候,其他人只能等待这个人出来归还钥匙或者不去争钥匙.为了争夺此钥匙,你很早就来图书馆拿到了钥匙,并打开自习室开始了学习,在你进入自习室期间,没有人会来打扰你.而当你学完了,就可以把钥匙进行归还.
可是,你肚子突然痛了啊!而你学习才学一半啊!你此时在想要不要把钥匙进行归还呢?经过深思熟虑,你把钥匙一同带进了厕所.此时,就没有人能打开这间自习室了啊!等你上完厕所依然可以进自习室进行学习.
锁操作
静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
- mutex:要初始化的互斥量
- attr:NULL
如果是栈上开辟的,需要对这个局部变量进行初始化
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁(即如果是全局的,不需要销毁互斥量)
- 不要销毁⼀个已经加锁的互斥量
- 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
int tickets=1000;
pthread_mutex_t lock;
void *routel(void* args)
{
std::string name = static_cast<const char*>(args);
while (true)
{
pthread_mutex_lock(&lock);
if(tickets>0)
{
usleep(10000);
printf("%s 抢占票号: %d\n", name.c_str(), tickets--);
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
}
return nullptr;
}
对某个线程来说,要么你这个线程还没访问,要么访问完了,对他来讲才有意义.
可不可以理解成在加锁和解锁这部分过程是原子的?不会被别人打扰,不就是一种逻辑上的原子概念吗?原子是加锁后诞生的结果

观点:锁的使用的最佳实践:加锁和解锁时,囊括的临界区尽量是最小集
原子性
既然我们说加锁能对全局tikets进行保护,可是我们的gmutex自己不就是全局变量吗?怎么保证自己是安全的?
锁本身就是临界资源 --> 意味着要保护好自己才能保护别人 --> 如何保证加锁和解锁是安全的? --> 锁肯定要保证是原子的!
互斥锁是如何保证原子性的?
有多条代码要执行,可能随时会被时钟中断,时间片到了被切换;
如果在执行自己代码期间,不会被任何人打扰,执行n条指令一定是原子的.
问题是,如何做到不被打扰的?
背景:
- 系统在自身的时间片调度范围内,要么主动让出自己的CPU资源,调用read系统调用,发现资源并不就绪,把自己的资源出让了;
- 进程正常跑,但因为时钟中断可能由于时间片到了被切换了,
- 硬件实现方案:关闭中断!
- 软件实现方案:一条汇编语句(保证原子)
内存搬到cpu资源时,是要拷贝的(意味着可能存在多个线程对未改变的值进行拷贝)
概念预备:swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性,即使是多处理器平台,访问内存的 总线周期也有先后,⼀个处理器上的交换指令执⾏时另⼀个处理器的交换指令只能等待总线周期。

本质是谁先把锁里面的 '1' 交换到自己的硬件上下文里!!!
原生C++11 mutex抢票Demo
#include <iostream>
#include <thread>
#include <mutex>
#include <unistd.h>
std::mutex mtx; // 定义一个互斥锁
int tickets = 10; // 初始票数
void routel(const std::string& name)
{
while (true)
{
// 使用lock_guard来锁定mutex,确保线程安全
std::lock_guard<std::mutex> lock(mtx);
if (tickets > 0)
{
usleep(10000); // 模拟抢票的延时
printf("%s 抢占票号: %d\n", name.c_str(), tickets--);
}
else
{
break;
}
}
}
int main()
{
// 创建一批线程,每个线程都去执行抢票逻辑
std::thread t1(routel, "thread-1");
std::thread t2(routel, "thread-2");
std::thread t3(routel, "thread-3");
std::thread t4(routel, "thread-4");
// 等待所有线程完成
t1.join();
t2.join();
t3.join();
t4.join();
return 0;
}
总结
本文深入探讨了Linux线程互斥机制,从多线程共享资源引发的问题出发,分析了临界资源与临界区的概念。通过"抢票"案例展示了多线程并发访问共享变量导致的数据不一致问题,并解析了汇编层面的非原子操作原理。文章详细介绍了互斥锁的使用方法,包括初始化、加锁、解锁等操作,并阐述了锁的原子性保证机制(硬件层面通过交换指令实现)。最后提供了C++11 mutex实现案例和基于RAII思想的互斥量封装方案,强调锁的最佳实践应保持临界区最小化。全文系统性地解决了多线程编程中的共享资源保护问题,兼顾理论分析与实践指导。
互斥量的封装
class Mutex
{
public:
Mutex()
{
//初始化锁
pthread_mutex_init(&_lock,nullptr);
}
void Lock()
{
//加锁
pthread_mutex_lock(&_lock);
}
void Unlock()
{
//解锁
pthread_mutex_unlock(&_lock);
}
~Mutex()
{
//毁坏锁
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock;
};
class LockGuard
{
public:
LockGuard(Mutex* mutex):_mutexp(mutex)
{
_mutexp->Lock();
}
~LockGuard()
{
_mutexp->Unlock();
}
private:
Mutex *_mutexp;
};
上面这段代码我为什么要这样设计呢?采用了RAII风格的加锁机制.
RAII的核心思想是将资源(比如内存、文件句柄、锁等)的管理和生命周期绑定到对象的生命周期上。具体来说,当一个对象被创建时,它获取相关资源(比如加锁),而当对象超出作用域时(即销毁),它自动释放资源(比如解锁)。这种方式确保了资源总能被正确地释放,避免了资源泄露或死锁的风险。
在这段代码中,LockGuard类是RAII的典型实现,它自动地在对象创建时加锁,在对象销毁时解锁,避免了手动管理锁的繁琐和可能的错误。
Mutex lock;
struct data
{
std::string name;
pthread_mutex_t *lock;
};
void *routel(void *args)
{
data *d = static_cast<data *>(args);
while (true)
{
{
LockGuard lockguard(&lock);
if (tickets > 0)
{
usleep(10000);
printf("%s 抢占票号: %d\n", (d->name).c_str(), tickets--);
}
else
{
break;
}
}
}
return nullptr;
}

更多推荐



所有评论(0)