想象一个大厨房里,十位厨师同时开工。一位厨师把刚做好的蛋糕放进共享冰箱,另一位厨师同时伸手去拿——手一滑,蛋糕摔了,灶台一片狼藉。这就是多线程程序里的“竞态条件”:多个线程同时读写同一份数据,结果互相踩踏。Python的threading锁,就是厨房里那把带锁的柜门——谁要用共享资源,先拿钥匙,用完再还,别人想用就得排队等。这篇文章会用这个厨房比喻,带你认识四种“门锁”:Lock互斥锁像单人洗手间,RLock可重入锁像自家房门钥匙能反复进,Semaphore信号量像限流闸机只放固定人数,Condition条件变量像后厨对讲机——厨师们互相通知“菜备好了”。你还会看到死锁这个最可怕的“厨房僵局”:两个厨师各拿一把钥匙,都在等对方手里的另一把,谁都不肯撒手。读完这篇,你就能在自己的多线程项目里给共享数据装上正确的锁,告别数据错乱和程序卡死。

岗位关联

对AI从业者而言,锁机制是构建线程安全服务的基石。无论你是写数据管道、做模型服务并发,还是训练多进程RL,锁都是防止数据错乱、保证结果可复现的必修课。

目录
  1. 可视化演示
  2. 竞态条件:厨房里的蛋糕事故
  3. Lock互斥锁:单人洗手间
  4. RLock可重入锁:自家房门的钥匙
  5. Semaphore信号量:限流闸机
  6. Condition条件变量:后厨对讲机
  7. 死锁与避免:两个僵持不下的厨师
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

通过Lock互斥锁的acquire/release机制、Semaphore信号量并发控制、以及死锁循环等待场景三个模式,展示Python多线程锁机制的核心原理与陷阱。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Lock互斥锁基础原理 Lock确保同一时刻只有一个线程进入临界区避免数据竞争 acquire获取锁release释放锁其他线程阻塞等待 lock.acquire() → critical section → lock.release() 互斥锁是多线程保护共享数据的基础工具

竞态条件:厨房里的蛋糕事故

厨房里最怕什么?最怕两个厨师同时伸手去端同一盘菜。你正准备把蛋糕从冰箱拿出来,旁边一个新手厨师也在同一秒打开了冰箱门——两只手同时碰到蛋糕盘,整个蛋糕糊在了地上。在Python多线程世界里,这种事故有一个学名:竞态条件。当两个或更多线程同时读写同一份共享数据,而操作顺序无法预测时,最后的结果取决于谁跑得更快,而不是谁的逻辑更对——这就是数据混乱的根源。

为什么会这样?因为“从冰箱拿蛋糕”这个动作并非原子操作。它其实分了好几步:打开冰箱门 → 伸手 → 抓住盘子 → 端出来。如果在“抓住盘子”之后、“端出来”之前,另一个线程插手了,蛋糕就掉了。在程序里,counter += 1 看起来是一行代码,实际在CPU层面却是“读取值 → 加1 → 写回”三步。线程A读取到100,还没来得及写回101,线程B也读取到了100——两个线程都写回101,结果应该是102的计数器变成了101,数据就这样被悄无声息地损坏了。

要解决竞态条件,核心就是保证原子性——把“打开冰箱门拿蛋糕”这一系列动作变成一个不可分割的整体。锁,就是为这件事而生的。它像厨房里的领取窗口:无论有多少厨师想进来,窗口一次只放一个人进来拿东西,拿完关上窗口,下一位再进。

回到厨房:理解了竞态条件,你就明白了为什么要给共享数据加锁。锁不是万能的,但没有锁,多线程的厨房必然一片狼藉。下一节,我们来看最基础的那把锁——Lock互斥锁。

竞态条件=多线程乱序执行∧¬原子性\text{竞态条件} = \text{多线程乱序执行} \land \neg \text{原子性}
这个式子想表达:竞态条件 = 多线程乱序执行并且缺少原子性。就像厨房里多个人同时动冰箱,却没有一个人能独占冰箱,事故就必然发生。
  1. 多线程乱序执行多个线程的指令交织执行,顺序不确定。
    多线程乱序执行:线程A和B的执行步骤任意交错\text{多线程乱序执行} : \text{线程A和B的执行步骤任意交错}
  2. 缺少原子性关键操作(读-改-写)被插入中断,不再是不可分割的整体。
    ¬原子性:读值与写回之间存在时间窗\neg \text{原子性} : \text{读值与写回之间存在时间窗}
  3. 合取关系两个条件同时成立,竞态条件才会产生。
    竞态条件  ⟺  乱序执行∧¬原子性\text{竞态条件} \iff \text{乱序执行} \land \neg \text{原子性}

