线程同步机制:Lock锁的使用、RLock可重入锁、死锁的产生与避免

说到多线程文件读写,有个问题绕不开——线程同步。

我刚开始写多线程程序时,觉得这玩意儿挺简单。不就是开几个线程一起干活嘛?结果一跑,数据全乱套了。两个线程同时往一个文件里写,你写一半我插一脚,最后文件里全是乱码。

嗯,这就是典型的竞态条件。说白了,就是多个线程同时操作共享资源,互相干扰。

1. Lock锁:最基础的同步工具

Python 的 threading.Lock 是最基础的锁。它的用法很简单:

import threading

lock = threading.Lock()

def write_file(data):
    lock.acquire()  # 获取锁
    try:
        # 这里是临界区,一次只能一个线程进来
        with open('data.txt', 'a') as f:
            f.write(data + '\n')
    finally:
        lock.release()  # 释放锁

我在项目中遇到过一个问题:有个同事忘了写 finally 块里的 release()。结果程序跑着跑着就卡死了。为什么?因为某个线程获取锁后抛了异常,锁没释放,其他线程永远等不到。

我的习惯:永远用 with 语句来管理锁,省心又安全。
lock = threading.Lock()

def write_file(data):
    with lock:  # 自动获取和释放锁
        with open('data.txt', 'a') as f:
            f.write(data + '\n')

这样写,就算里面抛异常,锁也会自动释放。我个人强烈推荐这种方式。

2. RLock可重入锁:同一个线程能重复获取

有时候,一个函数里调用了另一个函数,两个函数都需要同一把锁。用普通的 Lock 会怎样?

lock = threading.Lock()

def func_a():
    with lock:
        func_b()  # 这里会死锁!

def func_b():
    with lock:  # 同一个线程想再次获取锁,但锁已经被自己占着
        print('hello')

为什么会这样?因为 Lock 是不可重入的。同一个线程获取锁后,没释放之前再获取一次,就会把自己卡死。

这时候就需要 RLock(可重入锁)了:

rlock = threading.RLock()

def func_a():
    with rlock:
        func_b()  # 没问题,RLock允许同一个线程重复获取

def func_b():
    with rlock:  # 同一个线程可以再次获取
        print('hello')

RLock 内部维护了一个计数器。同一个线程每 acquire 一次,计数器加1;每 release 一次,计数器减1。只有计数器归零了,其他线程才能获取锁。

一句话总结:如果你不确定会不会出现嵌套锁的情况,直接用 RLock 更安全。

3. 死锁的产生:两个线程互相等待

死锁是线程同步里最头疼的问题。我来讲个真实案例。

有一次,我负责一个日志系统。两个线程分别写不同的文件,但偶尔需要交换文件句柄。代码大概长这样:

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

def thread_1():
    with lock_a:
        time.sleep(0.1)  # 模拟一些操作
        with lock_b:     # 等待lock_b
            print('thread_1 done')

def thread_2():
    with lock_b:
        time.sleep(0.1)  # 模拟一些操作
        with lock_a:     # 等待lock_a
            print('thread_2 done')

跑起来后,程序卡死了。为什么?

  • 线程1获取了 lock_a,等待 lock_b
  • 线程2获取了 lock_b,等待 lock_a
  • 两个线程都在等对方释放锁,谁也不让谁

这就是经典的死锁。

4. 死锁的避免:四个方法

我曾经被死锁坑过好几次,后来总结了几条经验:

方法一:固定锁的获取顺序

让所有线程都按同样的顺序获取锁。比如都先拿 lock_a 再拿 lock_b:

def thread_1():
    with lock_a:
        with lock_b:
            print('thread_1 done')

def thread_2():
    with lock_a:  # 同样先拿lock_a
        with lock_b:
            print('thread_2 done')

这样就不会出现循环等待了。

方法二:使用超时机制

acquire() 可以设置超时时间。拿不到锁就放弃,别死等:

if lock_a.acquire(timeout=5):  # 最多等5秒
    try:
        if lock_b.acquire(timeout=5):
            try:
                # 处理业务
                pass
            finally:
                lock_b.release()
    finally:
        lock_a.release()
else:
    print('获取锁超时,做其他处理')

方法三:尽量少用多把锁

能用一把锁解决的问题,就别用两把。锁越少,死锁概率越低。

方法四:使用 with 语句

前面说过,with 能保证锁一定会释放,避免因为异常导致锁没释放而引发死锁。

避坑指南:我曾经在一个项目里,为了追求性能,用了细粒度锁(多把锁分别保护不同数据)。结果死锁排查花了两天。后来我改成一把大锁,性能只降了5%,但代码稳定多了。有时候,简单就是最好的。

知识体系总览

下面这张图,把本章的核心逻辑串起来了:

线程同步机制知识体系 Lock 锁 RLock 可重入锁 死锁 • 一次只能一个线程获取 • 不可重入(会死锁) • 推荐用 with 语句 • 同一线程可重复获取 • 内部计数器管理 • 适合嵌套调用场景 • 两个线程互相等待 • 循环等待资源 避免死锁的四个方法 ① 固定锁的获取顺序 ② 使用超时机制 ③ 尽量少用多把锁 ④ 使用 with 语句管理锁

这张图把三个核心概念串起来了。你想想看,Lock 是最基础的,RLock 是它的升级版,而死锁则是使用不当的后果。底部的四个方法,就是我们的护身符。

核心要点:
  • 用 with 管理锁,别手动 acquire/release
  • 嵌套锁场景优先用 RLock
  • 多把锁时固定获取顺序,避免死锁
  • 加超时机制,别让线程死等

好了,线程同步这块就聊到这儿。记住一句话:锁是工具,不是目的。我们的目标是让多线程安全高效地读写文件,别为了用锁而用锁。

可可精品资料库