
概要&序論
Hello大家好,我是此方,本文开始,我们正式进入线程同步互斥章节的讲解,内容与前面的线程篇紧密相连。本文讲解互斥量的概念与接口操作。下一篇文章讲解互斥量的原理与封装。好的,我们开始吧。
一、 线程互斥
1.1 进程线程间的互斥相关背景概念
1.2 互斥量mutex
1.2.1多线程并发引发的问题
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
我们用一个模拟抢票的demo来测试这个问题:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
char *id = (char*)arg;
while ( 1 ) {
if ( ticket > 0 ) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
} else {
break;
}
}
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}

1.2.2引发问题的原因:抢票操作下非原子性
为什么会抢到负数?
if语句判断条件为真以后,代码可以并发的切换到其他线程usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段--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的内存地址
我们暂时给出一个结论:单条汇编语句是原子的。
1.2.3 整个问题触发的过程
ticket–的操作分为以下三步,对应三句汇编代码,
load:将共享变量ticket从内存加载到寄存器中update:更新寄存器里面的值,执行-1操作store:将新值,从寄存器写回共享变量ticket的内存地址

场景一:条件穿透与连续扣减
当 ticket = 1 时,线程 1~4 均通过 if 判断并进入 usleep 挂起。随后被陆续唤醒:
- 线程 1 挂起于写回前:线程 1 执行 load(读到 1)与 update(算得 0),在 store 前被切走,其上下文保存 %eax = 0,此时内存仍为 1。
- 线程 2 率先清零:线程 2 完整执行 load(1) → \rightarrow → update(0) → \rightarrow → store(写回 0),内存变为 0。
- 线程 3 扣至负数:线程 3 唤醒后,直接执行 load(读到当前内存 0) → \rightarrow → update(-1) → \rightarrow → store(写回 -1),内存变为 -1。
- 线程 4 继续扣减:线程 4 依次执行 load(读到 -1) → \rightarrow → update(-2) → \rightarrow → store(写回 -2),内存变为 -2。
- 线程 1 覆写掩盖异常:线程 1 恢复运行,基于保存的旧上下文(%eax = 0)直接执行 store 将 0 写回内存,覆盖了后续线程的计算结果。
场景二:上下文还原导致的数据覆写(导致错乱)
- 步骤 1(线程 A 载入旧值后被切走):假设内存中 ticket = 100。线程 A 执行 load 指令,把 100 读取到自身的 CPU 寄存器 %eax 中。准备执行 update 时时间片耗尽,线程 A 被挂起,系统将其上下文(%eax = 100)保存起来。
- 步骤 2(线程 B 持续扣减):线程 B 被调度运行,连续执行了多次 ticket– 操作,将内存中的 ticket 从 100 一路扣减修改到了 10。
- 步骤 3(线程 A 恢复上下文覆写内存):线程 A 再次被调度运行,系统恢复其上下文,CPU 寄存器 %eax 重新被还原为 100。线程 A 从被打断的 update 处继续执行(
100 - 1 = 99),随后执行 store 将 99 写回内存 ticket。 - 结果:线程 B 辛苦扣减的结果(变为 10)被瞬间抹去,内存中的 ticket 倒退回 99,造成严重的并发数据覆盖问题。
1.2.4如何解决问题:引出互斥锁
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。