Lock互斥锁:单人洗手间

Lock互斥锁就像写字楼里的单人洗手间:门一锁,外面的人只能排队等着,里面的人出来了,下一位才能进。在Python中,threading.Lock 就是这样一个简单的对象。它只有两个核心方法:acquire() 相当于锁门,release() 相当于开门。任何时刻,最多只有一个线程能“锁上门”进入临界区。

这解决了竞态条件吗?解决了。回到计数器例子:每个线程在执行 counter += 1 之前先 lock.acquire(),操作完成后再 lock.release()。由于锁的互斥性,线程A在读-改-写的过程中,线程B无法插入——它停在 acquire() 那一步等着,直到A释放锁才继续。这样,计数器的值就永远正确了。

但单人洗手间有个问题:如果马桶堵了(线程崩溃或忘记释放锁),外面的人永远等不到开门。Python的Lock支持 with 语句,用上下文管理器自动释放锁——相当于规定“用完洗手间必须冲水关门”,即使中途异常退出,锁也会被自动释放。这是最推荐的写法。

回到厨房比喻:Lock就是那个单人洗手间,简单直接,但有个隐藏的坑——如果你在持锁的代码里又调用了另一个需要同一把锁的函数,就会自己把自己锁在门外死等。这就是Lock不能重入的问题。下一节的可重入锁RLock,正是为这个场景准备的。

Lock:acquire()→临界区→release()\text{Lock} : \text{acquire()} \rightarrow \text{临界区} \rightarrow \text{release()}
这段式子表示Lock的使用范式:获取锁 → 进入临界区执行共享数据操作 → 释放锁。就像进洗手间锁门 → 上厕所 → 开门出来。
  1. acquire()加锁尝试获取锁。如果锁空闲则获取成功,否则阻塞等待。
    acquire():锁空闲⇒获取成功;锁占用⇒阻塞\text{acquire()} : \text{锁空闲} \Rightarrow \text{获取成功;锁占用} \Rightarrow \text{阻塞}
  2. 临界区操作被锁保护的共享数据操作代码段,同一时刻只能有一个线程进入。
    临界区:互斥访问⇒同时最多一个线程执行\text{临界区} : \text{互斥访问} \Rightarrow \text{同时最多一个线程执行}
  3. release()解锁释放锁,唤醒等待队列中的一个线程。
    release():释放锁⇒唤醒一个等待线程\text{release()} : \text{释放锁} \Rightarrow \text{唤醒一个等待线程}
python17 行
import threading lock = threading.Lock()counter = 0 def increment():    global counter    for _ in range(1000):        with lock:            counter += 1 threads = [threading.Thread(target=increment) for _ in range(10)]for t in threads:    t.start()for t in threads:    t.join()print(counter)  # 10000,永远正确

RLock可重入锁:自家房门的钥匙

RLock可重入锁,就像你自家房门的钥匙。你出门后进了自家院子,发现钥匙落在房间里,但没关系——这是你家,你能用备用钥匙再开一次。RLock允许同一个线程多次获取锁而不会阻塞自己,而Lock不行。这在递归函数或嵌套调用中特别有用。

如果用Lock,这个递归函数在第二次 acquire() 时就会永久阻塞——你自己把自己锁在门外了。换成RLock就安全了:

RLock的实现靠一个计数器:每次 acquire() 加1,每次 release() 减1,只有计数归零时锁才真正释放。就像你每次进家门都推一下计数门闩,只有把所有门闩都退掉,门才真正锁死。

回到厨房:RLock就像你家的房门钥匙,自己人可以反复进出,但外人还是进不来。什么场景需要RLock?当一个持锁函数内部又调用了另一个需要同一把锁的函数时,RLock救你一命。它让你不用费心去拆解嵌套结构,锁的粒度更友好。

