欢迎光临
我们一直在努力

对于Linux:线程互斥(锁)以及锁的封装的解析

开篇介绍:

hello 大家,那么我们前面学习了线程的局部存储以及线程封装,那么其实关于线程还有很多知识需要我们了解,比如本篇博客所要讲的线程互斥,也就是锁,那么它是维护线程安全的关键所在,所以,它究竟是什么,我们往下面看。

在这里我直接对锁的本质进行一个补充,不过这需要大家在看完了后面的生产者消费者模型之后,

才能有真正的理解,因为这个理解非常重要,所以我就直接放在开头,我这里就直接说了:

在这里我们一定要明确一点,锁并不是说真正的去把共享资源给牢牢锁起来,锁本身不会对共享资源做任何物理上的 “锁定”,锁的本质是控制每次只有一个线程能进入锁所保护的临界区去对共享资源进行操作

一、单锁(粗粒度锁)

1. 定义

单锁指用一把互斥锁保护整个共享资源或所有临界区,无论线程执行的是何种操作(如生产 / 消费、读 / 写),都必须竞争这同一把锁,同一时间只有一个线程能进入任意临界区。

2. 核心特点
  • “一刀切” 保护:锁的粒度大,覆盖整个资源或所有操作;
  • 串行化所有操作:不同类型的线程(如生产者 / 消费者)也需串行执行,即使操作不冲突;
  • 实现简单:只需维护一把锁,代码复杂度低,不易出错。
3. 适用场景

共享资源是不可分割的整体,或不同操作间存在强依赖、易冲突的场景:

  • 基于链表的阻塞队列:生产(尾插)和消费(头删)都依赖头尾节点,操作整体资源,单锁能避免链表结构错乱;
  • 简单的全局计数器:所有线程的增减操作都针对同一个变量,单锁足够保证原子性。
4. 优缺点
  • 优点:实现简单、逻辑清晰,能最大程度避免并发冲突;
  • 缺点:并发效率低,不同类型线程的无冲突操作也会被强制串行(如阻塞队列中生产者和消费者无法同时操作)。

二、多锁(细粒度锁)

1. 定义

多锁指用多把互斥锁分别保护共享资源的不同部分或不同类型的操作,每把锁只管控特定的临界区,不同锁的竞争相互独立。

2. 核心特点
  • “分区域” 保护:锁的粒度小,每把锁只覆盖资源的一个子集或一类操作;
  • “同斥异并”:同一类型的线程(如多个生产者)竞争同一把锁(互斥),不同类型的线程(如生产者 / 消费者)竞争不同锁(可并行);
  • 并发效率高:无冲突的操作能同时进行,充分利用多核资源。

比如你只设置了一把互斥锁,而程序中存在两个不同的临界区 —— 这两个临界区虽然对共享资源执行的是完全不同的操作(比如一个是往队列里加数据,一个是从队列里取数据),但因为共用这一把锁,规则就变得很明确:不管哪个临界区的线程先抢占到这把锁,进入自己的临界区执行操作,另一个临界区的线程就只能在锁的等待队列里阻塞等待,哪怕它要做的是完全不同的操作,也没法绕过这把锁去执行自己临界区里对共享资源的操作。这就是单锁机制的特点 —— 哪怕是针对共享资源的不同操作,同一时间也只能允许一个操作在临界区里执行。

最典型的例子就是我们之前讲的基于阻塞队列的生产者消费者模型:在这个模型里,每次只能有生产者线程执行 “生产数据并放入队列” 的操作,或者只能有消费者线程执行 “从队列取出数据并消费” 的操作,绝对不可能让生产者和消费者线程同时对阻塞队列进行操作。这背后的原因很明确:我们把阻塞队列看作一个不可分割的整体资源—— 生产者的 “尾插” 操作需要修改队列的尾节点,消费者的 “头删” 操作需要修改队列的头节点,两者本质上都是在操作这个整体资源的核心结构,一旦同时操作就可能导致队列链表断裂、节点指向错乱等问题。所以我们只需要用一把锁来管理这个整体资源,通过这把锁确保同一时间只有一种功能的线程(要么生产者、要么消费者)能获取到访问权限,进入临界区操作队列。

但循环队列的情况完全不同:我们并不是把循环队列当作一个单一的整体资源,而是把它划分成了多个相互独立的资源空间(也就是数组里的一个个槽位)。生产者线程的操作永远围绕 “rear 指针指向的空槽位” 展开,消费者线程的操作永远围绕 “front 指针指向的已填充槽位” 展开 —— 只要循环队列不是满的也不是空的,生产者和消费者操作的就是完全不同的资源空间,彼此之间不会产生冲突,完全可以同时进行操作。

正因为如此,我们不能再用单锁来管控循环队列:如果还用单锁,就会浪费它 “分块资源可并行” 的特性,让生产者和消费者被迫串行。所以我们需要给生产者线程和消费者线程各自分配一把专属的锁—— 生产者线程操作时抢占 “生产者锁”,消费者线程操作时抢占 “消费者锁”。这样设计的好处很明显:一方面,能防止多个生产者线程同时修改 rear 指针或操作同一个生产槽位,也能防止多个消费者线程同时修改 front 指针或操作同一个消费槽位;另一方面,生产者线程和消费者线程抢占的是不同的锁,彼此互不干扰,不会出现 “同一时间只能有一种功能线程访问临界区” 的情况,真正实现了生产和消费的并行执行。

希望大家理解!!!

线程互斥(锁):

一、从 “单任务” 到 “多任务”:

在计算机发展的早期,大多数程序都是 “单任务” 的 —— 就像一个人独自打理一家小商店:从接待顾客、查询商品库存、收款、更新库存,全程只有一个人操作,流程顺畅,不会出现任何混乱。但随着需求增长,商店需要多个店员同时工作(多任务并发),问题也随之而来:

1. 多任务并发的核心:“时间分片” 与 “任务切换”

计算机的 CPU 就像商店的 “工作区域”,虽然 CPU 的核心数量有限,但通过 “时间分片” 技术,能让多个任务 “轮流使用 CPU”—— 每个任务执行一小段时间(比如 10 毫秒),就被暂停,切换到下一个任务,看起来就像多个任务在同时执行。

这种切换本身是高效的,但当多个任务需要 “共享同一个资源” 时,就会出现致命问题。比如商店里只有一个库存账本(共享资源),两个店员同时做 “查询库存→销售商品→更新账本” 的操作:

  • 店员 A(任务 A)打开账本,看到 “矿泉水剩 1 瓶”(查询库存),刚要提笔记录 “已售出”,就被店长叫去整理货架(任务切换);
  • 店员 B(任务 B)拿起账本,同样看到 “矿泉水剩 1 瓶”(查询库存),直接记录 “已售出”,把账本上的数量改成 0(更新库存);
  • 店员 A 回来后,不管账本已经被修改,依然记录 “已售出”,把账本上的数量改成 – 1(库存错乱)。

2. 冲突的本质:“非原子操作” 被任务切换打断

这个例子中,“查询库存→销售→更新库存” 的流程被打断,核心原因是:这个流程不是 “一次性完成” 的—— 它被拆分成了多个独立的步骤,步骤之间可以插入其他任务的操作。

我们把 “无法被打断、要么全做完要么全不做” 的操作称为原子操作(就像用打火机点火:按下开关→火苗燃起,要么成功,要么失败,不会出现 “按下开关但火苗没燃起,却消耗了燃气” 的中间状态)。而大多数共享资源的访问流程(查询→操作→更新)都不是原子操作,这是并发冲突的根源。

更关键的是,即使没有 “人为打断”,计算机的硬件和操作系统也会自动进行任务切换(比如优先级更高的任务需要执行),导致流程被拆分,冲突无法避免。

二、共享资源与临界区:明确 “需要保护的核心区域”

要解决冲突,首先要明确 “什么需要保护”—— 我们需要先给关键概念下一个通俗的定义,避免混淆:

1. 共享资源:多任务的 “公共财产”

共享资源是指能被多个任务同时访问的资源,常见的有:

  • 硬件资源:打印机、扫描仪、U 盘;
  • 软件资源:公共文件(如配置文件)、数据库表、全局变量(如票池、库存数);
  • 网络资源:同一端口的网络连接、服务器的共享缓存。

这些资源的特点是 “一旦被多个任务同时修改,就会导致状态错乱”—— 比如打印机同时接收两个文件,打印出来的内容会交织在一起;数据库表同时被两个任务修改,会出现数据重复或缺失。

2. 临界区:必须 “独占执行” 的核心流程

临界区是指 “访问共享资源的核心步骤集合”—— 这些步骤必须连续执行,不能被打断,否则就会出现冲突。比如:

  • 售票系统的临界区:“查询剩余票数→生成订单→减少票数”;
  • 打印机的临界区:“检查打印机是否空闲→发送打印数据→标记打印机为忙碌→打印完成→标记为空闲”;
  • 银行转账的临界区:“查询转出账户余额→扣减余额→增加转入账户余额→记录交易日志”。

临界区的核心特征是:包含对共享资源的 “读 – 改 – 写” 操作—— 先读取资源状态,再根据状态进行修改,最后写回新状态。只要这个流程被打断,就可能出现数据不一致。

3. 非临界区:可以 “并发执行” 的无关流程

与临界区相对的是非临界区—— 不需要访问共享资源的流程,比如:

  • 售票系统中,用户输入身份信息、选择座位(未提交订单前);
  • 打印机的驱动程序检查本地文件格式;
  • 银行 APP 展示用户的历史交易记录(仅读取,不修改)。

非临界区的特点是 “多任务同时执行不会有任何问题”,所以我们不需要对其进行保护,否则会浪费系统资源(比如让多个用户同时输入信息时排队,会降低效率)。

三、互斥的三大核心条件:从 “无序争抢” 到 “有序

要解决临界区的冲突,必须实现互斥—— 即同一时间,只允许一个任务进入临界区。但 “互斥” 不是简单的 “禁止同时进入”,还需要满足三个关键条件,否则要么无法解决冲突,要么会导致新的问题:

1. 条件一:临界区的排他性(核心条件)

当一个任务进入临界区后,其他所有任务必须被阻止进入,直到该任务退出临界区—— 这是互斥的核心,也是解决冲突的根本。

生活场景类比:高速公路的隧道就是临界区(同一时间只能允许单向车流通过),隧道入口的信号灯就是互斥工具 —— 绿灯亮时,只有一个方向的车流进入隧道,红灯亮时,另一方向的车流必须等待,直到绿灯亮起。如果没有信号灯(没有互斥),双向车流同时进入隧道,就会发生碰撞(冲突)。

反例:如果信号灯故障,允许双向车流同时进入,就会出现 “多个任务同时进入临界区”,冲突依然存在。

2. 条件二:竞争的公平性(排队机制)

