第2章:多线程实战:threading模块详解,线程创建与管理,线程同步机制

多线程,说白了就是让程序同时干多件事。Python 的 threading 模块,是我们做并发最常用的工具之一。我刚开始用的时候也踩过不少坑,今天把这些经验掰开揉碎了讲给你听。

2.1 线程的创建与管理

Python 里创建线程有两种方式:直接实例化 Thread 类,或者继承 Thread 重写 run 方法。我个人习惯用第一种,代码更简洁。

2.1.1 直接创建线程

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() 方法。如果不调用 join(),主线程不会等子线程,程序可能直接退出了。我在项目中遇到过好几次这种问题——日志还没写完,进程就挂了。

2.1.2 继承 Thread 类

当你需要在线程里维护状态时,继承方式更合适。

class MyThread(threading.Thread):
    def __init__(self, name, delay):
        super().__init__()
        self.name = name
        self.delay = delay

    def run(self):
        print(f"{self.name} 启动")
        time.sleep(self.delay)
        print(f"{self.name} 完成")

threads = [MyThread(f"线程-{i}", i) for i in range(3)]
for t in threads:
    t.start()
for t in threads:
    t.join()
我的建议:如果只是执行一个函数,用第一种方式;如果需要在线程中保存多个属性或复杂状态,用继承方式。别为了炫技而过度设计。

2.2 线程的生命周期

一个线程从创建到结束,会经历几个状态。我画了张图帮你理解:

线程生命周期状态图 新建 (New) 就绪 (Runnable) 运行 (Running) 阻塞 (Blocked) 终止 (Dead) start() → 就绪 → 获取CPU → 运行 → 遇到I/O或锁 → 阻塞 → 条件满足 → 就绪 → 运行 → 结束

你想想看,线程从 start() 开始,进入就绪队列等待 CPU 调度。拿到 CPU 时间片就开始运行,如果遇到 I/O 操作或者锁,就进入阻塞状态。等条件满足了,又回到就绪队列排队。这就是线程的完整生命周期。

2.3 线程同步机制

多线程最头疼的问题是什么?数据竞争。多个线程同时读写同一个变量,结果就乱套了。我在生产环境遇到过线上订单金额被多线程改错的情况,从那以后我对同步机制格外重视。

2.3.1 Lock(互斥锁)

Lock 是最基本的同步工具。它保证同一时刻只有一个线程能访问共享资源。

import threading

counter = 0
lock = threading.Lock()

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

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

print(f"最终结果: {counter}")  # 应该输出 1000000
我曾经踩过的坑:忘记在 finally 块中释放锁,导致死锁。建议用上下文管理器 with lock:,自动释放,省心又安全。
# 推荐写法
def increment():
    global counter
    for _ in range(100000):
        with lock:
            counter += 1

2.3.2 RLock(可重入锁)

RLock 和 Lock 的区别是什么?说白了,RLock 允许同一个线程多次获取锁,而 Lock 不行。我举个例子你就明白了:

lock = threading.Lock()
rlock = threading.RLock()

# Lock 会死锁
def bad_example():
    with lock:
        with lock:  # 这里会阻塞,因为锁已经被自己占用了
            print("永远不会执行到这里")

# RLock 没问题
def good_example():
    with rlock:
        with rlock:  # 同一个线程可以重复获取
            print("RLock 允许重入")

什么时候用 RLock?当你的函数有递归调用,或者一个函数调用另一个需要同一把锁的函数时。我在写缓存更新逻辑时经常用到 RLock。

2.3.3 Semaphore(信号量)

Semaphore 控制同时访问资源的线程数量。比如数据库连接池,最多允许 5 个线程同时连接。

import threading
import time

# 最多允许3个线程同时访问
semaphore = threading.Semaphore(3)

def access_db(thread_id):
    with semaphore:
        print(f"线程 {thread_id} 获取数据库连接")
        time.sleep(2)
        print(f"线程 {thread_id} 释放数据库连接")

threads = [threading.Thread(target=access_db, args=(i,)) for i in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()
我的经验:Semaphore 的初始值就是最大并发数。如果设为 1,效果等同于 Lock。我在做爬虫限速时经常用 Semaphore 控制并发请求数,防止被目标网站封 IP。

2.4 三种同步机制对比

特性 Lock RLock Semaphore
可重入 否 是 否
控制粒度 单线程独占 单线程独占(可重入) 多线程并发(上限可控)
典型场景 保护简单共享变量 递归函数、嵌套调用 连接池、限流
死锁风险 高(忘记释放) 低(同一线程可重入) 中(需注意 acquire/release 配对)

2.5 实战避坑指南

  • 锁的粒度要小:只锁必要的代码段,别把整个函数都锁住。我见过有人把整个 HTTP 请求都放在锁里,性能直接崩了。
  • 避免死锁:多个锁嵌套时,保证所有线程按相同顺序获取锁。我曾经因为锁顺序不一致,排查了整整一个下午。
  • 用 with 语句:自动管理锁的获取和释放,代码更清晰,也减少出错概率。
  • 不要滥用线程:Python 的 GIL 限制了 CPU 密集型任务的并行能力。I/O 密集型任务用线程效果更好。
核心要点:线程同步不是银弹。能用不可变数据结构解决的问题,就别加锁。锁越多,性能越差,死锁风险越高。先想清楚共享什么、怎么共享,再动手写代码。