上篇热文:Linux线程:核心机制与优雅的 C++ 封装实践|附源码

目录

1. 多线程并发的安全隐患

1.1 抢票 Bug 现场重现

2. 刨根问底:对 tickets-- 的汇编级解剖

2.1 寄存器(物理独占)与 内存(共享资源)的冲突本质

2.2 核心背景概念厘清

3. 互斥锁(Mutex)

3.1 互斥锁的核心接口

3.2 锁的本质是什么?

3.3 改进后的抢票程序

4. 锁的实现原理:硬件方案 vs 软件方案

4.1 方案一:通过硬件实现锁(只在内核中实现)

4.2 方案二:通过软件(系统库)结合硬件原子指令实现锁

5. 互斥锁的底层实现原理

申请锁(lock)的汇编伪代码:

5.1 Swap/Exchange 的核心物理本质:共享变私有

释放锁(unlock)的汇编伪代码:

6. 优雅设计:RAII 风格的互斥锁硬核封装

6.1 封装核心代码 Mutex.hpp

6.2 使用 RAII 锁守卫优雅抢票 main.cc

7. 总结


1. 多线程并发的安全隐患

在多线程编程中,多线程能够共享进程的大部分资源,这为线程间交互提供了极大的便利。然而,当多个执行流在没有保护的情况下并发访问同一个共享变量时,灾难也就随之降临了。

为了直观地感受多线程并发带来的数据安全问题,我们先来看一个经典的多线程抢票 Demo

1.1 抢票 Bug 现场重现

我们通过一个全局变量 tickets 模拟 1000 张待售的门票,创建 4 个线程并发执行抢票逻辑。

Thread.hpp:代码见上篇热文

main.cc:

#include <iostream>
#include <vector>
#include "Thread.hpp"

int tickets = 1000; // 共享资源