如果多个任务同时请求进入临界区,且此时临界区是空的(没有任务在执行),必须只允许一个任务进入,其他任务进入 “等待队列”,按请求顺序排队,不能出现 “多个任务同时进入” 或 “插队” 的情况。

生活场景类比:三个用户同时点击 “购买最后一张票”,系统检测到票池非空(临界区空闲),只能让第一个请求的用户进入临界区(锁定票源),另外两个用户进入等待队列,等第一个用户完成购票(退出临界区)后,再依次处理下一个请求。

反例:如果系统没有排队机制,三个用户同时进入临界区,都查询到 “票已存在”,就会出现 “一票三卖” 的情况,违背互斥的初衷。

3. 条件三:非干扰原则(不影响临界区外的操作)

互斥机制只对临界区生效,不能影响非临界区的并发执行—— 即一个任务在非临界区执行时,其他任务的互斥操作不能阻止它,也不能被其他任务的互斥操作阻止。

生活场景类比:用户 A 在输入购票信息(非临界区),用户 B 在提交购票请求(进入临界区)—— 互斥机制只需要阻止其他用户提交购票请求,不需要阻止用户 A 输入信息;同样,用户 A 输入信息的过程,也不能影响用户 B 的购票操作。

反例:如果互斥机制把 “输入信息” 也当成临界区,让所有用户输入信息时排队,就会严重降低效率(本来可以同时输入,现在只能依次输入),这是对互斥机制的滥用。

四、实现互斥的工具:互斥量(mutex)的本质与工作逻辑

满足以上三个条件的工具,就是互斥量(mutex)—— 它的英文全称是 “Mutual Exclusion”,直译就是 “相互排斥”,本质是一把 “共享的锁”,绑定在临界区上,实现 “锁门 – 进门 – 开门” 的闭环管理。

我们可以把 mutex 想象成 “临界区的大门锁”,每个任务要进入临界区,都必须遵守以下规则:

1. 第一步:初始化 mutex—— 准备一把可用的锁

在使用锁之前,必须先 “初始化” 它 —— 就像买了一把新锁,需要先安装好,才能使用。mutex 的初始化有两种方式,对应不同的使用场景:

(1)静态初始化:“现成的锁”,开箱即用

这种方式就像买一把 “出厂已调试好的锁”,直接安装在门上就能用,不需要额外的 “组装” 步骤。它的核心特点是:

  • 锁的状态在程序启动时就已经初始化完成,属于 “全局资源”;
  • 不需要手动销毁,程序结束时,操作系统会自动回收锁占用的资源;
  • 适合场景:锁的数量固定、生命周期与程序一致(比如整个程序运行期间都需要用到的票池锁)。

生活场景类比:小区单元门的锁,从小区建成后就一直存在,居民不需要自己安装,也不需要自己拆除,直到小区报废。

// 静态初始化全局mutex(直接赋值即可,无需手动销毁)
pthread_mutex_t ticket_mutex = PTHREAD_MUTEX_INITIALIZER;

(2)动态初始化:“组装的锁”,按需创建与销毁

这种方式就像买一套 “锁的零件”,需要自己组装成完整的锁才能使用;使用完后,还要拆解零件,归还给商家(避免资源浪费)。它的核心特点是:

  • 锁的状态需要通过专门的 “初始化操作” 激活,属于 “动态资源”;
  • 必须手动销毁(否则会导致资源泄漏),且销毁时必须保证锁是 “空闲状态”(没有任务在使用);
  • 适合场景:锁的数量不固定(比如根据用户请求动态创建的文件锁)、生命周期与某个模块一致(模块退出时销毁锁)。
动态初始化接口:pthread_mutex_init
函数原型

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);

参数分析
参数名类型含义
mutex pthread_mutex_t * 指向要初始化的互斥锁对象的指针(必须是有效内存地址,不能为NULL)
attr const pthread_mutexattr_t * 互斥锁属性对象指针:- 传NULL:使用默认属性(对应PTHREAD_MUTEX_DEFAULT类型锁)- 非NULL:需先通过pthread_mutexattr_init初始化属性对象,用于设置锁类型(如递归锁、错误检测锁),那么我们直接传入nullptr就行了
返回值分析
返回值含义
0 初始化成功
EINVAL 参数无效:- mutex指针非法(如NULL)- attr指向的属性对象配置错误(如锁类型不支持)
ENOMEM 系统内存不足,无法初始化锁对象
代码示例

#include <pthread.h>
#include <stdio.h>
#include <string.h>

// 定义mutex变量(局部或全局均可,动态初始化无需全局)
pthread_mutex_t file_mutex;

int main() {
// 动态初始化:第二个参数为NULL表示使用默认属性
int ret = pthread_mutex_init(&file_mutex, NULL);
if (ret != 0) {
// 初始化失败时,通过strerror获取错误信息
printf("初始化mutex失败:%s\\n", strerror(ret));
return -1;
}

// 后续使用mutex…

return 0;
}

2. 第二步:加锁(lock)—— 申请进入临界区

任务要进入临界区前,必须调用 “加锁操作”—— 就像要进房间前,先尝试转动门锁:

(1)加锁的两种结果
  • 锁是空闲的(门没锁):任务成功加锁(转动门锁,把门反锁),直接进入临界区执行操作;
  • 锁被占用(门已锁):任务加锁失败,进入 “阻塞状态”(暂停执行),被放入 “等待队列”,直到锁被释放。
(2)加锁的原子性保障

加锁操作本身必须是原子操作—— 即 “检查锁的状态 + 修改锁的状态” 是一个不可打断的步骤。如果加锁不是原子的,会出现以下问题:

  • 任务 A 检查到锁是空闲的,准备加锁;
  • 此时任务 B 被切换进来,也检查到锁是空闲的,准备加锁;
  • 最终两个任务都加锁成功,同时进入临界区,冲突依然存在。

所以,mutex 的加锁操作底层依赖硬件提供的 “原子指令”(比如 x86 架构的xchgb指令),确保 “检查 + 修改” 一步完成,不会被打断。

生活场景类比:两个人同时伸手去拧门锁 —— 门锁的机械结构决定了,只有一个人能成功转动门锁(加锁成功),另一个人只能拧不动(加锁失败),不会出现 “两个人同时拧动门锁” 的情况。

(3)加锁的核心接口详解
① 阻塞加锁:pthread_mutex_lock
函数原型

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数分析
参数名类型含义
mutex pthread_mutex_t * 指向已初始化的互斥锁对象的指针(不能为NULL)
返回值分析
返回值含义
0 加锁成功
EINVAL mutex未初始化或已销毁
EDEADLK 死锁检测:- 对非递归锁重复加锁(如默认锁)- 仅错误检测锁(PTHREAD_MUTEX_ERRORCHECK)会返回此错误,默认锁直接死锁
EPERM 当前线程无权限操作锁(如尝试解锁其他线程持有的锁时触发,加锁时极少出现)
特性
  • 若锁已被其他线程持有,当前线程会阻塞,直到锁被释放;
  • 若锁是递归锁(PTHREAD_MUTEX_RECURSIVE),同一线程可重复加锁(计数器递增)。
② 非阻塞加锁:pthread_mutex_trylock
函数原型

int pthread_mutex_trylock(pthread_mutex_t *mutex);

参数分析

同pthread_mutex_lock。

返回值分析
返回值含义
0 加锁成功
EBUSY 锁已被其他线程持有(非阻塞,直接返回此错误,不等待)
EINVAL/EDEADLK/EPERM 同pthread_mutex_lock
特性
  • 非阻塞模式:锁被占用时不会等待,直接返回EBUSY;
  • 适用于 “尝试加锁,失败则做其他操作” 的场景(如轮询任务)。
(4)加锁的综合代码示例(结合售票场景)

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

// 共享资源:剩余票数
int ticket = 100;
// 静态初始化mutex
pthread_mutex_t ticket_mutex = PTHREAD_MUTEX_INITIALIZER;

// 线程函数:卖票逻辑
void* sell_ticket(void* arg) {
char* thread_name = (char*)arg;
while (1) {
// 加锁:进入临界区前申请锁(使用阻塞加锁)
int ret = pthread_mutex_lock(&ticket_mutex);
if (ret != 0) {
printf("%s 加锁失败:%s\\n", thread_name, strerror(ret));
break;
}

// 临界区:操作共享资源(读-改-写)
if (ticket > 0) {
// 模拟卖票的耗时操作(如打印票根、收款)
usleep(1000);
printf("%s 卖出第 %d 张票\\n", thread_name, ticket);
ticket–; // 修改共享资源
} else {
// 票已卖完,先解锁再退出(避免锁泄漏)
pthread_mutex_unlock(&ticket_mutex);
printf("%s 发现票已卖完,退出\\n", thread_name);
break;
}

// 解锁:退出临界区后释放锁
pthread_mutex_unlock(&ticket_mutex);
}
return NULL;
}

int main() {
pthread_t t1, t2, t3, t4;
// 创建4个卖票线程
pthread_create(&t1, NULL, sell_ticket, (void*)"线程1");
pthread_create(&t2, NULL, sell_ticket, (void*)"线程2");
pthread_create(&t3, NULL, sell_ticket, (void*)"线程3");
pthread_create(&t4, NULL, sell_ticket, (void*)"线程4");

// 等待所有线程执行完毕
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);

printf("所有线程执行完毕,剩余票数:%d\\n", ticket);
return 0;
}

3. 第三步:解锁(unlock)—— 退出临界区,释放锁

任务执行完临界区的操作后,必须调用 “解锁操作”—— 就像离开房间时,把门锁打开,让等待的人能进入,这一个步骤很关键,要是大家只顾着上锁而不去理会解锁的话,那么外面的人就BBQ了,完全无法进入:

