Linux 线程同步与互斥(一)线程的并发问题,线程的互斥锁,C++中的互斥锁
目录
一、线程的并发问题
复习前面的概念:
- 临界资源:多线程执行流被保护的共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
下面我们用C++线程写一段抢票的代码来引出线程的并发问题 :
这段代码定义了一个全局共享变量ticket(初始值 1000)作为抢票资源,在route线程函数中实现了抢票逻辑,函数内循环判断票量是否充足,模拟 1 毫秒抢票延迟后打印售票信息并扣减票数;在main函数中创建 4 个线程对象,让 4 个线程并发抢票,最后主线程调用Join等待所有线程执行完毕,以此模拟多线程并发抢票的场景,同时也暴露了无锁保护下共享资源的并发竞争问题。
运行结果:
为什么运行结果最后两行显示抢到的票是负数呢? 这到底是并发问题?竞争问题?还是同步互斥问题?
全部都是,它们是同一件事的不同叫法:
- 并发问题:多个线程同时跑
- 竞争问题(竞态条件 race condition):多个线程竞争同一个共享数据
- 同步互斥问题:没有互斥保护,导致执行顺序混乱,结果错误
本质一句话:没有互斥 → 线程乱序执行 → 共享数据被并发修改 → 结果错误
并发问题分析
怎么解决?

