Linux 多线程——线程互斥:从抢票问题到 Mutex 底层原理

一、线程为什么需要互斥

同一进程中的多个线程共享进程的地址空间,因此多个线程可能同时访问同一份数据。

例如:

复制代码
int ticket = 100;

如果创建多个线程共同执行售票逻辑:

复制代码
if (ticket > 0)
{
    printf("sell ticket:%d\n", ticket);
    ticket--;
}

多个线程都会访问和修改同一个 ticket

如果这些线程的执行过程发生交叉,就可能造成数据不一致。

因此,在学习互斥锁之前,需要先理解几个基本概念。讲义首先给出了共享资源、临界资源、临界区、互斥和原子性的概念。

1. 共享资源

可以被多个执行流共同访问的资源,就是共享资源。

例如:

复制代码
int ticket = 100;

多个线程都可以访问 ticket,所以 ticket 是共享资源。

2. 临界资源

需要受到保护、不能被多个线程随意并发访问的共享资源,称为临界资源。

例如售票程序中的:

复制代码
ticket

就是临界资源。

3. 临界区

线程中访问临界资源的那部分代码,称为临界区。

例如:

复制代码
if (ticket > 0)
{
    printf("ticket = %d\n", ticket);
    ticket--;
}

这段代码访问和修改了 ticket,所以它属于临界区。

简单记:

复制代码
ticket
  ↓
临界资源

访问 ticket 的代码
  ↓
临界区

4. 互斥

互斥指的是:

同一时刻只允许一个执行流进入临界区访问临界资源。

例如:

复制代码
线程1 ──→ 进入临界区

线程2 ──→ 等待
线程3 ──→ 等待
线程4 ──→ 等待

需要特别注意:

互斥并不是让整个程序变成单线程,而只是保证临界区同一时刻只能由一个线程执行。

5. 原子性

原子操作指的是一个操作在执行过程中不能被其他执行流看到"执行一半"的状态。

简单理解:

复制代码
要么完全没执行
要么已经全部执行完成

不存在中间状态。


二、为什么抢票程序会出问题

先看没有加锁的版本:

复制代码
int ticket = 100;

void* route(void* arg)
{
    char* id = (char*)arg;

    while (true)
    {
        if (ticket > 0)
        {
            usleep(1000);

            printf("%s sells ticket:%d\n",
                   id, ticket);

            ticket--;
        }
        else
        {
            break;
        }
    }

    return nullptr;
}

创建多个线程同时运行后,可能出现:

复制代码
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

讲义中的抢票实验就出现了票数变成 0、-1、-2 的情况。

问题的关键就在:

复制代码
ticket--;

看起来只有一行,但它并不是原子操作。


三、ticket-- 为什么不是原子的

从底层汇编角度看,ticket-- 大致可以分成三步:

复制代码
mov ticket, %eax
sub $1, %eax
mov %eax, ticket

分别对应:

复制代码
1. load
   从内存读取 ticket 到寄存器

2. update
   寄存器中的值减 1

3. store
   把结果重新写回内存

讲义也正是通过 load → update → store 三个步骤说明 --ticket 不是原子操作。

所以 CPU 完全可能在这三步之间发生线程切换。

假设:

复制代码
ticket = 1

线程 A:

复制代码
读取 ticket = 1

此时 CPU 把 A 切走。

线程 B 开始运行,也读取:

复制代码
ticket = 1

于是:

复制代码
线程A认为还有1张票
线程B也认为还有1张票

最终两个线程都可能执行售票。

问题的本质就是:

多个线程对共享数据的操作发生了交叉。


四、为什么 if(ticket > 0) 也没用

有人可能会觉得:

复制代码
if (ticket > 0)
{
    ticket--;
}

不是已经检查过了吗?

但是:

复制代码
if (ticket > 0)

和:

复制代码
ticket--;

并不是一个不可分割的整体。

例如:

复制代码
线程A:
判断 ticket > 0
       ↓
    成立
       ↓
CPU切换

线程B:
判断 ticket > 0
       ↓
    成立
       ↓
卖票并 ticket--

CPU重新切回线程A

线程A继续卖票

因此:

判断时条件成立,不代表真正执行修改时条件仍然成立。

所以售票中的:

复制代码
判断
+
输出
+
ticket--

必须作为一个整体进行保护。


五、使用 mutex 实现线程互斥

Linux pthread 库提供了互斥量:

复制代码
pthread_mutex_t

基本使用结构就是:

复制代码
pthread_mutex_lock(&mutex);

// 临界区

pthread_mutex_unlock(&mutex);

多个线程竞争同一把锁时:

复制代码
                 mutex
                   │
          ┌────────┼────────┐
          ↓        ↓        ↓
       thread1  thread2  thread3
          │
       抢锁成功
          │
          ↓
        临界区

thread2、thread3等待

当一个线程已经进入临界区后,其他线程就不能再进入。

讲义总结互斥要求时指出:临界区执行过程中不能让其他线程进入;多个线程同时申请进入临界区时,只能有一个线程成功。Linux 中通过互斥量完成这种保护。