RLock 状态=(持有线程,重入计数)\text{RLock 状态} = (\text{持有线程}, \text{重入计数})
RLock内部维护两个信息:当前持有锁的线程,以及该线程获取锁的次数。只有持有线程再次acquire才会计数增加,其他线程acquire仍然会被阻塞。
  1. 持有线程记录当前拥有锁的线程ID,其他线程尝试获取会被阻塞。
    持有者=当前获取锁的线程\text{持有者} = \text{当前获取锁的线程}
  2. 重入计数同一线程重复获取的次数,每acquire一次加1。
    计数=累计 acquire−累计 release\text{计数} = \text{累计 acquire} - \text{累计 release}
  3. 释放条件计数归零时锁才真正释放,等待线程才能获取。
    真正解锁  ⟺  计数=0\text{真正解锁} \iff \text{计数} = 0
text6 行
lock = threading.Lock()def recursive(n):    with lock:          # 第一次获取成功        if n == 0:            return        recursive(n-1)  # 第二次获取——死锁!
text7 行
rlock = threading.RLock()def recursive(n):    with rlock:          # 第一次获取,计数=1        if n == 0:            return        recursive(n-1)  # 第二次获取,计数=2,不阻塞# 完全释放需要等所有嵌套的 with 退出

Semaphore信号量:限流闸机

Semaphore信号量,就像地铁站入口的限流闸机——闸机只有N个通道,最多同时放N个人进站,第N+1个人必须等在闸机外。这跟Lock的区别在于:Lock的闸机只有1个通道(二进制信号量),Semaphore可以有N个通道。

Semaphore的应用场景非常典型:连接池限制。假设你的程序要访问数据库,但数据库最多只能承受5个并发连接。你不用Semaphore的话,100个线程同时怼上去,数据库直接崩溃。用 Semaphore(5) 限流,最多5个线程同时拿到“数据库使用权”,其他线程排队等,数据库安全了。

从实现上看,Semaphore内部维护一个计数器:acquire() 时计数减1(若计数为0则阻塞),release() 时计数加1(唤醒一个等待线程)。它和Lock的真正区别是——Lock是“排他”的,Semaphore是“限量”的;Lock管的是一个资源,Semaphore管的是一个资源池。

回到厨房:Semaphore像是后厨的“烤箱取用牌”——烤箱只有两个,想做烤鸭的厨师来了先拿牌,没牌就等着。前面几位用完还牌,下一位才能进。这种“限量通行”的思路在需要控制并发规模时非常实用。不过Semaphore只管数量不管条件——它不关心资源是否“准备好了”。下一节要讲的Condition,才管“什么时候准备好了”。

Semaphore:计数>0⇒acquire 成功,计数减1\text{Semaphore} : \text{计数} > 0 \Rightarrow \text{acquire 成功,计数减1}
计数=0⇒acquire 阻塞\text{计数} = 0 \Rightarrow \text{acquire 阻塞}
信号量公式:计数表示剩余可用资源数量。获取信号量时如果还有可用名额就扣减一个名额并进入;没有名额了就得排队等候。
  1. 计数表示剩余资源初始值 N 表示 N 个可用资源(闸机通道数)。
    初始值=N⇒最多 N 个线程并发访问\text{初始值} = N \Rightarrow \text{最多 N 个线程并发访问}
  2. acquire 扣减计数成功获取信号量,剩余资源数减1。
    acquire():计数←计数−1\text{acquire()} : \text{计数} \leftarrow \text{计数} - 1
  3. 计数为零阻塞所有资源被占完,新线程必须等待。
    计数=0⇒acquire 阻塞等待\text{计数} = 0 \Rightarrow \text{acquire 阻塞等待}
text7 行
semaphore = threading.Semaphore(3)  # 最多3个线程同时进入 def worker():    with semaphore:      # 获取信号量,计数减1        # 访问有限资源        time.sleep(1)    # 释放信号量,计数加1
text4 行
pool = threading.Semaphore(5)def query():    with pool:           # 最多5个线程同时进入        db.execute("SELECT ...")

Condition条件变量:后厨对讲机

Condition条件变量,是后厨里的对讲机系统。想象这个场景:烤箱里的蛋糕需要45分钟才能烤好,烤好的那一刻,烤箱“叮”一声,做蛋糕的师傅通知所有等待的客人来取蛋糕。如果没有对讲机,客人们只能围在烤箱前干等,或者每隔一分钟敲门问一次——前者是死等(忙等待),后者是轮询(浪费CPU)。

