【Linux线程同步】从数据竞争到 mutex、条件变量与生产者消费者(上篇)

🔥 本文定位:这是 Linux 线程同步系列上篇。我们从一段"偶尔出错"的售票代码出发,逐层拆解数据竞争、临界区、互斥锁、条件变量和阻塞队列。

💡 学习目标 :不仅会调用 pthread_mutex_lock()pthread_cond_wait(),还要真正理解 为什么 ticket-- 不是原子操作、锁建立了什么顺序、条件变量为什么必须绑定谓词、为什么 wait 必须放在 while 中,以及生产者消费者如何实现解耦与背压

📌 系列导航:上篇聚焦 mutex 与 condition variable;下篇继续讨论 POSIX 信号量、环形队列、线程池、线程安全/可重入、单例与死锁。


文章目录


一、为什么共享内存会带来并发问题

1.1 先分清四个概念

  • 共享资源:多个执行流都可能访问的对象,例如全局变量、堆对象、队列、文件或设备;
  • 临界资源:需要在并发访问时受到保护的共享资源;
  • 临界区:线程中读取或修改临界资源的那段代码;
  • 互斥:同一时刻只允许一个执行流进入指定临界区。

互斥保护的是访问协议,不是给变量加上一层"任何人都碰不到"的硬件外壳。同一个地址仍位于进程共享地址空间,只是所有遵守协议的线程都必须先取得同一把锁。

1.2 "线程栈变量一定私有"也要加限定

自动变量通常位于当前线程栈中,其他线程默认没有它的名字,但只要把地址或引用传出去,其他线程仍能访问。所谓"线程私有栈"描述的是每条线程独立使用自己的栈区域,并不代表硬件禁止其他线程寻址。

cpp 复制代码
int local = 42;
pthread_create(&tid, nullptr, worker, &local); // 地址已经跨线程共享

此时必须保证:

  1. local 在线程使用期间仍然存活;
  2. 若存在并发读写,要建立同步;
  3. 创建者不能提前离开作用域或销毁宿主对象。

1.3 数据竞争不是"结果不稳定"这么简单

在 C/C++ 内存模型中,如果两个线程并发访问同一内存位置,至少一个访问是写操作,并且两者之间没有 happens-before 关系,也没有使用合适的原子操作,就形成了 data race

对普通 C/C++ 对象发生数据竞争时,程序行为是未定义的 。这意味着编译器不只可能生成"丢失更新",还可以基于"程序没有数据竞争"的前提进行优化。因此,不能仅用 sleep()、降低优化等级或"在我的机器上正常"来证明线程安全。


二、ticket-- 为什么不是原子操作

2.1 一个有数据竞争的售票模型

cpp 复制代码
int ticket = 100;

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

    while (true) {
        if (ticket > 0) {
            usleep(1000); // 扩大竞态窗口,仅用于演示
            std::printf("%s sells ticket: %d\n", name, ticket);
            --ticket;
        } else {
            break;
        }
    }
    return nullptr;
}

多个线程可能同时观察到 ticket > 0,随后分别打印并写回。出现 0、负数或重复票号只是表象,根因是对 ticket 的并发访问没有同步。

2.2 一条 C++ 语句不等于一条不可分割的硬件操作

概念上,ticket-- 可以拆成三步:

text 复制代码
load   : 从内存读取 ticket 到寄存器
update : 寄存器中的值减 1
store  : 把结果写回 ticket

假设初值为 10:

text 复制代码
线程 A load 10        线程 B load 10
线程 A update 9       线程 B update 9
线程 A store 9        线程 B store 9

逻辑上执行了两次减一,内存里却只从 10 变成 9,这就是典型的 lost update

注意:这张交错图用于帮助理解风险,并不是 C++ 标准对数据竞争程序行为的保证。发生数据竞争后,语言层面已经进入未定义行为。

2.3 "原子变量"能否替代锁

如果只需要无条件计数,可以考虑原子类型:

cpp 复制代码
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);

但售票逻辑包含"检查大于 0、取得票号、递减、打印/交付"的复合不变量。单独把 ticket 改成 atomic<int> 并不会自动让整段业务成为一个事务。你需要 CAS 循环,或者直接用 mutex 把复合临界区保护起来。


三、mutex:把临界区变成互斥区

3.1 Pthreads mutex 基本接口

静态初始化:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

动态初始化与销毁:

cpp 复制代码
pthread_mutex_t mutex;

int rc = pthread_mutex_init(&mutex, nullptr);
// 使用 mutex
rc = pthread_mutex_destroy(&mutex);

