3. 线程同步与锁:Lock锁的基本使用、可重入锁RLock、死锁的产生与避免

说到多线程,有个问题绕不开——数据竞争。说白了,就是多个线程同时抢着修改同一份数据,结果数据乱套了。

我刚开始写多线程程序时,就吃过这个亏。一个计数器,两个线程各加100万次,结果跑出来不是200万,而是199万8千多。当时我还以为是Python的bug,查了半天……嗯,后来才明白,这是典型的线程不安全。

怎么解决?用锁。

3.1 Lock锁:最基础的同步工具

Python的threading.Lock是最简单的锁。它只有两个方法:acquire()和release()。一个线程拿到锁,其他线程就得等着。

看个例子:

import threading

counter = 0
lock = threading.Lock()

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

加了锁之后,结果就对了。为什么?因为counter += 1这个操作,在CPU层面其实是三步:读、加、写。不加锁的话,两个线程可能同时读到同一个值,然后各自加1再写回去——结果只加了1次。这就是所谓的竞态条件。

核心要点:锁的本质是让一段代码变成「原子操作」——要么不执行,要么一口气执行完,中间不能被其他线程打断。

我个人习惯用with语句来管理锁,省得忘记release():

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

这样写,锁会自动释放。哪怕中间抛了异常,锁也会被释放。我建议你养成这个习惯。

3.2 可重入锁RLock:同一个线程能重复拿锁

普通锁有个限制:同一个线程不能连续acquire()两次。否则会死锁——自己把自己锁死了。

举个例子:

lock = threading.Lock()
lock.acquire()
lock.acquire()  # 这里就卡死了!线程在等自己释放锁

为什么会这样?因为普通锁不记录「是谁拿的锁」。它只知道「锁被拿了」,但不知道是被谁拿的。所以第二次acquire()时,它发现锁已经被占用了,就老老实实等着——结果等的是自己。

RLock(可重入锁)解决了这个问题。它记录了「持有锁的线程」和「重入次数」:

rlock = threading.RLock()
rlock.acquire()
rlock.acquire()  # 没问题,重入次数变成2
rlock.release()  # 重入次数变回1
rlock.release()  # 重入次数变回0,锁真正释放

我在项目中遇到过这样一个场景:一个递归函数需要加锁,如果用普通锁,递归到第二层就死锁了。换成RLock,问题迎刃而解。

小技巧:如果你不确定用Lock还是RLock,优先用RLock。它更安全,性能损失也几乎可以忽略。我个人的代码里,90%的情况都用RLock。

3.3 死锁:最让人头疼的问题

死锁,说白了就是「你等我,我等你,谁也别想动」。两个线程各自拿着一把锁,又都在等对方手里的锁——这就僵住了。

看个经典例子:

import threading

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

def worker_1():
    with lock_a:
        print("worker_1 拿到锁A")
        with lock_b:
            print("worker_1 拿到锁B")

def worker_2():
    with lock_b:
        print("worker_2 拿到锁B")
        with lock_a:
            print("worker_2 拿到锁A")

t1 = threading.Thread(target=worker_1)
t2 = threading.Thread(target=worker_2)
t1.start()
t2.start()

这段代码跑起来,大概率会卡住。worker_1拿了A等B,worker_2拿了B等A——死锁了。

警告:死锁的程序不会报错,也不会崩溃,就是卡在那里不动。CPU占用率可能为0,也可能很高(如果线程在忙等)。排查起来很头疼。

3.4 如何避免死锁?

我曾经在一个线上服务里排查过死锁问题。那个服务每隔几天就卡死一次,重启就好。查了两天才发现,是三个线程互相等锁。从那以后,我总结了几条经验:

  1. 固定锁的顺序——所有线程都按同样的顺序拿锁。比如都先拿A再拿B,就不会出现循环等待。
  2. 使用超时机制——acquire(timeout=5),拿不到锁就放弃,别死等。
  3. 尽量少用多把锁——能用一把锁解决的问题,别用两把。
  4. 用RLock代替Lock——避免自己锁自己的情况。

其中第一条最实用。你想想看,如果所有线程都约定好「先拿A再拿B」,那worker_2就不会先拿B了,死锁自然消失。

def worker_2():
    with lock_a:  # 先拿A
        print("worker_2 拿到锁A")
        with lock_b:  # 再拿B
            print("worker_2 拿到锁B")

就这么简单的一改,死锁就没了。

3.5 本章知识体系

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

线程同步与锁 — 知识体系 线程同步与锁 Lock 基本锁 acquire() / release() with 语句自动管理 RLock 可重入锁 记录线程 + 重入次数 递归场景首选 死锁 循环等待资源 固定顺序 / 超时机制 核心原则:能少用锁就少用,能用一把别用两把

这张图把本章的三个核心知识点串起来了。左边是Lock,中间是RLock,右边是死锁。你顺着箭头看,就能理清它们之间的关系。

最后说一句:锁不是银弹。锁用多了,性能会下降,甚至可能比单线程还慢。我见过有人给每一行代码都加锁——那还不如不用多线程。合理的做法是:只给真正需要保护的共享资源加锁,锁的粒度越小越好。

本章小结:

  • Lock是最基本的锁,适合简单场景
  • RLock支持重入,适合递归或嵌套调用
  • 死锁的根源是循环等待,固定锁顺序是最有效的避免方法
  • 用with管理锁,省心又安全

可可精品资料库