并发编程基础:线程与进程、互斥锁、条件变量、读写锁、死锁的预防

并发编程,说白了就是让程序同时干多件事。很多初学者觉得这玩意儿离自己很远,其实不然。你写个网络服务器、做个图像处理、甚至写个简单的日志系统,都绕不开并发。我当年刚入行时,第一次接手一个多线程下载模块,结果各种崩溃、数据错乱,调试了整整三天。嗯,从那以后我就老老实实把并发基础啃透了。

进程 vs 线程:到底选哪个?

先搞清楚这两个概念。进程是资源分配的最小单位,线程是CPU调度的最小单位。一个进程里可以跑多个线程,它们共享进程的地址空间。

我个人的习惯是:需要强隔离、高安全性时用进程;需要高性能、频繁数据交换时用线程。举个例子,Chrome浏览器每个标签页是一个进程,这样某个页面崩溃不会影响其他页面。而一个游戏服务器里,处理玩家连接的往往是线程,因为要共享玩家数据。

特性 进程 线程
地址空间 独立 共享
创建开销
同步难度 低(IPC机制) 高(需锁机制)
崩溃影响 仅自身 可能拖垮整个进程
我的经验:在Linux上用fork()创建进程时,注意写时拷贝(COW)机制。子进程刚创建时和父进程共享物理内存,只有写入时才复制。所以别以为fork很轻量,如果父进程占了几G内存,fork瞬间会触发大量缺页中断。

互斥锁:最基础的同步工具

多线程访问共享数据时,必须用互斥锁保护。不然就会出现「数据竞争」——两个线程同时写一个变量,结果谁也不知道最终值是什么。

C++11提供了std::mutex,配合std::lock_guard使用最安全。我见过太多人手动lock/unlock,结果某个分支忘记unlock,直接死锁。

#include <mutex>
#include <thread>

std::mutex mtx;
int shared_counter = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);  // 自动加锁解锁
    ++shared_counter;
    // 离开作用域自动解锁
}
注意:锁的粒度要适中。锁得太细,保护不完整;锁得太粗,性能下降。我曾经在一个项目中,有人把整个请求处理函数都锁了,结果并发量一上来,性能直接腰斩。

条件变量:让线程学会等待

互斥锁解决了「互斥」问题,但没解决「同步」问题。比如生产者-消费者模型:消费者要等生产者生产出数据才能消费。轮询?太浪费CPU了。这时候就需要条件变量。

std::condition_variable配合std::unique_lock使用。核心是wait()notify_one()/notify_all()

#include <condition_variable>
#include <queue>

std::queue<int> data_queue;
std::mutex q_mtx;
std::condition_variable cv;

void producer() {
    for (int i = 0; i < 100; ++i) {
        std::lock_guard<std::mutex> lock(q_mtx);
        data_queue.push(i);
        cv.notify_one();  // 通知消费者
    }
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(q_mtx);
        cv.wait(lock, []{ return !data_queue.empty(); });
        int val = data_queue.front();
        data_queue.pop();
        lock.unlock();
        // 处理val...
    }
}

这里有个坑:wait()的第二个参数是谓词,用来防止「虚假唤醒」。为什么会有虚假唤醒?操作系统底层实现决定的,有些平台在信号量机制上会有这种问题。所以永远要在wait后面加条件判断

读写锁:读多写少的利器

互斥锁太粗暴了——读操作之间其实不需要互斥。如果共享数据读多写少,用读写锁能大幅提升性能。

C++17引入了std::shared_mutex,配合std::shared_lock(读锁)和std::unique_lock(写锁)。

#include <shared_mutex>

class ThreadSafeCache {
    std::map<std::string, int> cache_;
    mutable std::shared_mutex rw_mutex_;
public:
    int get(const std::string& key) const {
        std::shared_lock lock(rw_mutex_);  // 读锁,可共享
        auto it = cache_.find(key);
        return it != cache_.end() ? it->second : -1;
    }
    void set(const std::string& key, int val) {
        std::unique_lock lock(rw_mutex_);  // 写锁,独占
        cache_[key] = val;
    }
};
性能对比:在一个8核机器上,纯读场景下读写锁比互斥锁快3-5倍。但写操作频繁时,读写锁反而更慢,因为要维护读计数器的原子操作。

死锁的预防:别把自己锁死

死锁就是两个线程互相等待对方释放资源,结果谁也动不了。经典的「哲学家就餐问题」就是典型例子。

死锁产生的四个必要条件:

  1. 互斥:资源一次只能被一个线程占用
  2. 持有并等待:线程持有资源的同时等待其他资源
  3. 不可剥夺:资源只能由持有者主动释放
  4. 循环等待:存在线程-资源的环形链

预防死锁,说白了就是破坏上面任意一个条件。我常用的方法:

  • 固定锁顺序:所有线程按相同顺序加锁。比如先锁A再锁B,绝不反过来。
  • 使用std::lock():一次性锁住多个互斥量,避免分步加锁。
  • 超时机制:用try_lock_for()try_lock_until(),超时后释放已持有的锁。
// 固定锁顺序示例
std::mutex mtx1, mtx2;

void thread_a() {
    std::lock(mtx1, mtx2);  // 同时锁两个
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    // 安全操作...
}

void thread_b() {
    // 同样顺序,不会死锁
    std::lock(mtx1, mtx2);
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
}
我曾经踩过的坑:在回调函数里加锁,而回调函数本身又在锁的范围内被调用。结果就是同一个线程试图锁一个已经持有的非递归锁,直接死锁。解决方案:要么用std::recursive_mutex,要么重构代码避免这种嵌套。

并发编程的核心知识体系

下面这张图梳理了本章的核心脉络,方便你建立整体认知:

并发编程基础 进程 vs 线程 资源隔离 vs 共享内存 互斥锁 std::mutex + lock_guard 条件变量 wait / notify 机制 读写锁 shared_mutex / shared_lock 死锁预防 固定顺序 / std::lock 性能与粒度 锁的粒度控制 核心原则:最小化临界区,避免死锁,选择合适的同步原语 多读少写用读写锁,多写用互斥锁,同步用条件变量

并发编程没有银弹。不同的场景要选不同的工具。我个人建议:先从互斥锁+条件变量入手,这两个组合能解决90%的问题。等遇到性能瓶颈了,再考虑读写锁、无锁数据结构这些进阶方案。

调试技巧:gdb调试多线程程序时,info threads查看所有线程,thread apply all bt打印所有线程的调用栈。死锁时,看看每个线程在等哪把锁,基本就能定位问题。

嗯,并发编程的内容就讲到这里。记住:锁不是越多越好,而是越少越好。能不用锁就别用锁,能用原子操作就别用互斥锁,能用读写锁就别用互斥锁。这个道理,我是在被线上事故教育了好几次之后才真正理解的。


公众号:蓝海资料掘金营,微信deep3321