线程同步机制:Lock锁、RLock与死锁

各位同学,今天我们来聊聊多线程编程里最核心、也最容易踩坑的话题——线程同步。说白了,就是多个线程抢资源的时候,怎么保证不出乱子。

我在早年做支付系统的时候,就吃过线程不同步的亏。两个线程同时扣款,结果余额变成了负数。嗯,那场面,至今难忘。所以今天的内容,我建议你认真看。

一、为什么需要Lock锁?

先想一个问题:两个线程同时修改同一个变量,会发生什么?

我举个例子。假设有个全局变量 counter = 0,两个线程各自对它执行100万次 counter += 1。你猜最终结果是多少?

理论上应该是200万。但实际跑起来,可能只有180万,甚至更少。

为什么会这样?因为 counter += 1 不是原子操作。它拆成了三步:

  1. 读取 counter 的值
  2. 计算 counter + 1
  3. 写回 counter

两个线程同时执行,就可能出现「读取-计算-写回」的交叉。比如线程A读到0,线程B也读到0,各自加1后写回,结果还是1。明明加了两次,却只加了1。这就是经典的「竞态条件」。

核心结论: 当多个线程访问共享资源时,必须用锁来保证互斥访问。

二、Lock锁的基本用法

Python的 threading.Lock 是最基础的同步工具。用法很简单:

import threading

lock = threading.Lock()
counter = 0

def increment():
    global counter
    for _ in range(1000000):
        lock.acquire()
        counter += 1
        lock.release()

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter)  # 输出 2000000

这里 acquire() 和 release() 必须成对出现。我习惯用 with 语句来写,更安全:

def increment():
    global counter
    for _ in range(1000000):
        with lock:
            counter += 1

用 with 的好处是,即使中间抛异常,锁也会自动释放。你想想看,要是忘了写 release(),其他线程就永远拿不到锁了——这就是死锁的雏形。

三、RLock可重入锁

Lock 有个限制:同一个线程不能多次 acquire() 同一个锁。否则会把自己锁死。

举个例子:

lock = threading.Lock()
lock.acquire()
lock.acquire()  # 这里会阻塞,死锁!

这在递归调用或嵌套锁的场景下特别常见。比如:

def func1():
    with lock:
        func2()

def func2():
    with lock:  # 同一个线程再次申请同一个锁
        print("hello")

用普通 Lock,func2 永远等不到锁释放。这时候就需要 RLock(可重入锁)。

我的经验: 我在写一个缓存模块时,遇到过这个坑。一个函数加锁后调用了另一个加锁的函数,结果程序卡死了。排查了半天才发现是锁不可重入。后来统一改用 RLock,问题解决。

RLock 允许同一个线程多次 acquire(),内部维护了一个计数器。每次 acquire() 加1,release() 减1,只有减到0时,其他线程才能获取。

rlock = threading.RLock()

def func1():
    with rlock:
        func2()

def func2():
    with rlock:  # 同一个线程,没问题
        print("hello")

简单总结一下区别:

特性 Lock RLock
同一线程多次 acquire 阻塞(死锁) 允许
内部实现 二进制信号量 计数器+所有者
适用场景 简单互斥 递归/嵌套调用

四、死锁的产生

死锁,说白了就是「两个线程互相等对方释放锁,结果谁也动不了」。

经典场景是这样的:

lock_a = threading.Lock()
lock_b = threading.Lock()

def thread1():
    with lock_a:
        time.sleep(0.1)
        with lock_b:
            print("thread1 done")

def thread2():
    with lock_b:
        time.sleep(0.1)
        with lock_a:
            print("thread2 done")

线程1持有lock_a,等lock_b。线程2持有lock_b,等lock_a。两个线程都在等对方释放,程序就永远卡住了。

避坑指南: 我曾经在一个分布式任务调度系统里,因为锁的顺序不一致,导致整个集群的任务队列卡死。排查了整整一个下午,最后发现是A模块先锁A再锁B,B模块先锁B再锁A。从那以后,我定了个规矩:所有锁的获取顺序必须全局统一。

五、如何避免死锁

避免死锁,有几种常用策略:

  1. 固定锁顺序:所有线程按相同顺序获取锁。比如都先拿lock_a再拿lock_b。
  2. 超时机制:用 acquire(timeout) 设置超时,拿不到就放弃。
  3. 锁分级:给锁分配等级,只能从低等级往高等级获取。

我比较推荐第一种,简单有效。比如上面的例子,只要把thread2改成先拿lock_a再拿lock_b,死锁就解了。

超时机制也很实用:

if lock_a.acquire(timeout=5):
    try:
        if lock_b.acquire(timeout=5):
            try:
                # 业务逻辑
            finally:
                lock_b.release()
    finally:
        lock_a.release()
else:
    print("获取锁超时,做降级处理")

嗯,这里要注意:超时后一定要做降级处理,不能直接忽略。否则数据可能不一致。

六、知识体系总览

下面这张图,帮你理清本章的核心脉络:

线程同步机制知识体系 线程同步 Lock锁 RLock可重入锁 死锁 acquire/release with语句 可重入特性 计数器机制 产生原因 避免策略 核心:互斥访问 + 避免死锁

七、总结

今天的内容,说白了就三件事:

  • Lock:最基本的互斥锁,适合简单场景
  • RLock:可重入锁,适合递归或嵌套调用
  • 死锁:两个线程互相等待,必须通过固定顺序或超时来避免

我个人建议,在不确定的场景下,优先用RLock。虽然性能略低一点,但能避免很多隐晦的死锁问题。你想想看,一个线上bug排查的成本,可比这点性能开销大多了。

好了,今天就到这里。记住一句话:锁是工具,不是目的。能用简单锁解决的问题,别搞复杂了。

可可精品资料库