跳到主要内容
极客日志极客日志
首页博客AI提示词GitHub精选代理工具
搜索
|注册
博客列表
C++算法

C++ 与 Linux 多线程进阶:深入理解互斥锁

多线程编程中常遇到数据竞争问题,如经典的抢票场景导致票数变负。这是因为线程间存在“检查后执行”的竞态条件。引入互斥锁可保证临界区互斥访问,但需注意锁的范围不宜过大,避免持有锁休眠,同时确保所有分支路径都正确解锁以防死锁。此外,互斥锁仅保障互斥性而非公平性,实际开发中需结合具体场景权衡性能与安全性。

日志猎手发布于 2026/3/24更新于 2026/4/262 浏览
C++ 与 Linux 多线程进阶:深入理解互斥锁

1. 引言

在之前的多线程学习中,我们可能遇到过乱码或抢占输出的情况。这是为什么呢?本章我们通过一个经典案例来剖析这个问题。

2. 抢票场景下的数据竞争

假设我们有 100 张电影票,多个线程同时抢票会发生什么?我们先写一段代码看看:

#include <iostream>
#include <thread>
#include <vector>
#include <string>
#include <cstdio>
#include <unistd.h>

int ticket = 100;

void routine(std::string name) {
    while (true) {
        if (ticket > 0) {
            usleep(1000); // 模拟抢票耗时
            ticket--;
            printf("%s sell ticket, now tickets number:%d\n", name.c_str(), ticket);
        } else {
            std::cout << ticket << std::endl;
            break;
        }
    }
    return;
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 5; i++) {
        std::string name = "thread-";
        name += std::to_string(i);
        threads.emplace_back(routine, name);
    }
    for (auto& thread : threads) {
        thread.join();
    }
    return 0;
}

这里的公共资源是 ticket,五个线程去抢这个票数。我们用 usleep(1000) 模拟抢票消耗的时间。按理说,一旦没票了就应该停止。但运行结果可能会让你惊讶:

结果竟然跑到了 -4,票都没了怎么还能减成负数?这背后的原因其实很典型。

2-1 原因分析

如果是单线程,不会发生这种事。但在多线程环境下,核心问题是竞争。

看这段代码,每个线程进入函数都会读取 ticket 的数量,然后休息一秒,再进行自减。我们放慢过程,详细看看当票数为 1 时的情况:

  1. 线程 1 拿到 ticket 发现是 1,可以减减,随后休息 1 秒。
  2. 线程 2 启动,发现 ticket 也是 1,也可以减减,同样休息一秒。
  3. 线程 1 醒来,对 ticket 减减,变成 0。
  4. 线程 2 醒来,基于之前判断的'可以减减',再次对 ticket 减减,导致变成了 -1。

这就是经典的 check-then-act race(检查后执行竞态)。用汇编视角来看更清晰:

; if (ticket > 0)
LOAD R1, [ticket] ; R1 = ticket
CMP R1, 0         ; 比较 R1 和 0
JLE END_IF        ; 如果 <= 0,跳走
; usleep(1000)
CALL usleep
; ticket--
LOAD R2, [ticket] ; R2 = 当前 ticket (此时已被修改)
SUB R2, 1         ; R2 = R2 - 1
STORE [ticket], R2; 写回 ticket
END_IF:

关键点在于:判断时用的是 R1,真正减法时又重新 LOAD R2, [ticket] 读了一次内存。如果中间被其他线程修改了内存,当前的线程就会基于旧情报做出错误操作。

3. 引入锁的概念

为了防止这种乌龙事件,我们引入了锁。先不深究原理,直接看效果:

#include <iostream>
#include <thread>
#include <vector>
#include <string>
#include <cstdio>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void routine(std::string name) {
    while (true) {
        pthread_mutex_lock(&lock);
        if (ticket > 0) {
            usleep(1000);
            ticket--;
            printf("%s sell ticket, now tickets number:%d\n", name.c_str(), ticket);
            pthread_mutex_unlock(&lock);
        } else {
            pthread_mutex_unlock(&lock);
            break;
        }
    }
    return;
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 5; i++) {
        std::string name = "thread-";
        name += std::to_string(i);
        threads.emplace_back(routine, name);
    }
    for (auto& thread : threads) {
        thread.join();
    }
    return 0;
}

这次运行正常了,票数稳稳停在 0。这里的锁就是互斥锁 (Mutex):

  • 特点:'互斥'即其名,同一时间只有一个线程能持有锁。
  • 用法:pthread_mutex_lock() 加锁,pthread_mutex_unlock() 解锁。

3-1 临界区的概念

为什么要上锁?因为线程竞争导致公共资源混乱。所以一切访问公共资源的地方都需要上锁,一次只允许一个线程访问。这部分代码段被称为临界区 (Critical Section)。

  • 核心规则:同一时刻,只允许一个线程进入临界区。
  • 如果不保护:就会发生'竞态条件'(Race Condition),导致数据毁坏。
  • 保护方式:进入临界区前加锁(Lock),离开临界区后解锁(Unlock)。

3-2 解锁的时机

注意我的代码,无论是在 if 还是 else,我们都会解锁。有人可能会问,为什么不像下面这样统一解锁呢?

