Skip to content
Go back

线程间通讯方式:五种同步机制的原理与选型

一句话结论(30s)

线程间通讯的本质挑战不是传数据而是安全访问共享内存,因为同进程线程共享地址空间,直接读写全局变量既高效又危险。关键设计是按”临界区长度 + 等待方式”选锁:睡眠锁(互斥/读写/信号量)用空间换时间、自旋锁用时间换空间、条件变量解决”何时执行”。权衡是无竞争时 futex 用户态快速路径让锁开销降到几十周期,但临界区长短决定睡眠与自旋的胜负。

核心原理(2min)

互斥锁基于 futex,快速路径用户态 CAS 不陷内核;读写锁区分共享读/独占写并防写者饥饿;条件变量配合 mutex 原子完成”释放锁→睡眠”避免丢失唤醒;自旋锁忙等 + PAUSE 指令适配极短临界区;信号量是受保护的内核计数器,二进制等价互斥。选型看临界区长度与等待方式。

底层深入(5-10min)

线程间通讯的本质:共享地址空间

与进程间通讯不同,同一进程内的所有线程共享地址空间——全局变量、堆内存、打开的文件描述符对每个线程都是可见的。这种”共享一切”的特性带来了两个直接后果:

好处:线程间通讯极其高效。不需要管道、不需要消息队列、不需要共享内存的特殊映射——直接读写同一个全局变量就行。

代价:并发安全成为核心问题。多个线程同时读写共享数据必然导致竞态条件(Race Condition),因此线程间通讯的真正挑战不是”如何传递数据”,而是”如何安全地访问共享数据”。

想一想:为什么”共享地址空间”既是优势又是陷阱?因为直接读写全局变量零拷贝、极高效,但也意味着任何两个线程都能同时改同一块内存,竞态条件随之而来——高效和危险是同源的一体两面。

互斥锁(Mutex)

原理

互斥锁是最基础的线程同步原语,保证同一时刻只有一个线程能进入临界区。其底层依赖硬件原子指令,在 Linux 中基于 futex(Fast Userspace muTEX)系统调用实现。

futex 的精妙设计在于快速路径在用户态完成

// futex 的简化逻辑
void mutex_lock(int *lock) {
    // 快速路径:用户态 CAS
    if (__sync_bool_compare_and_swap(lock, 0, 1)) {
        return;  // 获取成功,未陷入内核
    }
    // 慢速路径:竞争发生,陷入内核等待
    while (1) {
        if (__sync_bool_compare_and_swap(lock, 0, 1))
            return;
        futex_wait(lock, 1);  // 把自己放入等待队列并睡眠
    }
}

void mutex_unlock(int *lock) {
    *lock = 0;
    futex_wake(lock, 1);  // 唤醒一个等待者
}

核心洞察:无竞争时 lock/unlock 完全在用户态完成,只有 2-3 条 CPU 指令(lock cmpxchg),延迟仅几十个 CPU 周期。只有在真正需要等待时才陷入内核。这种”无竞争不陷入内核”的设计是高性能锁的共同特征。

想一想:为什么无竞争时锁开销只有几十周期?因为 lock/unlock 全程在用户态 CAS 完成、不陷入内核;只有竞争发生才走 futex_wait 进内核睡眠。“无竞争不陷入内核”正是高性能锁的共同套路。

适用场景

临界区持有时间较长(微秒级以上)、可能涉及 I/O 操作、或需要公平排队的场景。

读写锁(RWLock)

原理

读写锁区分两种访问模式:共享读(多个读者可同时持有)和独占写(写者持有期间禁止任何读者或其他写者进入)。这种设计基于一个关键观察:大多数共享数据的访问模式是”读多写少”。

Linux 的 pthread_rwlock_t 内部维护两个计数器:活跃读者数 + 是否有写者等待(或活跃)。设计上需要解决的核心问题是写者饥饿——如果读者源源不断进来,写者可能永远等不到写锁。Linux glibc 默认实现中,当有写者等待时新的读者会被阻塞,优先让写者执行。

适用场景

明确读多写少的场景——配置信息缓存(n个查询线程读,1个后台线程定期刷新)、DNS 缓存、路由表等。

注意事项

读写锁比互斥锁多了额外的计数器维护开销,在临界区很小时(几行代码),互斥锁的简单性反而可能更快。需要根据实际负载做基准测试。

条件变量(Condition Variable)

原理

条件变量解决”等待某个条件满足”的问题。核心操作:

为什么需要配合 mutex? 考虑检查条件 → 等待这个操作:

// 错误做法:没有锁保护
while (!condition) {
    // 如果在这里另一个线程改变了 condition 并调用 signal
    // 当前线程还在 while 循环中,错过了这次 signal
    wait();  // 永远等不到下次 signal → 死等
}