void route()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));

    while (1)
    {
        // 临界区
        if (tickets > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", name, tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    std::vector<Thread> threads;
    for (int i = 0; i < 4; i++)
    {
        threads.emplace_back(route);
    }

    for (auto &thread : threads)
    {
        thread.start();
    }

    for (auto &thread : threads)
    {
        thread.join();
    }

    return 0;
}

最终问题是,出现了负数:

thread-2 sells ticket:-1
thread-1 sells ticket:-2
lwp: 553650, name: thread-1, join success
lwp: 553651, name: thread-2, join success
lwp: 553652, name: thread-3, join success
lwp: 553653, name: thread-4, join success

为什么票数会出现 0-1-2 这样的负数?

  1. if 条件判断的并发切换:tickets 的值为 1 时,四个线程都可能刚好通过了 tickets > 0 的判断。

  2. usleep 模拟业务耗时:usleep 的这 1 毫秒内,多个线程都在此等待。当它们被唤醒并继续执行时,都会傻乎乎地去执行 tickets--,从而将票数扣成了负数。

  3. tickets-- 操作本身并不是原子的!

2. 刨根问底:对 tickets-- 的汇编级解剖

很多初学者认为 tickets-- 只有一行代码,因而是“一步到位”的安全操作。其实不然!我们通过 objdump -d a.out 反汇编抢票的可执行程序,提取出 tickets-- 对应的汇编指令:

# 将共享变量 ticket 从内存加载到寄存器 eax 中 (Load)
mov 0x2004e3(%rip), %eax      # eax = tickets

# 更新寄存器里面的值,执行 -1 操作 (Update)
sub $0x1, %eax                # eax = eax - 1

# 将新值从寄存器写回到共享变量 ticket 的内存地址中 (Store)
mov %eax, 0x2004da(%rip)      # tickets = eax

从汇编层面看,一行简单的 tickets-- 实际上被拆分成了 三步操作

  1. Load:把内存中的数据读入 CPU 寄存器。

  2. Update:在 CPU 内部完成减一运算。

  3. Store:将计算结果写回内存。

2.1 寄存器(物理独占)与 内存(共享资源)的冲突本质

要理解多线程冲突,首先必须搞清楚 CPU 寄存器和物理内存的关系:

  • CPU 内的寄存器只有一套。寄存器内存储的内容,本质上是当前正在运行线程的硬件上下文(Thread Hardware Context)。这个上下文是线程独占的。当线程被切走时,它会带走并保存自己的这套上下文数据。

  • 物理内存中的数据则是多线程共享的。比如全局变量、静态变量等统统存放在物理内存中。

多线程冲突演练:

  1. 线程 A 将 tickets = 100 加载到自己的寄存器 eax 中,准备执行 -1 运算。

  2. 此时时钟中断触发,线程 A 被剥夺 CPU。线程 A 带着自己的硬件上下文(eax = 100)被挂起。

  3. 线程 B 登场,疯狂抢票,一路将内存中的 tickets100 扣减到了 10,并成功写回物理内存。

  4. 线程 A 重新获得 CPU 调度,恢复上下文:它把尘封的 eax = 100 重新读回 CPU,继续执行 sub 得到 99,然后一纸诉状将 99 写回内存。

  5. 灾难发生:线程 B 辛辛苦苦抢掉的 90 张票凭空消失了!内存中的 tickets 重新变回了 99

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

2.2 核心背景概念厘清

为了彻底解决这类问题,我们必须理解以下四个操作系统级的核心概念:

  • 共享资源:多个执行流共同访问的资源(如全局变量 tickets)。

  • 临界资源:任何时候只允许一个执行流访问的资源。

  • 临界区:每个线程内部,直接访问临界资源的代码段(如 route 函数中对 tickets 进行判断和扣减的代码)。

  • 原子性:一项操作要么已经做完,要么完全没做,中间状态不会被任何调度机制打断。只有一条汇编指令的操作才具备天然的原子性。

  • 互斥(Mutual Exclusion):在任何时刻,保证有且只有一个执行流进入临界区访问临界资源,通常用于对临界资源起保护作用。

3. 互斥锁(Mutex)

解决并发安全问题的核心思路就是变并行、并发为串行。 Linux 系统提供了一把金钥匙——互斥量(mutex,俗称互斥锁)

3.1 互斥锁的核心接口

  1. 静态分配初始化

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    
  2. 动态分配初始化

    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
    
    参数:
        mutex:要初始化的互斥量
        attr: NULL
  3. 销毁互斥锁(注意:静态初始化的锁不需要销毁,且不能销毁一个处于加锁状态的锁,已经销毁的互斥量,要确保后面不会有线程再尝试加锁):

    int pthread_mutex_destroy(pthread_mutex_t *mutex);
    
  4. 加锁与解锁

    int pthread_mutex_lock(pthread_mutex_t *mutex);   // 申请锁,不成功则阻塞挂起
    int pthread_mutex_unlock(pthread_mutex_t *mutex); // 释放锁,唤醒等待队列中的线程
    

3.2 锁的本质是什么?

  1. 锁本身也是共享资源:如果锁本身不是共享的,线程怎么去共同竞争它呢?既然锁也是共享资源,那么申请锁的过程,必须是原子的

  2. 申请锁的本质,是申请“执行许可”:锁就像是临界区的绿色通行证。只有抢到这张通行证的线程,才被允许执行临界区代码;没有抢到的,就必须在安全区外面排队挂起。

  3. 申请锁必须一视同仁绝对不可以让线程一部分加锁申请,另一部分不申请直接硬闯临界区! 如果一个线程在不申请锁的情况下直接去访问临界资源,那这就是在亲手写 Bug,锁的保护屏障将瞬间形同虚设。

3.3 改进后的抢票程序

我们在临界区的前后分别加入 pthread_mutex_lockpthread_mutex_unlock

#include <iostream>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"

int tickets = 1000; // 共享资源
pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER; // 静态初始化全局锁

void route()
{
    char name[64];
    pthread_getname_np(pthread_self(), name, sizeof(name));

    while (true)
    {
        // 1. 申请锁(加锁必须在 if 判断之前!)
        pthread_mutex_lock(&glock); 
        
        if (tickets > 0)
        {
            usleep(1000); // 即使在这里被切走,其他线程也无法获取锁进入临界区
            printf("%s sells ticket: %d\n", name, tickets);
            tickets--;
            
            // 2. 抢票成功,释放锁
            pthread_mutex_unlock(&glock);
        }
        else
        {
            // 3. 没票了,在退出循环前务必释放锁!否则会导致死锁!
            pthread_mutex_unlock(&glock);
            break;
        }
    }
}

int main()
{
    std::vector<Thread> threads;
    for (int i = 0; i < 4; i++)
    {
        threads.emplace_back(route);
    }

    for (auto &thread : threads)
    {
        thread.start();
    }

    for (auto &thread : threads)
    {
        thread.join();
    }

    return 0;
}

思考:加锁会带来什么开销?该如何优化?加锁会把并发执行退化位串行执行,导致程序执行效率大幅降低。因此,我们在设计多线程代码时,必须要保证临界区范围缩到最小!只保护那些访问临界资源的核心代码。

4. 锁的实现原理:硬件方案 vs 软件方案

在计算机发展史上,要实现“原子性”和“互斥锁”,通常有两种截然不同的路线:一种是在内核实现的纯硬件方案,另一种是软硬结合的互斥量方案。

4.1 方案一:通过硬件实现锁(只在内核中实现)

在早期的单 CPU 操作系统或在操作系统内核的某些极限制场景下,硬件实现锁的逻辑非常简单粗暴:

核心思想:让线程在执行关键代码时不做任何切换

  • 物理手段关闭 CPU 的时钟中断(Disable Interrupts)。

  • 执行流程:当线程准备进入临界区时,直接发出硬件指令关闭时钟中断。由于时钟中断被屏蔽,CPU 无法再通过调度机制切换到其他线程。该线程在临界区内跑完关键代码后,再重新打开时钟中断(Enable Interrupts)。

  • 结果:在这段“无中断”的黄金时间内,线程的执行绝对不会受到任何外界打扰,天然拥有了无可匹敌的原子性

  • 劣势:这种方法只适用于单核 CPU 的内核态,不能暴露给用户态,否则用户线程一旦恶意关中断不开启,整个系统将陷入死机。

4.2 方案二:通过软件(系统库)结合硬件原子指令实现锁

为了给用户态多线程提供安全的互斥保护,现代计算机体系结构在芯片中设计了专用的原子交换指令(如 swapexchange)。基于这些指令,操作系统和 pthread 库在应用层实现了我们常用的 互斥量 (Mutex)

5. 互斥锁的底层实现原理

为什么互斥锁的申请是绝对安全的?让我们来看看 pthread_mutex_lockpthread_mutex_unlock 对应到 CPU 内部的底层伪代码

申请锁(lock)的汇编伪代码:

lock:
    movb $0, %al        # 1. 将线程独占的寄存器 al 内部置为 0 (al = 0)
    xchgb %al, mutex    # 2. 【核心!】原子交换指令,将寄存器 al 的值与物理内存中的 mutex 变量进行交换
    if(al 寄存器的内容 > 0){
        return 0;       # 3. 成功申请到锁,获得执行许可,进入临界区
    } else {
        挂起等待;        # 4. 没抢到通行证,挂起等待被唤醒
        goto lock;      # 5. 被唤醒后重新返回起点竞争
    }

5.1 Swap/Exchange 的核心物理本质:共享变私有

  • 物理内存中的 mutex 在初始化时被置为 1。这个 1,就是全系统唯一的“通行证”。

  • 当线程 A 执行 xchgb %al, mutex 时,只用了一条汇编指令,就把寄存器 al0 和内存中 mutex1 进行了原子交换

  • 本质:它把一个原本存放在公共内存、被大家共享的数据内容(1),瞬间变成了一个线程私有的寄存器内容(1)

  • 由于寄存器内容由当前线程独占,这就意味着,一旦线程 A 把这个 1 换到了自己的寄存器里,其他线程不管怎么执行交换,交换得到的永远只能是 0!除非线程 A 主动归还(即释放锁),否则谁也别想得到这个 1

  • 这个私有化后的“1”就是锁!这种设计妙就妙在:不需要多次判断,用一条原子交换指令,就完美完成了“所有权”的转移。

释放锁(unlock)的汇编伪代码:

unlock:
    movb $1, mutex      # 1. 把私有的 "1" 重新放回共享内存,归还通行证
    唤醒等待 Mutex 的线程; # 2. 唤醒在等待队列中阻塞挂起的线程
    return 0;

6. 优雅设计:RAII 风格的互斥锁硬核封装

在实际的项目开发中,直接调用 lockunlock 非常容易因为分支遗漏(如 breakreturn)或程序异常而漏掉解锁,导致系统瞬间陷入死锁

为此,我们可以利用 C++ 的 RAII(Resource Acquisition Is Initialization) 机制,在对象的生命周期构造函数中加锁,析构函数中自动解锁,打造一个永不漏锁的安全守卫。

6.1 封装核心代码 Mutex.hpp

#ifndef __MUTEX_HPP
#define __MUTEX_HPP

#include <iostream>
#include <pthread.h>

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 *lockp): _lockp(lockp)
    {
        _lockp->Lock();
    }
    ~LockGuard()
    {
        _lockp->Unlock();
    }
