RSS
菜单
全部文章快讯开发科技深度热点

threading 与 multiprocessing 实战(Python 从精通到入门 · 07)

内容摘要

知道了 GIL 的脾气,这一篇讲怎么真刀真枪地用线程和进程:锁住共享状态、跑满多核、在进程间传递数据,以及 Windows 上那些会让你踩坑的细节。

知道了 GIL 的脾气,这一篇讲怎么真刀真枪地用线程和进程:锁住共享状态、跑满多核、在进程间传递数据,以及 Windows 上那些会让你踩坑的细节。

你将学到

  • threading 的基础用法,以及为什么日常更该用 ThreadPoolExecutor
  • 用 Lock / RLock / Event / Queue 解决共享状态,而不是用 sleep 碰运气
  • 守护线程是什么、什么时候会被直接掐死
  • multiprocessing 与进程池:为什么它绕得过 GIL,以及 IPC 的三种姿势
  • fork 与 spawn 的区别,以及 Windows 上的两条铁律
  • 一段能跑的实测,对比 CPU 密集用多进程、IO 密集用多线程

前置知识

建议先读 06 - GIL 真相与并发模型选择,本篇的所有选型都建立在它的结论上。


一、threading 基础:从裸线程到线程池

最朴素的写法是 Thread(target=..., args=...) + start() + join()。裸线程的痛点很明显:手动管理启停、拿不到返回值、异常会被吞。日常工作请直接用 concurrent.futures.ThreadPoolExecutor,池子会自动维护线程生命周期:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def fetch(name, delay):
    time.sleep(delay)
    return f"{name}:ok"

# 方式 1:map,保持输入顺序返回结果
with ThreadPoolExecutor(max_workers=5) as pool:
    print(list(pool.map(fetch, ["a", "b", "c"], [0.3, 0.2, 0.1])))
# 输出: ['a:ok', 'b:ok', 'c:ok'](顺序与输入一致)

# 方式 2:submit + as_completed,谁先完成先处理(池子 with 退出时自动 shutdown)
with ThreadPoolExecutor(max_workers=5) as pool:
    for fut in as_completed([pool.submit(fetch, f"t{i}", 0.1 * (i + 1)) for i in range(3)]):
        print(fut.result())          # 输出: t0:ok / t1:ok / t2:ok(按完成先后)

max_workers 怎么定? IO 密集任务里它是"并发上限",可远大于 CPU 核数(几十到几百,取决于下游能扛多少);CPU 密集任务多线程没意义,见第六节。

二、共享状态:锁、事件、队列

Lock / RLock

import threading

counter = 0
lock = threading.Lock()

def incr():
    global counter
    for _ in range(100_000):
        with lock:            # 上下文管理器自动 acquire/release
            counter += 1      # 读-改-写整体原子,结果不会丢