加锁与解锁:

cpp 复制代码
int rc = pthread_mutex_lock(&mutex);
// critical section
rc = pthread_mutex_unlock(&mutex);

Pthreads 函数通常直接返回错误号,而不是通过 errno 报错:

cpp 复制代码
int rc = pthread_mutex_lock(&mutex);
if (rc != 0) {
    std::fprintf(stderr, "pthread_mutex_lock: %s\n", std::strerror(rc));
}

这里讨论的是默认普通 mutex。若显式使用 robust mutex,pthread_mutex_lock() 返回 EOWNERDEAD 时调用线程实际上已经取得锁,但受保护状态被标记为不一致,需要按 robust mutex 协议修复并调用 pthread_mutex_consistent()

3.2 临界区的正确边界

修正售票代码:

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

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

    while (true) {
        pthread_mutex_lock(&mutex);

        if (ticket <= 0) {
            pthread_mutex_unlock(&mutex);
            break;
        }

        const int sold = ticket--;
        pthread_mutex_unlock(&mutex);

        // 与共享状态无关的慢操作放到锁外
        std::printf("%s sells ticket: %d\n", name, sold);
    }
    return nullptr;
}

这里把"检查与递减"放在同一临界区,同时把打印移到锁外,缩短持锁时间。

3.3 锁的范围不是越大越安全

锁太小:复合不变量被拆开,仍可能竞态。

cpp 复制代码
lock();
bool available = ticket > 0;
unlock();

if (available) {
    lock();
    --ticket;                 // 两次加锁之间状态可能已变化
    unlock();
}

锁太大:把 I/O、睡眠、网络请求或耗时计算放进临界区,会让其他线程长时间等待,程序虽然"正确",并发度却接近单线程。

正确原则是:围绕共享不变量确定临界区,以满足正确性为前提尽量缩短持锁时间。

3.4 RAII 避免遗漏 unlock

手写多条返回路径很容易漏解锁:

cpp 复制代码
pthread_mutex_lock(&mutex);
if (error) {
    return nullptr; // 锁泄漏
}
pthread_mutex_unlock(&mutex);

C++ 项目优先使用 RAII:

cpp 复制代码
std::mutex mutex;

void update() {
    std::lock_guard<std::mutex> guard(mutex);
    // 离开作用域自动解锁
}

若为了学习封装 pthread_mutex_t,包装类应禁止复制,并让 guard 持有引用:

cpp 复制代码
class Mutex {
public:
    Mutex() { check(pthread_mutex_init(&mutex_, nullptr)); }
    ~Mutex() { pthread_mutex_destroy(&mutex_); }

    Mutex(const Mutex&) = delete;
    Mutex& operator=(const Mutex&) = delete;

    void lock() { check(pthread_mutex_lock(&mutex_)); }
    void unlock() noexcept {
        if (pthread_mutex_unlock(&mutex_) != 0) {
            std::terminate(); // 解锁失败说明同步不变量已经被破坏
        }
    }
    pthread_mutex_t* native_handle() { return &mutex_; }

private:
    static void check(int rc) {
        if (rc != 0) throw std::system_error(rc, std::generic_category());
    }
    pthread_mutex_t mutex_{};
};

class LockGuard {
public:
    explicit LockGuard(Mutex& mutex) : mutex_(mutex) { mutex_.lock(); }
    ~LockGuard() { mutex_.unlock(); }

    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;

private:
    Mutex& mutex_;
};

教学代码常用 (void)rc 丢弃返回值,但工程代码至少应记录、传播或转换错误,不能假设同步 API 永远成功。


四、互斥锁在 Linux 中如何工作

4.1 不应把实现简化成"永远进入内核"

现代 Linux 用户态线程库通常基于原子指令与 futex 构建高层同步原语。

典型思路是:

  1. 无竞争时,用户态原子操作直接取得锁;
  2. 发现锁已被持有时,进入竞争路径;
  3. 必要时通过 futex 让线程在内核中睡眠;
  4. 解锁方发现存在等待者时,执行唤醒;
  5. 被唤醒不等于已经持锁,线程仍要重新竞争。

这解释了两个事实:

  • mutex 在无竞争时可以很快;
  • 高竞争、长临界区和频繁唤醒仍会带来调度与缓存开销。

4.2 原子指令只是构建锁的基础

PDF 使用 swap/exchange 解释锁状态切换,这个方向有助于理解"为什么抢锁本身能原子完成",但真实 pthread_mutex_t 还要处理等待者、调度、mutex 类型、robust/priority 属性等问题。