Condition有两个核心方法:wait() 让当前线程释放锁并挂起等待;notify() 唤醒一个或所有等待的线程。它的标准用法是配合Lock使用:

生产者和消费者模式是Condition最经典的舞台。生产者负责“做菜”,做好后通过 notify() 喊一声“菜好了”;消费者负责“端菜”,没菜时通过 wait() 安心等待而不是空转。关键细节是:wait() 会自动释放锁并挂起线程,被唤醒后会重新获取锁再继续执行——这保证了对共享数据检查的安全性。

回到厨房:Condition就像后厨的对讲机——客人不用干等,听到“叮”一声再来取就行。它比Semaphore高级的地方在于:Semaphore只告诉你“能不能进”,Condition告诉你“什么时候可以进”。当你的线程需要等待某个条件成立再继续时(比如队列有数据了、资源准备好了),Condition是正确选择。

Condition:wait()释放锁挂起⇒notify()唤醒⇒重新竞争获取锁\text{Condition} : \text{wait()释放锁挂起} \Rightarrow \text{notify()唤醒} \Rightarrow \text{重新竞争获取锁}
条件变量公式:wait 会释放锁并让线程休眠,notify 唤醒等待线程,被唤醒的线程需要重新获取锁才能继续执行——这是防止条件检查被插队的关键机制。
  1. wait释放锁挂起当前线程释放持有的锁并进入等待队列,不占用CPU。
    wait():释放锁∧线程挂起\text{wait()} : \text{释放锁} \land \text{线程挂起}
  2. notify唤醒等待者生产者通知等待线程“条件满足了”,一次唤醒一个等待线程。
    notify():唤醒等待队列中的一个线程\text{notify()} : \text{唤醒等待队列中的一个线程}
  3. 重新获取锁被唤醒的线程需要重新竞争锁后才能继续执行后续代码。
    唤醒后:重新获取锁⇒继续执行\text{唤醒后} : \text{重新获取锁} \Rightarrow \text{继续执行}
text13 行
condition = threading.Condition() def consumer():    with condition:        while not data_ready:            condition.wait()       # 释放锁,挂起等待        process(data)              # 被唤醒后处理数据 def producer():    with condition:        data = produce_data()        data_ready = True        condition.notify()         # 唤醒等待的消费者

死锁与避免:两个僵持不下的厨师

最要命的厨房事故来了:两位厨师各持一把钥匙——厨师A端着锅要开储物柜(锁1),厨师B抱着食材罐准备开灶台(锁2)。A需要锁2才能放下锅去开锁1,B需要锁1才能放下罐子去开锁2。两个人就这样站在原地,谁都不肯先放手,厨房彻底瘫痪——这就是死锁。

死锁发生的四个必要条件(缺一不可):互斥(资源一次只能被一个线程使用)、持有并等待(线程持有一个资源又在等待另一个)、非抢占(资源不能被强制夺走)、循环等待(线程之间形成环路等待对方的资源)。这不是理论,是真实程序里会遇到的灾难:

怎么避免死锁?最有效的策略是顺序加锁——保证所有线程都以相同的顺序获取锁。转账的例子中,让两个线程都先锁“账户编号小的那个账户”,环路等待就断了。

另一个兜底方案是 lock.acquire(timeout=...)——设定超时时间,拿不到锁就放弃并回滚操作,避免无限期僵持。还有 tryacquire() 非阻塞尝试获取锁,获取失败立即返回而不是傻等。

回到厨房:死锁的悲剧根源在于“贪心”——每个厨师都想同时握住两把钥匙。解决之道是规矩:大家都按“先锁一号柜”的顺序来,谁也抢不了谁的。顺序加锁、设置超时、tryacquire,这些就是化解厨房僵局的三大法宝。

