锁机制入门: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
嗯,锁这个东西,说白了就是「用空间换安全」。多花点心思在设计上,总比线上出事故强。