线程互斥与同步(上)
目录
线程互斥
互斥相关背景概念
互斥量 mutex
什么情况下会引发线程互斥?
解决方案:
锁原理
死锁问题
其他
线程互斥
互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量 mutex
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
多个线程并发的操作共享变量,会带来一些问题
什么情况下会引发线程互斥?
如下代码,很简单的买票系统
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <unistd.h>
class TicketSystem
{
public:TicketSystem(int total) : tickets(total) {}void sellTicket(){while (true){if (tickets > 0){// 模拟处理时间std::this_thread::sleep_for(std::chrono::milliseconds(10));tickets--;std::cout << "售出1张票,剩余: " << tickets<< " (线程ID: " << std::this_thread::get_id() << ")\n";//usleep(10);//稍做等待防止单一线程独占锁}else{break;}}}int getRemaining(){return tickets;}private:int tickets;
};int main()
{const int TOTAL_TICKETS = 100;const int THREAD_NUM = 10;TicketSystem system(TOTAL_TICKETS);std::vector<std::thread> threads;for (int i = 0; i < THREAD_NUM; ++i){threads.emplace_back([&system](){ system.sellTicket(); });}for (auto &t : threads){t.join();}std::cout << "最终剩余票数: " << system.getRemaining() << std::endl;return 0;
}
运行结果
可以看到票出现了负数
这里其实就是临界区的非原子性问题,
对于其中的ticket--时,在进行汇编指令后会变成
// 取出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的内存地址
在其运行时可能会发生如下错误
也就是说线程在运行ticket--指令时如果在其中汇编代码第三步之前(也就是写回共享变量ticket的内存地址的步骤之前)由于时间片用完等原因被挂起,在原线程中就没有对内存中的tickets的值进行写回,如果此时后面的线程进行修改内存但是不论怎么修改,最后都会被重新执行的原线程给覆盖,比如如果原线程本来是要将99写到内存但是在最后一步被挂起,而后面的线程已经将内存给修改为1,但是如果此时原线程重新执行将又会把值写回内存变成99,是其他进程的努力付之东流。
哦哦哦~,但是这里还有一个问题啊,为什么会有负数的出现呢,这里其实是逻辑运算的问题,tickets > 0这也是非原子性的,如果在判读为true后进行线程切换将会造成后面的线程已经将内存里的ticket改变为负数或0但是原线程重新执行时因为已经判断过造成ticket的负数出现.
所以为了避免这种情况加入了锁的概念
解决方案:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量!
互斥量用于多线程编程中的同步机制,用于防止多个线程同时访问共享资源,从而避免数据竞争和不一致性。其主要目的是保证在任何时刻,只有一个线程可以访问特定的资源或代码段
互斥锁的工作原理:
- 初始化:在互斥锁被使用之前,需要对其进行初始化。这通常是在创建线程之前完成的
- 加锁(Lock):当一个线程需要访问共享资源时,它会尝试获取互斥锁。如果锁当前未被其他线程持有,则该线程将成功获取锁,并继续执行其后续代码。如果锁已被其他线程持有,则该线程将被阻塞,直到锁被释放为止
- 临界区:持有互斥锁的线程可以安全地访问共享资源或执行临界区代码。临界区是指那些需要同步访问的代码段
- 解锁(Unlock):当线程完成对共享资源的访问后,它会释放互斥锁。这允许其他被阻塞的线程获取锁并访问共享资源
- 销毁:在不再需要互斥锁时,可以将其销毁。这通常是在程序结束或线程终止时进行的
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <unistd.h>
class TicketSystem
{
public:TicketSystem(int total) : tickets(total) {}void sellTicket(){while (true){pthread_mutex_lock(&mutex); // 加锁 阻塞其他线程if (tickets > 0){// 模拟处理时间std::this_thread::sleep_for(std::chrono::milliseconds(10));tickets--;std::cout << "售出1张票,剩余: " << tickets<< " (线程ID: " << std::this_thread::get_id() << ")\n";pthread_mutex_unlock(&mutex); // 解锁//usleep(10);//稍做等待防止单一线程独占锁}else{pthread_mutex_unlock(&mutex); // 解锁break;}}}int getRemaining(){return tickets;}private:int tickets;pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 通过宏 PTHREAD_MUTEX_INITIALIZER 快速初始化一个默认属性的互斥锁
};int main()
{const int TOTAL_TICKETS = 100;const int THREAD_NUM = 10;TicketSystem system(TOTAL_TICKETS);std::vector<std::thread> threads;for (int i = 0; i < THREAD_NUM; ++i){threads.emplace_back([&system](){ system.sellTicket(); });}for (auto &t : threads){t.join();}std::cout << "最终剩余票数: " << system.getRemaining() << std::endl;return 0;
}
静态初始化:通过宏
PTHREAD_MUTEX_INITIALIZER
快速初始化一个默认属性的互斥锁。作用:锁的初始状态为「未锁定」(unlocked)。
很好我们可以返现我们解决了线程并发的问题,但是又出现了一个新的问题怎么全是一个线程在执行呢,这里是因为这是一开始是原线程先拿到锁抢到票,但是之后原线程对锁的竞争力就会远强于其它线程了,因为它将锁刚一释放就马上又获取了,所以我们采取的方法可以是:原线程2完票之后可以让它短暂睡眠一会儿,这样其它线程就能够来争夺锁了,放开注释即可。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期
互斥锁原理
- 第一步:首先将al寄存器清零
- 第二步:将al寄存器中的内容和mutex的内容做一次交换,这个动作其实就是我们申请锁的动作
- 第三步:判断,如果锁的数量大于0就会申请成功并退出,如果不大于0就会挂起等待
在整个加锁的过程中只有一个数值1存在,这个1要么保存在某个线程的私有的寄存器中,要么保存在共享的互斥锁变量mutex里。如果某个线程的寄存器中的值为1,说明该线程已经加锁成功。
锁本身其实也是作为共享资源存在的,那锁本身需要保护吗?
答案是不需要,因为这个锁是互斥的
任何时候只有一个线程能成功将
mutex
从1
改为0
,其他线程必须等待。
死锁问题
死锁(Deadlock)是指多个线程或进程因互相等待对方释放资源而陷入无限阻塞的状态。常见的死锁原因包括:
互斥条件:资源一次只能被一个线程占用(如锁)。
占有并等待:线程持有至少一个资源,同时等待获取其他资源。
非抢占条件:已分配给线程的资源不能被强制剥夺,只能由线程主动释放。
循环等待:多个线程形成环形等待链(如 T1 等待 T2 的资源,T2 等待 T1 的资源)。
其他
互斥量的加锁和解锁操作会带来一定的性能开销,尤其是在高并发场景下。为了减少开销,可以:
尽量减少临界区的范围;
使用读写锁(
std::shared_mutex
)替代互斥量;使用无锁数据结构(Lock-Free Data Structures)。