上面这幅图标注了多线程抢票出现负数票的两大关键代码段,也就是我们说的临界区:
- if (ticket > 0) : 判断票量是否充足
- ticket-- :扣减票数
在多线程并发执行的环境中,每个线程都是独立的执行流,它们本身不直接存储共享数据,所有线程共同操作的共享变量比如票数 ticket 都存放在内存当中,而 CPU 无法直接对内存中的数据进行计算,任何线程想要修改这个共享变量,都必须先执行读取操作,把内存里的 ticket 值加载到当前线程独占使用的 CPU 寄存器中,所有的加减、比较运算都只能在寄存器里完成,运算结束之后,线程再把寄存器里更新后的值写回到内存对应的位置,才能真正完成一次修改,这个 “从内存读入寄存器 → 在寄存器中运算 → 写回内存” 的完整流程,是所有线程操作共享数据的固定规则,而且每一次独立的操作,比如 if 判断里的比较、ticket-- 里的减法,都会重新从内存读取最新的值,不会默认沿用之前寄存器里的旧数据,同时线程在执行过程中随时可能被硬件时钟中断打断,触发线程切换,操作系统会保存当前线程的寄存器内容和程序计数器 PC 指针,也就是线程上下文,但只会保存线程自身的运行状态,不会同步、锁定或保护内存中的共享数据,其他线程被调度运行后,依然可以按照同样的规则,从内存读取同一个 ticket 变量到自己的寄存器中进行判断和修改,内存中的共享数据对所有线程都是公开可访问的,这就为多个线程交叉读写、互相覆盖结果埋下了根本隐患,也是后续不加锁时 if 判断和 ticket-- 操作出现数据不一致、最终产生负数的底层基础。
假设现在就剩一张票了,ticket = 1,有两个线程A、B:
首先线程 A 运行,代码对应的底层指令:
if (ticket > 0) :
- 从内存读 ticket → 将 ticket 的值存入寄存器 R1(R1 = 1)
- 比较 R1 > 0 : 与立即数0进行比较,如果小于等于0,就跳转到后续的减减操作
当跳转到 ticket-- 的地址时,此时线程 A 已经知道:我要执行 ticket-- 了,但还没执行 ticket-- 的任何指令。突然此时线程 A 被切走了(硬件中断或其他原因),操作系统会保存线程 A 的上下文,线程上下文包括寄存器 R1 的值,程序计数器 PC指针(指向 ticket-- 的第一条指令)。然后线程 A 被暂停。之后线程 B 运行,假设线程 B 完整的执行了整个逻辑:
if (ticket > 0) :
- 读内存 ticket → 1
- if 判断成立
- 满足 if 循环后进入 ticket-- 进行 -- 操作
ticket-- :
- 读内存 ticket = 1 → R1 = 1
- -- 操作减为0
- 写回内存 → 现在内存里 ticket = 0
- 现在内存里是 0。
线程B刚好完整的执行完之后线程 A 被恢复,操作系统恢复线程 A 的上下文 : 把 PC 恢复成 “正在执行 ticket--”,把 R1 恢复成 “切走前的值 1”,重点来了:线程 A 不会重新执行 if,它从上次暂停的位置直接开始执行 ticket--。线程 A 继续执行 ticket--。现在线程 A 要做的是 ticket-- 对应的的三条指令 :
- 重新从内存读 ticket → 0(因为线程 B 已经改成 0 了)
- R1-- 把 0 减减为 -1
- 将 -- 后 -1 写回内存
此时最终写回内存的结果就是:ticket = -1
相关问题:
问题 1:if()语句和 ticket-- 执行时都要先从内存读 ticket 到 CPU?
是的。绝对是。而且它们读的是同一块内存 ticket,只是用的寄存器可能不同。关键是 if 读一次 ticket 值会得到一个值。ticket-- 再读一次 ticket 的值时可能已经被别的线程改掉了。这就是最大的漏洞来源。
问题 2:线程 A 恢复后会不会继续执行 ticket--,而不是重新执行 if?
完全对。不会重新执行 if(),只会继续执行 ticket--。因为线程上下文的 PC 指针直接指向 ticket-- 的第一条指令。CPU 恢复上下文后,会根据 PC 指针从断点继续往下跑。所以它不会回头,不会重新判断 if,直接接着减减执行。
问题 3:线程 A 回来后读 0,减成 -1?
完全正确。线程 B 已经把内存 ticket 从 1减为 0 了。线程 A 恢复后执行 -- 操作会重新从内存读到0。0 - 1 = -1 后写回内存。这就是 ticket 变 -1 的真实底层过程。
问题 4:线程切换发生在 ticket-- 之前,这个时机才导致负数?
你说的完全正确。这就是负数出现的最关键时机。如果线程切换发生在if 之前 → 不影响,if 之后、ticket-- 之前 → 最危险,因为别的线程可以完整跑完整个逻辑。所以线程切换正好插在 “if 成立” 和 “真正去减” 之间,这才导致多个线程同时认为 “有票可以减”,然后一起减,最终变成负数。
问题5 : usleep(1000) 在代码中起什么作用? usleep 会导致时钟中断引发线程切换吗?
是的,usleep 是主动触发线程切换,比时钟中断还狠、还稳,是专门用来复现这个负数 bug 的 “神助攻”。usleep 是一个系统调用,作用是让当前线程主动挂起指定的微秒数(比如 usleep(1000) 就是睡 1 毫秒)。当线程执行 usleep 时,会发生主动触发用户态→内核态切换:线程发起系统调用,CPU 立刻切到内核态。内核直接把当前线程挂起,放入等待队列:因为线程自己说 “我要睡一会儿”,内核直接剥夺它的 CPU 使用权。调度器立刻调度下一个就绪线程运行:不等时钟中断,不等时间片用完,马上切走。睡眠时间结束后,线程被唤醒,重新进入就绪队列,等待下次调度。
这个位置,就是我们之前说的“if 成立、ticket-- 之前”的黄金断点,而且 usleep 会100% 触发线程切换,不像时钟中断是概率性的。
问题6 : 那这个负数最多可以减到多少?还是负一、负二、负三、负四都行?
都行!而且理论上没有下限,想负多少就能负多少。只要满足一个前提 ——有多少个线程在 ticket 还大于 0 的时候,抢先通过了 if 判断,最终票数就会变成 初始票数 - 通过 if 的线程数。比如初始 ticket = 1:
- 2 个线程都通过 if → 1 - 2 = -1
- 3 个线程都通过 if → 1 - 3 = -2
- 4 个线程都通过 if → 1 - 4 = -3
- 100 个线程都通过 if → 1 - 100 = -99
之所以能这样,是因为if 判断和 ticket-- 不是原子的,线程切换、usleep 都会让大量线程在 ticket 还没被真正减掉之前,就已经 “拿到了减票资格”,每个拿到资格的线程,后面都会稳稳执行一次减减,不管后来内存里 ticket 变成 0 还是负数,它们都会顺着 PC 指针继续减下去,所以最终结果可以是 -1、-2、-3、-4…… 一直到任意负整数,完全取决于有多少线程 “挤进去” 通过了 if 这道关。
问题7 : 那进程切换呢?我们上面说的只是线程切换呀,那和进程切换有什么关系呢?
进程切换和线程切换在 “抢票出负数” 这件事上,原理一模一样,只是切换的单位更大、上下文保存更多而已。我们之前讨论的线程切换,是同一个进程内部多个线程之间的切换,它们共享同一片内存空间,所以 ticket 变量对所有线程都可见,切换时只需要保存线程的寄存器和 PC 指针;而进程切换是不同进程之间的切换,每个进程有独立的内存空间和页表,切换时不仅要保存 CPU 寄存器与 PC 指针,还要切换虚拟地址空间、刷新 TLB,开销远大于线程切换,但如果我们让多个不同进程同时访问同一个共享内存中的 ticket 变量,那么进程切换带来的效果和线程切换完全一致,比如进程 A 刚执行完 if 判断确认 ticket 大于 0,还没执行 ticket-- 就发生了进程切换,操作系统保存进程 A 的完整上下文并调度进程 B 运行,进程 B 同样从共享内存中读取 ticket 并完成减操作,之后进程 A 被切换回来,依旧会按照上下文记录的位置继续执行 ticket--,不会重新判断 if 条件,最终同样会因为读取到已经被修改为 0 的 ticket 值而减成负数,而且和线程场景一样,只要有足够多的进程在 ticket 未被修改前通过 if 判断,最终 ticket 可以变成 - 1、-2、-3 等任意负数,时钟中断同样会触发进程切换,usleep 这类阻塞操作也会让当前进程主动挂起并触发调度,本质上无论是进程切换还是线程切换,核心都是打断了 if 判断与 ticket-- 这组非原子操作,导致多个执行流同时通过合法性检查并重复修改共享数据,最终引发数据不一致。
并发问题的解决

