1. 从一次数据错乱说起:为什么裸奔的多线程迟早要出事
刚接触多线程那会儿,我写过一个批量处理订单的小工具。逻辑特别简单:开五个线程,每个线程从共享的任务队列里取一条记录,处理完把计数器加一。单线程跑的时候一切正常,结果一上多线程,计数器每次跑出来的结果都不一样,有时候是 498,有时候是 503,明明总共就 500 条数据。更离谱的是,日志里偶尔会出现两条线程处理同一条记录的情况。
当时我盯着屏幕看了半天,代码逻辑翻来覆去检查了好几遍,就是找不到问题。后来一位做后端的老哥看了一眼,说了句:"你那个计数器加一的操作,是读-改-写三步,中间随时可能被别的线程插一脚。"这句话点醒了我——问题根本不在业务逻辑,而在于多个线程同时访问共享资源时,没有任何协调机制。
这就是线程同步要解决的核心问题。所谓线程同步,不是让线程排队一个个执行(那是串行),而是让多个线程在访问共享资源时,能够按照某种规则协调彼此的行为,保证数据的一致性和程序的正确性。它解决的场景非常具体:多个线程读写同一块内存、同一个文件、同一个连接池、同一个缓存,只要存在"共享"和"并发",就必然需要同步。
这篇文章适合两类人看:一类是刚学多线程、被各种锁和关键字绕晕的新手,另一类是写过并发代码但总是"凭感觉加锁"、出了问题不知道怎么排查的开发者。我会把常见的几种同步方式从原理到实操讲透,包括它们各自适合什么场景、有什么坑、怎么选。文中所有代码示例都是我自己跑过的,参数和结论可以直接参考。
在展开之前,先把一个基础概念钉死:竞态条件。它指的是程序的输出依赖于多个线程执行的相对时序。只要你的代码里存在"先检查后执行"或者"读-改-写"这类复合操作,且操作对象是共享的,就存在竞态条件。同步机制的本质,就是把这些复合操作变成原子操作,或者给它们加上访问秩序。
2. 互斥锁:最基础也最容易用错的同步手段
2.1 互斥锁到底锁住了什么
互斥锁(Mutex)是线程同步里最基础的工具,它的语义很简单:同一时刻只允许一个线程持有锁,其他线程想拿锁就得等着。但很多人对"锁住了什么"这件事理解是模糊的。锁本身不锁任何数据,它锁的是代码路径。你在一段代码前后加上加锁和解锁,等于告诉系统:这段代码同一时间只能有一个线程进来。
用一个生活类比:公司只有一间会议室,门上有把锁,钥匙只有一把。谁拿到钥匙谁就能进去开会,其他人只能在门外等,等里面的人出来把钥匙还回来。这里的"会议室"就是共享资源,"钥匙"就是锁。关键在于,所有想进会议室的人都必须遵守"先拿钥匙再进门"的规则,只要有一个人不遵守,整个秩序就崩了。
对应到代码里,问题往往出在"有人不遵守规则"。比如你在一处加了锁,另一处忘了加,那没加锁的那条路径就成了漏洞。这也是互斥锁最容易踩的坑:锁的覆盖范围必须完整,所有访问共享资源的路径都要被同一把锁保护。
2.2 一个能复现问题的计数器示例
先看不加锁的版本,用 Python 演示,因为它的线程模型直观,容易观察现象:
import threading counter = 0 def worker(): global counter for _ in range(100000): counter += 1 threads = [threading.Thread(target=worker) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() print(counter)理论上 5 个线程各加 10 万次,结果应该是 500000。但实际跑下来,结果通常在 30 万到 45 万之间浮动。原因就是counter += 1在底层被拆成了三步:读取 counter 的值、加一、写回。两个线程可能同时读到同一个值,各自加一后写回,等于有一次加法被覆盖了。
加上锁之后:
import threading counter = 0 lock = threading.Lock() def worker(): global counter for _ in range(100000): with lock: counter += 1 threads = [threading.Thread(target=worker) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() print(counter)这次结果稳定是 500000。with lock这种写法会在进入代码块时自动加锁,退出时自动解锁,即使中间抛异常也能保证解锁,比手动lock.acquire()和lock.release()安全得多。
2.3 锁的粒度:太粗拖慢性能,太细容易漏
锁的粒度指的是锁保护的代码范围有多大。粒度太粗,比如把整个循环都包在锁里,那多线程就退化成串行了,性能还不如单线程。粒度太细,比如只锁住写回那一步,读和加一还在外面,那锁就形同虚设。
我一般的做法是:只锁住真正访问共享资源的那几行。像上面计数器例子,锁住counter += 1就够了,循环本身、函数调用这些不需要锁。判断标准很简单——问自己"这几行代码有没有读写共享变量",有就锁,没有就不锁。
但这里有个反直觉的点:有时候锁的粒度不能只按"共享变量"来划。如果两个共享变量之间存在不变式关系(比如账户 A 减一百、账户 B 加一百,两者之和必须不变),那这两个操作必须放在同一把锁里,否则中间状态会被其他线程看到。这就是为什么转账操作必须整体加锁,不能拆成两次独立的加锁。
2.4 死锁:两个线程互相等对方手里的锁
死锁是多线程里最经典的问题。场景是这样的:线程 1 拿到了锁 A,正在等锁 B;线程 2 拿到了锁 B,正在等锁 A。两边都不肯放手,程序就卡死了。
import threading lock_a = threading.Lock() lock_b = threading.Lock() def task1(): with lock_a: print("task1 got lock_a") with lock_b: print("task1 got lock_b") def task2(): with lock_b: print("task2 got lock_b") with lock_a: print("task2 got lock_a") t1 = threading.Thread(target=task1) t2 = threading.Thread(target=task2) t1.start() t2.start() t1.join() t2.join()这段代码跑起来大概率会卡住。避免死锁有几个实用原则:统一加锁顺序(所有线程都按 A 再 B 的顺序拿锁)、使用带超时的锁(拿不到就放弃并释放已持有的锁)、尽量减少同时持有的锁数量。我个人最推荐第一条,因为它在设计阶段就能规避问题,不需要运行时处理。
注意:死锁不一定发生在两把锁之间。一把锁也可能死锁,比如同一个线程重复获取同一把不可重入的锁。这种叫自死锁,排查时容易被忽略。
3. 条件变量:让线程学会"等通知"而不是空转
3.1 忙等待为什么是浪费
有些场景下,线程需要等某个条件成立才能继续。比如消费者线程要等队列里有数据才能取,生产者线程要等队列有空位才能放。最朴素的做法是写个循环不停检查:
while not queue: pass # 空转等待这种叫忙等待,线程在疯狂消耗 CPU 做无用功。如果队列要等好几秒才有数据,这几秒 CPU 就被白白烧掉了。更糟的是,如果多个线程都在忙等,CPU 占用率会飙到很高,但实际什么活都没干。
条件变量就是来解决这个问题的。它的思路是:条件不满足时,线程主动让出 CPU 进入等待状态;当条件可能满足时,由另一个线程发通知把它唤醒。这样等待期间线程不占 CPU,被唤醒后才重新检查条件。
3.2 条件变量必须和锁配合使用
条件变量不能单独用,它必须绑定一把互斥锁。原因在于"检查条件"和"进入等待"这两步之间必须原子,否则会出现丢失唤醒的问题。想象一下:线程 A 检查队列为空,准备进入等待;就在这个间隙,线程 B 往队列里放了数据并发出了通知;然后线程 A 才真正进入等待——结果就是 A 永远等不到那个已经错过的通知。
标准用法是这样的:
import threading queue = [] lock = threading.Lock() cond = threading.Condition(lock) def consumer(): with cond: while not queue: cond.wait() item = queue.pop(0) print(f"consumed {item}") def producer(): with cond: queue.append("data") cond.notify()这里有几个细节值得说。第一,cond.wait()会自动释放锁并进入等待,被唤醒时会重新获取锁,所以外面用with cond包着是安全的。第二,条件检查必须用while而不是if,因为被唤醒后条件不一定真的成立了(可能被其他线程抢先消费了),需要重新检查。第三,notify()只唤醒一个等待线程,notify_all()唤醒全部,选哪个取决于你的场景。
3.3 生产者消费者模型的完整实现
把上面的片段补全,就是一个经典的生产者消费者模型:
import threading import time import random queue = [] MAX_SIZE = 5 lock = threading.Lock() cond = threading.Condition(lock) def producer(pid): for i in range(10): with cond: while len(queue) >= MAX_SIZE: cond.wait() item = f"p{pid}-{i}" queue.append(item) print(f"produced {item}, queue size {len(queue)}") cond.notify_all() time.sleep(random.uniform(0.1, 0.3)) def consumer(cid): while True: with cond: while not queue: cond.wait() item = queue.pop(0) print(f"consumer {cid} consumed {item}, queue size {len(queue)}") cond.notify_all() time.sleep(random.uniform(0.1, 0.3)) producers = [threading.Thread(target=producer, args=(i,)) for i in range(2)] consumers = [threading.Thread(target=consumer, args=(i,), daemon=True) for i in range(3)] for p in producers: p.start() for c in consumers: c.start() for p in producers: p.join()这个模型里,生产者等"队列不满",消费者等"队列不空",两个条件共用一把锁和一个条件变量。用notify_all()是因为生产者和消费者等待的是不同条件,只唤醒一个可能唤醒错对象,导致该醒的没醒。
3.4 条件变量和轮询的取舍
条件变量不是万能的。如果条件变化非常频繁,或者等待时间极短,条件变量的加锁、等待、唤醒这一套开销可能比简单轮询还大。我一般这样判断:等待时间可能超过毫秒级,用条件变量;等待时间在微秒级且变化频繁,可以考虑自旋等待。但绝大多数业务场景下,条件变量都是更优解,因为业务里的等待往往是秒级甚至更长的。
4. 信号量:控制同时访问资源的线程数量
4.1 信号量和互斥锁的本质区别
互斥锁的值只有 0 和 1,同一时刻只允许一个线程进入。信号量则是一个计数器,可以设置成任意正整数 N,表示同一时刻允许 N 个线程同时访问。这是两者最本质的区别。
打个比方:互斥锁是单人厕所,一次只能进一个人;信号量是停车场,有 N 个车位,前 N 辆车可以直接进,第 N+1 辆就得等有人开走才能进。信号量的核心操作有两个:acquire(申请一个许可,计数器减一,减到零就阻塞)和release(释放一个许可,计数器加一,唤醒等待的线程)。
4.2 用信号量限制并发连接数
一个很实际的应用场景是限制对某个外部服务的并发请求数。假设某个接口最多承受 10 个并发连接,超过就会拒绝服务,那就可以用信号量把并发控制在 10 以内:
import threading import time semaphore = threading.Semaphore(10) def call_service(task_id): with semaphore: print(f"task {task_id} acquired permit") time.sleep(1) # 模拟请求耗时 print(f"task {task_id} released permit") threads = [threading.Thread(target=call_service, args=(i,)) for i in range(50)] for t in threads: t.start() for t in threads: t.join()50 个任务同时启动,但任意时刻最多只有 10 个在执行,其余的在with semaphore处排队。这种模式在爬虫限速、数据库连接池、API 调用限流里非常常见。
4.3 信号量做互斥锁用:可以,但没必要
信号量初始值设为 1 时,行为上等价于互斥锁。但我不建议这么用,原因有两个:一是语义不清晰,读代码的人看到信号量会以为你在控制并发数,结果发现是 1,容易困惑;二是信号量没有"持有者"的概念,任何线程都能 release,而互斥锁通常要求谁加锁谁解锁,这个约束在排查问题时很有价值。工具要用在对的语义上,别为了省事混用。
4.4 信号量的常见坑:release 次数不匹配
信号量最容易出的问题是 acquire 和 release 次数不匹配。多 release 一次,计数器就虚高,等于放宽了并发限制;少 release 一次,计数器就永久减少,最终可能导致所有线程都卡住。所以用信号量时,release 一定要放在 finally 块里,或者用with语句,确保异常情况下也能释放。
# 不推荐 semaphore.acquire() do_something() # 这里抛异常,release 就执行不到了 semaphore.release() # 推荐 with semaphore: do_something()5. 读写锁与原子操作:读多写少场景的优化思路
5.1 读写锁解决的是什么问题
互斥锁有个明显的浪费:如果多个线程都只是读共享数据,彼此之间根本不会冲突,但互斥锁还是让它们排队。读操作之间应该是可以并发的,只有写操作才需要独占。读写锁就是为这个场景设计的。
读写锁的规则是:读锁可以被多个线程同时持有,写锁只能被一个线程持有,且写锁和读锁互斥。也就是说,读读并发、读写互斥、写写互斥。对于读多写少的场景,比如配置缓存、路由表、字典查询,读写锁能显著提升吞吐量。
不过要注意,读写锁的实现复杂度比互斥锁高,加解锁的开销也更大。如果临界区非常短,或者读写比例没那么悬殊,用读写锁反而可能更慢。我一般只在读操作明显多于写操作(比如 10:1 以上)且临界区有一定耗时时才考虑它。
5.2 原子操作:不需要锁的同步
原子操作指的是不可被中断的一个或一系列操作,它在硬件层面保证要么全部完成,要么完全没执行,中间不会被其他线程插入。最典型的就是原子自增、原子比较并交换(CAS)。
用原子操作做计数器,就不需要加锁了:
import threading counter = 0 lock = threading.Lock() # 加锁版本 def increment_with_lock(): global counter for _ in range(100000): with lock: counter += 1 # 很多语言提供了原子类型,比如 Java 的 AtomicInteger # Java 示例: # AtomicInteger counter = new AtomicInteger(0); # counter.incrementAndGet();原子操作的优势是性能好,因为它不需要操作系统介入做线程调度,直接在 CPU 指令层面完成。但它的适用范围有限,只能用于单个变量的简单操作。一旦涉及多个变量的复合逻辑,还是得回到锁。
5.3 CAS 的 ABA 问题
CAS 操作有个著名的坑叫 ABA 问题。假设线程 1 读到变量值是 A,准备把它改成 C;在这期间,线程 2 把 A 改成了 B,又改回了 A;线程 1 执行 CAS 时发现值还是 A,就以为没人动过,顺利改成 C。但实际上中间已经发生过变化,如果这个变化对业务有影响,就会出问题。
解决 ABA 的常见办法是加版本号,每次修改版本号加一,CAS 时同时比较值和版本号。很多语言的标准库已经内置了带版本号的原子引用,用的时候留意一下就行。
5.4 各种同步方式的选型对照
把前面讲的几种方式整理成一张表,方便对照选择:
| 同步方式 | 核心语义 | 适用场景 | 主要风险 |
|---|---|---|---|
| 互斥锁 | 同一时刻一个线程进入 | 通用共享资源保护 | 死锁、粒度不当 |
| 条件变量 | 等待条件成立再继续 | 生产者消费者、等待通知 | 丢失唤醒、虚假唤醒 |
| 信号量 | 限制同时访问的线程数 | 限流、连接池、资源配额 | release 次数不匹配 |
| 读写锁 | 读并发、写独占 | 读多写少的缓存场景 | 写饥饿、开销偏大 |
| 原子操作 | 单变量不可中断操作 | 计数器、标志位 | ABA 问题、适用范围窄 |
选型的时候,先问自己三个问题:共享资源是什么类型?读写比例大概多少?临界区有多长?答案基本就能定位到合适的工具。拿不准的时候,先用互斥锁保证正确性,等性能真的成为瓶颈再优化,别一上来就追求花哨的方案。
6. 排查同步问题的实战思路
6.1 先确认问题是不是同步引起的
不是所有多线程的诡异现象都是同步问题。有时候是逻辑 bug,有时候是资源耗尽,有时候是外部依赖不稳定。判断方法很简单:把并发改成串行,看问题是否消失。如果串行跑一百遍都正常,并发跑几遍就出错,那基本可以锁定是同步问题。
另一个信号是"结果不确定"。同步问题导致的错误往往具有随机性,同样的输入,有时候对有时候错,错误的位置也不固定。这种不确定性正是竞态条件的典型特征。
6.2 缩小范围:从共享变量入手
定位同步问题时,我会先把所有被多个线程访问的变量列出来,然后逐个检查它们的访问路径。重点看三类操作:读-改-写、先检查后执行、多个相关变量的联合更新。这三类是竞态条件的高发区。
检查的时候有个技巧:看有没有哪条访问路径没加锁。很多时候问题不是锁用错了,而是某条路径漏加了锁。比如一个缓存,读的时候加了锁,写的时候忘了加,那写操作就可能和读操作撞上。
6.3 用日志和断点辅助定位
同步问题难复现,所以日志要打足。我一般会在加锁前后、条件等待前后、共享变量修改前后都打上带线程 ID 和时间戳的日志。这样一旦出问题,能还原出各个线程的执行时序,看出是谁在什么时候插了队。
不过要注意,打日志本身会影响时序,有些问题加了日志就复现不出来了。这种情况可以用更轻量的方式,比如记录关键操作的序号,或者用专门的并发测试工具反复压测。
6.4 几个我踩过的真实坑
第一个坑是在锁里做耗时操作。有次我在锁保护的代码块里调了一个网络请求,结果所有线程都堵在那把锁上,吞吐量惨不忍睹。后来把网络请求挪到锁外面,只在更新共享状态时加锁,性能立刻上来了。教训是:锁里只放必须原子执行的代码,任何 IO、计算、等待都尽量挪出去。
第二个坑是用可变对象做锁。有些语言里锁可以是任意对象,但如果这个对象被重新赋值了,锁就换了,同步就失效了。所以锁对象一旦创建就不要改,最好用专门的锁类型。
第三个坑是忘记处理虚假唤醒。条件变量的 wait 可能在没有 notify 的情况下自己醒来(虽然罕见,但标准允许),所以条件检查必须用 while 循环,醒来后重新验证条件。用 if 的话,虚假唤醒后会直接往下执行,访问到还没准备好的数据。
第四个坑是锁的嵌套顺序不一致。两个模块各自加锁,A 模块先锁 X 再锁 Y,B 模块先锁 Y 再锁 X,单独测都没问题,一组合就死锁。这种问题在大型项目里特别隐蔽,我的经验是给所有锁定一个全局顺序,所有代码都按这个顺序加锁。
7. 写在最后的一点个人体会
线程同步这件事,理论看着简单,真写起来坑一个接一个。我自己的原则是:能不用共享状态就不用。如果每个线程能有自己独立的数据,通过消息传递来协作,那大部分同步问题根本不会出现。这也是为什么很多现代并发模型(比如 Actor 模型、协程加通道)都在强调"不要通过共享内存来通信,而要通过通信来共享内存"。
但现实里完全避开共享状态不现实,所以锁还是得会用。我的建议是,先把互斥锁和条件变量这两个基础工具吃透,它们能覆盖八成以上的场景。信号量、读写锁、原子操作这些,等遇到具体需求再学,别一上来就贪多。写并发代码的时候,宁可保守一点多加点锁,也别为了性能冒险省锁——正确性永远排在性能前面。
最后分享一个我常用的自检清单,写完任何一段并发代码,我都会过一遍:所有共享变量的访问路径都加锁了吗?锁的粒度是不是刚好?有没有可能死锁?条件等待用的是 while 吗?异常路径下锁能正常释放吗?这几个问题都过了,代码基本就稳了。