应用层不要依赖 pthread_mutex_t 的内部字段,也不要手写一个普通整数加 while 循环就声称实现了等价 mutex。内存序、阻塞策略、公平性和异常退出都比一个交换指令复杂。

4.3 mutex 不保证固定公平顺序

等待者最终由实现和调度策略决定谁先继续执行,不能假设严格 FIFO。若业务需要公平队列、优先级或限流,应显式设计调度协议,而不是依赖"每个线程迟早轮到"。


五、条件变量:等待状态变化而不是轮询

5.1 mutex 解决安全,条件变量解决等待

互斥锁保证同一时刻只有一个线程修改队列,但无法回答:

队列为空时,消费者应该怎样高效等待新任务?

忙轮询会浪费 CPU:

cpp 复制代码
while (true) {
    pthread_mutex_lock(&mutex);
    bool empty = queue.empty();
    pthread_mutex_unlock(&mutex);
    if (!empty) break;
}

条件变量允许线程等待某个由共享状态表达的谓词,例如:

text 复制代码
消费者可继续:queue 非空 或 系统正在停止
生产者可继续:queue 未满 或 系统正在停止

5.2 条件变量不是"存放条件的变量"

真正的条件存在共享数据中,condition variable 负责把等待者挂起并在状态变化后通知它重新检查。

text 复制代码
predicate = !queue.empty() || stopping
cond      = 等待/通知机制
mutex     = 保护 predicate 所依赖的共享数据

signal 也不是可累积消息。若发出通知时没有等待者,它通常不会替未来的等待者保存"一张票"。因此程序正确性必须来自谓词,而不是依赖通知次数。

5.3 Pthreads 条件变量接口

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_cond_init(&cond, nullptr);
pthread_cond_wait(&cond, &mutex);
pthread_cond_signal(&cond);      // 至少唤醒一个等待者
pthread_cond_broadcast(&cond);   // 唤醒所有等待者
pthread_cond_destroy(&cond);

pthread_cond_signal() 不承诺唤醒哪个线程,被唤醒的线程也必须在返回前重新取得关联 mutex。


六、pthread_cond_wait 为什么必须接收 mutex

6.1 错误写法:先 unlock,再 wait

cpp 复制代码
pthread_mutex_lock(&mutex);

while (!ready) {
    pthread_mutex_unlock(&mutex);
    // 这里存在丢失唤醒窗口
    pthread_cond_wait(&cond, &mutex);
    pthread_mutex_lock(&mutex);
}

pthread_mutex_unlock(&mutex);

在显式 unlock 与真正进入等待之间,另一个线程可能:

  1. 获得 mutex;
  2. ready 改为 true
  3. 调用 signal;
  4. 释放 mutex。

此时当前线程还没进入等待队列,通知已经过去,之后可能永久睡眠。

6.2 pthread_cond_wait 的原子交接

调用前,当前线程必须持有 mutex。pthread_cond_wait() 负责:

text 复制代码
持有 mutex 并检查谓词
        ↓
原子地释放 mutex + 进入条件变量等待
        ↓
收到通知或发生伪唤醒
        ↓
重新竞争并取得 mutex
        ↓
从 pthread_cond_wait 返回
        ↓
再次检查谓词

这里的"原子"是针对"其他线程取得同一 mutex 后再通知该 condition variable"这一交互而言,目的就是关闭丢失唤醒窗口。

6.3 为什么必须用 while,而不是 if

正确模板:

cpp 复制代码
pthread_mutex_lock(&mutex);

while (!predicate()) {
    pthread_cond_wait(&cond, &mutex);
}

// mutex 仍由当前线程持有,predicate 此刻为真
consume_or_update_state();
pthread_mutex_unlock(&mutex);

原因至少有三个:

  1. POSIX 允许 spurious wakeup
  2. broadcast 会唤醒多个线程,但第一个获得锁的线程可能先把资源取走;
  3. 从 signal 到当前线程重新获得 mutex 之间,谓词可能再次变化。

因此:通知只是"状态可能变化了",不是"资源已经分配给你"。

6.4 signal 放在锁内还是锁外

只要谓词变化受到同一 mutex 保护,两种写法都可能正确:

cpp 复制代码
pthread_mutex_lock(&mutex);
ready = true;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

或:

cpp 复制代码
pthread_mutex_lock(&mutex);
ready = true;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);

第一种更容易审查"状态变化与通知"的关系;第二种有时减少被唤醒线程立刻阻塞在 mutex 上的机会。选择应基于明确谓词和性能测量,而不是套用绝对口诀。


七、生产者消费者与 BlockingQueue