1.3互斥量的接口
加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码
1.3.1 互斥量的初始化与销毁
在多线程编程中,使用互斥锁需要包含头文件 pthread.h,互斥量的类型为 pthread_mutex_t。初始化互斥量有两种主要方式:
- 静态分配:使用宏 PTHREAD_MUTEX_INITIALIZER 静态初始化全局或静态互斥量。例如 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;。这种静态初始化的互斥量不需要手动释放,当程序运行结束时会自动释放。
- 动态分配:通过调用函数 pthread_mutex_init 进行动态初始化。
- 函数声明:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);。
- 参数 mutex:指向需要被初始化的互斥量对象的指针。
- 参数 attr:互斥量的属性设置,通常传入 NULL 使用默认属性。
销毁互斥量需要调用 pthread_mutex_destroy 函数:
- 函数声明:int pthread_mutex_destroy(pthread_mutex_t *mutex);。
- 销毁注意事项:
- 使用 PTHREAD_MUTEX_INITIALIZER 静态初始化的互斥量不需要进行销毁。
- 切勿销毁一个处于已加锁状态的互斥量。
- 已经被销毁的互斥量,必须确保后续不会有任何线程再次尝试对其加锁。
1.3.2 互斥量的加锁与解锁
互斥量的加锁与解锁包含以下核心接口:
- 阻塞加锁:int pthread_mutex_lock(pthread_mutex_t *mutex);。
- 非阻塞加锁:int pthread_mutex_trylock(pthread_mutex_t *mutex);。
- 解锁操作:int pthread_mutex_unlock(pthread_mutex_t *mutex);。
- 返回值:函数执行成功时返回 0,失败时返回错误号。
1.3.2.1 加锁时的执行流状态
在调用 pthread_mutex_lock 申请锁时,执行流可能面临以下情况:
- 获取成功:若互斥量当前处于未锁状态,该函数会将互斥量锁定并返回成功。持锁成功的线程可以继续向后运行,访问临界区代码和临界资源。
- 获取失败与阻塞:发起函数调用时,若其他线程已经锁定该互斥量,或者有多个线程同时申请互斥量且当前线程未竞争到锁,pthread_mutex_lock 调用会导致当前线程陷入阻塞(执行流被挂起),直到互斥量被释放解锁。
1.3.3 互斥锁的本质与原子性保障
- 锁本身是临界资源:竞争申请锁时,所有多线程都必须能够先看到锁,因此锁本身就是一种临界资源。
- 加解锁的原子性:申请锁的过程必须是原子的。无论是 pthread_mutex_lock、pthread_mutex_trylock 还是 pthread_mutex_unlock,其内部操作均为原子操作。
- 锁的核心能力:锁提供的能力的本质,是把执行临界区代码由并行转换为串行。在持锁线程执行期间,其操作不会被打扰,这也是一种变相的原子性表现。
1.3.4 进程间互斥锁的实现与应用
互斥锁默认的作用域为进程内部(PTHREAD_PROCESS_PRIVATE)。若结合共享内存(shm),并配合进程间共享属性,即可将互斥锁扩展至多进程间的同步与互斥。
1.3.4.1 实现理论与核心要点
- 首地址挂载锁结构:将共享内存的首地址通过强制类型转换(如 (pthread_mutex_t *)shm)映射为互斥锁对象,使所有映射该共享内存的进程均能访问同一把锁。
- 修改共享属性:必须在初始化锁前,通过 pthread_mutexattr_setpshared 将属性设置为 PTHREAD_PROCESS_SHARED。
- 避免静态初始化:共享内存中的锁只能通过 pthread_mutex_init 动态初始化,不可使用 PTHREAD_MUTEX_INITIALIZER。
1.3.4.2 实践代码示例
#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>
#include <pthread.h>
struct SharedData {
pthread_mutex_t mutex; // 挂载在共享内存头部的互斥锁
int data; // 进程间共享的临界资源
};
// 1. 初始化进程间互斥锁(由创建共享内存的进程执行)
void init_process_mutex(struct SharedData *shm) {
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED); // 设置进程间共享
pthread_mutex_init(&(shm->mutex), &attr); // 动态初始化
pthread_mutexattr_destroy(&attr);
}
// 2. 跨进程加锁访问示例
void access_shared_resource(struct SharedData *shm) {
pthread_mutex_lock(&(shm->mutex)); // 强转头部地址获取锁,阻塞等待
// 临界区操作
shm->data++;
pthread_mutex_unlock(&(shm->mutex)); // 解锁
}
1.4添加互斥锁优化后的抢票代码

#include <iostream>
#include <pthread.h>
#include <string>
#include <vector>
#include <unistd.h>
int ticket = 10000 ;
pthread_mutex_t LOCK = PTHREAD_MUTEX_INITIALIZER ;
void* BuyTicket(void* args){
std::string* name = (std::string*)args ;
std::cout<<"I am The Child Thread["<< *name <<"]"
<<"My ThreadId is" <<pthread_self()<<std::endl;
while(true){
pthread_mutex_lock(&LOCK);
if(ticket > 0){
usleep(1);
ticket--;
std::cout<<"进程["<<*name<<"]抢到一张票"<<"ticket="<<ticket<<std::endl;
pthread_mutex_unlock(&LOCK);
}
else {
pthread_mutex_unlock(&LOCK);
break;
}
}
return nullptr ;
}
int main(){
std::vector<pthread_t> ptd ;
for(int i = 0 ; i < 5 ; i ++){
pthread_t tid = 0 ;
std::string* name = new std::string("Thread" + std::to_string(i));
pthread_create(&tid , NULL,BuyTicket ,name);
ptd.push_back(tid);
}
for(int i = 0 ; i < 5 ; i++){
pthread_join(ptd[i],nullptr);
}
}

1.5 关于互斥锁的思考
1.5.1 关乎锁机制的两个核心问题
-
问题一:如果有线程不遵守规则,不加锁就直接访问临界资源会怎样?
- 解答:这属于严重的程序 Bug。互斥锁是一种软约束与编程约定,必须保证所有访问该临界资源的线程都严格遵守“先申请锁 → \rightarrow → 访问临界区 → \rightarrow → 释放锁”的规范。若有线程不加锁直接访问临界资源,锁的保护机制将彻底失效。
-
问题二:加锁之后,在临界区内部允许线程切换吗?切换了会怎样?
- 解答:完全允许切换。
- 影响与原理:即便持锁线程在临界区内被切走,也不会带来安全问题。因为该线程只是暂时失去了 CPU 执行权,但它并未释放锁(属于持有锁被切换)。在此期间,其他线程被调度运行并尝试申请锁时,均会因锁被占用而陷入阻塞。所有线程都必须等待该持锁线程重新被调度、执行完代码并释放锁后,才能重新展开锁的竞争。
1.5.2 一个故事理解临界区的原子性:超级自习室比喻
- 自习室与钥匙:把临界资源比作一间只能容纳一人的“超级自习室”,把互斥锁比作自习室门口唯一的“钥匙”。只有一个线程能拿到钥匙并进入自习室。
- 门外的等待者:自习室门外站着许多等待自习的人(其他竞争锁的线程)。
- 站在门外看门内:站在门外的人,无法干预你在自习室里的自习过程。即使你在自习途中停下来休息(持有锁被系统切换),门外的人依然进不去,因为钥匙还在你身上。
- 原子性的本质:对于门外排队的人来说,你的自习过程只有两种有意义的状态——要么你还没进去,要么你已经自习完毕并带钥匙出来了。这种“要么不做,要做就做完”的整体验象,对于门外的人而言就是原子性。

转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/Z2314246476/article/details/163534229




