第三章 多线程编程:threading模块详解、线程安全与锁机制

各位同学,今天我们来聊聊多线程。说实话,我在刚入行那会儿,对多线程是又爱又怕。爱的是它能榨干CPU的每一滴性能,怕的是那些莫名其妙的死锁和竞态条件。有一次线上服务半夜挂了,查了半天发现是线程间共享变量没加锁——嗯,从那以后我对锁机制就格外上心了。

3.1 threading模块核心组件

Python的threading模块,说白了就是帮我们管理线程的瑞士军刀。我个人习惯把线程分为两类:工作线程守护线程。工作线程干完活就退,守护线程则陪着主进程到最后一刻。

核心类一览:

  • Thread:线程对象,你直接new它就行
  • Lock:互斥锁,一次只让一个线程进
  • RLock:可重入锁,同一个线程可以多次获取
  • Condition:条件变量,线程间通信用的
  • Event:事件标志,简单粗暴的同步方式

来看个最基础的例子:

import threading
import time

def worker(name):
    print(f"线程 {name} 开始工作")
    time.sleep(2)
    print(f"线程 {name} 结束工作")

# 创建线程
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))

# 启动线程
t1.start()
t2.start()

# 等待线程结束
t1.join()
t2.join()

print("所有线程执行完毕")

你想想看,如果不用join(),主线程可能直接跑完了,子线程还在后台默默干活。我在项目中就吃过这个亏——主线程退出了,子线程的日志还没写完,排查问题的时候一脸懵。

3.2 线程安全与锁机制

线程安全是个老生常谈的话题。为什么需要锁?因为多个线程同时读写共享数据时,会出现数据竞争。举个例子:

counter = 0

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

threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"期望值: 1000000, 实际值: {counter}")

跑一下你会发现,实际值永远小于1000000。为什么会这样?因为counter += 1在底层是三步操作:读取、加1、写回。线程A读到counter=5,还没来得及写回,线程B也读到了5,然后两个线程都写回6——明明加了两次,结果只加了1。

避坑指南:我曾经在压测时发现QPS上不去,查了半天发现是锁粒度太大。记住:锁的范围越小,并发性能越好。能用局部变量就别用全局变量,能用原子操作就别用锁。

3.2.1 Lock vs RLock

Lock是基础互斥锁,一个线程获取后,其他线程只能等着。但有个坑——同一个线程不能重复获取同一个Lock,否则会死锁。

RLock(可重入锁)就灵活多了。同一个线程可以多次获取,内部有个计数器,获取一次加1,释放一次减1,减到0才真正释放。

特性 Lock RLock
同一线程重复获取 ❌ 会死锁 ✅ 可以
性能 略高 略低(有计数开销)
适用场景 简单互斥 递归调用、嵌套锁

看个RLock的典型用法:

lock = threading.RLock()

def outer():
    with lock:
        print("外层锁获取")
        inner()

def inner():
    with lock:
        print("内层锁获取——同一个线程,没问题")

# 如果这里用Lock,inner()会直接卡死
outer()

我个人建议:除非你非常确定不会嵌套获取锁,否则直接用RLock更安全。我在重构一个遗留系统时,就因为把RLock换成了Lock,导致线上递归调用直接死锁——那次教训让我记住了:不要为了那点性能牺牲安全性

3.3 死锁的避免

死锁,说白了就是线程们互相等着对方释放资源,结果谁也动不了。经典的死锁四条件:

  • 互斥:资源一次只能被一个线程占用
  • 持有并等待:线程拿着一个锁,还想要另一个
  • 不可剥夺:锁只能由持有者主动释放
  • 循环等待:线程A等B,B等A

实战技巧:我曾经在写一个交易系统时,设计了统一的锁获取顺序——所有线程都按「账户ID从小到大」的顺序获取锁。这样就不会出现A等B、B等A的循环了。你想想看,如果所有线程都先拿小ID的锁,再拿大ID的锁,死锁自然就破了。

来看一个会死锁的例子:

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

def thread_1():
    with lock_a:
        print("线程1获取锁A")
        with lock_b:
            print("线程1获取锁B")

def thread_2():
    with lock_b:
        print("线程2获取锁B")
        with lock_a:
            print("线程2获取锁A")

# 启动后大概率死锁
t1 = threading.Thread(target=thread_1)
t2 = threading.Thread(target=thread_2)
t1.start()
t2.start()

避免死锁的几个实用方法:

  1. 固定锁顺序:所有线程按相同顺序获取锁
  2. 使用超时lock.acquire(timeout=5),拿不到就放弃
  3. 尽量少用锁:能用队列就用队列,能用原子操作就用原子操作
  4. 锁粒度要小:别一把锁锁住整个系统

嗯,这里要特别提一下超时机制。我在一个爬虫项目中,用try-finally配合超时,确保即使拿不到锁也不会卡死整个线程池:

if lock.acquire(timeout=3):
    try:
        # 临界区代码
        pass
    finally:
        lock.release()
else:
    print("获取锁超时,跳过本次操作")

3.4 实战建议

最后,分享几个我在高并发项目中的经验:

  • 能用concurrent.futures就别手写线程:它帮你管理了线程池和异常处理
  • IO密集型用多线程,CPU密集型用多进程:Python的GIL决定了这点
  • 锁的粒度要细,但别太细:太细了性能提升有限,反而增加复杂度
  • 写单元测试时一定要模拟并发:用threading.Barrier让线程同时出发,更容易暴露问题

总结一句话:多线程编程的核心不是「怎么创建线程」,而是「怎么安全地共享数据」。锁是工具,不是目的。能用无锁数据结构解决的问题,就别上锁。

好了,这一章的内容就到这里。下一章我们会聊聊线程池与异步编程,到时候我会分享一个用asyncio重构爬虫的真实案例——那个项目让我对并发编程的理解又上了一个台阶。