7.1 为什么需要生产者消费者模型

生产者不直接调用消费者,而是把任务放入有界缓冲区;消费者从缓冲区取任务。

它带来三项核心价值:

  • 解耦:生产和处理逻辑只依赖队列契约;
  • 并发:生产者和消费者可以在不同线程推进;
  • 削峰与背压:队列吸收短时波动,满时阻塞或拒绝继续生产。

无界队列只能削峰,不能形成有效背压。若生产速度长期大于消费速度,内存仍会持续增长。

7.2 有界阻塞队列的两个谓词

text 复制代码
消费者等待条件:队列非空,或系统停止
生产者等待条件:队列未满,或系统停止

需要:

  • 一把 mutex 保护队列、容量和停止状态;
  • not_empty 条件变量唤醒消费者;
  • not_full 条件变量唤醒生产者。

7.3 一个更完整的 Pthreads BlockingQueue

下面保留 PDF 的 Pthreads 主线,但补上停止协议、const 引用和错误边界:

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

template<class T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity)
        : capacity_(capacity) {
        if (capacity_ == 0) {
            throw std::invalid_argument("capacity must be positive");
        }
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&not_empty_, nullptr);
        pthread_cond_init(&not_full_, nullptr);
    }

    BlockingQueue(const BlockingQueue&) = delete;
    BlockingQueue& operator=(const BlockingQueue&) = delete;

    bool push(const T& value) {
        pthread_mutex_lock(&mutex_);

        while (queue_.size() == capacity_ && !closed_) {
            pthread_cond_wait(&not_full_, &mutex_);
        }

        if (closed_) {
            pthread_mutex_unlock(&mutex_);
            return false;
        }

        queue_.push(value);
        pthread_cond_signal(&not_empty_);
        pthread_mutex_unlock(&mutex_);
        return true;
    }

    bool pop(T& out) {
        pthread_mutex_lock(&mutex_);

        while (queue_.empty() && !closed_) {
            pthread_cond_wait(&not_empty_, &mutex_);
        }

        if (queue_.empty() && closed_) {
            pthread_mutex_unlock(&mutex_);
            return false;
        }

        out = std::move(queue_.front());
        queue_.pop();
        pthread_cond_signal(&not_full_);
        pthread_mutex_unlock(&mutex_);
        return true;
    }

    void close() {
        pthread_mutex_lock(&mutex_);
        closed_ = true;
        pthread_cond_broadcast(&not_empty_);
        pthread_cond_broadcast(&not_full_);
        pthread_mutex_unlock(&mutex_);
    }

    ~BlockingQueue() {
        // 调用方必须先停止并 join 所有使用者
        pthread_cond_destroy(&not_empty_);
        pthread_cond_destroy(&not_full_);
        pthread_mutex_destroy(&mutex_);
    }

private:
    std::queue<T> queue_;
    std::size_t capacity_;
    bool closed_{false};
    pthread_mutex_t mutex_{};
    pthread_cond_t not_empty_{};
    pthread_cond_t not_full_{};
};

7.4 为什么不需要手工维护 waiter 计数

PDF 示例通过 _consumer_wait_num > 0 判断是否 signal。多数情况下没有必要:对没有等待者的 condition variable 调用 signal 是允许的,通知不会因此成为错误。

手工 waiter 计数本身也是共享状态,会增加不变量和维护成本。除非有明确性能数据证明需要优化,否则直接在状态变化后 signal/broadcast 更易验证。

7.5 析构不是停止协议

不能在还有线程等待 condition variable 或使用 mutex 时销毁它们。正确关闭顺序通常是:

text 复制代码
停止接收新任务
    ↓
在 mutex 保护下写入 closed/stopping
    ↓
broadcast 唤醒所有等待者
    ↓
等待生产者与消费者退出(join)
    ↓
销毁队列、condition variable 与 mutex

八、常见错误与高频面试题

8.1 常见错误清单

  1. 认为一条 C++ 语句天然是原子操作;
  2. 把数据竞争仅理解为"偶尔丢一次更新",忽略未定义行为;
  3. volatile 替代 mutex 或 atomic;
  4. 加锁只保护写,不保护与写并发的普通读;
  5. 在锁内执行 sleep、网络 I/O 或大块计算;
  6. 多条 return/exception 路径手写 unlock,造成锁泄漏;
  7. if 包围 pthread_cond_wait()
  8. 把 condition variable 当成可累积消息队列;
  9. 在没有明确谓词的情况下 wait/signal;
  10. 队列析构时仍有线程正在等待;
  11. sleep() 猜等待线程已经启动;
  12. 忽略 Pthreads API 的返回错误号。