pthread_mutex_lock(&lock);
if (ticket > 0) {
    // ...
    // pthread_mutex_unlock(&lock);
} else {
    // pthread_mutex_unlock(&lock);
    break;
}
pthread_mutex_unlock(&lock);

如果 break 了,后面的 unlock 就永远不会执行。这会导致一种典型的死锁诱因:一个线程在持有锁的情况下直接退出(如 break、return 或异常),而未释放锁,导致其他需要该锁的线程永远等待。

死锁产生的四个必要条件(Coffman 条件)包括互斥、占有并等待、不可剥夺和循环等待。虽然这里只是演示,但养成好习惯很重要。

3-3 线程拿着锁睡觉

这是我们代码的另一个隐患:里面的 usleep 应该放在锁外面。避免锁拿着线程进行睡觉是非常不合理的,这会阻塞其他线程。

综合下来,我们的程序应该是这样的(见上文第 3 节代码,已将 usleep 移出锁外,或者保持原样但需注意影响)。为了安全起见,建议将耗时操作移出临界区:

// 优化后的逻辑示意
while (true) {
    pthread_mutex_lock(&lock);
    if (ticket > 0) {
        ticket--;
        printf("%s sell ticket, now tickets number:%d\n", name.c_str(), ticket);
    } else {
        pthread_mutex_unlock(&lock);
        break;
    }
    pthread_mutex_unlock(&lock);
    usleep(1000); // 耗时操作移到这里
}

3-4 一个现象

即便有了锁,我们也发现一直是某个特定线程在进行抢票。这说明这段时间里它反复拿到了 CPU,并且每次也都先抢到了那把锁。

它先抢到 CPU,于是更有机会再次执行到 pthread_mutex_lock;而锁一旦被它释放,它又很快再次抢回来了。所以互斥锁,并不能保证公平。

4. 总结

这篇文章从一张"神奇的负数车票"开始,带我们走进了多线程编程中最头疼的问题——竞态条件。当我们用五个线程同时去抢那 100 张票时,本该在票数为 0 时就停止的程序,竟然一路狂奔到了 -4。这背后的元凶就是经典的 check-then-act race:线程 A 刚判断完票数大于 0,还没完成减减操作,就被线程 B 抢占了 CPU;等 A 回来继续执行时,手里的"旧情报"已经失效了,却还要对已经变了的票数再做一次减减。这种对公共资源的并发访问,如果不加以保护,数据就会像脱缰的野马一样乱套。

为了解决这个问题,我们引入了互斥锁(Mutex)这个"交通警察"。它保证同一时间只有一个线程能进入临界区——也就是访问共享资源的那段代码。加锁和解锁的时机很有讲究:锁的范围要刚好覆盖对公共资源的操作,但不能太大(比如不能把 usleep 也包进去,否则就是"拿着锁睡觉",白白浪费别人的时间);同时每一个分支路径都要记得解锁,不然就会触发死锁,让其他线程永远等在那里。文章最后也提了一个有趣的现象:即便有了锁,线程 2 还是能把票抢光——这说明互斥锁只保证互斥,不保证公平,谁抢到 CPU 谁就有机会先拿到锁。

总的来说,线程互斥是多线程编程的必修课。理解临界区、掌握锁的粒度、警惕死锁的四个必要条件,这些基本功打扎实了,才能写出既高效又安全的多线程程序。毕竟,在这个并发为王的时代,让线程们"有序竞争"比"野蛮抢食"要靠谱得多。

目录

  1. 1. 引言
  2. 2. 抢票场景下的数据竞争
  3. 2-1 原因分析
  4. 3. 引入锁的概念
  5. 3-1 临界区的概念
  6. 3-2 解锁的时机
  7. 3-3 线程拿着锁睡觉
  8. 3-4 一个现象
  9. 4. 总结
  • 💰 8折买阿里云服务器限时8折了解详情
  • 💰 8折买阿里云服务器限时8折购买
  • 🦞 5分钟部署阿里云小龙虾了解详情
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • Flutter 三方库 mediapipe_core 的鸿蒙化适配指南
  • JavaScript 二维码跨平台处理实战技巧与优化
  • 基于 YOLOv11 系列的电动自行车违规载人检测系统开发实践
  • Python 与 SQLAlchemy:数据库管理入门指南
  • VS Code 禁用 GitHub Copilot 代码补全方法
  • SubtitleEdit Purfview Faster Whisper XXL 引擎安装失败排查与修复
  • 人工智能发展历程与现状分析
  • Flutter 使用 wasm_ffi 库在鸿蒙端集成 WebAssembly 实现高性能计算
  • 基于 Trae 部署与配置 Claude Code 实践
  • 前端 JS 调用后端 API 的三种常用方法
  • Spring Boot 智驿 AI 系统毕业设计项目介绍与功能实现
  • 二分查找算法详解与实战案例
  • WebGIS 开发中 WKT 转 GeoJSON 的技巧与 Leaflet 加载应用
  • Flutter 三方库 arcane_helper_utils 鸿蒙化适配指南
  • 法律领域自然语言处理应用与实战指南
  • 基于 Coze 构建专属 AI 应用:从智能体到 Web 部署
  • Python 爬取携程景区评论数据实战
  • Java 面试题解析:main 方法可以继承吗?
  • GitHub 访问加速实战:8 种方案实测与配置指南
  • Python 本地 AI 问答系统搭建:环境配置与 RAG 实践

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online