线程同步与互斥怎么理解
讲完 IPC 之后,下一步最自然就是线程同步与互斥。
因为线程之间不像进程那样需要复杂的 IPC,它们共享同一个进程的地址空间,所以通信本身通常并不困难,真正困难的是:
多个线程同时访问同一份共享数据时,怎么保证结果不乱。
这就是线程同步与互斥要解决的问题。
一、为什么线程比进程更容易出并发问题
同一进程内的线程共享:
- 代码段
- 数据段
- 堆
- 全局变量
- 打开的文件
- 地址空间
这意味着线程之间通信很方便,但也意味着:
- 多个线程可能同时修改同一变量
- 多个线程可能同时操作同一个数据结构
- 一个线程刚改完,另一个线程就可能读到一半状态
如果不加控制,就会出现:
- 数据错乱
- 竞态条件
- 脏读 / 脏写
- 丢失更新
所以线程间更大的问题不是“怎么通信”,而是:
怎么协调共享资源访问顺序。
二、什么是互斥
互斥最简单的理解是:
同一时刻,只允许一个线程进入临界区。
所谓临界区,就是那段:
- 访问共享变量
- 修改共享数据结构
- 操作临界资源
的代码。
为什么要互斥
因为很多操作看起来是一行代码,实际上底层并不是原子的。
比如:
count++;它可能会被拆成:
- 读
count - 加 1
- 写回
如果两个线程同时执行,就可能导致最后少加一次。
所以互斥要解决的是:
不让多个线程同时改同一份共享资源。
三、什么是同步
同步不是“只能一个人进”,而是:
多个线程之间按照预期顺序配合执行。
例如:
- 线程 A 先生产数据
- 线程 B 后消费数据
这时要求的不是“一次只能一个线程运行”,而是:
B 必须等 A 先做完某件事
所以:
- 互斥关注“同一时刻谁能进”
- 同步关注“先后顺序怎么安排”
这是最本质的区别。
四、最常见的同步原语有哪些
线程同步与互斥最常见的机制主要有:
- 互斥锁(Mutex)
- 信号量(Semaphore)
- 条件变量(Condition Variable)
有些场景还会提到:
- 读写锁
- 自旋锁
- 屏障(Barrier)
- 原子变量
但最基础、最常考的是前三个。
五、互斥锁(Mutex)
互斥锁的作用最直接:
保证同一时刻只有一个线程能进入临界区。
使用方式
通常是:
- 进入临界区前加锁
- 离开临界区时解锁
如果另一个线程此时也想进,但锁已经被占用,那它就必须等待。
适合什么场景
适合:
- 保护共享变量
- 保护链表、队列、哈希表等共享结构
- 保证一次只有一个线程做修改
本质
互斥锁解决的是:
同时访问会不会冲突
它不负责表达复杂顺序关系。
六、信号量(Semaphore)
信号量本质上是:
一个计数器 + P / V 操作
它既可以做互斥,也可以做同步。
1. 用于互斥
如果信号量初始化为 1,就相当于:
- 同一时刻只允许一个线程获得访问资格
这时它的作用和互斥锁类似。
2. 用于同步
如果信号量初始化为 0,或者其他资源数量值,那它可以表示:
- 某个条件还没满足
- 当前可用资源还有多少
比如生产者消费者问题里常见:
mutex = 1full = 0empty = N
所以信号量最灵活的地方在于:
它既能做“谁先谁后”,也能做“当前还能进几个人”。
七、条件变量(Condition Variable)
条件变量经常和互斥锁一起出现。
它的作用是:
让线程在“某个条件不满足”时进入等待,并在条件满足后被唤醒。
例如:
- 队列为空,消费者先等待
- 生产者放入数据后,通知消费者继续
它为什么重要
因为有时候我们不是单纯地要“互斥”,而是:
等某个状态变成我们想要的样子
这时候仅靠互斥锁不够,还要有“等待条件”和“通知对方”的能力。
条件变量最常见搭配
- 互斥锁负责保护共享状态
- 条件变量负责等待和通知状态变化
为什么条件变量总要和互斥锁一起使用
条件变量本身既不是“条件”,也不是“锁”。真正要等的条件,通常体现在某个共享状态上,例如:
ready == true- 队列非空
- 缓冲区未满
所以更准确地说:
ready这类共享变量负责记录“条件是否成立”- 互斥锁负责保护这些共享变量的读写
- 条件变量负责让线程在条件不满足时先等待,在条件变化后再被唤醒
如果没有互斥锁保护,就可能出现一种经典问题:
- 线程 A 刚检查完条件,发现还不满足
- 线程 B 立刻修改了状态,并发出通知
- 线程 A 这时才真正进入等待
这样线程 A 就可能错过这次通知,后面一直睡下去。这就是所谓的“丢失唤醒”。
条件变量之所以要和互斥锁一起使用,本质上就是为了让:
检查条件、释放锁、进入等待、修改状态、发出通知,这几步能够正确衔接起来,不留下竞态窗口。
为什么 wait 通常要写在 while 里
被 signal 唤醒,不代表条件一定已经稳定满足。
原因通常有几个:
- 可能出现虚假唤醒
- 线程醒来后还要重新竞争互斥锁
- 在它重新拿到锁之前,条件可能已经被别的线程改掉
- 多个等待线程被唤醒时,真正能拿到资源的可能只有一个
所以标准写法通常是:
pthread_mutex_lock(&mutex);
while (!ready) {
pthread_cond_wait(&cond, &mutex);
}
pthread_mutex_unlock(&mutex);这里的 while 表达的是:
线程每次醒来后,不是直接往下执行,而是先重新检查条件;条件不满足就继续等待。
条件队列和抢锁队列是怎么配合的
可以把等待过程粗略理解成两类队列:
1. 条件队列
放的是那些:
已经拿到过锁,但检查后发现条件不满足,所以先去等待条件变化的线程。
2. 抢锁队列
放的是那些:
想进入临界区,但当前还没拿到锁,只能先排队等待互斥锁释放的线程。
一个线程调用 wait 时,大致会经历:
- 先持有互斥锁
- 发现条件不满足
- 进入条件队列
- 原子地释放互斥锁并睡眠
另一个线程修改状态并 signal 之后,被唤醒的线程并不会立刻继续执行,而是会先回到“重新竞争锁”的流程里。只有再次拿到互斥锁,wait 才会真正返回。
所以可以简单记成:
条件队列负责“等条件”,抢锁队列负责“等锁”。
如果放到 Java 里看,ReentrantLock + Condition 和 AQS 里的 Condition queue + sync queue,本质上也是同样的配合思路。
八、互斥和同步最容易混的地方
很多人一开始会把它们混成一回事。
你可以这样记:
互斥
解决“同一时刻能不能一起访问”
同步
解决“执行顺序要不要受约束”
例如:
- 两个线程同时改一个计数器 → 互斥问题
- 一个线程必须等另一个线程先把数据准备好 → 同步问题
九、经典问题:生产者消费者
这是同步 / 互斥里最经典的模型。
场景
- 生产者线程负责放数据
- 消费者线程负责取数据
- 中间有一个共享缓冲区
会遇到的问题
1. 缓冲区是共享资源
所以多个线程不能同时乱改缓冲区结构,这里需要:
- 互斥
2. 缓冲区可能空或满
- 空了,消费者要等
- 满了,生产者要等
这里需要:
- 同步
所以生产者消费者问题里通常既要互斥,也要同步。
十、生产者-消费者问题
这是同步和互斥最经典的综合问题。
场景
- 生产者线程负责把数据放进缓冲区
- 消费者线程负责从缓冲区取出数据
- 中间有一个共享缓冲区
会遇到的两类问题
1. 缓冲区是共享资源
所以多个线程不能同时乱改缓冲区结构,这里需要:
- 互斥
2. 缓冲区可能空或满
- 空了,消费者要等
- 满了,生产者要等
这里需要:
- 同步
所以生产者消费者问题里通常既要互斥,也要同步。
为什么常用 3 个信号量
经典写法通常会有:
mutex = 1:保护缓冲区临界区full = 0:表示当前已有产品数量empty = N:表示当前空槽数量,N 为缓冲区大小
生产者放数据前
P(empty):先占一个空槽P(mutex):进入临界区- 放入数据
V(mutex):退出临界区V(full):表示多了一个可消费数据
消费者取数据前
P(full):先占一个已有数据P(mutex):进入临界区- 取出数据
V(mutex):退出临界区V(empty):表示多了一个空槽
所以这道题最应该记住的是:
mutex负责互斥,full和empty负责同步。
十一、经典同步问题:哲学家就餐
哲学家就餐问题的本质是:
多个线程竞争多个共享资源时,如何避免死锁和资源长期抢不到。
问题模型
- 5 个哲学家围着桌子
- 桌上只有 5 把叉子
- 每个哲学家吃饭时必须同时拿到左右两把叉子
为什么最朴素的方案会死锁
如果每个哲学家都按同样顺序:
- 先拿左边叉子
- 再拿右边叉子
那么极端情况下,5 个人可能同时都拿到了左边那把叉子。此时桌上已经没有剩余叉子,所有人都在等待右边那把,于是形成循环等待,最终死锁。
为什么加一个全局互斥虽然能解决,但效率低
如果规定“谁准备拿叉子时,其他人都别动”,那当然不会死锁,但这会导致:
- 一次只能一个哲学家吃饭
- 明明桌子资源允许两个人同时吃,也没有利用起来
所以这种做法只是“保命”,效率并不高。
更合理的思路是什么
经典改进方法有两个方向:
1. 改变拿资源顺序
例如:
- 偶数编号先拿左边再拿右边
- 奇数编号先拿右边再拿左边
这样就破坏了“所有人都按同样顺序形成闭环等待”的条件。
2. 引入状态判断
记录每个哲学家当前处于:
- 思考
- 饥饿
- 进餐
并限制:
- 只有左右邻居都没进餐时,当前哲学家才能真正进入进餐状态
这样既避免死锁,也能保留一定并发度。
这道题的核心意义
哲学家就餐问题不是让你背代码,而是让你理解:
多线程竞争多个资源时,如果资源申请顺序不合理,就很容易形成死锁。
十二、经典同步问题:读者-写者
读者-写者问题的核心在于:
读和写对共享资源的访问规则不一样。
基本规则
1. 读-读允许
多个读者可以同时读。
2. 读-写互斥
有写者时不能读,有读者时不能写。
3. 写-写互斥
多个写者不能同时写。
为什么它比普通互斥更复杂
因为这里不是简单地“一次只能一个线程访问”,而是:
- 多个读者可以并行
- 写者必须独占
所以调度策略就会有不同倾向。
三种经典策略
1. 读者优先
只要当前还有读者在读,或者又来了新的读者,读者就更容易继续进入。
优点
- 读性能高
- 读者吞吐量好
缺点
- 写者可能长期得不到机会,发生写者饥饿
2. 写者优先
一旦写者准备写,后来的读者先别进。
优点
- 写者不容易饿死
缺点
- 如果写者持续不断,读者反而可能饿死
3. 公平策略
不特别偏袒读者,也不特别偏袒写者,让双方按更公平的顺序竞争。
优点
- 更平衡
- 不容易长期饿死某一方
缺点
- 实现会更复杂
这道题真正想让你学会什么
不是背哪套代码,而是理解:
并发控制不仅仅只有“互斥”,还要考虑吞吐、公平性和饥饿问题。
十三、自旋锁和阻塞锁
前面讲锁的时候,如果再往下走一步,很容易碰到“忙等待锁”和“无忙等待锁”的区别。
自旋锁
自旋锁可以理解成:
拿不到锁时,不睡眠,而是一直循环尝试获取锁。
最典型的基础模型是:
- 锁变量为
0表示空闲 - 锁变量为
1表示已被占用 - 线程反复原子地尝试把
0改成1
这通常依赖:
- CAS
- Test-and-Set
- Exchange
等原子操作。
优点
- 如果临界区很短,锁很快就会释放,自旋往往比睡眠 / 唤醒更省事
缺点
- 如果锁持有时间长,会白白浪费 CPU
- 单核下尤其不友好
阻塞锁 / 互斥锁
如果拿不到锁,不在原地转,而是:
- 把当前线程挂到等待队列
- 让出 CPU
- 等锁可用时再被唤醒
优点
- 不浪费 CPU
缺点
- 睡眠 / 唤醒 / 切换有额外开销
一句话区分
自旋锁是“拿不到就一直转”,阻塞锁是“拿不到就睡一会儿”。
十四、为什么加了锁还会死锁
因为“加锁”本身不是万能的。
如果多个线程:
- 锁的顺序不一致
- 一个拿了 A 锁再等 B 锁
- 另一个拿了 B 锁再等 A 锁
就可能形成循环等待,导致死锁。
所以:
同步原语能解决并发问题,但用不好也会引入新的问题。
十五、线程同步和 IPC 的关系
可以顺手和 IPC 对比一下。
进程间通信
因为进程空间隔离,所以重点是:
- 怎么传数据
- 怎么共享数据
线程间协作
因为线程默认就共享地址空间,所以重点反而变成:
- 怎么避免数据竞争
- 怎么保证执行顺序
所以你可以记:
线程之间更关注“同步和互斥”,进程之间更关注“通信和同步”。
十六、总结
线程同步与互斥的核心在于:多个线程共享资源时,怎么保证访问不冲突、执行顺序符合预期。互斥解决的是“临界区一次只能一个线程进”,同步解决的是“线程之间的先后依赖关系”。常见工具包括互斥锁、信号量和条件变量;经典问题包括生产者消费者、哲学家就餐和读者写者,它们分别体现了临界区保护、资源竞争、死锁风险以及公平性等不同并发控制重点。进一步理解自旋锁和阻塞锁的区别后,对线程同步这块的整体认识就会更完整。