private:
    Mutex *_lockp;
};

#endif

6.2 使用 RAII 锁守卫优雅抢票 main.cc

有了 LockGuard,即使我们在 if-else 分支中直接 break 退出循环,在退出作用域的一瞬间,局部变量 lockguard 的析构函数就会雷打不动地执行解锁,彻底规避了死锁隐患:

#include <iostream>
#include <thread>
#include <mutex>
#include <unistd.h>
#include "Mutex.hpp"

int tickets = 1000;
Mutex lock;
// std::mutex lock;

void buyTicket(int id)
{
    while (true)
    {
        // lock.lock();
        // lock.Lock();
        // 临界区
        LockGuard lockguard(&lock); // RAII风格的加锁逻辑
        if (tickets > 0)
        {
            usleep(1000);
            std::cout << "Thread " << id << " bought ticket, remaining: " << --tickets << std::endl;
            // lock.Unlock();
            // lock.unlock();
        }
        else
        {
            // lock.unlock();
            // lock.Unlock();
            break;
        }
    }
}

int main()
{
    std::thread threads[4];
    for (int i = 0; i < 4; ++i)
    {
        threads[i] = std::thread(buyTicket, i + 1);
    }

    for (auto& t : threads)
    {
        t.join();
    }

    std::cout << " " << std::endl;
    return 0;
}