死锁  ⟺  互斥∧持有并等待∧非抢占∧循环等待\text{死锁} \iff \text{互斥} \land \text{持有并等待} \land \text{非抢占} \land \text{循环等待}
四个必要条件同时满足,死锁才成立。打破任何一个就能打破死锁——最常见的是“顺序加锁”打破循环等待条件。
  1. 互斥条件资源同一时刻只能被一个线程占用,不能共享。
    互斥:资源独占\text{互斥} : \text{资源独占}
  2. 持有并等待线程持有已有资源的同时,还在等待获取其他资源。
    持有并等待:持有一个资源∧等待另一个资源\text{持有并等待} : \text{持有一个资源} \land \text{等待另一个资源}
  3. 循环等待多个线程形成等待环路,A等B、B等A。
    循环等待:T1→T2→⋯→T1\text{循环等待} : T_1 \rightarrow T_2 \rightarrow \cdots \rightarrow T_1
  4. 破局之道四个条件中任意一个不成立,死锁即可避免。
    破局:¬互斥∨¬等待∨¬非抢占∨¬循环等待\text{破局} : \neg \text{互斥} \lor \neg \text{等待} \lor \neg \text{非抢占} \lor \neg \text{循环等待}
python9 行
def transfer(from_acct, to_acct, amount):    with from_acct.lock:       # 先锁自己的账户        with to_acct.lock:     # 再锁对方的账户            from_acct.balance -= amount            to_acct.balance += amount # 线程1: transfer(A, B, 100)# 线程2: transfer(B, A, 100)# A被线程1锁住,B被线程2锁住,双方都等对方锁的资源——死锁!
python6 行
def transfer(from_acct, to_acct, amount):    acct1, acct2 = sorted([from_acct, to_acct], key=lambda a: a.id)    with acct1.lock:        with acct2.lock:            from_acct.balance -= amount            to_acct.balance += amount
python25 行
import threadingimport time # 死锁复现def deadlock_demo():    lock1 = threading.Lock()    lock2 = threading.Lock()     def worker_a():        with lock1:            time.sleep(0.01)            with lock2:   # 等待锁2,而锁2可能在B手中                pass     def worker_b():        with lock2:            time.sleep(0.01)            with lock1:   # 等待锁1,而锁1在A手中                pass     t1 = threading.Thread(target=worker_a)    t2 = threading.Thread(target=worker_b)    t1.start(); t2.start()    t1.join(timeout=1); t2.join(timeout=1)    print("死锁发生:两个线程互相等待,卡死不动")

AI 应用场景

场景1:多线程Dataloader中的共享数据保护

阶段:数据准备与预处理

解决的问题:在训练AI模型时,多个线程同时读取并预处理数据(如图像缩放、文本tokenize),共享的数据索引表和缓存字典容易被并发写坏。传统做法是加一个巨大的全局锁,但性能骤降。用细粒度的RLock分别保护索引计数器、样本缓存和日志队列,既保证数据安全又保持并发吞吐。

具体案例:一个图像分类数据集有10万张图片,4个预处理线程同时读取数据并写入缓存。如果不加锁,tokenize结果时好时坏——同一个batch的数据有时对有时错。给共享的 cache_dict 加RLock后,线程A写缓存时线程B读取会等锁释放,数据一致性得到保证。

场景2:模型推理服务的连接池限流

阶段:模型部署与推理

解决的问题:AI模型服务需要同时响应大量推理请求,但GPU显存或数据库连接数有限。使用Semaphore信号量控制并发推理的线程数,避免100个请求同时涌入导致GPU OOM或数据库连接风暴。

具体案例:一个BERT服务部署在单张GPU上,显存只能同时跑4个batch推理。用 Semaphore(4) 控制推理线程并发数,第5个请求到达时优雅排队等待,请求平均延迟稳定,GPU显存占用安全。

场景3:强化学习环境中的条件同步

阶段:强化学习训练循环

解决的问题:在异步RL训练中,多个环境交互线程产生经验数据,训练线程负责消费。用Condition条件变量实现生产者-消费者模型:环境线程通过notify唤醒训练线程处理新数据,训练线程无数据时wait等待,避免忙轮询浪费CPU。

具体案例:在分布式PPO训练中,8个环境交互线程持续产出 (state, action, reward) 经验元组,训练线程批量消费。没有Condition时训练线程需要不断巡检经验池是否有数据,CPU空转严重。引入Condition后,交互线程产出一条经验就notify一次,训练线程wait挂起被精准唤醒,CPU使用率下降40%。

Python 演示