正确做法是检查条件、进入等待必须在锁保护下原子完成。pthread_cond_wait 内部原子性地完成”释放锁→睡眠”这个操作,避免了丢失唤醒(Lost Wakeup)问题。

想一想:为什么条件变量必须配 mutex?因为”检查条件 → 进入等待”必须原子完成;否则在检查和 wait 之间 signal 到达就会被错过,导致 Lost Wakeup,线程永远等不到下次 signal、死等。

适用场景

生产者-消费者模型(BlockingQueue 的实现基石)、线程池的任务等待、任何需要”等待某个条件成立再继续”的场景。

自旋锁(Spinlock)

原理

自旋锁不睡眠,而是在获取失败时原地循环等待(忙等/busy-wait),不断通过原子操作检查锁是否释放。x86 上的典型实现:

void spin_lock(int *lock) {
    while (__sync_bool_compare_and_swap(lock, 0, 1) == 0) {
        // 使用 PAUSE 指令提示 CPU 这是自旋循环
        // 1. 减少流水线惩罚(避免错误的顺序执行推测)
        // 2. 降低功耗(P-state 微调)
        // 3. 在超线程中让渡执行资源给同核心的另一个逻辑线程
        _mm_pause();  
    }
}

_mm_pause()(编译为 PAUSE 指令)是 x86 自旋锁实现的关键细节。不加 PAUSE 的自旋循环会导致 CPU 流水线充满错误预测、锁总线争用发热、以及在超线程架构中白白占满执行单元。

适用场景

临界区极短(几十个时钟周期,比如更新一个原子计数器、操作链表的一个节点)。因为睡眠/唤醒的开销(用户态↔内核态切换)约几千个周期,如果临界区就几十个周期,睡眠的代价远大于自旋的代价。

想一想:为什么临界区极短时自旋胜过睡眠?因为睡眠唤醒要用户态↔内核态切换、约几千周期,而临界区才几十周期;空转等一小会比”睡一觉再醒”便宜得多,所以短临界区选自旋、长临界区选睡眠。

重要限制

用户态自旋锁不能长时间持有——调度器不知道线程在自旋,可能在锁持有者还没释放时就抢占了它,导致其他线程空转整个时间片。内核态自旋锁可以通过关抢占或关中断来避免这个问题。

信号量

信号量的设计在进程 IPC 部分已经详述,在线程场景中原理完全相同——一个受保护的内核计数器。但线程场景下更常用二进制信号量(初始化为 1,等价于互斥锁)或计数信号量(线程池并发度控制)。

在线程场景中选择互斥锁还是信号量:如果只是互斥,用互斥锁——语义更明确,编译器可以做更多优化(如死锁检测)。如果需要信号计数(限流、资源池),用信号量。

选型对比

机制等待方式适用临界区长度读者并发开销
互斥锁睡眠不支持中等
读写锁睡眠支持高(额外计数)
条件变量睡眠N/AN/A中等
自旋锁忙等极短不支持低(无内核态切换)
信号量睡眠不支持中等

总结

线程间通讯的机制选择说到底是一道时空权衡题:睡眠锁用空间换时间(等待时让出 CPU),自旋锁用时间换空间(不切换、低延迟但浪费 CPU)。条件变量则解决了一个不同的维度——“何时执行”而非”谁执行”。理解每种机制背后的硬件依赖(原子指令、futex、PAUSE、关中断)和在调度器眼中的语义(可睡眠 vs 不可睡眠),才能在高并发场景中做出正确的选型决策。

章末提问

追问 1:线程间通讯和进程间通讯最大的不同是什么?

回答思路:结论先行——线程共享地址空间,所以真正的挑战不是”传数据”而是”安全访问共享内存”。因为同进程线程直接读写全局变量零拷贝、极高效,但也因此带来竞态条件;进程间通讯要靠管道/消息队列/共享内存映射等额外机制传数据。

追问 2:futex 为什么能让互斥锁在无竞争时几乎零开销?

回答思路:结论先行——因为快速路径在用户态用 CAS 完成、不陷入内核。因为无竞争时 lock cmpxchg 只需 2-3 条 CPU 指令、延迟几十周期;只有竞争发生才走 futex_wait 把自己放进内核等待队列睡眠。这种”无竞争不陷入内核”的设计是高性能锁的共同特征。

追问 3:自旋锁和互斥锁分别适合什么临界区?为什么?

回答思路:结论先行——自旋锁适合极短临界区(几十周期),互斥锁适合长临界区(微秒级/含 IO)。因为睡眠唤醒要用户态↔内核态切换、约几千周期,临界区很短时睡眠的代价远大于空转等待,所以用自旋;临界区很长时自旋会白白空转整个时间片、浪费 CPU,不如睡眠让出 CPU。


Share this post on:

Previous Post
虚拟内存与物理内存——操作系统最优雅的"骗局
Next Post
用户态和内核态:CPU权限隔离的底层原理