(1)解锁的核心流程
  • 把锁的状态从 “占用” 改成 “空闲”(打开门锁);
  • 检查 “等待队列”:如果有任务在等待,就唤醒其中一个任务(按排队顺序),让它重新尝试加锁;
  • 任务退出临界区,继续执行非临界区的操作。
  • (2)解锁的接口详解:pthread_mutex_unlock
    函数原型

    int pthread_mutex_unlock(pthread_mutex_t *mutex);

    参数分析

    同pthread_mutex_lock(指向已初始化且被当前线程持有的锁)。

    返回值分析
    返回值含义
    0 解锁成功
    EINVAL mutex未初始化或已销毁
    EPERM 当前线程未持有此锁(如解锁其他线程的锁,或解锁未加锁的锁)
    EAGAIN 递归锁计数器未减至 0(仅递归锁可能出现,解锁次数少于加锁次数)
    (3)解锁的关键约束
    • 解锁操作必须由持有锁的线程执行,否则返回EPERM;
    • 递归锁需解锁次数与加锁次数一致,否则锁不会真正释放。
    (4)解锁的错误示例与正确做法

    // 错误示例:解锁未加锁的锁
    void* wrong_unlock(void* arg) {
    pthread_mutex_t* mutex = (pthread_mutex_t*)arg;
    // 未加锁直接解锁,行为未定义(可能崩溃或报错)
    pthread_mutex_unlock(mutex);
    return NULL;
    }

    // 正确示例:加锁后必须解锁,且解锁前确保临界区操作完成
    void* correct_unlock(void* arg) {
    pthread_mutex_t* mutex = (pthread_mutex_t*)arg;
    pthread_mutex_lock(mutex);

    // 临界区操作:修改共享资源
    printf("执行临界区操作…\\n");

    // 确保操作完成后再解锁
    pthread_mutex_unlock(mutex);
    return NULL;
    }

    4. 第四步:销毁 mutex—— 回收锁的资源

    当锁不再需要使用时,必须 “销毁” 它 —— 就像不再需要房间时,把锁拆下来,回收材料:

    (1)销毁的接口详解:pthread_mutex_destroy
    函数原型

    int pthread_mutex_destroy(pthread_mutex_t *mutex);

    参数分析
    参数名类型含义
    mutex pthread_mutex_t * 指向已初始化的互斥锁对象的指针(不能为NULL)
    返回值分析
    返回值含义
    0 销毁成功
    EINVAL mutex未初始化或已销毁
    EBUSY 锁仍被持有(未解锁),无法销毁
    (2)销毁的约束条件
    • 静态初始化的锁不需要销毁:由操作系统统一回收,就像小区单元门的锁,不需要居民自己拆除;
    • 动态初始化的锁必须销毁:否则会导致资源泄漏(就像租房子时不拆锁,自己的锁会丢失,且占用房东的资源);
    • 不能销毁已加锁的锁:就像不能在有人还在房间里时拆锁,会导致房间里的人无法出来,外面的人无法进去(返回EBUSY错误码);
    • 销毁后的锁不能再使用:就像拆下来的锁,不能再用来锁门,否则会失效(返回EINVAL错误码)。
    (3)销毁的代码示例

    #include <pthread.h>
    #include <stdio.h>
    #include <string.h>

    int main() {
    pthread_mutex_t dynamic_mutex;
    // 动态初始化
    int ret = pthread_mutex_init(&dynamic_mutex, NULL);
    if (ret != 0) {
    printf("初始化失败:%s\\n", strerror(ret));
    return -1;
    }

    // 使用锁…

    // 销毁锁(必须确保锁已解锁)
    ret = pthread_mutex_destroy(&dynamic_mutex);
    if (ret != 0) {
    printf("销毁mutex失败:%s\\n", strerror(ret));
    return -1;
    }

    return 0;
    }

    线程运行到上锁和解锁的流程(重点):

    在了解线程的上锁与解锁流程前,我们先把复杂的技术概念转化成日常场景:假设计算机里的多个线程,就像中午饭点涌向食堂的一群学生;这些线程要共同访问的“共享资源”(比如全局变量、数据库连接、打印机等),就是食堂里唯一的一个打饭窗口;而我们说的“锁”,就是这个打饭窗口前的一圈围栏门,围栏门上挂着一把唯一的钥匙——只有拿到这把钥匙(成功获取锁)的学生,才能钻进围栏里打饭,其他学生哪怕来得再急,也只能在围栏外乖乖排队,等前面的人把钥匙还回来(释放锁)才能上前。

    为啥非要搞这么一套“钥匙+围栏”的规矩?因为如果没有锁,所有学生(线程)都会一拥而上抢着打饭(访问共享资源):有人刚拿起勺子(修改数据到一半),就被别人挤开,勺子掉在地上(数据被打断导致错乱);有人还没选好菜品(操作未完成),窗口就被其他人占用,最后谁都没法顺利打到饭(程序出现异常)。所以锁的核心作用,就是给共享资源加一道“专属使用权”的门槛,保证同一时间只有一个线程能操作它,这就是线程安全的关键。

    一、线程上锁→解锁的完整流程

    1. 线程“瞄准目标”:发现需要访问共享资源(走到食堂,盯上打饭窗口)

    每个线程在运行时,都有自己的“任务清单”(预设的代码逻辑),可能是计算一个数值、处理一段数据,或是调用某个工具。当线程执行到某一行代码时,突然发现:“哦,我现在要用到那个大家都能碰的共享资源了!”——这就像学生本来在食堂门口玩手机、和同学聊天,肚子饿了才反应过来,自己的核心目标是去打饭窗口领餐。

    举个具体例子:线程A的任务是统计“食堂今日就餐人数”,这个“就餐人数计数器”就是全局共享资源(所有统计线程都要修改它)。当线程A运行到“计数器+1”这行代码时,就相当于它走到了打饭窗口前,心里清楚“我必须用这个窗口(计数器)才能完成任务”,但它此时并不知道,有没有其他线程(学生)正在用这个窗口。

    这一步里,线程的状态还是“运行中”,只是任务目标从“执行自己的独立代码”切换到了“准备访问共享资源”,就像学生从“自由活动”切换到“准备打饭”的状态。

    2. 核心环节:尝试“抢钥匙”(获取锁),两种结果对应两种状态

    线程明确要访问共享资源后,下一步就会主动“检查锁的状态”,这个动作就像学生走到围栏门前,抬头看一眼“钥匙是不是挂在门口”“门有没有锁上”——锁的状态只有两种:“未被持有”(门开着,钥匙在)和“已被持有”(门紧锁,钥匙不在),线程的后续行为会完全根据这两种状态调整。

    (1)幸运情况:锁是“未被持有”的(围栏门没锁,钥匙挂在门口)

    如果线程检查发现,锁当前没有被任何其他线程持有(就像学生看到围栏门敞开着,钥匙好好挂在门把手上),它会立刻执行“上锁”操作——这个操作快如闪电,就像学生一把抓起钥匙,“咔哒”一声把围栏门锁上,然后把钥匙揣进自己兜里。

    此时有两个关键变化:① 锁的状态从“未被持有”变成“已被持有”,相当于围栏门从“敞开”变成“专属占用”;② 线程的状态依然是“运行中”,但它获得了“共享资源的独占使用权”,就像学生锁上门后,心里踏实了:“现在这个窗口归我了,没人能抢了”。

    这里有个容易被忽略的细节:“上锁”动作是“原子性”的——简单说就是“要么完全拿到锁,要么完全没拿到”,不会出现“线程A拿到一半钥匙,突然被线程B抢走”的情况。就像学生拿钥匙锁门,整个动作一瞬间完成,不会中途被打断,这是锁能保证“互斥”的核心基础。

    (2)无奈情况:锁是“已被持有”的(围栏门锁着,钥匙被别人拿走了)

    如果线程检查发现,锁已经被其他线程持有(就像学生走到围栏前,看到门是锁着的,门把手上空荡荡的,钥匙早就被里面的人拿走了),它不会像“没头苍蝇”一样反复敲门、抢门,而是会立刻进入“阻塞等待”状态——相当于学生乖乖走到围栏外的排队队伍里,停下脚步,安安静静地等着,不再做任何和“打饭”无关的操作。

    这里的“阻塞”不是“线程死机”,而是线程主动“暂停执行后续代码”,把自己的“运行资格”交还给操作系统。操作系统会把这个线程从“运行队列”移到“锁的等待队列”里,不再给它分配CPU资源——这就像食堂阿姨不会给排队的学生发餐盘,只有等前面的人打完饭,才会叫下一个。这样做的好处是“不浪费CPU资源”:如果线程不阻塞,而是一直反复检查锁的状态(这种行为叫“自旋”),会像个疯子一样不停问“好了没?好了没?”,白白占用CPU的运算能力,而阻塞状态下的线程几乎不消耗任何资源。

    补充一个场景:如果有多个线程同时发现锁被持有,它们都会依次进入等待队列,按“先来后到”的顺序排队(少数锁支持“优先级抢占”,但大部分普通锁是“先进先出”),就像多个学生陆续赶来,都看到门锁着,就自动排成一队,没人插队。

    3. 独占资源:安心操作,不受干扰(钻进围栏,慢慢打饭)

    成功获取锁的线程,此时就拥有了“共享资源的绝对使用权”,它可以放心大胆地对共享资源执行各种操作——比如修改全局计数器、读写数据库里的一条记录、调用打印机打印文件等,这个过程中,其他线程哪怕也运行到了“需要访问该共享资源”的代码,也会因为拿不到锁而进入阻塞等待状态,完全无法干扰它。

    用打饭场景细化:拿到钥匙的学生钻进围栏后,不用着急,他可以慢慢看窗口里的菜品(浏览共享资源的数据),选一份糖醋排骨、一份番茄炒蛋(修改数据的具体内容),甚至觉得菜不够,再让阿姨加一勺饭(补充操作)——哪怕外面排队的学生再多、再急,也只能站在围栏外看着,没法进去打扰。

    这里要强调“独占性”的重要性:如果没有锁的保护,多个线程同时操作共享资源会出大问题。比如两个线程同时修改“就餐人数计数器”(初始值100),线程A先把100改成101,但还没来得及保存,线程B就冲进来把100改成101,最后计数器变成101,而不是正确的102;但有了锁之后,线程A改完101并保存好,才会释放锁,线程B才能进来修改,最终结果就是正确的102,这就是锁避免“数据竞争”的核心价值。

    另外,线程在“独占资源”阶段,执行的操作可以是任意长度的(只要在合理范围内),就像学生打饭可以慢一点,但不能一直占着窗口不走(对应线程不能持有锁太久,否则会导致其他线程一直等待,影响程序效率)。

    4. 完成操作:归还钥匙,唤醒等待者(打完饭,开门放行)

    线程完成所有对共享资源的操作后,必须执行“解锁”操作——这一步至关重要,相当于学生打完饭,把餐盘端好,走到围栏门口,从兜里掏出钥匙,“咔哒”一声打开门锁,再把钥匙挂回门把手上,然后走出围栏。

    解锁操作会触发两个关键变化:① 锁的状态从“已被持有”变回“未被持有”,相当于围栏门重新敞开,钥匙回归“公共可用”状态;② 操作系统会自动查看“该锁的等待队列”,如果有线程在排队,就会唤醒队列里的第一个线程(按先进先出顺序),让它从“阻塞等待”状态恢复到“运行中”状态。

    被唤醒的线程会立刻“回头”,重新执行步骤2(尝试获取锁)——此时它会发现锁已经是“未被持有”的,于是立刻上锁,钻进围栏开始操作共享资源;而等待队列里剩下的线程,会继续保持阻塞状态,直到前面的线程都完成解锁。

    补充一个“反面教材”:如果线程拿到锁后,忘记解锁(比如学生把钥匙揣走了,没挂回门口),那么等待队列里的所有线程都会永远处于阻塞状态,永远无法获取锁——这种情况就是“死锁”的一种,就像食堂的打饭窗口永远被锁着,后面的学生永远打不上饭,程序也会因此卡住,无法继续运行。所以“上锁必须解锁”是线程编程的铁律。

    还有一个细节:执行解锁操作的线程,在解锁后会继续执行自己后续的代码(比如学生打完饭,会去餐桌吃饭,而不是停在窗口),它的状态不会受到影响,只有等待队列里的线程会被唤醒。

    二、实战例子:两个线程抢用打印机

    为了让大家更直观理解,我们用“两个线程抢着用打印机”的场景,完整走一遍流程:

  • 线程A的任务是打印“月度工作报告”,线程B的任务是打印“会议纪要”,打印机就是它们的共享资源,打印机对应的“打印锁”就是围栏门和钥匙。

  • 线程A先运行到“打印代码”,发现需要使用打印机,于是检查“打印锁”的状态——此时锁是未被持有的,线程A立刻上锁,开始控制打印机打印工作报告。

  • 1秒后,线程B也运行到“打印代码”,检查“打印锁”的状态——发现锁已经被线程A持有(打印机正在被用),于是线程B自动进入阻塞等待状态,暂停所有操作,操作系统把它放进“打印锁的等待队列”里。

  • 线程A花了30秒打印完工作报告,执行“解锁”操作,把“打印锁”的状态改回未被持有,然后继续执行自己的后续代码(比如保存打印记录)。

  • 操作系统检测到“打印锁”被释放,且等待队列里有线程B,于是唤醒线程B——线程B从阻塞状态恢复运行,立刻检查“打印锁”的状态,发现未被持有,立刻上锁,开始控制打印机打印会议纪要。

  • 线程B打印完会议纪要,执行解锁操作,“打印锁”回归未被持有状态;由于等待队列里没有其他线程,所以没有线程被唤醒,打印机处于空闲状态。

  • 如果没有“打印锁”:线程A刚把工作报告的前5页放进打印机,线程B就冲进来把会议纪要的内容传进去,最后打印出来的文件会是“工作报告+会议纪要”的乱码内容,完全无法使用——这就是没有锁保护的灾难性后果。

    三、核心关键点总结

    1. 互斥性:同一时间,资源只归一个线程用

    锁就像“一夫一妻制”的钥匙,也像公共电话亭的门——同一时间只有一个线程能持有锁、使用共享资源,其他线程只能排队。这就保证了共享资源不会被多个线程同时操作,从根源上避免了数据错乱和操作冲突。

    补充:互斥性是锁的核心特性,不管是哪种类型的锁(比如互斥锁、自旋锁、读写锁),都具备这个基础能力,只是适用场景不同。

    2. 阻塞与唤醒:不浪费资源,排队有秩序

    拿不到锁的线程会“乖乖排队”(阻塞),而不是反复检查锁的状态(自旋)——这就像排队打饭的学生不会一直敲门打扰里面的人,而是安静等待,避免了CPU资源的无效消耗。当锁被释放时,等待队列里的线程会被“按顺序唤醒”,保证每个线程都有机会使用资源,不会出现“插队”或“漏等”的情况。

    补充:有些特殊场景下(比如线程操作共享资源的时间极短),会用“自旋锁”(线程不阻塞,而是短暂自旋等待),但这种锁适用范围窄,大部分日常场景还是用“阻塞锁”更高效。

    3. 原子操作:上锁/解锁,一步到位不中断

    上锁和解锁的过程是“原子性”的——简单说就是“要么做完,要么没做”,不会出现“一半完成”的中间状态。就像学生拿钥匙锁门,不会出现“钥匙插进去了,但还没锁上,突然被别人抢走钥匙”的情况;线程获取锁时,也不会出现“锁的状态正在修改,突然被其他线程打断”的问题,这是锁能保证互斥性的底层基础。

    补充:原子操作是硬件和操作系统共同支持的,普通的代码执行(比如赋值、计算)不具备原子性,但锁的操作被系统特殊处理,保证了其原子性。

    4. 必须成对:上锁就要解锁,避免死锁

    这是最容易踩坑的点!线程拿到锁后,一定要在操作完成后执行解锁——哪怕操作过程中出现异常,也要保证解锁(比如用try-catch语句,在finally块里解锁)。就像学生打完饭必须把钥匙挂回门口,要是把钥匙带走,后面的人就永远打不上饭了;线程忘记解锁,等待队列里的线程会永远阻塞,程序就会卡住。

    四、一句话总结整个流程

    线程就像打饭的学生,看到需要的共享资源(打饭窗口)后,尝试拿钥匙(获取锁),拿到就独占资源操作(打饭),做完就还钥匙(释放锁),没拿到就排队等(阻塞),直到被唤醒再重试——这就是上锁→解锁的全部逻辑,核心就是“有序排队用资源,避免混乱出问题”。

    五、mutex 的底层实现:原子指令如何保证互斥?

    mutex 能实现 “同一时间只有一个任务加锁”,核心依赖硬件提供的原子交换指令—— 这类指令的特点是 “执行过程不可打断”,即使多个任务同时触发,也会按顺序执行,不会出现中间状态。

    我们用 “共享变量” 模拟锁的状态,用生活场景拆解原子指令的工作原理:

    1. 锁的底层状态:一个简单的 “标记位”

    mutex 的底层本质是一块内存,存储着一个 “状态标记”,只有两个值:

    • 1:锁空闲(标记为 “可用”);
    • 0:锁被占用(标记为 “已用”)。

    这个标记位就像卫生间门上的 “有人 / 无人” 指示灯:灯亮(1)表示空闲,灯灭(0)表示占用。

    2. 加锁(lock)的原子流程:“交换标记位”

    每个任务想加锁时,会执行以下三个步骤(由原子指令xchg保证不可打断):

  • 任务在自己的 “临时存储区”(比如 CPU 的寄存器)里存放一个 “0”(代表 “我要占用锁”);
  • 调用原子交换指令,把 “临时存储区的 0” 和 “锁的状态标记” 进行交换 —— 比如锁原本是 1(空闲),交换后,临时存储区变成 1,锁的状态变成 0(占用);
  • 任务检查自己的临时存储区:
    • 如果临时存储区的值是 1:说明交换前锁是空闲的,现在已经成功加锁(锁状态变成 0),任务可以进入临界区;
    • 如果临时存储区的值是 0:说明交换前锁已被占用(其他任务已经把锁的状态改成 0),任务进入 “阻塞状态”,被放入等待队列,暂停执行。
  • 生活场景类比:多个用户同时想使用卫生间,每个用户手里拿一个 “占用牌”(0),卫生间门上挂着 “空闲牌”(1);用户同时伸手去换牌 —— 只有第一个用户能把 “空闲牌(1)” 换成自己的 “占用牌(0)”,其他用户只能换到 “占用牌(0)”,只能在外面排队。

    3. 解锁(unlock)的流程:“恢复标记位 + 唤醒等待任务”

    任务退出临界区后,会执行以下两个步骤:

  • 调用普通指令,把锁的状态标记从 0 改回 1(恢复成 “空闲” 状态,相当于把 “空闲牌” 挂回卫生间门上);
  • 通知操作系统的 “等待队列”:锁已经空闲,唤醒队列中第一个任务(按 FIFO 顺序),让它重新执行加锁流程(再次尝试交换标记位)。
  • 4. 为什么原子指令能保证互斥?

    因为原子交换指令是 “单条硬件指令”——CPU 执行一条指令时,会独占系统总线(连接 CPU 和内存的通道),其他 CPU 核心或任务无法同时访问内存中的锁状态。即使多个任务同时触发加锁操作,硬件也会按 “总线周期” 的顺序执行指令:一个任务的交换指令没执行完,另一个任务的交换指令只能等待,不会出现 “两个任务同时把锁的状态改成 0” 的情况。

    生活场景类比:超市收银台的 “扫码枪”—— 同一个扫码枪同一时间只能扫描一个商品,即使两个收银员同时伸手,也只能依次扫描,不会出现 “一个商品被两个收银员同时扫描” 的情况。

    六、mutex 的常见类型:不同场景选不同的锁

    mutex 不是 “一刀切” 的工具,根据不同的使用场景,Linux 系统提供了四种常见类型,每种类型都有自己的特点和适用场景:

    1. 默认锁(PTHREAD_MUTEX_DEFAULT):最常用的基础锁

    • 特点:非递归、非错误检测 —— 同一任务不能重复加锁(重复加锁会导致死锁);解锁未加锁的锁时,行为未定义(可能导致程序崩溃);
    • 适用场景:大多数普通场景,比如简单的售票系统、文件读写,只要保证 “加锁一次、解锁一次”,就能正常使用;
    • 初始化接口示例:

      // 默认锁的初始化(静态/动态均可,无需额外属性)
      pthread_mutex_t default_mutex = PTHREAD_MUTEX_INITIALIZER;

    • 生活类比:普通的房门锁 —— 同一个人不能在门已锁的情况下再拧一次锁(会卡住);也不能在门没锁的情况下拧解锁(会损坏锁)。

    2. 递归锁(PTHREAD_MUTEX_RECURSIVE):支持重复加锁的锁

    • 特点:同一任务可以多次加锁,每次加锁后,锁的 “计数器” 加 1;解锁时,计数器减 1,只有计数器减到 0 时,锁才会真正释放;
    • 适用场景:递归函数调用(比如递归遍历目录时,需要多次访问同一个文件锁)、同一个任务需要多次进入临界区的场景;
    • 初始化与使用接口示例:

      #include <pthread.h>
      #include <stdio.h>
      #include <string.h>

      int main() {
      pthread_mutex_t recursive_mutex;
      pthread_mutexattr_t attr;

      // 初始化属性对象
      pthread_mutexattr_init(&attr);
      // 设置锁类型为递归锁
      pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
      // 用属性初始化mutex
      pthread_mutex_init(&recursive_mutex, &attr);

      // 同一任务重复加锁(不会死锁)
      pthread_mutex_lock(&recursive_mutex);
      pthread_mutex_lock(&recursive_mutex); // 成功加锁,计数器=2

      // 解锁两次才会释放锁
      pthread_mutex_unlock(&recursive_mutex); // 计数器=1
      pthread_mutex_unlock(&recursive_mutex); // 计数器=0,锁释放

      // 销毁属性对象和mutex
      pthread_mutexattr_destroy(&attr);
      pthread_mutex_destroy(&recursive_mutex);
      return 0;
      }

    • 生活类比:带密码的行李箱锁 —— 设置密码后,第一次开锁需要输入密码(计数器从 1 减到 0);如果中途不小心按了锁扣(重复加锁,计数器从 0 加到 1),不需要再输入密码,只需再按一次锁扣就能解锁(计数器减到 0)。

    3. 错误检测锁(PTHREAD_MUTEX_ERRORCHECK):调试专用锁

    • 特点:会检测锁的使用错误,比如重复加锁、解锁未加锁的锁,检测到错误时会返回错误码(不会导致死锁或崩溃);
    • 适用场景:程序调试阶段 —— 帮助开发者定位锁的使用错误(比如忘记解锁、重复加锁),上线后可以换成默认锁(错误检测会带来少量性能开销);
    • 使用接口示例:

      #include <pthread.h>
      #include <stdio.h>
      #include <string.h>

      int main() {
      pthread_mutex_t errorcheck_mutex;
      pthread_mutexattr_t attr;

      pthread_mutexattr_init(&attr);
      pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK);
      pthread_mutex_init(&errorcheck_mutex, &attr);

      // 第一次加锁(成功)
      int ret = pthread_mutex_lock(&errorcheck_mutex);
      printf("第一次加锁:%s\\n", ret == 0 ? "成功" : strerror(ret));

      // 重复加锁(错误检测锁会返回EDEADLK错误)
      ret = pthread_mutex_lock(&errorcheck_mutex);
      printf("重复加锁:%s\\n", ret == 0 ? "成功" : strerror(ret));

      // 解锁未加锁的锁(返回EPERM错误)
      pthread_mutex_unlock(&errorcheck_mutex); // 解锁一次
      ret = pthread_mutex_unlock(&errorcheck_mutex); // 再次解锁(未加锁)
      printf("解锁未加锁的锁:%s\\n", ret == 0 ? "成功" : strerror(ret));

      pthread_mutexattr_destroy(&attr);
      pthread_mutex_destroy(&errorcheck_mutex);
      return 0;
      }

    • 生活类比:带报警功能的门锁 —— 如果重复锁门,会发出 “滴滴” 报警声(提示错误),而不是卡住或损坏。

    4. 超时锁(PTHREAD_MUTEX_TIMED):支持超时等待的锁

    • 特点:加锁时可以设置超时时间,如果超过时间还没拿到锁,会返回超时错误,任务可以继续执行其他操作,不会永久阻塞;
    • 适用场景:需要避免永久阻塞的场景(比如网络请求中的锁 —— 如果超时还没拿到锁,就放弃请求,返回 “操作超时” 提示);
    • 使用接口示例:

      #include <pthread.h>
      #include <stdio.h>
      #include <string.h>
      #include <time.h>

      int main() {
      pthread_mutex_t timed_mutex;
      pthread_mutexattr_t attr;
      struct timespec timeout;

      // 初始化属性对象,设置锁类型为超时锁
      pthread_mutexattr_init(&attr);
      pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_TIMED);
      pthread_mutex_init(&timed_mutex, &attr);

      // 另一个线程先占用锁(模拟锁被占用的场景)
      pthread_mutex_lock(&timed_mutex);

      // 设置超时时间:当前时间+2秒
      clock_gettime(CLOCK_REALTIME, &timeout);
      timeout.tv_sec += 2;

      // 尝试加锁(超时时间2秒)
      int ret = pthread_mutex_timedlock(&timed_mutex, &timeout);
      if (ret == ETIMEDOUT) {
      printf("加锁超时,未拿到锁\\n");
      } else if (ret == 0) {
      printf("加锁成功\\n");
      pthread_mutex_unlock(&timed_mutex);
      } else {
      printf("加锁失败:%s\\n", strerror(ret));
      }

      // 释放被占用的锁
      pthread_mutex_unlock(&timed_mutex);

      pthread_mutexattr_destroy(&attr);
      pthread_mutex_destroy(&timed_mutex);
      return 0;
      }

    • 生活类比:餐厅排队取号 —— 取号后如果超过 30 分钟还没轮到,号码失效,顾客可以选择离开或重新取号,不会一直等下去。

    七、使用 mutex 的常见陷阱与避坑指南

    即使理解了 mutex 的原理和接口,实际使用中也容易出现问题,以下是四个最常见的陷阱及避坑方法:

    1. 陷阱一:死锁 —— 任务互相等待对方的锁

    场景

    任务 A 持有锁 1,想要申请锁 2;任务 B 持有锁 2,想要申请锁 1—— 两个任务都在等待对方释放锁,永远无法继续执行。

    生活类比:两个人各自拿着对方需要的钥匙,互相等待对方先把钥匙给自己,结果谁也进不了门。

    避坑方法
    • 按固定顺序加锁:所有任务都按 “先锁 1、后锁 2” 的顺序申请锁,避免交叉等待;

      // 正确:所有线程都按“锁A→锁B”的顺序加锁
      void* thread_func(void* arg) {
      pthread_mutex_lock(&lockA);
      pthread_mutex_lock(&lockB); // 按固定顺序申请

      // 临界区操作…

      pthread_mutex_unlock(&lockB);
      pthread_mutex_unlock(&lockA);
      return NULL;
      }

    • 避免嵌套锁:尽量不要在一个临界区内部申请另一个锁,减少死锁的可能性;
    • 设置超时时间:使用超时锁,如果超时还没拿到锁,就释放已持有的锁,重新尝试。

    2. 陷阱二:锁泄漏 —— 忘记解锁导致锁永久占用

    场景

    任务加锁后,因为异常(比如程序崩溃、return 语句跳过了解锁操作)没有解锁,锁被永久占用,其他任务无法进入临界区。

    生活类比:有人进卫生间后,忘记开门就走了,其他人永远无法使用这个卫生间。

    避坑方法
    • 使用 RAII 机制(C++):用LockGuard等类封装锁,把加锁放在构造函数,解锁放在析构函数 —— 即使程序抛出异常,析构函数也会自动执行,确保解锁;

      #include <pthread.h>

      class LockGuard {
      public:
      // 构造函数加锁
      explicit LockGuard(pthread_mutex_t& mutex) : _mutex(mutex) {
      pthread_mutex_lock(&_mutex);
      }
      // 析构函数解锁(出作用域自动执行)
      ~LockGuard() {
      pthread_mutex_unlock(&_mutex);
      }

      // 禁止拷贝和赋值(避免锁被意外复制)
      LockGuard(const LockGuard&) = delete;
      LockGuard& operator=(const LockGuard&) = delete;

      private:
      pthread_mutex_t& _mutex;
      };

      // 使用示例
      void* safe_thread_func(void* arg) {
      pthread_mutex_t* mutex = (pthread_mutex_t*)arg;
      LockGuard lock(*mutex); // 自动加锁

      // 临界区操作,即使抛出异常也会自动解锁
      printf("执行临界区操作…\\n");

      return NULL; // 出作用域,析构函数自动解锁
      }

    • 检查所有退出分支:在临界区的每个退出分支(比如if/else、break、return)都要确保解锁,避免遗漏。

    3. 陷阱三:锁粒度太大 —— 临界区包含无关操作

    场景

    把非临界区的操作(比如 sleep、打印日志、网络请求)也包在锁内,导致锁的占用时间过长,其他任务等待过久,降低并发效率,因为我们要知道,当某一个线程持有锁的时候,那么它就会霸占着那一块代码,而其他线程就跟个傻子一样在外面阻塞着无法访问代码块,必须得等值持有锁的那个线程解锁了之后,它们才能访问到。

    生活类比:有人进卫生间后,锁上门,却在里面玩手机、打电话,外面的人一直等,卫生间的使用效率极低。

    避坑方法
    • 最小化临界区:只把 “读 – 改 – 写” 共享资源的核心步骤包在锁内,无关操作放在锁外;

      // 错误:锁粒度太大,包含sleep(非临界区操作)
      void* wrong_granularity(void* arg) {
      pthread_mutex_lock(&mutex);
      if (ticket > 0) {
      usleep(1000); // 无关操作,延长锁占用时间
      printf("卖出第 %d 张票\\n", ticket);
      ticket–;
      }
      pthread_mutex_unlock(&mutex);
      return NULL;
      }

      // 正确:最小化临界区,无关操作放锁外
      void* correct_granularity(void* arg) {
      pthread_mutex_lock(&mutex);
      int temp = 0;
      if (ticket > 0) {
      temp = ticket;
      ticket–;
      }
      pthread_mutex_unlock(&mutex); // 尽早解锁

      if (temp > 0) {
      usleep(1000); // 无关操作放锁外
      printf("卖出第 %d 张票\\n", temp);
      }
      return NULL;
      }

    • 避免锁内阻塞:不要在临界区执行可能阻塞的操作(比如 sleep、pthread_join、网络请求),这些操作会延长锁的占用时间。

    4. 陷阱四:错误的锁类型选择 —— 用错锁导致问题

    场景

    在递归函数中使用默认锁(重复加锁导致死锁);在需要调试的场景中使用默认锁(无法定位锁的使用错误)。

    避坑方法
    • 根据场景选锁:递归场景用递归锁,调试场景用错误检测锁,避免永久阻塞用超时锁;
    • 上线前优化:调试阶段用错误检测锁,上线后换成默认锁,平衡安全性和性能。

    八、总结:mutex—— 并发编程的 “安全基石”

    mutex 的本质是 “用硬件原子指令实现的互斥工具”,它的核心价值是解决多任务并发场景下 “共享资源访问冲突” 的问题:

    • 从逻辑上,它把多个任务对共享资源的 “无序争抢”,变成 “有序排队→独占访问→释放锁” 的流程,确保临界区的排他性;
    • 从实现上,它通过 “原子加锁 + 阻塞等待 + 唤醒机制”,兼顾了安全性和效率 —— 既保证了共享资源的一致性,又不影响非临界区的并发执行;
    • 从接口上,pthread_mutex系列函数(init/lock/unlock/destroy)提供了清晰的操作规范,不同锁类型适配不同场景需求;
    • 从使用上,只需遵守 “加锁 – 执行 – 解锁” 的规则,并规避死锁、锁泄漏等陷阱,就能安全实现线程互斥。

    没有 mutex,多任务并发就会陷入 “混乱争抢” 的状态,数据错乱、资源浪费等问题层出不穷;有了 mutex,多个任务才能像分工明确的店员一样,有序协作,高效完成工作。因此,mutex 是并发编程的 “安全基石”,也是每个后端开发者、系统编程开发者必须掌握的核心工具。

    示例代码:

    那么下面我就给出示例代码,这一部分代码很重要,希望大家重视:

    #include <iostream>
    #include <pthread.h>
    #include <unistd.h>
    #include <string>
    #include <cstdio>
    #include <cstdlib>
    #include <cstring>

    //那么接下来要学习的就是线程互斥,这也是一个较为重要的知识点
    //那么我们知道,同一进程内的多个线程可以对同一个全局变量进行随意访问以及更改(要是没有进行线程局部存储的话)
    //我们叫可以被多个线程进行访问的资源为共享资源,然后叫线程内访问共享资源的那一部分代码为临界区
    //没有访问的就叫作非临界区,这是一个共识,需要知道
    //那么同样的,由于多个线程可以对共享资源进行随意访问以及更改的话
    //就有可能会出现问题,我们可以先看下面的代码运行的结果

    // 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;
    // }
    // }
    // return nullptr;
    // }
    // 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);
    // return 0;
    // }

    //一次执行结果:
    // thread 4 sells ticket:100
    // …
    // thread 4 sells ticket:1
    // thread 2 sells ticket:0
    // thread 1 sells ticket:-1
    // thread 3 sells ticket:-2

    //可以看到,明明我们在代码中要求了ticket不能小于0,可是最后结果怎么会小于0呢?
    //其实这就和多线程同时访问共享资源有关
    //可以这么想,我们是设置了if (ticket > 0),可是是有多个线程进行这段代码呀
    //然后ticket这个全局变量就是一个线程更改的话,那么其它线程所看到的也都是更改后的变量值
    //那么就会出现一个问题,可能多个线程在ticket还>0的时候去访问if (ticket > 0)内的代码块
    //也就是多个线程都判断进进了if (ticket > 0)内的代码块,那么此时ticket也确确实实是大于0的
    //可是呢,如果ticket只是为1呢?那么多个线程都通过了,然后开始进行if (ticket > 0)内的代码块
    //那么一个线程诶,对ticket–了,ticket变为0,那么按道理来说就不符合if (ticket > 0)的条件了
    //其他线程就得不进行if (ticket > 0)内的代码块了
    //但是可不要忘记了,其他线程可是在ticket还>0的时候就通过了if判断进入if (ticket > 0)内的代码块
    //那么你其中一个线程是使ticket为0了,可是我其他线程也是在执行if (ticket > 0)内的代码块呀
    //而且因为我们已经在前面通过了if判断,那么在出if (ticket > 0)内的代码块之前,我就不会再进行if判断了
    //所以,其余线程也对ticket–了,那么这么一来,ticket就变得小于0了
    //• if 语句判断条件为真以后,代码可以并发的切换到其他线程
    //• usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
    //• –ticket 操作本身就不是一个原子操作

    //要解决以上问题,需要做到三点:
    //• 代码必须要有互斥行为:当某个线程代码进入临界区执行时,不允许其他线程进入该临界区。
    //• 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
    //• 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
    //要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量,也就是mutex,那么也是在pthread里面封装好了
    //接下来我就来介绍Linux中锁🔒,也就是互斥量的用法

    //那么我们要使用锁,就肯定得先创建锁,那么对于锁,有两中初始化方法
    //第一种:创建全局的锁,那么这个锁就是直接在全局中进行创建的,是静态创建锁,创建的方法也很简单:
    //pthread_mutex_t 锁名 = PTHREAD_MUTEX_INITTALIZE,是的,就是这么简单,锁变量的类型就是pthread_mutex_t
    //第二种:调用创建锁函数创建的锁,也就是动态创建锁
    // int pthread_mutex_init(pthread_mutex_t *restrict mutex, constpthread_mutexattr_t *restrict attr);
    //那么对于该函数,传入的第一个参数就得是我们在调用该函数前创建的互斥量的地址
    //但是我们需要注意,那就是在调用该函数前创建的互斥量需要在全局里面创建,也就是创建全局变量的互斥量
    //然后再在main主线程里面进行初始化,为什么要这样子呢?
    //其实是因为要是是在main主线程里面创建的锁变量的话,那么在其他线程是无法直接访问到的
    //那么也就无法进行上锁,所以我们需要创建全局的锁变量
    //这一点需要注意
    //而第二个参数就更简单了,我们直接传入nullptr就行,即使用系统默认
    //所以方法就是如下:
    //pthread_mutex_t 锁名;
    //pthread_mutex_init(&锁名,nullptr)
    //由此就创建了一个锁

    //然后就是对锁的销毁的问题,因为我们创建锁本质上也是申请资源,所以我们不用锁了肯定也得销毁锁
    //那么对于锁的销毁也是有讲究的
    //对于我们使用第一个方法创建的锁,即创建全局的锁:pthread_mutex_t 锁名 = PTHREAD_MUTEX_INITIALIZER
    //它是不需要我们手动去销毁的,系统会帮我们自己进行销毁的,因为它是全局的哈哈哈
    //那么对于我们使用第二个方法创建的锁,就需要我们手动去销毁锁,同样要用到函数pthread_mutex_destory
    //int pthread_mutex_destroy(pthread_mutex_t *mutex);
    //使用方法也很简单,调用该函数,然后传入创建的锁变量的地址即可,很简单
    //但是对于锁的销毁,也是有需要注意的点的
    //那么第一点就是我们不能对还未解锁的锁去进行销毁,因为我们是创建了锁,但是还得锁上,才代表锁正式生效
    //那么我们要销毁锁,自然是得在锁已经销毁的情况下,你不能锁还在锁着,然后就把锁给销毁了吧,那这不是很荒唐
    //然后第二点就是说,我们已经销毁了的锁,是不能在后续又被上锁的
    //那么这个肯定也是毋庸置疑的一个点,你锁都被销毁了,然后还指望把这个锁锁上,这不纯纯有毛病吗哈哈哈
    //所以就是已经销毁的互斥量,要确保后面不会有线程再尝试加锁

    //然后就是锁的上锁和解锁问题,这也是一个较为重要的点
    //因为我们前面是创建了锁,但是也仅仅只是创建,锁还是空的,所以如果我们想要让锁真正的发挥用途,就得进行上锁
    //那么这个上锁就很有讲究了,就是调用上锁的函数的位置很有讲究
    //因为上锁实际上是对某一段代码块进行上锁,那么一般就是对临界区进行上锁
    //那么我们知道,一个线程运行的函数里面,一般不会都是临界区,也有非临界区
    //那么我们锁的上锁就得注意了,只针对临界区进行上锁,而不对非线程区进行上锁
    //所以调用上锁函数的位置就很有讲究了
    /***************************************************************************************
    * *
    * ╭──────────────────────────────────────────────────────────────────────────────────╮ *
    * │ 🟢 非临界区 ── 线程可并行执行 (parallel section) │ *
    * ╰──────────────────────────────────────────────────────────────────────────────────╯ *
    * │ *
    * ▼ *
    * 🔒 ╔══════════════════════════════════════════════════════════════════════════════╗ *
    * ║ lock(&mutex); // 加锁 ║ *
    * ╚══════════════════════════════════════════════════════════════════════════════╝ *
    * │ *
    * ▼ *
    * 🔴 ╔══════════════════════════════════════════════════════════════════════════════╗ *
    * ║ 临界区 (critical section) ║ *
    * ║ ┌────────────────────────────────────────────────────────────────────┐ ║ *
    * ║ │ 任意时刻仅允许 1 个线程进入;共享数据在此被安全访问 / 修改 │ ║ *
    * ║ └────────────────────────────────────────────────────────────────────┘ ║ *
    * ╚══════════════════════════════════════════════════════════════════════════════╝ *
    * │ *
    * ▼ *
    * 🔓 ╔══════════════════════════════════════════════════════════════════════════════╗ *
    * ║ unlock(&mutex); // 解锁 ║ *
    * ╚══════════════════════════════════════════════════════════════════════════════╝ *
    * │ *
    * ▼ *
    * ╭──────────────────────────────────────────────────────────────────────────────────╮ *
    * │ 🟢 非临界区 ── 线程可并行执行 (parallel section) │ *
    * ╰──────────────────────────────────────────────────────────────────────────────────╯ *
    ***************************************************************************************/
    //我们实际上要达到的目的就是如上这样
    //所以可以看到,我们调用上锁函数的位置是要在临界区上面,然后调用解锁函数的位置则是要在临界区下面
    //上锁函数:int pthread_mutex_lock(pthread_mutex_t *mutex);
    //解锁函数:int pthread_mutex_unlock(pthread_mutex_t *mutex);
    //返回值:成功返回0,失败返回错误号
    //调用 pthread_ lock 时,可能会遇到以下情况:
    //• 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
    //• 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,
    //但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

    // 加锁解锁的核心总结
    // 加锁时机:必须在进入临界区(操作共享资源)前上锁,且要覆盖 “检查条件(if) + 操作资源” 的完整原子逻辑,
    // 避免中途被其他线程打断。
    // 解锁时机:临界区代码执行完毕后立即解锁;
    // 任何可能退出临界区的分支(如else、break、return)都要先解锁再退出,绝对不能带着锁退出。
    // 核心原则:锁只包裹 “操作共享资源的临界区”,不包含无关代码(如 sleep、独立计算),避免延长锁占用时间;
    // 加锁和解锁必须严格成对,防止死锁或锁泄漏。

    //所以我们就可以把上面的代码进行一个改造

    //可以创建全局的锁,不需要调用初始化锁函数,也不需要进行销毁
    pthread_mutex_t gmutex=PTHREAD_MUTEX_INITIALIZER;

    //创建全局的锁变量
    //让其他线程可以直接访问到该所变量,同时进行上锁
    pthread_mutex_t mutex;

    int ticket = 100;
    void *route(void *arg)
    {
    char *id = (char *)arg;
    while (1)
    {
    //if语句和if语句里面的代码块就是临界区了
    //所以我们要在这上面上锁
    pthread_mutex_lock(&mutex);
    if (ticket > 0)
    {
    usleep(1000);
    printf("%s sells ticket:%d\\n", id, ticket);
    ticket–;
    //那么我们在if语句前面上锁了之后,当if语句结束的时候
    //我们就得解锁,不然的话就会导致一个线程上锁运行if里面的代码块
    //而当它出了if语句之后,却没有进行解锁,也就是说这个线程还持有着锁,即还占据着if语句里面的代码块
    //这就导致其他线程无法访问if里面的代码块了,因为被锁住了
    //所以我们要在if语句结束前进行解锁,代表下一个线程可以进行访问
    pthread_mutex_unlock(&mutex);
    }
    //而出来if语句,就又不是临界区了,所以也要进行解锁
    //注意,是在else语句里面解锁,可不是直接就在两个中间解锁
    //这样子就扰乱if、else语句了
    else
    {
    //为什么我们也要在else语句里面进行解锁呢?
    //其实是因为我们是在if语句前面上锁的,那么我们也仅仅只是在if语句结束前有进行解锁
    //可是要是线程没有进入if语句呢(ticket为0了),也就是说线程进入else语句里了
    //那么那么就又有问题出现了,依旧是线程占据锁、占据代码块的问题
    //一个进程持有锁了,然后它进入了没有解锁的else语句里面,那么当它出了else语句之后
    //他还是持有那个锁,也就代表着其他线程无法访问else语句了
    //就会造成其他进程堵塞着,嗷嗷待哺的等着else语句解锁了然后走else语句里的代码块
    //所以,我们也要在else语句里面进行解锁
    pthread_mutex_unlock(&mutex);
    break;
    }
    // mutex 的使用规则是:每一次成功的pthread_mutex_lock,都必须有对应的pthread_mutex_unlock。
    // 代码中pthread_mutex_lock(&mutex)是在循环开头执行的(只加锁一次),但后续有两个可能的执行路径:
    // 路径 1:ticket > 0 → 处理售票 → 解锁 → 继续循环(下一次循环会重新加锁);
    // 路径 2:ticket == 0 → 无需售票 → 必须先解锁 → 退出循环。
    // 如果路径 2 不解锁,就破坏了 “加锁 – 解锁” 的成对关系,锁会被永久占用。
    // 2. 避免 “锁泄漏” 导致死锁
    // 你可以把 mutex 想象成 “卫生间的门钥匙”:
    // 线程加锁 = 拿钥匙进门;
    // 处理完事情(if 分支)= 出门还钥匙(解锁);
    // 发现没事情可做(else 分支)= 也得先出门还钥匙(解锁),不能把钥匙带在身上走了,否则别人永远进不去。
    // 如果 else 分支不解锁,相当于线程 “拿着钥匙跑了”,其他线程永远拿不到钥匙(锁),程序卡死。
    }
    return nullptr;
    }
    int main()
    {
    pthread_t t1, t2, t3, t4;

    pthread_mutex_init(&mutex,nullptr);

    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);

    //销毁锁
    pthread_mutex_destroy(&mutex);
    return 0;
    }
    //至此线程互斥就差不多了,后面我们会封装一个锁对象来使用。

    锁的封装:

    那么我们依旧是老样子,mutex一系列函数太麻烦,我们直接来个封装就完事了,就想线程函数的封装一样。

    先想明白:为啥要折腾 “封装”?——C 语言原生锁的 “坑” 有多深

    你可以把 Linux 下的pthread_mutex想象成一套 “散装的锁具零件”:

    • 有个 “锁芯”(pthread_mutex_t变量),但它自己不会动;
    • 要开锁 / 关锁,得喊专门的 “师傅”(pthread_mutex_lock/unlock函数);
    • 新锁芯得自己 “组装”(pthread_mutex_init),用完得自己 “报废”(pthread_mutex_destroy);
    • 要是你忘了组装就用,锁芯直接卡死;忘了报废,锁芯占着仓库位置,别人用不了;
    • 更坑的是:你关了锁出门,结果半路摔了一跤(代码抛异常),钥匙丢了 —— 锁永远关着,别人进不来(死锁)。

    这些 “坑” 总结下来就是:C 语言的锁是 “裸奔” 的,全靠人手动伺候,一不留神就出问题。而我们封装的目的,就是给这套 “散装零件” 装个 “外壳”,让它变成 “一键操作” 的智能锁 —— 你只管说 “我要锁门”,剩下的 “装锁芯、看门、最后拆锁芯” 全不用管。

    第一步:用 “类” 把 “锁芯 + 操作” 绑成 “成品锁”—— 面向对象的 “打包术”

    C++ 的 “类” 就像一个 “产品包装盒”,能把 “零件(数据)” 和 “说明书(操作方法)” 装在一起。咱们设计Mutex类,就是要做一个 “成品锁产品”:

    1. 先藏好 “核心零件”—— 把锁芯变成私有成员

    你买一把锁,肯定不希望别人随便拆你的锁芯吧?所以我们把pthread_mutex_t _mutex(锁芯)放在类的private里 —— 外界看不见、摸不着,只能通过我们提供的按钮(方法)来用锁。

    • 为啥这么做?要是让用户直接碰锁芯,他可能手贱改了锁芯的状态(比如把 “已上锁” 改成 “未上锁”),或者拿错锁芯(比如用 A 锁的芯去开 B 锁),直接乱套。藏起来,就杜绝了这种 “作死操作”。
    2. 出厂就 “装好锁芯”—— 构造函数自动初始化

    你买新锁,拿到手肯定是组装好的,不会让你自己拼吧?所以我们在Mutex的构造函数里,直接调用pthread_mutex_init(&_mutex, nullptr)—— 用户只要写Mutex my_lock;,这把锁就自动完成 “组装”,能直接用了。

    • 还加了 “故障提示”:要是组装失败(比如系统没资源了),会打印错误原因(strerror)。就像你买的锁组装时发现零件坏了,商家会告诉你 “哪里出问题了”,而不是让你对着一堆零件懵逼。
    3. 报废时 “回收锁芯”—— 析构函数自动销毁

    你家锁不用了,扔的时候总得把锁芯拆下来回收吧?不然占地方还不安全。所以我们在Mutex的析构函数里,调用pthread_mutex_destroy(&_mutex)—— 当my_lock这个对象用完消失时(比如出了函数作用域),锁芯会自动 “报废回收”,不会留在系统里占资源。

    • 同样加了故障提示:要是销毁失败(比如锁还锁着就想拆),会告诉你原因 —— 就像你想拆还锁着的锁,工具会提醒你 “先开锁再拆”。
    4. 给锁装 “开关按钮”—— 包装 Lock/Unlock 方法

    原生的pthread_mutex_lock太长了,记不住?没关系,我们给类加两个简单的按钮:Lock()和Unlock()。用户按my_lock.Lock()就是上锁,按my_lock.Unlock()就是解锁 —— 不用记那些拗口的 C 函数名,就像用家电按按钮一样简单。

    • 还是加故障提示:要是上锁失败(比如锁已经坏了),会告诉你 “上锁失败,原因是 XXX”—— 比原生接口只返回个破数字(比如 – 1)友好多了,新手也能看懂。
    5. 禁止 “复制锁”—— 删了拷贝构造和赋值

    你能想象 “复制一把锁” 有多可怕吗?比如你有一把锁 A,复制出一把锁 B—— 锁 B 的锁芯其实和锁 A 是同一个!你把锁 A 锁上,锁 B 以为自己也锁上了;你把锁 B 拆了,锁 A 的锁芯也没了 —— 直接乱套。

    • 所以我们用= delete删掉拷贝构造和赋值运算符:谁要是想写Mutex lock2 = lock1;,编译器直接报错 —— 从根上掐死这种作死操作,就像商家告诉你 “这锁是定制的,不能复制”。
    6. 留个 “后门”——GetMutex 方法

    有时候用户可能需要用底层的锁芯(比如配合条件变量pthread_cond_wait,这函数非要个锁芯指针),所以我们加个GetMutex()方法,让用户能拿到锁芯的地址 —— 但只是 “看一眼”,不是让他改,就像你家锁留个检修口,师傅能看但不能随便拆。

    第二步:再加一层 “智能防护”——LockGuard 的 “自动开关门” 魔法

    就算有了Mutex类,还是可能出问题:比如你按了Lock()上锁,结果中途接了个电话(代码抛异常),忘了按Unlock()—— 锁就永远关着,别人进不来(死锁)。这就像你出门锁了门,结果钥匙丢了,自己也进不去了。

    怎么办?我们需要一个 “智能跟班”——LockGuard类,它的核心魔法是:你让它跟着锁,它就会 “自动开门、自动关门”(RAII 机制:资源获取即初始化)。

    1. LockGuard 是 “锁的小跟班”

    创建LockGuard时,必须把它绑在一把Mutex上(比如LockGuard guard(my_lock);)—— 就像你带个跟班出门,跟班必须跟着你,不能乱跑。

    2. 跟班 “一上岗就锁门”—— 构造函数自动 Lock

    LockGuard的构造函数里,直接调用_mutex.Lock()—— 你一创建guard这个跟班,它就立刻帮你把my_lock锁上。不用你自己按Lock(),省事儿。

    3. 跟班 “下班必开门”—— 析构函数自动 Unlock

    当guard这个跟班 “下班”(出了作用域),它的析构函数会自动调用_mutex.Unlock()—— 不管你是正常下班(代码跑完),还是半路摔了一跤(抛异常),跟班都会记得开门。

    • 举个例子:

      void func() {
      Mutex my_lock;
      LockGuard guard(my_lock); // 跟班上岗,自动上锁
      // 临界区代码
      if (something_wrong) {
      return; // 半路跑路,跟班会自动解锁
      }
      throw std::exception(); // 抛异常,跟班也会自动解锁
      } // 正常下班,跟班解锁

      要是没有跟班,你在return或throw的地方忘了写Unlock(),锁就永远关着了 —— 有了跟班,永远不会忘。

    4. LockGuard 是 “一次性的”—— 不用手动管

    LockGuard不用你调用任何方法,它的生命周期就是 “临界区的生命周期”:临界区开始,它创建;临界区结束,它销毁。就像你进房间,灯自动开;你出房间,灯自动关 —— 全程不用碰开关。

    第三步:总结一下 —— 从 “散装零件” 到 “智能锁” 的进化史

    咱们整个封装过程,就是把 Linux 的原生锁:

  • 从 “散装锁芯 + 零散工具”(C 接口)→ 打包成 “成品锁”(Mutex类):有自己的锁芯,能自动组装 / 回收,有简单的开关按钮,还不能复制;
  • 再给成品锁加个 “智能跟班”(LockGuard):自动开关门,永远不会忘,就算出意外也能保证门关上 / 打开。
  • 最终用户用的时候,就像这样:

    void sell_ticket() {
    static Mutex ticket_lock; // 一把成品锁,自动组装
    {
    LockGuard guard(ticket_lock); // 跟班上岗,自动上锁
    // 卖票的临界区代码——放心写,不用管锁
    } // 跟班下班,自动解锁
    } // ticket_lock用完,自动回收锁芯

    全程不用记任何底层函数,不用操心 “装锁、拆锁、忘解锁”—— 这就是封装的终极目的:把复杂的底层细节藏起来,给用户一个 “傻瓜式” 的工具,让他只关注 “要锁什么”,不用管 “锁怎么用”。

    最后补一句:为啥要这么折腾?—— 程序员的 “懒” 是生产力

    其实所有封装的本质,都是程序员为了 “偷懒”:这次封装好了,下次用锁直接拿Mutex和LockGuard就行,不用再写那些重复的初始化、销毁、加解锁代码;而且不会出错 —— 毕竟机器比人靠谱,自动的东西永远比手动的少犯错。

    这就像你第一次做饭要自己买菜、洗菜、切菜、炒菜,麻烦得要死;但你可以买个 “预制菜”(封装好的类),直接加热就能吃 —— 省事儿,还不容易翻车。咱们写代码也是一样,封装就是做 “预制菜”,让自己和同事少踩坑,多省心。

    示例代码:

    下面我就给出示例代码:

    Mutex.hpp:

    #pragma once
    #include <iostream>
    #include <cstdio>
    #include <cstring>
    #include <cstdlib>
    #include <unistd.h>
    #include <pthread.h>
    #include <functional>
    #include <vector>
    #include <string>

    // 那么在本文件主要是做一个简单的对mutex的各个函数接口的封装
    // 其实和线程封装类似,本质上就是封装为一个锁对象

    namespace mutex
    {
    class Mutex
    {
    public:
    // 删除不要的拷贝和赋值
    Mutex(const Mutex &) = delete;
    const Mutex &operator=(const Mutex &) = delete;
    // 构造函数
    Mutex() // 不需要外界传入 pthread_mutex_t 变量,直接使用我们该类的成员变量即可
    {
    // 那么在用户创建锁对象时就得去初始化锁了
    // 即调用锁的初始化函数
    int ret_pthread_mutex_init = pthread_mutex_init(&_mutex, nullptr);
    if (ret_pthread_mutex_init != 0)
    {
    std::cout << "pthread_mutex_init failed: " << strerror(ret_pthread_mutex_init) << std::endl;
    }
    else
    {
    std::cout << "Init Mutex Success!" << std::endl;
    }
    }
    // 析构函数
    ~Mutex() // 我们在析构函数去负责把锁销毁,那么用户在外界就不需要再调用锁销毁函数,当创建的锁对象销毁时,创建的锁也一起销毁
    {
    // 即把锁销毁
    // 调用销毁锁的函数
    int ret_pthread_mutex_destroy = pthread_mutex_destroy(&_mutex);
    if (ret_pthread_mutex_destroy != 0)
    {
    std::cout << "pthread_mutex_destroy failed: " << strerror(ret_pthread_mutex_destroy) << std::endl;
    }
    else
    {
    std::cout << "Destroy Mutex Success!" << std::endl;
    }
    }
    // 上锁函数
    void Lock()
    {
    // 其实就是调用上锁函数
    int ret_pthread_mutex_lock = pthread_mutex_lock(&_mutex);
    if (ret_pthread_mutex_lock != 0)
    {
    std::cout << "pthread_mutex_lock failed: " << strerror(ret_pthread_mutex_lock) << std::endl;
    }
    else
    {
    std::cout << "Lock Mutex Success!" << std::endl;
    }
    }
    // 解锁函数
    void Unlock()
    {
    // 其实就是调用解锁函数
    int ret_pthread_mutex_unlock = pthread_mutex_unlock(&_mutex);
    if (ret_pthread_mutex_unlock != 0)
    {
    std::cout << "pthread_mutex_unlock failed: " << strerror(ret_pthread_mutex_unlock) << std::endl;
    }
    else
    {
    std::cout << "Unlock Mutex Success!" << std::endl;
    }
    }
    // 获取到锁变量指针
    pthread_mutex_t *GetMutex()
    {
    return &_mutex;
    }

    private:
    pthread_mutex_t _mutex; // 锁变量
    };

    // 采用RAII风格,进行锁管理
    class LockGuard
    {
    public:
    LockGuard(Mutex &mutex)
    : _mutex(mutex)
    {
    _mutex.Lock();
    }
    ~LockGuard()
    {
    _mutex.Unlock();
    }

    private:
    Mutex &_mutex; // Mutex类变量
    };
    // 这个LockGuard类是基于RAII(资源获取即初始化)思想实现的自动锁管理工具,
    // 核心作用是简化互斥锁的使用、避免锁泄漏(死锁)、保证异常安全,具体价值分 3 点:
    // 自动完成 “加锁 – 解锁”,避免手动遗漏,
    // 当创建LockGuard对象时,构造函数会自动调用_mutex.Lock()加锁;
    // 当LockGuard对象离开作用域(比如代码块结束、函数返回)时,析构函数会自动调用_mutex.Unlock()解锁
    // —— 无需手动写Lock/Unlock,
    // 彻底避免 “忘记解锁导致锁被永久占用(死锁)” 的问题。
    // 保证异常安全,防止锁泄漏若代码在临界区中抛出异常(或执行return/break等分支),
    // 手动管理锁时容易 “跳过解锁步骤”;而LockGuard的析构函数会无论如何都被执行(C++ 中对象出作用域必析构),
    // 因此即使发生异常,锁也能被正常释放。
    // 简化代码,减少重复操作无需在每个临界区前后重复写Lock和Unlock,
    // 只需在临界区开头创建LockGuard对象,就能自动完成锁的生命周期管理,让代码更简洁、易维护。
    }

    Main.cc:

    // 测试文件
    #include "Mutex.hpp"

    // 抢票的代码就可以更新成为
    using namespace mutex;
    int ticket = 1000;
    Mutex g_mutex; // 依旧是创建全局mutex类变量,使得可以多个线程看到以及进行上锁
    void *route(void *arg)
    {
    char *id = (char *)arg;
    while (1)
    {
    LockGuard lockguard(g_mutex); // 使用RAII风格的锁
    // 不需要我们去手动解锁了,当代码块结束时就会自动调用其析构函数进行解锁
    // 每次循环创建LockGuard lockguard(g_mutex)时,
    // 它是while 循环内的局部对象—— 只要代码执行到 “离开LockGuard的作用域”
    // (比如if分支执行完、else分支break跳出循环),这个对象就会被自动销毁
    // (C++ 局部对象出作用域必析构)
    // 其实就是进行了一次while循环之后,那么在上次while循环里创建的LockGuard就会自动析构销毁
    // 然后在新的一次while循环中会创建新的LockGuard变量
    // 所以你无论是走完if语句还是走完else语句,本质上都是进行完了一次while循环
    // 所以就不需要我们手动销毁了,还是C++11的好东西啊
    if (ticket > 0)
    {
    usleep(1000);
    printf("%s sells ticket:%d\\n", id, ticket);
    ticket–;
    }
    else
    {
    break;
    }
    }
    return nullptr;
    }
    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);
    }

    结语:于代码的秩序里,看见编程的温度

    当我们敲下最后一行LockGuard的析构代码,看着终端里 “Destroy Mutex Success!” 的提示静静浮现时,这场关于 “线程互斥” 的探索才算真正落了幕。从最初看着售票系统里票额变成负数时的困惑,到理解 “非原子操作” 与 “任务切换” 的冲突根源;从对着pthread_mutex_lock的参数手册反复琢磨,到用 C++ 类封装出 “开箱即用” 的锁对象;从担心 “忘解锁导致死锁” 的焦虑,到依靠 RAII 机制实现 “自动开关门” 的安心 —— 我们走过的,从来不止是 “学会用一把锁” 的技术路径,更是一段 “从解决问题到优雅解决问题” 的思维成长。

    你或许会记得,最开始面对多线程数据错乱时的手足无措:明明逻辑上if(ticket>0)已经把门关住了,可系统偏要 “钻空子”,让多个线程闯进去把票额改成负数。那时我们才懂,计算机世界的 “并发” 从来不是简单的 “多个人一起干活”,而是 “多个人抢一个工具时,得讲规矩”。mutex 的出现,本质上就是给混乱的并发场景立下了 “规矩”—— 它像工地里的 “操作证”,只有拿到证的人才能碰共享的机器,其他人必须排队等;它又像厨房里的 “刀架”,一把刀一次只能给一个厨师用,避免你切菜时我伸手,最后伤了手也毁了菜。我们学的不是 “怎么调用 lock 和 unlock”,而是 “怎么给共享资源建立秩序”。

    而封装的过程,更是让我们从 “用工具” 变成了 “造工具”。最初的pthread_mutex是散落的零件:你得自己装锁芯(init)、自己拧钥匙(lock)、自己拆锁(destroy),一步错就可能让整个程序卡死。可当我们把这些步骤塞进Mutex类的构造和析构里,当我们让LockGuard像个贴心的跟班一样 “上岗就锁门,下班就开门” 时,代码突然有了 “人情味”—— 它开始替我们记住那些容易遗忘的细节,替我们挡住那些可能踩进去的陷阱。这不是 “偷懒”,而是程序员对 “可靠性” 的追求:我们写的代码不仅要 “能跑”,更要 “不容易错”;不仅要自己能看懂,更要让接手的同事不用反复确认 “这里有没有忘解锁”。

    我总觉得,RAII 机制最动人的地方,不在于它简化了代码,而在于它藏着一种 “责任意识”。每个锁的生命周期,对应着一段临界区的安全;每个对象的构造与析构,对应着资源的申请与释放。我们不再是 “用完就丢” 的开发者,而是 “借了东西必须还” 的管理者 —— 就像你从图书馆借了书,总得记得还回去,不然下一个人就借不到了;就像你用完打印机,总得把它恢复到空闲状态,不然后面的同事只能干等着。这种对 “资源” 的敬畏,对 “协作” 的理解,才是比 API 手册更重要的东西。

    当然,我们也一定记得那些踩坑的时刻:比如第一次写嵌套锁导致死锁,看着四个线程卡在那里一动不动,像四个赌气的孩子互相拽着胳膊;比如把usleep放进临界区,让锁的粒度大到整个程序变成 “伪串行”,效率低得让人哭笑不得。可这些坑从来不是 “浪费时间”,而是让我们把 “互斥” 的本质刻进骨子里 —— 死锁教会我们 “按顺序做事”,锁粒度教会我们 “别把无关的事也关进笼子里”,锁泄漏教会我们 “每个操作都要有闭环”。编程从来不是 “一次写对” 的魔法,而是 “在错误里磨出经验” 的修行。

    写到这里,突然想起最开始的那个问题:“为什么要学线程互斥?” 答案或许藏在我们最后改好的售票系统里 —— 当四个线程井然有序地卖出每一张票,当终端里整整齐齐地打印出 “thread 1 sells ticket:1000” 到 “thread 4 sells ticket:1”,没有负数,没有错乱,那一刻我们看到的不只是 “正确的结果”,更是 “混乱中的秩序”。这世界上的程序,说到底都是在解决 “人怎么和机器协作,机器怎么和机器协作” 的问题:线程需要 mutex 来避免冲突,团队需要规范来避免内耗,社会需要规则来避免混乱 —— 道理从来都是相通的。

    而 mutex 只是并发世界的第一扇门。门外还有条件变量(让线程学会 “等通知” 而不是 “瞎等”)、读写锁(让读多写少的场景更高效)、原子操作(让简单的计数不用加锁),还有更复杂的线程池、分布式锁…… 我们今天埋下的种子,会在未来的项目里长成遮风挡雨的树。或许有一天,你会在写高并发接口时,突然想起今天学的 “最小化临界区”;在排查生产环境的死锁问题时,突然想起今天画的 “锁的顺序图”;在设计工具类时,突然想起今天写的LockGuard—— 那些曾经啃过的知识点,终究会变成你手里的 “武器”。

    最后,想对你说:别害怕那些看起来复杂的概念,别畏惧那些调不通的 bug。线程互斥的学习,就像学骑自行车 —— 一开始总要摔几次,可等你掌握了平衡感,就再也不会想起 “怎么握车把”,只会专注于前方的路。编程的乐趣,从来不是背会多少 API,而是当你用代码驯服了混乱,让机器按你的意愿有序运转时,那种 “创造秩序” 的成就感。

    继续往前走吧。去写更健壮的代码,去解决更复杂的问题,去把今天学到的 “秩序”,变成明天项目里的 “基石”。你手里的锁,不只是锁住共享资源的工具,更是锁住 “可靠性” 的钥匙 —— 而你,终会成为那个能给复杂系统建立秩序的开发者。

    赞(0)
    未经允许不得转载:171主机测评 » 对于Linux:线程互斥(锁)以及锁的封装的解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址