六、pthread mutex基本接口

1. 定义互斥量

复制代码
pthread_mutex_t mutex;

2. 初始化

动态初始化:

复制代码
pthread_mutex_init(&mutex, nullptr);

完整接口:

复制代码
int pthread_mutex_init(
    pthread_mutex_t* mutex,
    const pthread_mutexattr_t* attr
);

学习阶段第二个参数一般使用:

复制代码
nullptr

也可以静态初始化:

复制代码
pthread_mutex_t mutex =
    PTHREAD_MUTEX_INITIALIZER;

这两种方式都是讲义介绍的标准初始化方法。


3. 加锁

复制代码
pthread_mutex_lock(&mutex);

如果锁当前没有被其他线程持有:

复制代码
锁空闲
 ↓
当前线程抢锁成功
 ↓
进入临界区

如果锁已经被其他线程持有:

复制代码
锁被占用
 ↓
当前线程抢锁失败
 ↓
阻塞等待

讲义指出,如果 mutex 已经被其他线程锁定,pthread_mutex_lock 可能使当前线程进入阻塞状态,等待锁被释放。


4. 解锁

复制代码
pthread_mutex_unlock(&mutex);

表示当前线程已经完成临界区操作,可以让其他线程继续竞争这把锁。


5. 销毁

复制代码
pthread_mutex_destroy(&mutex);

一般在线程全部结束,并且确定后面不会继续使用该 mutex 时销毁。


七、使用mutex改造抢票程序

核心代码如下:

复制代码
int ticket = 100;
pthread_mutex_t mutex;

