锁机制入门:threading.Lock的使用场景与常见陷阱

说到并发编程,锁这个东西,可以说是又爱又恨。

爱它,是因为它能保护共享数据,防止多线程打架。恨它,是因为用不好就会死锁、性能暴跌,甚至比不用锁还糟糕。

我在做量化交易系统的时候,每天都要跟锁打交道。交易所的行情数据、订单簿、账户余额,哪个不是多线程共享的?稍有不慎,就是几百万的损失。

今天咱们就来聊聊Python里最基础的锁——threading.Lock。别看它简单,坑可不少。

1. 什么时候需要锁?

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

counter = 0

def increment():
    global counter
    for _ in range(100000):
        counter += 1

# 启动两个线程
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start()
t2.start()
t1.join()
t2.join()

print(counter)  # 期望200000,实际可能只有199xxx

为什么会这样?

因为 counter += 1 不是原子操作。它背后有三步:读取、加1、写回。两个线程同时执行,就会互相覆盖。

核心结论:只要多个线程同时读写同一个可变对象,就需要锁。

我在项目中遇到过最典型的场景——订单簿的更新。行情线程不断推送新的买卖盘口,策略线程同时读取做决策。不加锁?轻则读到脏数据,重则直接崩溃。

2. threading.Lock 的基本用法

Python的锁用起来很简单,就两个方法:acquire()release()

import threading

lock = threading.Lock()
counter = 0

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

# 或者用with语句,更安全
def safe_increment_with():
    global counter
    for _ in range(100000):
        with lock:
            counter += 1

我个人习惯用 with 语句。为什么?因为万一你在锁里面抛了异常,release() 可能就不会执行了。而 with 会自动帮你释放,省心。

小技巧:能用 with 就别手动 acquire/release。这是血的教训换来的经验。

3. 常见陷阱一:死锁

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

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

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

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

这段代码跑起来,大概率会卡死。thread_1 拿着 lock_a 等 lock_b,thread_2 拿着 lock_b 等 lock_a。互相等待,死锁形成。

我曾经在开发交易网关的时候,就因为锁的顺序不一致,导致整个系统卡了整整30秒。那30秒里,交易所的行情数据全丢了,回放都来不及。

避坑指南:我曾经犯过一个低级错误——在锁里面调用了外部API。外部API超时了,锁就一直不释放,整个系统都跟着卡死。记住:锁里面不要做任何可能阻塞的操作,比如网络请求、文件读写、数据库查询。

4. 常见陷阱二:锁的粒度

锁的粒度,就是你要保护的数据范围。粒度太粗,性能差;粒度太细,容易出错。

粒度 优点 缺点
粗粒度(全局锁) 实现简单,不容易出错 并发性能差,一次只能一个线程干活
细粒度(分段锁) 并发性能好,多个线程可以同时操作不同部分 实现复杂,容易死锁

在量化交易系统里,我一般这样权衡:

  • 行情数据更新频繁,用细粒度锁,按品种分段
  • 账户余额变动少,用粗粒度锁,简单可靠
  • 订单状态管理,用读写锁(后面会讲)

5. 常见陷阱三:重入锁与不可重入

Python的 threading.Lock不可重入的。什么意思?就是同一个线程不能连续两次 acquire 同一个锁。

lock = threading.Lock()

def recursive_func(n):
    with lock:
        if n > 0:
            recursive_func(n - 1)  # 这里会死锁!

recursive_func(5)  # 程序卡死

如果你需要重入,用 threading.RLock(可重入锁)。它允许同一个线程多次 acquire,内部有个计数器记录次数。

建议:除非你明确知道自己在做什么,否则优先用 RLock。它比 Lock 更宽容,不容易写出死锁代码。

6. 实战:一个简单的订单簿保护

来看个真实场景。假设我们有一个订单簿,行情线程不断更新,策略线程不断读取。

class OrderBook:
    def __init__(self):
        self._lock = threading.Lock()
        self._bids = {}  # 买盘
        self._asks = {}  # 卖盘

    def update(self, side, price, volume):
        """行情线程调用"""
        with self._lock:
            if side == 'buy':
                self._bids[price] = volume
            else:
                self._asks[price] = volume

    def get_best_bid(self):
        """策略线程调用"""
        with self._lock:
            if not self._bids:
                return None
            return max(self._bids.keys())

    def get_best_ask(self):
        """策略线程调用"""
        with self._lock:
            if not self._asks:
                return None
            return min(self._asks.keys())

这个设计有几个要点:

  • 所有对共享数据的访问都通过锁保护
  • 每个方法内部只持锁很短时间,读完/写完就释放
  • 不要在锁里面做复杂计算,比如排序、聚合

7. 锁的性能影响

锁不是免费的。每次 acquire/release 都有开销。在高频交易场景下,这个开销可能成为瓶颈。

我做过一个测试:

  • 不加锁:1000万次操作,耗时0.3秒
  • 加锁:1000万次操作,耗时2.1秒

差了7倍。所以,能不用锁就不用锁。实在要用,尽量缩小临界区。

性能优化原则:锁的范围越小越好,锁的持有时间越短越好。

8. 总结一下

threading.Lock 是并发编程的基石。用好了,系统稳定高效;用不好,各种诡异问题。

记住几个要点:

  • 共享数据必须加锁
  • with 语句管理锁
  • 避免死锁:锁顺序一致、锁内不要阻塞
  • 控制锁粒度:粗了性能差,细了容易错
  • 需要重入时用 RLock

嗯,锁这个东西,说白了就是「用空间换安全」。多花点心思在设计上,总比线上出事故强。

threading.Lock 知识体系 threading.Lock 使用场景 共享数据保护 多线程读写 临界区隔离 常见陷阱 死锁 锁粒度不当 不可重入 最佳实践 用with语句 缩小临界区

专注资料整理