python82 行
"""线程锁四件套演示:计数器保护 + 可重入 + 信号量限流 + 条件同步"""import threadingimport timeimport random class SharedCounter:    def __init__(self):        self.value = 0        self.lock = threading.RLock()     def increment(self):        with self.lock:            self.value += 1            # 嵌套调用Rlock仍然安全            self.check()     def check(self):        with self.lock:            if self.value % 1000 == 0:                print(f"计数到: {self.value}") def lock_demo():    counter = SharedCounter()    threads = [threading.Thread(target=counter.increment)               for _ in range(10)]    for t in threads:        t.start()    for t in threads:        t.join()    print(f"Lock/RLock保护结果: {counter.value}") def semaphore_demo():    pool = threading.Semaphore(2)    active = []     def worker(idx):        with pool:            active.append(idx)            print(f"线程{idx}进入, 活跃数={len(active)}")            time.sleep(random.uniform(0.1, 0.3))            active.remove(idx)     threads = [threading.Thread(target=worker, args=(i,))               for i in range(5)]    for t in threads:        t.start()    for t in threads:        t.join()    print("信号量限流完成") def condition_demo():    cv = threading.Condition()    items = []     def producer():        for i in range(3):            with cv:                items.append(i)                print(f"生产: {i}")                cv.notify_all()            time.sleep(0.1)     def consumer():        for _ in range(3):            with cv:                while not items:                    cv.wait()                item = items.pop(0)                print(f"消费: {item}")     p = threading.Thread(target=producer)    c = threading.Thread(target=consumer)    c.start()    p.start()    p.join()    c.join()    print("条件变量同步完成") if __name__ == "__main__":    lock_demo()    semaphore_demo()    condition_demo()
目的:演示Python threading四种锁的典型用法——RLock保护计数器、Semaphore控制连接池并发量、Condition实现生产者消费者模式。
  1. counter = SharedCounter()创建带RLock保护的共享计数器对象。
  2. with self.lock:使用上下文管理器自动获取并释放锁,即使在异常时也能安全解锁。
  3. self.check()持锁时调用另一个需要同一把锁的方法,验证RLock的可重入性。
  4. pool = threading.Semaphore(2)创建容量为2的信号量,限制同时进入的线程数。
  5. with pool:获取信号量后进入有界资源区,退出时自动释放。
  6. cv = threading.Condition()创建条件变量,与锁配套使用实现线程间的等待与通知。
  7. cv.wait()消费者无数据时释放锁并挂起等待,不忙消耗CPU。
  8. cv.notify_all()生产者产出数据后唤醒全部等待的消费者线程。
  9. time.sleep(random.uniform(0.1, 0.3))模拟每个线程占用有限资源的时间不同,制造竞争。

常见误区

误区我在代码里加了 `threading.Lock()`,就再也不会有竞态条件了,可以放心并发读写所有变量。

正解错。锁只保护被 acquire()/release()(或 with lock)包裹的那段代码。如果你在临界区之外仍然直接读取共享变量,那些读取操作依然会被并发干扰。锁就像厨房的储物柜门——只有放进柜子里的东西才是安全的,放在柜子外面的食材该被偷还是被偷。正确做法是:所有对共享变量的读和写,都必须统一在锁的保护下进行,一个都不能漏。

为何常见:初学者往往只关注“写”操作加锁,忽略了“读”也需要加锁。没有锁保护的一致性读,读到的可能是另一个线程写了一半的中间状态。

误区RLock支持“重入”,所以多个线程可以同时进入临界区执行代码。

正解完全错误。RLock的“可重入”指的是同一个线程可以多次获取锁而不阻塞自己。RLock内部记录了持有锁的线程ID——持锁者可以反复进入,其他线程照样被拒之门外。这就像你家房门钥匙只能自己用,别人拿不到进不来。如果RLock真让多线程同时进入,那它就不是锁而是信号量了。

为何常见:把“重入”理解成了“多人进入”,这是字面意思的误导。实际上“重入” = 同一线程重新进入自己被允许进入的区域,是垂直方向上的递归嵌套,不是水平方向上的多方共享。

误区程序卡死了,肯定是哪里忘了加锁,多补几个锁就能解决问题。