那要解决这个并发问题,我们就要对共享资源进行保护,保护共享资源的本质就是保护临界区,保护临界区最好的方式就是每次只让一个线程访问共享资源。
引出锁🔒:
二、锁是什么?
引入互斥锁
我们在上面多线程抢票的代码中出现的并发问题会导致竞态条件,最终出现票数为负数的异常情况。要彻底解决这个问题,就需要引入互斥锁(Mutex)这一多线程同步的核心机制 —— 它就像一把临界区的 “钥匙”,保证同一时间只有一个线程能进入操作共享资源的代码段,从根源上杜绝并发修改带来的数据混乱
互斥锁的作用就是给这段操作共享资源的 “临界区” 加上保护:线程进入临界区前,必须先申请加锁;若锁已被其他线程持有,申请线程会阻塞等待,直到锁被释放;线程执行完临界区代码后,必须手动解锁,把锁交给其他线程竞争。通过这种方式,原本并发执行的临界区代码,变成了同一时间仅一个线程可执行的串行逻辑,避免了多线程同时修改ticket的问题,保证了票数从 1000 依次递减到 0,不会出现负数。
相关函数介绍
锁的数据类型 pthread_mutex_t
互斥锁的核心数据类型是pthread_mutex_t,它本质是一个共用体 (union),我们操作的pthread_ mutex_t 只是用户态的句柄,真正的锁状态、线程调度逻辑由内核维护,它是互斥锁的 “载体”,所有锁操作都围绕这个变量展开。
锁的初始化
互斥锁在使用前必须完成初始化,根据锁的定义位置,有两种标准初始化方式:
(1)函数式初始化:pthread_mutex_init
这个函数是pthread线程库里的,需包含头文件<pthread.h>
第一个参数 mutex 要初始化的互斥锁变量的指针;第二个参数 attr 是锁的属性配置指针,我们一般传 NULL/nullptr 表示使用默认属性的普通互斥锁。返回值成功返回 0,失败返回对应错误码。
适用场景:这种函数式的初始化适用局部栈上的锁、堆上动态分配的锁,必须在运行期手动调用该函数完成初始化,使用完后还需调用pthread_mutex_destroy销毁锁,释放内核资源。
(2)静态初始化宏:PTHREAD_MUTEX_INITIALIZER
本质是 POSIX 标准提供的编译期常量,等价于pthread_mutex_init(&mutex, NULL),直接给锁的结构体赋初始值,将锁状态设为空闲。
适用场景:适用于全局锁、静态修饰的锁,在程序启动时自动完成初始化,无需手动调用pthread_ mutex_init,也无需手动销毁。
加锁 pthread_mutex_lock
pthread_mutex_lock 的唯一参数 pthread_mutex_t *mutex 就是我们要申请抢占的那把互斥锁的地址(指针)。我们在main里定义的 pthread_mutex_t mutex,是锁的本体;传进去的 &mutex,就是锁的指针,告诉内核“我们要抢这把锁”。
- 如果锁空闲则线程直接持有,进入临界区;
- 若锁已被其他线程持有,线程会阻塞,直到锁被释放后被唤醒。
需要注意的是普通的互斥锁不支持同一线程重复加锁,否则会直接阻塞,导致死锁。
解锁 pthread_mutex_unlock
解锁的参数和加锁同理,线程释放持有的互斥锁,让其他线程有机会竞争锁。
锁的销毁 pthread_mutex_destroy
销毁通过pthread_mutex_init初始化的互斥锁,释放锁占用的内核资源,避免资源泄漏。适用函数式初始化的局部 / 堆锁,全局静态初始化的锁无需调用。销毁锁前,必须确保所有线程已释放锁、无线程阻塞等待,否则会导致未定义行为。
C语言实现
下面我们就在抢票的代码中加上锁,观察能否抢票成功
这段代码是基于 C 语言、调用 pthread 库实现的多线程安全抢票程序。我们先定义全局票数和一把全局互斥锁,并使用PTHREAD_MUTEX_INITIALIZER静态初始化;主线程创建多个线程并让它们执行同一个抢票函数,每个线程在进入临界区前必须调用pthread_mutex_lo ck抢占同一把锁,确保同一时刻只有一个线程能判断票数、打印信息并执行票减一操作,完成后立即pthread_mutex_unlock释放锁,让其他线程继续竞争。
运行结果:
这次代码正常运行,没有出现抢票是负数的情况。
C++实现
我们上面看到的是原生 pthread 线程库基于 C 语言的实现,所有变量直接暴露,用裸指针传递参数,代码虽然简单,但缺乏封装性。而在实际的 C++ 项目开发 中,为了提高代码的可维护性和安全性,我们通常会面向对象封装。于是,我们把之前那些零散的参数(线程名字、互斥锁指针)打包进一个 thread_data 类,通过构造函数初始化,在创建线程时直接传递这个类对象。这样一来,线程函数内部就可以通过类成员安全地访问资源,不再使用裸指针,也让代码结构更加清晰。
但是要注意的是 C++11 及以后标准库提供的<thread>、<mutex>等 C++ 原生多线程函数,后面我们会讲,但这里我们仍然调用底层的 pthread 库里的函数(pthread_create、pthread_mutex_lock等)。
还有一个点就是上面的锁是全局的,也就是给全局变量加锁,全局的锁是不用初始化和销毁的,下面我们来看局部的锁 :
程序首先在主函数中定义并初始化唯一一把互斥锁,随后通过自定义的 thread_data 类,将每个线程的名称和这把锁的地址封装到四个对象中。在创建线程时,将这四个对象分别作为参数传递给线程执行函数。在线程函数内部,通过接收的对象指针,就可以访问到对应的线程名称和全局共用的那把锁。随后线程进入抢票循环,先尝试加锁,确保同一时刻只有一个线程能进入临界区,在判断还有票后执行打印和票数减一操作,完成后释放锁,让下一个线程继续竞争执行。整个过程中,虽然创建了四个线程对象,但它们共用同一把锁,从而保证了对共享票数的安全访问,避免了并发竞争带来的数据异常。
运行结果:
这次代码正常运行,没有出现抢票是负数的情况。
相关问题
1. 加锁的原则问题
加锁会将临界区从并行变为串行,以时间换安全,但同时必然会带来性能损耗与执行时间增加,因此必须遵守临界区尽可能短的核心原则,只给真正访问共享资源的代码加锁,把无关的代码移出临界区,最小化锁的持有时间,既保证线程安全,又最大化程序并发效率。
2. 给全局变量上的锁难道不也是共享资源吗?
给全局变量加的互斥锁本身确实是共享资源,所有线程都需访问同一把锁来完成同步,但其自身的安全是由操作系统内核保障的,pthread_mutex_lock与pthread_mutex_unlock被设计为原子操作,执行过程不会被线程调度打断,要么加锁成功、要么加锁失败,从根源上避免了锁状态的并发修改问题;而锁的初始化需区分场景:
- 静态宏初始化在程序启动时完成,天然安全
- 动态函数式初始化需在多线程启动前由主线程一次性执行,确保无并发风险,最终以锁的原子性为基础,实现了对普通共享资源的安全保护
3. 一些线程遵守先加锁后解锁,一些线程能不能不遵守,也就是不加锁呢?
绝对不可以!访问临界共享资源时,所有线程必须严格遵守先加锁后解锁的规则,不能有任何例外,否则互斥锁会完全失效。
4. 那申请不成功的线程在干什么?
当线程竞争互斥锁失败时,它不会持续占用 CPU 等,而是会进入内核态的阻塞等待队列进行阻塞等待,被挂起不再消耗 CPU 资源;直到持有锁的线程执行unlock释放锁后,内核才会唤醒等待队列中的线程,使其再次尝试竞争锁。
5. 加锁本质
加锁的本质就是保证临界区代码(如锁申请 / 释放、数据访问等)具备原子性。原子性意味着一段操作要么完整执行完毕,要么完全不执行,不会出现 “执行到一半被线程调度打断” 的中间状态。互斥锁通过内核与 CPU 的底层配合,实现了这种原子性:
- 从指令层面,CPU 保证单条汇编指令的执行不可被打断;
- 从系统层面,内核通过阻塞与唤醒机制,保证了竞争锁的线程在等待锁期间不会占用 CPU,确保了临界区代码的安全执行。
三、锁的原理
锁被分为软件锁和硬件锁。
硬件锁及原理
在计算机底层,并发问题的根源往往并非来自复杂的算法,而是源于硬件中断的不可预测性。最典型的就是时钟中断,它是操作系统调度的心脏,每隔固定时间就会打断当前正在运行的线程,强制触发线程切换。这种硬件中断让原本应该一气呵成的指令(如 ticket--)被拆解得支离破碎。为了解决这个硬件层面引发的并发问题,人类发明了硬件锁—— 它不依赖操作系统调度,而是通过 CPU 硬件指令或者直接屏蔽中断的方式,强行让一段代码在执行期间不被任何中断打断,从而保证指令的原子性。而当我们深入到软件层面,操作系统无法总是依赖硬件屏蔽中断(因为那样会影响系统响应速度),于是我们在软件层面模拟了硬件锁的逻辑,通过互斥锁(mutex)这种软件机制,来限制同一时间只有一个线程能进入临界区,这就是从硬件原子性到软件互斥性的完整逻辑链路。
下面我们先来学习一下时钟中断与线程切换之间的关系,有助于我们了解硬件锁及原理
时钟中断与线程切换
系统硬件定时器会每隔固定时间(如 1 毫秒)触发一次时钟中断,CPU 收到中断信号后,无论当前执行的是用户态代码还是内核态代码,都会立刻暂停当前执行流,保存现场,跳转到内核态。CPU进入内核态后,会执行操作系统的时钟中断处理函数(timer interrupt handler),核心逻辑分两步:
- 时间片计数与超时判 : 操作系统为每个线程/进程分配了固定的时间片(比如10ms),时钟中断每触发一次,就给当前正在运行的线程/进程的时间片计数减 1;当时间片计数归 0 时,就判定当前线进程的 CPU 使用权到期,必须触发调度。
- 调度器执行上下文切换 : 调度器会从就绪队列中选出下一个要执行的线程/进程,完成上下文切换保存当前线程/进程的 CPU 寄存器、程序计数器、栈指针等现场信息到 PCB;加载下一个线程/进程的现场信息到 CPU 寄存器;恢复执行,从内核态切回用户态,执行新线程/进程的代码。
每次进入时钟中断处理函数,内核只会对当前正在 CPU 上运行的线程进行处理:将其剩余时间片减 1 毫秒,随后判断时间片是否耗尽。若时间片仍有剩余,则直接恢复当前线程的执行,切回用户态继续运行;若时间片已用完,则触发线程调度,保存当前线程的上下文信息并将其放回就绪队列,再从就绪队列中选取下一个线程投入运行,并为该新线程分配一整段完整的时间片(如 10 毫秒)。此后,在新线程运行的过程中,后续每一次到来的时钟中断依旧会按同样规则,只对当前占用 CPU 的这个线程扣减时间片并判断是否需要切换,不在 CPU 上运行的线程不会参与时间片的计算与分配,直到它们再次被调度上 CPU 时才会重新获得完整时间片。整个过程循环往复,时钟中断作为固定触发源,只负责检查与扣减当前运行线程的时间片,线程切换仅在时间片耗尽时才会发生,从而实现平稳、有序的时间片轮转调度。
硬件层面上,会导致线程切换、进而引发并发问题的,不只有时钟中断,还有很多其他硬件中断,比如 I/O 中断、键盘鼠标中断、网卡中断、磁盘中断等。只要是能让 CPU 从用户态切入内核态、并触发调度器执行的中断,都可能打断当前线程、切换到其他线程,最终造成多个线程同时竞争临界资源的问题。只不过时钟中断是最频繁、最典型、最容易导致线程在临界区中间被切走的一种,所以我们通常用它来理解并发问题的根源。
而我们写的抢票代码出现超卖、票数为负等问题,本质上确实是因为线程切换,和硬件中断直接相关:线程在执行 if(ticket>0) 到 ticket-- 这段临界区的过程中,被时钟中断或其他硬件中断打断,切换到另一个线程,另一个线程也去读取并修改同一份票数,最终导致数据不一致。但我们在代码里使用的 pthread_mutex_t 互斥锁,属于软件层面的解决方案,它并不去屏蔽硬件中断,而是通过软件规则和 CPU 原子指令,保证同一时刻只有一个线程能进入临界区,即便发生线程切换,其他线程也会因为抢不到锁而无法操作临界资源。
所以归根结底:
不管是时钟中断还是其他中断引发的线程切换,最终造成并发问题的根本原因,都是多个线程在切换过程中交替访问了同一份临界资源。
抢票代码出现负数的真正、唯一、根本根源不是时钟中断本身,不是线程切换本身,而是 ticket-- 不是原子操作,却被多个线程并发执行。ticket-- 在汇编里是 3 条指令,这三条指令不是一次性执行完的。因此线程切换可以发生在这三条指令的任意中间。线程切换只是让问题暴露出来的条件,而非根源;真正根源是共享变量的并发读写缺乏原子性保护。如果不发生线程切换,ticket-- 就算是 3 条指令,也会一口气执行完,不会乱。正是时钟中断触发了线程切换,切换恰好发生在 3 条指令中间,才让错误发生。所以线程切换是让问题暴露出来的直接原因、必要条件。
切换线程本身是操作系统正常行为,不是错误行为。真正错误的是我们让多线程并发执行一个拆成多条指令的操作,却没有任何保护。即使没有时钟中断,只要有任何方式能让两个线程交替执行那 3 条指令,结果照样错。比如线程主动 sleep,缺页中断,I/O 阻塞都会触发切换,都会错。
所以问题的本质是:共享变量的操作不是原子的,却被并发执行。这是根源、本质、设计错误。线程切换(常由时钟中断引起)只是出现负数的直接原因,根本原因是并发访问非原子的共享操作。
上面说的时钟中断是硬件级锁,硬件级锁的原理就是在临界区执行期间,屏蔽时钟中断或者其他只要会导致线程切换的硬件中断;屏蔽中断后,CPU 不会收到时钟中断信号,也就不会触发线程切换,临界区代码会完整执行完,再恢复中断,天然保证原子性;但这种方式成本极高,只能用于内核态的短临界区,用户态无法直接使用,因此我们用的是软件级互斥锁。
我们现在学的 pthread_mutex_t 互斥锁就是软件层面的锁。由操作系统内核 + 语言库实现。依赖 线程调度、阻塞、互斥算法等软件机制。
互斥锁的原理
下面我们来看软件级互斥锁,也就是pthread_mutex_lock等函数的锁的原理