void* route(void* arg)
{
    const char* id =
        static_cast<const char*>(arg);

    while (true)
    {
        pthread_mutex_lock(&mutex);

        if (ticket > 0)
        {
            usleep(1000);

            printf("%s sells ticket:%d\n",
                   id, ticket);

            ticket--;

            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }

    return nullptr;
}

整个过程:

复制代码
lock
 ↓
判断 ticket
 ↓
售票
 ↓
ticket--
 ↓
unlock

讲义中的改进版本也是通过这种方式保护整个售票临界区。

这里有一个很重要的细节:

复制代码
else
{
    pthread_mutex_unlock(&mutex);
    break;
}

不能写成:

复制代码
else
{
    break;
}

因为在执行 break 之前,当前线程仍然持有 mutex。

如果直接退出:

复制代码
线程A获得锁
 ↓
发现没票
 ↓
直接break
 ↓
线程退出
 ↓
锁没有释放
 ↓
其他线程一直抢不到锁

所以要记住:

成功 lock 以后,最终一定要有对应的 unlock。


八、加锁以后线程还能被CPU切走吗

当然可以。

例如:

复制代码
pthread_mutex_lock(&mutex);

ticket--;

pthread_mutex_unlock(&mutex);

假设线程 A 获得 mutex:

复制代码
线程A获得锁
 ↓
开始执行临界区
 ↓
CPU把A切走

这时候线程 B 开始运行,并调用:

复制代码
pthread_mutex_lock(&mutex);

但是 mutex 仍然属于 A,因此:

复制代码
线程B抢锁失败
 ↓
等待

等 A 再次被调度:

复制代码
线程A继续运行
 ↓
完成临界区
 ↓
unlock

B 才有机会继续竞争锁。

因此:

mutex 不是禁止线程调度。

mutex 真正保证的是:

即使持锁线程被 CPU 切换出去,其他线程也不能进入同一个临界区。


九、为什么锁本身必须依赖原子操作

假设我们自己设计一把锁:

复制代码
mutex = 1:锁空闲
mutex = 0:锁被占用

然后:

复制代码
if (mutex == 1)
{
    mutex = 0;
}

看起来似乎可以抢锁。

实际上还是不安全。

例如:

复制代码
mutex = 1

线程A                  线程B
  │
发现mutex==1
                       发现mutex==1
  │
mutex=0
                       mutex=0

两个线程最终都认为:

复制代码
我获得锁了

问题在于:

复制代码
检查 mutex

和:

复制代码
修改 mutex

仍然是两个独立操作。

所以锁最重要的一步是:

"检查锁状态"和"修改锁状态"必须一次性完成。

这就需要 CPU 提供原子指令。


十、exchange / xchg如何实现互斥

讲义介绍了利用 swap/exchange 原子交换指令实现 mutex 的基本思想。

假设:

复制代码
mutex = 1:锁空闲
mutex = 0:锁被占用

简化后的加锁伪代码:

复制代码
lock:
    movb $0, %al
    xchgb %al, mutex

    if (%al > 0)
        return 0;

    挂起等待;
    goto lock;

首先:

复制代码
movb $0, %al

表示:

复制代码
AL = 0

然后:

复制代码
xchgb %al, mutex

原子交换:

复制代码
AL
和
mutex

第一个线程抢锁

原来:

复制代码
AL = 0
mutex = 1

交换后:

复制代码
AL = 1
mutex = 0

AL = 1 说明 mutex 原来处于空闲状态,因此:

复制代码
当前线程抢锁成功

同时:

复制代码
mutex = 0

表示锁已经被占用。


第二个线程抢锁

第二个线程同样先执行:

复制代码
AL = 0

但是现在:

复制代码
mutex = 0

交换:

复制代码
AL = 0
mutex = 0

于是:

复制代码
AL == 0

说明这把锁之前就已经被其他线程持有。

所以:

复制代码
抢锁失败
 ↓
等待

多个线程竞争时:

复制代码
               mutex = 1
                   │
              原子 xchg
                   │
        ┌──────────┼──────────┐
        ↓          ↓          ↓
     thread1    thread2    thread3
        │          │          │
      成功        失败        失败
        │
    mutex = 0
        │
        ↓
      临界区

所以互斥锁底层最核心的一点就是:

使用原子操作一次性完成"检查锁状态 + 修改锁状态"。

这样无论多少线程同时竞争,也只有一个线程能够成功。


十一、为什么unlock可以直接释放锁

释放锁时不需要再次竞争。

因为执行 unlock 的线程本身已经持有这把锁。

所以只需要把:

复制代码
mutex = 0

恢复成:

复制代码
mutex = 1

例如:

复制代码
movb $1, mutex

过程:

复制代码
线程持有mutex
 ↓
完成临界区
 ↓
mutex = 1
 ↓
锁重新空闲
 ↓
唤醒等待线程

需要注意:

被唤醒的线程并不是直接获得锁。

它只是重新进入:

复制代码
lock
 ↓
xchg
 ↓
重新竞争mutex

因此:

复制代码
lock

负责竞争锁;

复制代码
unlock

负责释放锁。

另外,同一个临界资源如果在多个地方被访问,都应该遵守同一套加锁规则,否则仍然可能发生数据竞争。


十二、使用RAII管理互斥锁

使用 pthread 时,我们经常手动写:

复制代码
pthread_mutex_lock(&mutex);

// 临界区

pthread_mutex_unlock(&mutex);

最大的风险就是:

忘记 unlock。

例如:

复制代码
pthread_mutex_lock(&mutex);

if (error)
{
    return nullptr;   // 忘记解锁
}

pthread_mutex_unlock(&mutex);

因此可以把 mutex 封装起来。

讲义中首先封装了 Mutex 类,再通过 LockGuard 实现 RAII 风格的锁管理。

例如:

复制代码
class LockGuard
{
public:
    LockGuard(Mutex& mutex)
        : _mutex(mutex)
    {
        _mutex.Lock();
    }

    ~LockGuard()
    {
        _mutex.Unlock();
    }

private:
    Mutex& _mutex;
};

使用:

复制代码
LockGuard guard(mutex);

创建对象时:

复制代码
构造函数
 ↓
Lock()

对象离开作用域时:

复制代码
析构函数
 ↓
Unlock()

这就是 RAII:

利用对象生命周期自动管理资源。

这样即使:

复制代码
return;
break;

提前离开作用域,guard 的析构函数仍然会自动调用,从而释放 mutex。


十三、C++11中的标准互斥锁

C++11 已经提供了:

复制代码
#include <mutex>

std::mutex mutex;

以及:

复制代码
std::lock_guard<std::mutex>

例如:

复制代码
std::mutex mutex;

void func()
{
    std::lock_guard<std::mutex> guard(mutex);

    // 临界区
}

执行过程:

复制代码
创建 guard
 ↓
自动 lock
 ↓
执行临界区
 ↓
离开作用域
 ↓
guard 析构
 ↓
自动 unlock

互斥题分析

第一题:if中应该判断是否有锁,即lock=false

第二题: 左边swap只有一条语句,交换具有原子性,右边有3条语句实现,每一条语句在实现时都有可能被其他线程切换走,不具备原子性,所以不能

相关推荐
玖石书34 分钟前
Ubuntu 更换 USTC 镜像源完整指南(AMD64 / AARCH64)
linux·运维·ubuntu
zbyyd37 分钟前
Linux 进程间通信(IPC):信号、消息队列、共享内存与信号量详解
linux·运维·c语言
Titan20241 小时前
HTTPS基础知识梳理
服务器·网络·c++·网络协议·学习·http·https
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】SSE协议
c++·学习·面试·大模型·sdk
郝学胜_神的一滴1 小时前
C++11 工程级应用 06:自己造一个类似Python的Range迭代器
c++·后端
一只QAQ1 小时前
c++项目
java·c++·算法
闲云野鹤在人间2 小时前
KVM虚拟化实战|CentOS‑Stream8 两种安装方式+模板制作+基础使用
linux·运维·服务器·centos
小张同学a.2 小时前
Docker 容器实战 2—— docker 仓库
linux·运维·服务器·docker·容器
Chester_19992 小时前
CSP202206C.角色授权
开发语言·数据结构·c++·蓝桥杯