正解恰恰相反,死锁通常是锁太多而顺序混乱导致的。死锁的四个条件是互斥、持有并等待、非抢占、循环等待——多条锁互相嵌套时就容易形成环路等待。就像两个厨师各拿一把钥匙死等对方——问题不在“没有钥匙”,而在“每人手里只有一把钥匙还想要另一把”。解决死锁靠的是统一加锁顺序、tryacquire 超时放弃,而不是简单堆叠更多的锁。

为何常见:遇到卡死第一反应是“保护不足”,很少有人想到“锁多了互抢”才是罪魁祸首。死锁和竞态条件是两种相反的并发问题——竞态是“抢得太乱”,死锁是“等得太死”。

误区Semaphore不就是带有计数功能的Lock吗?用哪个都一样,反正都是防止并发问题。

正解语义不同,不能随意替换。Lock是互斥——同一时刻只允许一个线程访问,适合保护一份独占资源(文件、单个数据库连接)。Semaphore是限流——允许N个线程同时访问,适合管理一个资源池(连接池、线程池)。用 Semaphore(1) 确实等价于Lock(称为二元信号量),但 Semaphore(3) 允许3个线程同时进入,这在Lock下根本无法实现。选择哪种锁取决于你要保护的是“单个资源”还是“一组资源”。

为何常见:看起来都是“获取/释放”的模式,但背后的并发模型完全不同。把 Semaphore(1) 当Lock用没问题,把 Semaphore(5) 当Lock用就是灾难——五个线程同时写同一个文件。

误区`condition.wait()` 就是让线程sleep休息一下,等时间到了再醒过来继续跑。

正解wait() 和 sleep() 有本质区别。第一,wait() 会自动释放当前持有的锁,而 sleep() 完全不知道锁的存在——持锁sleep会导致其他线程全部卡死。第二,wait() 的唤醒不是靠时间到了,而是靠其他线程调用 notify()/notify_all() 主动通知。像后厨对讲机——你不该指望“歇够了就自己醒”,你得等别人喊你。推荐使用 while 循环包住 wait(),防止被虚假唤醒或唤醒后条件仍然不满足。

为何常见:从名字上看wait像sleep,但wait后面的锁处理逻辑完全是另一套心智模型。如果不明白wait会释放锁,很容易写出“持锁等待导致全局卡死”的bug。

面试题

Q1. 下面哪种竞态条件的定义最准确?

单选题 · easy

  • A.多个线程同时运行同一个函数
  • B.多个线程并发读写共享数据,且结果依赖线程执行顺序← 正确答案
  • C.程序占用的内存空间不足
  • D.递归函数调用栈溢出

答案:B。竞态条件的本质是多个线程同时操作共享数据,最终结果不确定,取决于线程调度的先后顺序。厨房里两个厨师同时拿同一盘菜,谁先拿、谁后拿决定了谁拿到——这就是竞态。A只是“并发执行”不是“竞争共享数据”,C和D是资源问题,与竞态无关。

Q2. Lock 互斥锁的核心作用是?

单选题 · easy

  • A.让程序运行得更快
  • B.保证同一时刻只有一个线程进入临界区← 正确答案
  • C.允许多个线程同时写共享变量
  • D.自动修复数据错误

答案:B。Lock通过 acquire 和 release 构造互斥区域,同一时刻最多一个线程进入临界区执行代码。就像单人洗手间同一时刻只容一人如厕。加锁不可能让程序更快,反而有开销;它也不能修复错误,只能用互斥防止错误发生。

Q3. 关于 RLock,下列说法正确的是?

单选题 · easy

  • A.同一线程可以多次获取,不会阻塞自己← 正确答案
  • B.任何线程都可以同时获取 RLock
  • C.RLock 只能被获取一次
  • D.RLock 比 Lock 慢所以不适合使用

答案:A。RLock的关键特性是“可重入”——同一线程多次 acquire 不会阻塞,内部计数递增,释放时计数递减到0才真正解锁。就像自家房门钥匙能反复进出自己家。RLock依然互斥,不是任何线程都可进入;RLock虽比Lock略慢,但可重入的价值远超这点开销。

Q4. threading.Semaphore(3) 创建的信号量,初始计数为?

单选题 · mid

  • A.3,表示最多3个线程可以同时获取← 正确答案
  • B.1,表示同一时刻只有1个线程可访问
  • C.0,表示没有线程可以获取
  • D.无限,表示不限制并发数量