我们在代码中首先定义了一个 pthread_mutex_t 类型的互斥锁变量,这个变量本质上是内存中的一块共享数据,由于所有线程在使用锁时传递的都是它的地址,因此所有线程看到和操作的都是同一块内存中的同一个锁状态。


再如上图 CPU 里的 eax 寄存器内部的一部分区域叫做 al,这个 al 就是 eax 寄存器的低 8 位部分,,属于当前线程私有的硬件资源,只能被当前线程访问和修改,不会被其他线程直接看到,这个 al 可以存储内容,al 存储的内容同样可以是 0 或 1,锁其实没有什么神奇的,上面我们定义的那个互斥锁变量 mutex 本质就是一个普通的内存变量,这个变量在内存中就是一个状态值 : 1 代表锁是空闲状态,0 代表锁正被占用。随后调用 pthread_mutex_init 函数对这个锁进行初始化,该函数在底层会将锁对应的状态值初始化为1,mutex 等于 1 就代表这把锁当前处于空闲未被占用的状态,任何线程都可以去竞争获取这把锁。

当某个线程调用 pthread_mutex_lock 尝试加锁时,函数底层会先操作 CPU 内部的 al,加锁的第一步就是把立即数 0 存入这个 al 中(每个线程加锁时都会做),让寄存器内的值暂时为 0,紧接着会执行 CPU 提供的原子交换指令 xchg ,也就是 swap/exchange 指令,这条指令由硬件保证绝对原子性,执行过程中不会被任何时钟中断、线程切换或其他 CPU 操作打断,它的作用是把 al 中的值和内存中 mutex 变量的值进行一次性互换,此时原本内存里 mutex 是 1 (初始化为 1),AL 寄存器是 0,交换之后 AL 寄存器就变成了 1,而内存中的 mutex 被修改为 0。