8.2 高频面试题

问题 1:互斥与同步有什么区别?

互斥解决"同一时刻谁能进入临界区",同步解决"线程在什么状态和顺序下继续执行"。mutex 主要保护共享不变量,condition variable 让线程等待谓词变化;两者通常配合使用。

问题 2:为什么 i++ 不是线程安全的?

它通常包含读取、计算、写回多个步骤。普通对象上的无同步并发读写会形成数据竞争,在 C/C++ 中属于未定义行为。

问题 3:pthread_cond_wait 返回时 mutex 是什么状态?

调用前线程必须持有 mutex;wait 原子释放 mutex 并进入等待;返回前重新取得 mutex,所以函数成功返回时当前线程再次持有该 mutex。

问题 4:为什么条件等待必须使用 while?

因为可能发生伪唤醒,多个等待者可能同时醒来,而且谓词在重新取得 mutex 前可能再次变化。返回只代表"应该重新检查",不代表条件必然为真。

问题 5:signal 会保存到下一次 wait 吗?

不会把通知作为未来可消费的计数保存。正确性必须依赖受 mutex 保护的共享谓词。若需要累积许可,应考虑 semaphore 或显式计数器。

问题 6:为什么条件变量一定要配 mutex?

谓词依赖共享数据,需要 mutex 保护;同时 wait 必须原子完成"释放 mutex + 进入等待",以关闭检查谓词与真正睡眠之间的丢失唤醒窗口。

问题 7:被 signal 唤醒是否已经获得锁?

不是。等待线程需要重新竞争关联 mutex,取得后 pthread_cond_wait() 才返回。

8.3 权威参考


总结

上篇的完整主线可以压缩为:

text 复制代码
共享对象被并发访问
        ↓
普通读写缺少 happens-before → data race
        ↓
mutex 保护共享不变量与临界区
        ↓
无竞争走用户态快路径,竞争时可能借助 futex 阻塞
        ↓
condition variable 让线程等待谓词变化
        ↓
BlockingQueue 用 not_empty / not_full 实现解耦与背压

请牢记:

  1. 数据竞争在 C/C++ 中是未定义行为,不只是结果偶尔不对
  2. 锁保护的是共享不变量,临界区要正确且尽量短
  3. 条件变量绑定的是谓词,不是可累积通知
  4. pthread_cond_wait() 必须在持锁状态调用,并始终放在 while 循环中
  5. 唤醒不等于条件为真,也不等于已经获得 mutex
  6. 有界队列提供背压,析构前必须先停止并 join 所有使用者

📖 下篇预告:《Linux 线程同步进阶:POSIX 信号量、环形队列、线程池、死锁与线程安全(下篇)》


如果本文对你有帮助,欢迎点赞、收藏。下篇将把这些同步原语组合成环形队列与线程池,并系统梳理死锁、可重入、单例和 STL/智能指针的线程安全边界。

相关推荐
晨曦花锦1 小时前
Vim基础操作与Ollama模型管理,顺便配置Nginx反向代理
linux·nginx·编辑器·vim
Eloudy1 小时前
Ubuntu 22.04 从源码编译安装 ROS 2 Jazzy 笔记
linux·笔记·ubuntu
承渊政道1 小时前
10:30提交预约会不会撞上10:00的会议?我用飞算JavaAI3.9.1和3.9.9跑了四个时间段
java·springboot·ai编程·飞算javaai·java代码生成
SL_staff1 小时前
风控规则如何从硬编码解耦?我们在 JVS-Rules 中的实践与思考
java·spring boot·设计模式
raindayinrain1 小时前
深入理解Linux内核-页表,TLB,高速缓存,性能优化
linux·性能优化·高速缓存·页表·tlb
数据库技术讲堂1 小时前
从 GitOps 到数据库变更:NineData 如何打通 CI/CD 的数据库治理链路
java·数据库·ci/cd
00后程序员张1 小时前
Windows / Linux / Mac 上不用 Xcode 把 IPA 上传到 App Store,upload 命令详解
android·ios·小程序·https·uni-app·iphone·webview
深海呐2 小时前
Java 位运算符的实际应用:不止面试刷题,业务同样能用
java·位运算符·java 位运算·java 开发技巧·状态位设计·java 高阶用法·位运算符实战
2602_959960922 小时前
电商场景Java面试:Spring Boot、JVM、Redis、Kafka、微服务与分布式事务考点解析——谢飞机的作死面试记
java·jvm·spring boot·redis·面试题