答案:A。Semaphore(3) 初始计数为3,最多允许3个线程同时通过 acquire 进入临界区,后续的线程必须等前面线程 release 后才能进入。就像地铁站只有3个闸机通道,同时最多进3个人。计数0的情况很危险(初始就阻塞),计数无限则相当于没有限流。

Q5. 死锁产生的四个必要条件中,最容易通过“顺序加锁”打破的是哪个?

单选题 · mid

  • A.互斥条件
  • B.持有并等待
  • C.非抢占条件
  • D.循环等待条件← 正确答案

答案:D。顺序加锁让所有线程以相同顺序获取资源,从而打破资源等待的环路——线程A等B、B等A的闭环不复存在。就像食堂规定所有人都先排一号窗口再排二号窗口,就不会出现A拿了一号的票等二号的位、B拿了二号的票等一号的位这种僵局。四个条件要同时满足才会死锁,打破任一个即可。

Q6. Condition 中 wait() 被调用后会发生什么?

单选题 · mid

  • A.线程持续持有锁并等待
  • B.线程释放锁并进入等待队列← 正确答案
  • C.线程直接终止并释放所有资源
  • D.线程自动重试获取锁

答案:B。wait() 会原子地释放当前持有的锁,并将线程挂起到等待队列中。被 notify() 唤醒后,线程需要重新竞争获取锁才能继续执行。这是条件变量的核心——如果不释放锁,其他线程无法进入临界区修改条件,整个程序就会卡死(类似持锁 sleep 的陷阱)。A是错误做法会死锁,C和D与wait的实际行为不符。

Q7. 两个线程分别执行函数 f 和 g,f 先获取 lock1 再获取 lock2,g 先获取 lock2 再获取 lock1。可能发生什么?

单选题 · hard

  • A.一定发生死锁
  • B.可能发生死锁,取决于调度顺序← 正确答案
  • C.一定不发生死锁
  • D.必有一个线程崩溃

答案:B。这种交叉加锁顺序是死锁的教科书案例,但并非一定发生——如果线程A在lock1和lock2都获取成功后快速完成释放,线程B再按序获取就没有问题。只有两个线程同时各自持有一把锁时互相等待,才会死锁。就像两个厨师同时各抓住一把钥匙,才会形成僵局。所以是“可能死锁”,锁顺序不一致是死锁的高危信号。

Q8. 以下哪些方法可以有效避免死锁?

多选题 · mid

  • A.所有线程统一加锁顺序← 正确答案
  • B.使用 acquire(timeout) 设置超时← 正确答案
  • C.尽可能减少持锁时间← 正确答案
  • D.增加更多不同类型的锁

答案:ABC。A通过统一顺序打破循环等待条件,B通过超时放弃打破“持有并等待”和“非抢占”条件,C减少持锁时间虽然不直接打破四条件但能显著降低死锁发生的概率窗口。D反而增加死锁风险——锁越多越容易形成环路等待,就像钥匙越多越容易拿错。

Q9. 在 Python 多线程中,哪些场景应该使用 threading.Lock?

多选题 · easy

  • A.保护一个全局计数器变量← 正确答案
  • B.限制同时访问数据库的连接数为5个
  • C.保护一个共享配置文件不被并发写坏← 正确答案
  • D.实现生产者消费者模式的消息同步

答案:AC。Lock适用于保护独占资源——单个计数器、单个配置文件,同一时刻只有一个线程能操作。B应该用Semaphore(5)做资源池限流,D应该用Condition做条件通知。就像单人洗手间用普通门锁,5通道闸机用信号量限流,后厨协同用对讲机。选错锁型要么性能下降要么逻辑错误。

Q10. 关于 tryacquire() 非阻塞获取锁,下列说法正确的是?

单选题 · hard

  • A.获取不到锁时立即返回 False,不会阻塞← 正确答案
  • B.获取不到锁时自动重试直到成功
  • C.只适用于 RLock
  • D.会让程序运行速度变慢

答案:A。tryacquire() 获取锁失败时会立即返回 False,程序可以执行其他逻辑或稍后再试,而不是阻塞在acquire上。这在需要“拿到就做,拿不到就放弃”的场景很实用——比如优化器线程尝试获取锁以减少死锁风险。它不会自动重试,不是RLock专用,也不一定让程序变慢(避免阻塞反而可能提升吞吐)。