Linux 线程同步与互斥——互斥锁

在学习多线程时,最开始写的程序可能都比较简单,每个线程执行自己的任务,彼此之间没有太多联系。但是,一旦多个线程开始操作同一份数据,问题就出现了。

比如一个简单的售票系统,总共有 100 张票,创建四个线程同时卖票。按理说,票应该从 100 张一直卖到 1 张,最后刚好售完。

但实际运行时,可能会出现卖出 0 号票甚至负数票的情况。这并不是打印出了问题,而是多线程访问共享数据时发生了冲突。

一、为什么多个线程操作同一个变量会出问题?

先看一个简化版的售票程序:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

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

    while (true)
    {
        if (ticket > 0)
        {
            usleep(1000);
            std::cout << name
                      << " sells ticket: "
                      << ticket << std::endl;
            ticket--;
        }
        else
        {
            break;
        }
    }

    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3, t4;

    pthread_create(&t1, nullptr, route,
                   (void*)"thread 1");
    pthread_create(&t2, nullptr, route,
                   (void*)"thread 2");
    pthread_create(&t3, nullptr, route,
                   (void*)"thread 3");
    pthread_create(&t4, nullptr, route,
                   (void*)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    return 0;
}

问题出现在这段逻辑:

cpp 复制代码
if (ticket > 0)
{
    // 处理售票业务
    ticket--;
}

单看代码似乎没有什么问题:先判断还有没有票,有票就卖出一张,然后把票数减一。

但多线程程序不能保证一个线程执行完这几行代码,另一个线程才开始执行。

假设此时 ticket == 1,线程 A 判断 ticket > 0,结果为真。就在它准备继续执行时,线程 B 获得了 CPU,也判断 ticket > 0,同样得到真。

接下来,两个线程都可能继续执行售票操作。最终,原本只有一张的票可能被重复售出,变量甚至会被减成负数。

关键不是 if 写错了,而是判断有没有票、执行售票业务、修改票数,这几个操作必须作为一个整体受到保护。

二、临界资源和临界区

理解互斥锁之前,需要先把几个概念区分开。

共享资源:多个线程都能够访问的资源,例如全局变量、堆上的共享对象。

临界资源:多个执行流共享,并且需要保护其访问过程的资源。

临界区:程序中访问临界资源的那段代码。

在售票程序中,ticket 是共享变量,对它进行检查和修改的代码就是需要重点保护的临界区。

如果多个线程同时进入临界区,就可能产生数据竞争。为了让这些操作安全进行,需要让线程按照一定规则访问临界资源。

这就是互斥的作用。

三、互斥到底是什么意思?

互斥要求:在同一时刻,最多只允许一个执行流进入受到保护的临界区。

当一个线程进入临界区后,其他线程如果也想进入,就需要等待。等当前线程离开临界区并释放锁,其他线程才有机会继续执行。

Linux 的 Pthreads 提供了互斥量,也就是 mutex,来完成这项工作。

常用接口如下:

cpp 复制代码
#include <pthread.h>

// 静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 动态初始化
pthread_mutex_t mutex2;
pthread_mutex_init(&mutex2, nullptr);

// 加锁
pthread_mutex_lock(&mutex);

// 解锁
pthread_mutex_unlock(&mutex);

// 销毁动态初始化的互斥量
pthread_mutex_destroy(&mutex2);

如果一个线程调用 pthread_mutex_lock() 时,互斥量还没有被锁定,那么它可以获得锁;如果锁已经被其他线程持有,当前线程通常会阻塞等待。

这样一来,多个线程就不能同时进入由同一把锁保护的临界区。

需要注意,动态初始化的互斥量应在不再使用、并且没有线程持有或等待该锁时销毁。已经加锁的互斥量不能随意销毁。

四、给售票程序加锁

在检查票数之前加锁,在售票逻辑执行完后解锁:

cpp 复制代码
void* route(void* arg)
{
    const char* name = static_cast<const char*>(arg);

    while (true)
    {
        pthread_mutex_lock(&mutex);

        if (ticket > 0)
        {
            std::cout << name
                      << " sells ticket: "
                      << ticket << std::endl;

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

    return nullptr;
}

这里的锁保护范围很重要。

不能只给 ticket-- 加锁,却把 if (ticket > 0) 放在锁外面。因为判断和扣减之间仍然可能被其他线程打断,多个线程仍可能同时认定还有票。

正确做法是把相关操作一起放进临界区:

  1. 获取互斥锁。
  2. 判断票数是否大于零。
  3. 有票就完成售票及票数更新。
  4. 释放互斥锁。

即使没有票,也需要先释放锁再退出循环,否则后续的线程可能一直等不到锁。

五、为什么 ticket-- 也不是原子操作?

有时候会觉得,ticket-- 明明只是一条 C++ 语句,怎么还会出问题?

因为一条 C++ 语句并不一定对应一条机器指令。

从执行过程来看,修改变量通常涉及读取原值、计算新值,再将结果写回内存。多个线程如果交错执行这些步骤,就可能造成更新丢失。

这也引出了原子操作的概念:对于一个满足原子性要求的操作,从其他执行流观察时,不会出现只完成了一部分的中间状态。

互斥锁的意义就在于保护一段临界区,使其他线程不能同时进入这段受保护的代码。

需要特别注意,给普通变量加互斥锁后,只有遵守同一套加锁规则的访问才会受到这把锁的保护。其他线程如果绕过锁直接修改共享变量,问题依然存在。

六、用 RAII 管理锁

上面的代码还有一个容易出错的地方:每条执行路径都要记得解锁。

如果临界区里面有很多条件判断,代码稍微复杂一点,就可能在某个分支漏掉 pthread_mutex_unlock()。一旦发生这种情况,其他线程就可能一直阻塞。

在 C++ 中,可以利用 RAII 把加锁和解锁交给对象的生命周期管理。

基本思路是:对象构造时加锁,对象析构时解锁。

cpp 复制代码
#pragma once
#include <iostream>
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }
        pthread_mutex_t *Get()
        {
            return &_mutex;
        }

    private:
        pthread_mutex_t _mutex;
    };

    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }
        ~LockGuard()
        {
            _mutex.Unlock();
        }

    private:
        Mutex &_mutex;
    };
}

使用时只需要创建一个局部对象:

cpp 复制代码
void sell()
{
    LockGuard guard(mutex);

    if (ticket > 0)
    {
        ticket--;
    }
}

当 sell() 执行结束、离开作用域时,guard 会自动析构并释放锁。即使中途执行 return 或 break 离开相应作用域,也可以利用析构函数释放锁。

这里的 mutex 应当是一个长期有效的 Mutex 对象。使用这类封装时,还需要保证它不会在线程仍然使用锁时提前析构。

C++ 标准库已经提供了 std::mutex 和 std::lock_guard,实际写现代 C++ 程序时也经常使用它们。

七、锁是不是加得越多越安全?

不是。

锁的目的是保护共享资源,而不是把整个程序都锁起来。

临界区太大,其他线程就要等待更长时间,并发能力会下降;临界区太小,又可能没有把所有相关操作保护完整。

另外,持有一把锁时还要谨慎地获取其他锁。如果多个线程以不同顺序申请多把锁,就可能发生死锁。

所以,使用互斥锁时,应该先明确哪些数据需要保护,再划定最小但完整的临界区,并保证每条执行路径都能正确释放锁。

八、总结

多线程共享数据时,真正危险的往往不是某一行代码,而是多个操作之间的执行顺序。

售票问题说明,检查票数和扣减票数不能被拆开保护。互斥锁负责保证同一时刻只有一个线程进入受保护的临界区,而 RAII 则能让锁的释放更可靠。

相关推荐
a5986521 小时前
ARM服务器部署Docker云手机,多开实例性能实测
服务器·arm开发·docker·云手机
撩得Android一次心动1 小时前
Jetpack Compose 知识点整理3【个人用】
android·笔记·学习·kotlin·android compose
TDTC_XQ1 小时前
Linux网络编程accept的讲解
linux·服务器·c++
Python自动化直播1 小时前
自动化笔记:素材管理、命名、轮换的几条土办法
linux·运维·数据库
威嵌神州2 小时前
国产化加固计算机项目选型笔记|板卡、整机、平板与定制模块评估要点
笔记·fpga开发·电脑
阿梦Anmory2 小时前
规划领域定义语言(PDDL)学习笔记
笔记·学习
开开心心就好2 小时前
白天晚上自动调蓝光,护眼工具亮度可调
服务器·开发语言·算法·pdf·ocr·intellij-idea·idea
西安同步电子2 小时前
卫星信号丢失怎么办?NTP 网络校时服务器的守时能力
运维·服务器·网络
绛橘色的日落(。・∀・)ノ2 小时前
C#学习笔记
笔记·学习·c#