7. 总结

本文从物理硬件和底层汇编的维度,深度剖析了线程互斥的完整面貌:

  1. 冲突的根源:CPU 物理寄存器只有一套(作为线程独占的硬件上下文),而内存中的全局/静态变量是多线程共享的。并发切换时,上下文的覆盖机制导致了多线程冲突。

  2. 锁的本质:锁本身是共享资源,申请锁即是申请“执行许可”。所有线程必须统一守规矩申请锁,否则就是在写 Bug。

  3. 硬件关中断方案:通过物理“屏蔽时钟中断”让进程不发生任何切换,从而在多核之前或内核级实现了硬原子性。

  4. 软件 Swap 指令方案:利用芯片的 swap/xchgb 指令,通过一步原子的交换操作,将原本物理共享的“1”(内存值)转化为了线程独占的“1”(寄存器值),完成了天衣无缝的所有权转移。

  5. 现代 C++ 最佳实践:采用 RAII 设计模式对锁进行 LockGuard 封装,依靠其声明周期作用域,让系统自动在出临界区时解锁,优雅消除了死锁可能。

下一篇硬核预告: 有了互斥锁,多线程虽然安全了,但常常会带来“饥饿问题”(某个线程抢锁能力极强,导致其他线程干等挂起)。

在下一篇文章中,我们将引入 条件变量线程同步,并在此基础上,亲手带大家手撕基于固定大小环形队列的 生产者-消费者模型(Producer-Consumer Model)

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