Linux操作系统学习之---线程互斥(互斥锁)
任何技术都是一体两面的 , Linux下线程的设计在本身不带来额外内存资源耗费的情况下 , 实现了任务处理效率上的提升 , 但也带来了一些问题 .
多个线程都能访问同一个进程PCB资源 , 不加管控的话势必会出现冲突
一般来说 , 多个线程并发访问同一份资源造成的问题叫做数据不一致
一.黄牛抢票:
下面看一个抢票的例子 , 每个线程就相当于一个抢票的人 .
数据不一致问题(未加锁):
BuyTicket函数里的capacity–是真正访问共享资源的代码.- 在临界区前使用usleep()休眠 , 增加竞态窗口 .
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;int ticket = 1000;void* BuyTicket(void* input)
{char* ch = static_cast<char*>(input);while(true){if(ticket >= 0){usleep(1000);printf("%s买票:%d\n",ch,ticket);ticket--;}else{break;}}return nullptr;
}int main()
{pthread_t t1;pthread_t t2;pthread_t t3;pthread_t t4;pthread_create(&t1,nullptr,BuyTicket,(void*)"thread1");pthread_create(&t2,nullptr,BuyTicket,(void*)"thread2");pthread_create(&t3,nullptr,BuyTicket,(void*)"thread3");pthread_create(&t4,nullptr,BuyTicket,(void*)"thread4");pthread_join(t1,nullptr);pthread_join(t2,nullptr);pthread_join(t3,nullptr);pthread_join(t4,nullptr);return 0;
}
运行结果:
![[黄牛抢票数据不一致.png]]
加锁:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
int ticket = 1000;
pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;//初始化锁void* BuyTicket(void* input)
{char* ch = static_cast<char*>(input);while(true){pthread_mutex_lock(&mylock); //在临界区外加锁if(ticket >= 0){usleep(1000);printf("%s买票:%d\n",ch,ticket);ticket--;pthread_mutex_unlock(&mylock); //退出临界区前解锁}else{pthread_mutex_unlock(&mylock);break;}}return nullptr;
}int main()
{//正常调用逻辑,同上return 0;
}
![[黄牛抢票数据一致.png|500]]
二.数据不一致的原因:
ticket–的非原子性 :
- c语言是编译型语言 , 在编写代码时通过一行
ticket--;来达到让变量自减的目的 .- 但是在编译链接后 , 我们的代码往往会被编译成多条指令 .
- 可以暂时粗略的理解为 — 一条汇编代码的执行才是原子的
ticket–的底层逻辑:
冯诺依曼体系结构规定 : 内存里的数据和代码要载入到CPU才能被运算和执行 !!!
可以理解为一句ticket--的代码 , 在底层分三步执行:
- 将内存里的ticket变量载入到CPU的寄存器 .
- 由CPU的计算器进行运算 , 将得到的结果暂存在寄存器.
- 最后再将结果从寄存器会写到内存 , 完成 一条上层语句
ticket--的逻辑.
问题在于 :这三步不是完全连续的(不是原子的) , 而是可能被打断 !!!

根源一(非主要矛盾):tickets-- 操作的非原子性
-
假如存在线程a 和 线程b , 都有修改
ticket变量的需求 , 并且ticket还剩下100. 就类似于两个用户都要抢同一个高铁上的坐票 , 还有100张票. -
线程a先执行 , 将ticket从内存载入CPU , 然后由CPU做–运算 , 此时
ticket=99. -
不幸的时刻来了 , CPU触发时钟中断 , 发现线程a时间片耗尽 , 将其从调度队列剥离 . 但是也将线程a的硬件上下文存到他的pcb里 . 也就是说 : 线程a停止前 , 认为自己抢了一张票后 , 还剩下 99张!!!
-
线程b随后执行 , 他是一个抢票大师 , 阴差阳错的在线程a重新调度前执行了
100次ticket--, 最后内存里的ticket被更新为0. -
灾难到来 , 线程b重新调度时 , 恢复硬件上下文 , 将自己pcb里保存的
ticket=99存到了CPU寄存器里 , 然后执行之前剩下来的写回内存操作.
自此 , 内存中原本已经归零的ticket变量 , 这样一折腾 , 又变成了99. 造成了严重的数据不一致问题 .
根源二(主要矛盾):if (tickets > 0) 判断的并发执行
上面只是阐述了多线程访问共享资源时确实可能出错这个事实 , 接下来在黄牛抢票的程序里为啥ticket最后回变为负数???
下面是黄牛抢票程序的部分代码:
if(ticket >= 0){usleep(1000);ticket--; printf("%s买票:%d\n",ch,ticket);}
可以发现 , 除了ticket--比较关键外 , 判断语句if(ticket >= 0)也很关键 . 按道理来说 , 当ticket已经被减到-1 , 就不能再让任何线程的代码执行代码块内部的ticket--, 那为啥还会出现问题???
CPU里存在
运算器和控制器两大部件 , 运算器负责算术运算—比如ticket--的操作 , 控制器负责逻辑运算—就是这里的if(ticket >= 0)语句!!!
于是就说得通了, 逻辑类似于刚才的ticket-- , 同样是非原子的操作.
ticket和0得先载入到CPU寄存器 .- 然后由控制器进行
if(ticket >= 0)的逻辑运算. - 最后还得将得到的真假值写回到内存 .
试想 :
如果线程1刚执行完
if(ticket >= 0),刚好被操作系统剥离 , 然后线程2也执行if(ticket >= 0)后被剥离 , 以此类推… 四个线程自己的硬件上下文数据里记录的都是从if(ticket >= 0)判断成功的代码开始运行 , 那这四个线程就都可以执行到ticket--;, 即便此时ticket已经为0了!!!
概念总结 :
线程安全问题 :
当多个线程并发访问(即都想访问,也有权利访问,甚至是同时访问)共享资源时 , 由于缺乏合适的保护机制 , 导致程序出现了非预期的结果 , 就称为线程安全问题.
三.解决方案 : 互斥锁(mutex)
互斥锁是为了解决多线程中的问题 , 所以linux下相关函数在线程相关的头文件
<pthrea.h>
1.全局锁:
全局锁的使用很简单 , 一个锁类型
pthread_mutex_t, 一个宏定义PTHREAD_MUTEX_INITIALIZER, 以及两个库函数phtread_mutex_lock和pthread_mutex_unlock
使用示例(简洁):
//mylock就是一个初始好后的互斥锁变量
pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;
//加锁
pthread_mutex_lock(&mylock);
//解锁
pthread_mutex_lock(&mylock);
2. 局部锁:
pthread_mutex_init( , ): 参数一是锁变量的指针 , 参数二是控制初始化方式的指针(通常传nullptr用默认的).pthread_mutex_init( ): 参数是锁变量的指针 , 用于初始化锁.pthread_mutex_lock( ): 参数是锁变量的指针,用于为线程加锁.pthread_mutex_unlock( ): 参数是锁变量的指针 , 用于解锁.pthread_mutex_destroy(): 参数是锁变量的指针 , 用于销毁锁 .哪个线程创建,就那个线程销毁!!!
3.互斥锁的封装 :
mutex.hpp(类)
设计思路 :
-
分为两个类 ,
class Mutex和class LockGuard. -
Mutex类用于封装底层的pthread_mutex_lock函数和pthread_mutex_unlock函数 ,同时也在构造和析构构里加入pthread_mutex_init和pthread_mutex_destroy来保证锁的生命周期随对象 -
只要创建锁,必然是用来对线程的临界区代码加锁以及解锁 . 因此把加锁和解锁的函数调用进一步封装到
LockGuard类中. -
自此达到了一个效果 :
LockGuard类在局部域创建对象时自动调用构造来创建锁和加锁,在出了局部域后自动调用析构来释放锁和销毁锁 . 我们需要做的就是—把LockGuard对象的创建放在临界区的外面一层 .
```c++
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>namespace mutex_module
{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) :_mutex(mutex){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex& _mutex;};
}
main.cpp
#include"mutex.hpp"
using namespace mutex_module;int capacity = 1000;void* routine(void* input)
{while(1){LockGuard guard(mylock); //类if(capacity >= 1){usleep(1000);std::cout << ch << "抢票:" << capacity << std::endl;capacity--;}else{break;}//执行下一次while循环前,对象guard对象会调用析构释放锁资源}return nullptr;
}
int main()
{Mutex mylock;pthread_t tid1 = 0;pthread_t tid2 = 0;pthread_t tid3 = 0;pthread_t tid4 = 0;pthread_create(&tid1,nullptr,routine,(void*)"线程一:");pthread_create(&tid2,nullptr,routine,(void*)"线程二:");pthread_create(&tid3,nullptr,routine,(void*)"线程三:");pthread_create(&tid4,nullptr,routine,(void*)"线程四:");pthread_join(tid1,nullptr);pthread_join(tid2,nullptr);pthread_join(tid3,nullptr);pthread_join(tid4,nullptr);return 0;
}
4.互斥锁如何解决问题:
前面说到 , 数据不一致的原因有两个 :
ticket--这一算术运算非原子操作, 即ticket–的指令执行时可能会被终端.if (tickets > 0)这一逻辑运算是原子操作 , 但是可能会让其他线程在加锁前在临界区内已经占位置了.
有了互斥锁后的多线程并发抢票流程(线程一/线程二)
5.互斥锁的底层原理:
互斥锁的实现分我硬件或软件层 , 硬件层就是暂时的将CPU的时钟中断关闭 , 从而使得非原子的操作也不能被打断 , 但是这样做的话弊端很大 , 一旦程序出现bug , 后果是灾难性的.因此互斥锁的实现还是通过软件层更理想!!!
1️. 互斥锁,本质上就是一个 0/1 的小开关
可以把互斥锁理解成一个共享的整型变量,比如:
-
•
0:表示锁空闲,没人用,你可以来抢!
-
•
1:表示锁被占用,有别的线程在使用,你得等!
线程想进入临界区,就得先去“抢锁”——也就是尝试把锁状态从 0 改成 1,只有改成功了,才代表抢到了锁,才能进去操作共享资源。
2. 但!锁也是公共资源,所以“抢锁”这个动作,得是原子的!
问题来了:如果多个线程同时去读锁的状态,都看到是 0,然后都去尝试改成 1,那不就乱套了吗?
所以,对锁的“读取 + 修改”操作必须是原子的! 也就是说,它不能被拆成多步,也不能被线程切换、中断、多核并发干扰,必须一次性搞定:要么成功拿到锁,要么失败什么都不改。
3️. 底层实现:依靠原子指令
那这个“原子地修改锁状态”的操作,是怎么完成的呢?
答案是:通过一条硬件支持的原子指令!
比如在 x86 架构上,可能会用 LOCK XCHG(带锁的交换指令),或者更通用的 CAS(Compare And Swap)。这些指令在执行时不会被线程切换、中断等干扰,是真正的原子操作。
抢锁成功返回 0,失败返回 1,就这么简单粗暴,但非常有效。
4️ 线程切换?
如果线程在加锁时,刚把锁状态加载到寄存器,然后突然发生了线程切换,寄存器内容被换出,新线程也看到锁是 0,那会不会也去抢锁?
其实不会!因为 锁变量本身是存储在共享内存里的,不是线程的寄存器或栈里。
虽然线程可能会把锁的值临时加载到寄存器里加速访问,但最终修改一定会写回内存,而且这个写回是受原子指令保护的。所以,寄存器切换不影响锁的真实状态。
5️ 线程中断?
还有个细节:如果线程 A 成功加锁(锁状态变为 1),然后突然被时钟中断打中,被调度器踢出了 CPU,这把锁会不会失效?
不会!因为锁状态已经原子地修改为 1,并且写回内存了。其他线程再来抢锁时,一定会读到锁是 1,它们就知道:“有人用了,我得等。”
所以,只要成功加锁,这把锁就是你的,哪怕你突然被切走了,也没人能抢走!
6️ 总结:
| 关键点 | 说明 |
|---|---|
| 1. 锁是一个 0/1 状态变量 | 表示锁当前是否被占用 |
| 2. 对锁的修改必须是原子的 | 不能被线程切换、中断等干扰,防止数据竞争 |
| 3. 通常通过硬件原子指令完成(如 xchg / CAS) | 即一条汇编指令 , 无法被打断 |
| 4. 锁变量在共享内存中,修改后对其他线程立即可见 | 即便有寄存器切换,锁状态依旧正确 |
技术的两面性
因为加锁带来了竞争、上下文切换、原子指令本身的开销,还有多核间的缓存同步等问题。锁是安全的代名词,但也是性能的潜在瓶颈,临界区要尽量小!