完成交换后底层会判断 al 中的值是否大于 0,因为此时 al 是 1,条件成立,代表当前线程成功获取到了这把互斥锁,而内存中的 mutex 变为 0 也就向所有其他线程标记这把锁已经被占用,无法再被其他线程获取。当其他线程也调用。
这是当其他线程再 pthread_mutex_lock 尝试加锁时,同样会先把 0 存入 al ,再执行原子交换 xchgb 指令与内存中已经是 0 的 mutex 互换,交换之后它们的 al 依然是 0,判断条件不成立,加锁失败,这些线程会被挂起并进入等待状态,无法继续向后执行,也就完全无法进入临界区。成功加锁的线程在获取锁之后,就独占了临界区的访问权限,临界区中的代码比如 ticket-- 对应的三条汇编指令并不会被硬件变成单条原子指令,但因为其他线程都被锁阻挡在临界区之外,即使发生线程切换,切走的也只是当前持有锁的线程,其他线程依然无法进入临界区操作共享资源,所以当前线程可以安全、完整、 不被打扰的地把临界区内的所有指令一次性执行完毕,不会出现多个线程交错执行导致的数据混乱。

当线程执行完临界区的所有逻辑后,会调用 pthread_mutex_unlock 进行解锁,解锁操作在底层会直接把数值 1 重新写回内存中的 mutex 变量,让锁恢复到空闲的初始状态,同时唤醒之前因抢锁失败而等待的线程,这些被唤醒的线程会再次通过原子交换指令竞争这把锁,整个过程依靠共享内存中的 mutex 状态、CPU 原子交换指令以及对 AL 寄存器结果的判断,实现了同一时刻只有一个线程能够进入临界区执行代码的互斥效果,这就是软件层面互斥锁的完整实现原理。
四、互斥锁的本质(重要)
其实根本没有什么魔法让那三条指令 “瞬间执行完”。那三条指令该怎么走还是怎么走。真正起作用的是用 mutex 的 0/1 做了一道严格的门:同一时刻,只放一个线程进去执行这三条指令,其他线程全部拦在外面不让进。所以就是靠条件判断 + 原子交换,实现了排队执行。
锁的本质,就是看哪个线程能通过原子交换,把 1 抢到 al 中。谁拿到 al = 1,谁就拥有了这段临界资源的独占权;其他线程再怎么交换,al 都只能是 0,全都进不来。于是,拿到 1 的这个线程,就能安安稳稳、顺顺利利把那一堆指令一口气执行完。虽然它本身不是硬件原子指令,但效果上等价于原子操作,这就是锁的魔力。
所以申请资源要加锁,因为申请资源就是买票,本质就是对临界资源的预订机制。
五、C++中的互斥锁
首先,我们现在接触 C++ 锁,脑子里先记住:C++ 里做线程互斥,一共会碰到两个长得容易混的东西,一个是 std::mutex,一个是 std::lock_guard,这两个有什么联系吗?
![]()
std::mutex,它才是 C++ 里真正意义上的锁本身,我们可以把它理解成一扇门上那把实实在在的锁。它定义在<mutex>头文件中,是操作系统提供互斥能力、再被 C++ 封装出来的底层锁对象。它自己身上带有几个成员函数:lock()用来手动上锁,一旦锁被占用,别的线程就会卡在这里等待;unlock()用来手动释放锁,让别的线程有机会获取;还有try_lock()可以尝试拿锁、拿不到也不会阻塞。所有线程互斥保护共享变量、解决我们之前售票负数问题的根本依靠,全靠这个std::mutex。但它有一个很大的缺点:全程需要程序员手动控制上锁和解锁,代码里只要有 if 分支、else 分支、break、return、异常抛出,每一个离开临界区的路口都必须手动写一遍unlock,漏掉任何一处,程序就会死锁卡死,维护起来非常麻烦、很容易出错。
所以就有了第二个 std::lock_guard。注意,lock_guard 不是一把新锁,它自己不具备上锁的底层能力,手里离不开 std::mutex。我们可以把它理解成一个专门帮你看管mutex的管家、一个自动管理工具类。它同样在<mutex>头文件里,专门依托std::mutex工作,用到了 C++ 一个很核心的思想叫 RAII,也就是依靠对象的生命周期管理资源:当我们在代码里创建一个 lock_guard 对象的时候,它的构造函数会自动帮你调用 mutex 的 lock () 完成上锁;当这个lock_guard对象走出当前大括号的作用域、生命周期结束被销毁时,它的析构函数会自动调用 mutex 的 unlock () 完成解锁。有了它之后,你再也不用在各个分支里手动写解锁,不管代码是正常跑完、中途跳出、还是抛出异常,只要对象销毁,锁一定释放,完美规避漏掉解锁造成的死锁问题。
最后梳理一遍二者关系:std::mutex是锁本体,负责底层真正的互斥加解锁;std::lock_guard是锁的自动管理器,只负责帮你自动化调用 lock 和 unlock,不能脱离 mutex 单独使用;一个负责干活,一个负责管理,搭配在一起才是 C++ 最规范的写法。
下面放上对照代码,先看纯手动只用 mutex 的写法:
只用 mutex : 全程手动写,自己手动 lock() 加锁,自己必须在 if 里写 unlock,自己必须在 else 里也写 unlock,少写一个 unlock,直接死锁
再看 mutex 搭配 lock_guard 自动管理的写法:
mutex + lock_guard : 全程自动,只需要创建一个 lock_guard 对象,自动加锁,自动解锁,不管你怎么退出,都不会忘解锁,绝对不会死锁
六、C++互斥锁的封装
下面我们对上面C++中的 std::mutex 和 std::lock_guard 进行封装,封装出属于我们自己的 mutex 类 :
首先我们定义了 Mutex 类,在类的私有成员中持有 pthread_mutex_t 类型的原生锁实例_lock,在构造函数中调用 pthread_mutex_init 完成锁的初始化,析构函数中调用 pthread_mutex_destroy 完成锁的销毁,同时对外提供 Lock 和 Unlock 成员函数,分别封装 pthread_mutex_lock 和 pthread_mutex_unlock 操作;
接着定义了 LockGuard 类,采用 RAII 即资源获取即初始化的设计思想,在类的私有成员中持有 Mutex 类的引用_lockref,构造函数中自动调用传入 Mutex 对象的 Lock 方法完成加锁,析构函数中自动调用 Unlock 方法完成解锁,利用对象的生命周期来自动管理锁的加锁与解锁操作,彻底规避了手动加锁解锁时容易遗漏解锁导致的死锁问题;
在测试代码中,定义了全局共享变量 ticket 作为总票数,同时实例化自定义的 Mutex 类对象作为全局互斥锁,在 route 线程函数中,将操作 ticket 的 if 判断与 ticket-- 的卖票逻辑包裹在临界区内,通过创建 LockGuard 对象自动完成加锁,临界区执行完毕离开作用域时 LockGuard 对象自动析构完成解锁,保证同一时刻只有一个线程能进入临界区操作共享资源,即使在 if 分支中加入 usleep 模拟业务延迟触发线程切换,也不会出现多个线程同时操作 ticket 导致的负数问题,最后在 main 函数中通过 pthread_create 创建多个线程并发执行卖票逻辑,再通过 pthread_join 等待所有线程执行完成,完整验证了自定义封装锁的互斥效果与并发安全性,整个封装逻辑完全复刻了 C++ 标准库中 std::mutex 与 std::lock_guard 的设计思想,将底层原生 C 接口封装为符合 C++ 面向对象与 RAII 规范的安全互斥工具。
运行结果 :
七、总结
本文探讨了多线程并发中的临界资源保护问题。通过抢票案例分析了无锁保护下共享变量ticket出现负数票的并发问题根源:非原子操作被线程切换打断导致数据竞争。介绍了互斥锁(mutex)的解决方案,包括C语言的pthread_mutex_t实现和C++的std::mutex与std::lock_guard封装。深入剖析了锁的底层原理:通过CPU原子指令和内存状态交换实现线程互斥。最后展示了如何用RAII思想封装自定义锁类,确保临界区安全访问。锁的本质是通过软件机制模拟硬件原子性,解决多线程并发访问共享资源的问题。
谢谢大家的观看!
更多推荐





互斥锁的核心数据类型是pthread_mutex_t,它本质是一个共用体 (union),我们操作的pthread_ mutex_t 只是用户态的句柄,真正的锁状态、线程调度逻辑由内核维护,它是互斥锁的 “载体”,所有锁操作都围绕这个变量展开。


这段代码是基于 C 语言、调用 pthread 库实现的多线程安全抢票程序。我们先定义全局票数和一把全局互斥锁,并使用PTHREAD_MUTEX_INITIALIZER静态初始化;主线程创建多个线程并让它们执行同一个抢票函数,每个线程在进入临界区前必须调用pthread_mutex_lo ck抢占同一把锁,确保同一时刻只有一个线程能判断票数、打印信息并执行票减一操作,完成后立即pthread_mutex_unlock释放锁,让其他线程继续竞争。








mutex + lock_guard : 全程自动,只需要创建一个 lock_guard 对象,自动加锁,自动解锁,不管你怎么退出,都不会忘解锁,绝对不会死锁



所有评论(0)