ts = [threading.Thread(target=incr) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print(counter)   # 输出: 400000(这次一定对)

# RLock(可重入锁)允许同一线程多次获取,适合递归或方法间互相调用:
rlock = threading.RLock()
def outer():
    with rlock: inner()       # 同一线程再次拿锁,RLock 不会死锁
def inner():
    with rlock: pass
outer()
# 注意:普通 Lock 非重入,同一线程重复 acquire() 会把自己锁死

Event:线程间的红绿灯

Event 适合"一个线程发信号、多个线程等待"的场景,别用 sleep 轮询。

import threading, time

ready = threading.Event()          # 初始为"未置位"

def producer():
    time.sleep(0.5)
    print("生产者:数据准备好了")
    ready.set()                    # 置位,唤醒所有等待者

def consumer(name):
    ready.wait()                   # 阻塞,直到 set() 被调用;可加 timeout
    print(f"{name}:开始消费")

threading.Thread(target=producer).start()
for i in range(2):
    threading.Thread(target=consumer, args=(f"消费者{i}",)).start()
# 输出: 生产者:数据准备好了 / 消费者0:开始消费 / 消费者1:开始消费

Queue:最省心的线程安全队列

queue.Queue 内部自带锁,是生产者-消费者模型的标准答案,比手动加锁优雅得多。

import queue, threading

q = queue.Queue(maxsize=10)

def producer():
    for i in range(5):
        q.put(i)                   # 队列满则阻塞
    q.put(None)                    # 哨兵,通知消费者结束

def consumer():
    while True:
        item = q.get()             # 队列空则阻塞
        if item is None:
            break
        print(f"消费: {item}")
        q.task_done()

p = threading.Thread(target=producer); c = threading.Thread(target=consumer)
p.start(); c.start(); p.join(); c.join()
# 输出: 消费: 0 / 消费: 1 / 消费: 2 / 消费: 3 / 消费: 4

三、守护线程:会被"当场处决"

import threading, time

def background():
    while True:
        time.sleep(1); print("后台心跳...")

t = threading.Thread(target=background, daemon=True)
t.start()
time.sleep(2.5)
print("主线程结束")
# 输出: 后台心跳... / 后台心跳... / 主线程结束
# 进程随即退出,后台线程被直接终止——不会执行任何收尾逻辑

守护线程的语义是"主线程一退出就陪葬"。 日志刷新、连接归还这类需要收尾的工作,不要放在守护线程里;纯心跳、纯监控则可以设成守护线程,省得手动 join。

四、multiprocessing:绕开 GIL

多进程的核心思想:每个进程有自己独立的解释器和独立的 GIL,因此能同时占满多个 CPU 核。 代价是进程间不能共享内存(默认),数据要序列化后拷贝。进程池 ProcessPoolExecutor 让并发管理自动化,并支持 map 返回结果:

from multiprocessing import Process, Queue
from concurrent.futures import ProcessPoolExecutor

def cpu_task(q, n):
    q.put(sum(i * i for i in range(n)))   # 结果通过 Queue 送回主进程

def square(x):
    return x * x

if __name__ == "__main__":                # ⚠️ Windows 必须写,见第六节
    q = Queue()
    procs = [Process(target=cpu_task, args=(q, 2_000_000)) for _ in range(2)]
    for p in procs: p.start()
    for p in procs: p.join()
    print([q.get() for _ in procs])       # 输出: [两个整数]

    with ProcessPoolExecutor(max_workers=4) as pool:
        print(list(pool.map(square, range(5))))   # 输出: [0, 1, 4, 9, 16]

进程间通信(IPC)三种姿势

from multiprocessing import Process, Pipe

# 1) Queue:多生产者多消费者,简单可靠(内部有锁,会做序列化)
# 2) Pipe:两端点一对一,速度更快,适合固定两个进程通信
# 3) 共享内存(shared_memory):大数据量场景,省去序列化拷贝,需自己同步

def child_job(conn):
    conn.send("hi"); conn.close()             # 模块级函数才可 pickle

if __name__ == "__main__":
    parent, child = Pipe()                    # 返回一对 Connection
    p = Process(target=child_job, args=(child,))
    p.start()
    print(parent.recv())                      # 输出: hi
    p.join()

经验法则:小数据用 Queue/Pipe,大数据(几十 MB 以上、numpy 数组)用共享内存,否则光是序列化就能吃掉并行省下的时间。

五、fork vs spawn:启动方式决定一切

import multiprocessing as mp

# fork:直接复制父进程内存(Linux 默认),启动快,
#       但多线程程序 + fork 可能死锁(子进程只 fork 了当前线程)
# spawn:全新启动一个解释器,重新 import 模块,干净但慢
print(mp.get_start_method())   # Linux: fork / macOS、Windows: spawn

# 显式指定(3.14 起 fork 在 POSIX 被标为不安全,建议统一用 spawn)
ctx = mp.get_context("spawn")
ctx.Process(target=print, args=("hi",)).start()   # 输出: hi
fork spawn
平台默认 Linux Windows / macOS
启动速度 快 慢(要重新导入)
内存 写时复制,省 独立,费
线程安全 有死锁风险 安全
全局变量 子进程继承 不继承,需参数传入

六、Windows / macOS 上的两条铁律

既然默认是 spawn,就有两个必须遵守的规则:

from concurrent.futures import ProcessPoolExecutor

# 铁律 1:入口必须用 if __name__ == "__main__": 保护。原因:spawn 会重新 import
#   主模块,若进程创建代码在模块顶层,子进程 import 时会再次执行到它 -> 无限递归。
# ❌ 模块顶层直接 with ProcessPoolExecutor() as pool: pool.map(work, range(5))
def work(x):
    return x + 1

# 铁律 2:传给子进程的东西必须能被 pickle 序列化
# ❌ pool.map(lambda x: x + 1, range(5))   # 报错:Can't pickle <lambda>

# ✅ 正确:放进 main 守卫 + 用模块顶层定义的普通函数
if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        print(list(pool.map(work, range(5))))   # 输出: [1, 2, 3, 4, 5]

除这两条外还有:子进程里改全局变量父进程看不到;进程池里 print 输出可能交错;multiprocessing 在 Jupyter / 交互式解释器里经常不工作(拿不到可 import 的主模块)。

七、实测:多进程 vs 多线程

import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def cpu_task(n):                 # CPU 密集:纯计算
    return sum(i * i for i in range(n))

def io_task(sec):                # IO 密集:纯等待
    time.sleep(sec)

def bench(fn, label):
    start = time.perf_counter()
    fn()
    print(f"{label}: {time.perf_counter() - start:.3f}s")

if __name__ == "__main__":
    N, K = 4_000_000, 4

    bench(lambda: [cpu_task(N) for _ in range(K)], "CPU 串行   ")
    bench(lambda: list(ThreadPoolExecutor(4).map(cpu_task, [N] * K)), "CPU 多线程 ")
    bench(lambda: list(ProcessPoolExecutor(4).map(cpu_task, [N] * K)), "CPU 多进程 ")

    bench(lambda: [io_task(0.5) for _ in range(8)], "IO 串行    ")
    bench(lambda: list(ThreadPoolExecutor(8).map(io_task, [0.5] * 8)), "IO 多线程  ")

    # 参考输出(4 核机器,量级示意,非精确值):
    # CPU 串行   : 2.60s
    # CPU 多线程 : 2.75s   <- 抢 GIL,几乎没快,甚至更慢
    # CPU 多进程 : 0.80s   <- 约 3 倍加速,真并行
    # IO 串行    : 4.001s
    # IO 多线程  : 0.502s  <- 8 倍并发,接近理论极限

一句话结论:CPU 密集选进程,IO 密集选线程。 反过来选,除了让代码更复杂,没有任何收益。

常见坑

# ❌ 坑 1:用 sleep 轮询代替 Event
while not data_ready:
    time.sleep(0.01)     # 浪费 CPU,还可能有肉眼可见的延迟

# ✅ 正解:用 Event 精确唤醒
event.wait()             # 零轮询开销,事件一到立刻继续
# ❌ 坑 2:守护线程里做必须完成的工作
t = threading.Thread(target=lambda: write_log_to_disk(), daemon=True)
# 主线程一退出,日志可能只写了一半,文件损坏
# ✅ 正解:需要收尾的线程设成非守护,并显式 join:
# t = threading.Thread(target=write_log_to_disk); t.start(); t.join()
# ❌ 坑 3:多进程里改全局变量,指望父进程看到(内存不共享,只改了子进程副本)
g = 0
def bump():
    global g
    g = 100          # 父进程永远还是 0
# ✅ 正解:要共享结果用 Queue / Pipe 传回,或用 multiprocessing.Value("i", 0)

小结

  • 日常用 ThreadPoolExecutor / ProcessPoolExecutor,自动管理生命周期,比裸 Thread/Process 省心。
  • 共享状态优先用队列(自带锁)而非手动加锁;必须共享标量时用 Lock,递归场景用 RLock。
  • 守护线程 = 主线程退出即被处决,收尾工作(日志、连接归还)别放里面。
  • 多进程能绕开 GIL 是因为每个进程有独立的解释器和 GIL;IPC 三件套 Queue 通用、Pipe 快、SharedMemory 省拷贝。
  • Windows/macOS 默认 spawn:必须写 if __name__ == "__main__":,传入对象必须可 pickle;实测一句话:CPU 密集用多进程,IO 密集用多线程。

延伸阅读

  • Python 官方文档:concurrent.futures、multiprocessing、queue 模块
  • 《Python 并发编程实战》关于 IPC 与进程池调优的章节
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《threading 与 multiprocessing 实战(Python 从精通到入门 · 07)》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。