第十二章 进程和线程

0 阅读43分钟

第十二章 进程和线程


1 什么是进程

1.1 程序和进程不是一回事

先说清楚这两个词,后面全靠它们。

你写的 .py 文件,躺在磁盘上,那叫程序。它是一堆指令,不动,不占 CPU,不占内存。

双击运行它,操作系统把它读进内存,分配一块地盘,登记一个号码,开始执行里面的指令。这个"跑起来的那一份"叫进程。

   磁盘上的程序                      跑起来的进程
   ┌──────────────┐                ┌──────────────────┐
   │  report.py   │   双击运行      │  进程            │
   │ 一行行的代码  │ ───────────►   │  编号 pid = 8123 │
   │ 不动,不占CPU │                │  有自己的一块内存  │
   └──────────────┘                └──────────────────┘
        磁盘上只有一份              可以同时跑起来三份

同一个程序可以跑出多个进程。你开三个浏览器窗口,多半就是三个进程,跑的是同一份代码,各干各的。

1.2 每个进程有自己的一亩三分地

这是进程最要紧的一条性质。操作系统给每个进程划一块独立的内存,别的进程看不见,也改不了。

   进程 A                              进程 B
   ┌──────────────────┐               ┌──────────────────┐
   │ total = 0        │               │ total = 0        │
   │ 打开文件 表      │      ✗        │ 打开文件 表      │
   │ 变量、堆栈      │   看不见对方    │ 变量、堆栈      │
   └──────────────────┘               └──────────────────┘

好处是安全。一个进程崩了,不会把另一个拖下去。你写的脚本死循环了,浏览器照常能用,就是这个道理。

坏处是麻烦。两个进程要交换数据,不能直接读写对方的变量,得走别的路。第三节整节都在讲这条路怎么修。

1.3 看一眼你自己的这个进程

Python 里查这些东西用两个模块,os 管和操作系统的接口,multiprocessing 管进程。

>>> import multiprocessing as mp
>>> import os
>>> import threading
>>> os.cpu_count()
32
>>> mp.cpu_count()
32
>>> mp.get_start_method()
'spawn'
>>> mp.get_all_start_methods()
['spawn']
>>> hasattr(os, "fork")
False
>>> mp.current_process()
<_MainProcess name='MainProcess' parent=None started>
>>> mp.current_process().name
'MainProcess'
>>> os.getpid() == mp.current_process().pid
True
>>> mp.parent_process() is None
True

cpu_count() 是这台机器能同时跑多少个任务,我这里是一台 32 核的机器。get_start_method() 是创建新进程的方式,Windows 上只有 spawn 一种,fork 在这个系统上压根不存在,hasattr 一问就知道。

current_process() 返回当前这个进程对象,你自己的程序跑在 MainProcess 里,它没有父进程,所以 parent_process() 是 None。等你在子进程里问这句话,答案就变成你自己的主进程了。

线程那边也有对应的东西。

>>> threading.current_thread().name
'MainThread'
>>> threading.active_count()
1
>>> [t.name for t in threading.enumerate()]
['MainThread']
>>> threading.main_thread() is threading.current_thread()
True

任何一个 Python 程序启动的时候,都自带一个进程和一个线程。那个线程叫主线程,所有你写在顶格的代码都跑在它里面。

1.4 并发和并行,差一个字

这两个词在很多文章里被混着用,其实说的是两件事。

并发是"看起来同时"。一个 CPU 核,先跑一下任务一,停,再跑一下任务二,停,切换得够快,人就觉得是两个一起在跑。实际上同一时刻只做了一件。

并行是"真的同时"。两个核,一个跑任务一,一个跑任务二,同一时刻两件事都在推进。

并发(一个核来回切)

  任务一  ████        ████        ████
  任务二      ████        ████        ████
          ─────────────────────────────────► 时间

并行(两个核各干各的)

  核 一   ████████████████████
  核 二   ████████████████████
          ─────────────────────────────────► 时间

什么时候能真并行,取决于任务类型。

任务在等(等网络回包、等磁盘读写、等用户输入)的时候,CPU 是闲着的,一个核可以趁这个空档去干别的,并发就够了。任务在计算(解析日志、压缩文件、算哈希)的时候,CPU 一直满载,要真提速就得并行,得加核。

判断的办法很土但有效,跑的时候打开任务管理器,看 CPU 占用。占用率低说明在等,用线程;占用率打满说明在算,用进程。

1.5 进程不是白给的

进程有成本,而且不低。

操作系统要给它分配内存页、建页表、登记文件描述符、把解释器和你的代码装进去。这套流程走完,进程才能开始执行你的第一行代码。

我这台机器上实测,起 1 个空进程(什么也不干,起来就退出)要 0.45 秒左右。起 8 个一起起,0.49 秒。因为它们是并行启动的,所以 8 个并没有比 1 个贵 8 倍。

这个数字记一下,后面选方案的时候要用。它决定了什么样的任务值得开进程。


2 创建进程的常用方法

Python 里开进程用 multiprocessing 模块,标准库自带,不用装。

2.1 五行的第一个多进程程序

import multiprocessing as mp
import os

def worker():
    print("  子进程开始干活", flush=True)

if __name__ == "__main__":
    p = mp.Process(target=worker, name="工人一号")
    print("启动前", p.is_alive(), p.pid, flush=True)
    p.start()
    print("启动后", p.is_alive(), p.pid is not None, "名字", p.name, flush=True)
    p.join()
    print("收工后", p.is_alive(), "退出码", p.exitcode, flush=True)
    print("子进程跟主进程不是同一个:", p.pid != os.getpid(), flush=True)
启动前 False None
启动后 True True 名字 工人一号
  子进程开始干活
收工后 False 退出码 0
子进程跟主进程不是同一个: True

四步走。Process() 造对象,start() 真正把进程开起来,join() 等它干完,is_alive() 看它还在不在。

start() 之前,pid 是 None,进程还不存在。start() 之后系统才分配编号。join() 之后 is_alive() 变回 False,exitcode 是 0,表示正常退出。

join() 这两个字容易让人误会。它不是"加入",是"等"。主进程走到 join() 这一行就停下来,一直等到子进程结束才往下走。不写 join(),主进程自己先跑完退出,子进程会被留下来变成孤儿(Windows 上会被直接带走,Linux 上会被 1 号进程收养)。

代码里那些 flush=True 是为了让输出按发生的顺序显示。子进程和主进程共用同一个终端,输出会互相插队,不加 flush 看到的顺序可能是乱的。

2.2 Windows 上必须加的那道守卫

上面那段代码里有个 if __name__ == "__main__":,看着像可有可无的仪式感。在 Windows 上它不是仪式,是必需。

原因在启动方式上。Windows 没有 fork,创建新进程只能 spawn,做法是重新启动一个 Python 解释器,把你的脚本从头 import 一遍,import 完了再去找你要执行的那个函数。

   spawn 启动子进程的过程

   父进程                                子进程
   ┌──────────────┐                    ┌──────────────────┐
   │ p.start()    │ ──► 新开解释器 ──►  │ import 你的脚本   │
   │              │    传函数和参数      │ 找到 target 函数  │
   │              │                    │ 执行它            │
   └──────────────┘                    └──────────────────┘
                                              │
                                              │ 假如脚本顶格就写了
                                              │ p.start()
                                              ▼
                                        又去开一个子进程
                                              │
                                              ▼
                                        再 import,再 start
                                              │
                                              ▼
                                          无限套娃

如果 p.start() 写在顶格,子进程 import 脚本的时候会执行它,于是又开一个子进程,循环下去。Python 会在第二轮就拦住你,报这么一句。

RuntimeError:
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.

        This probably means that you are not using fork to start your
        child processes and you have forgotten to use the proper idiom
        in the main module:

            if __name__ == '__main__':
                freeze_support()
                ...

加上守卫之后,子进程 import 脚本时 __name__ 是 '__mp_main__',不是 '__main__',守卫里的代码不会执行,套娃就断了。

规矩记死。所有 start() 的调用,所有 Pool 的创建,都放进 if __name__ == "__main__": 里面。 包括你写的测试代码、临时脚本。Linux 和 macOS 上不加也能跑(它们默认用 fork),但哪天代码搬到 Windows 上就炸,不如一开始就养成习惯。

2.3 Process 的常用参数和属性

造对象的时候能传这些。

参数作用不传的时候
target子进程要执行的函数不传就得继承 Process 重写 run
args传给 target 的参数,必须是元组空元组
kwargs关键字参数,字典空字典
name进程名字,调试时看日志用Process-1、Process-2 这样自动编号
daemon是不是守护进程False

对象造出来之后能读这些。

属性 / 方法含义
start()真正开进程,每个对象只能调一次
join(timeout)等它结束,timeout 不传就一直等
is_alive()还活着吗
pid进程号,没启动的时候是 None
name名字
daemon是不是守护进程
exitcode退出码,0 是正常,负数是被信号掐掉的,没结束是 None
terminate()发终止信号,让它立刻停
close()释放资源,join 过之后才能调

没启动之前看一眼这些属性,都是空着的。

>>> p = mp.Process(target=print, args=("你好",), name="打招呼")
>>> p.name
'打招呼'
>>> p.daemon
False
>>> p.pid is None
True
>>> p.ident is None
True
>>> p.is_alive()
False
>>> p.exitcode is None
True

daemon 这个词得多说一句。守护进程的意思是"你死我也死"。主进程退出的时候,守护子进程会被直接终止,不等它干完,也不给它清理的机会。默认 False,主进程会等所有非守护子进程结束才退出。

设成 True 的场景是那种"可有可无的后台活",比如定时上报心跳。主程序都关了,心跳也没意义。但凡子进程手里有没写完的文件、没提交的事务,都别设守护。

2.4 起一堆进程,把结果收回来

真正用起来多半是开好几个进程分头干活,然后把结果收回来。

import multiprocessing as mp
import os

def worker(idx, q):
    q.put((idx, os.getpid()))

if __name__ == "__main__":
    q = mp.Queue()
    ps = [mp.Process(target=worker, args=(i, q)) for i in range(4)]
    for p in ps:
        p.start()
    for p in ps:
        p.join()
    got = sorted(q.get() for _ in range(4))
    print("回收到", len(got), "条,编号", [i for i, _ in got])
    print("四个进程号互不相同:", len({pid for _, pid in got}) == 4)
    print("四个都不是主进程:", all(pid != os.getpid() for _, pid in got))
    print("退出码:", [p.exitcode for p in ps])
回收到 4 条,编号 [0, 1, 2, 3]
四个进程号互不相同: True
四个都不是主进程: True
退出码: [0, 0, 0, 0]

注意 start() 和 join() 分成了两个循环。要是写成一个循环里 start() 完立刻 join(),那就变成一个干完再开下一个,等于串行,白忙活。

这里用队列把结果传回来(队列下一节细讲)。不用队列的话,子进程的返回值你拿不到,target 函数的返回值会被丢掉。

2.5 继承 Process 类

子进程要干的事复杂一点,或者要存点自己的状态,就写个类。

import multiprocessing as mp
import os
import time

class Worker(mp.Process):
    def __init__(self, idx, q):
        super().__init__(name="工人-%d" % idx)
        self.idx = idx
        self.q = q

    def run(self):
        self.q.put((self.idx, os.getpid()))

def boom():
    raise ValueError("子进程里炸了")

if __name__ == "__main__":
    q = mp.Queue()
    ps = [Worker(i, q) for i in range(3)]
    for p in ps:
        p.start()
        print("%s 启动,进程号有值吗 %s" % (p.name, p.pid is not None))
    for p in ps:
        p.join()
    got = sorted(q.get() for _ in range(3))
    print("回收", len(got), "条,编号", [i for i, _ in got])
    print("都是自己人:", all(pid != os.getpid() for _, pid in got))
    print("退出码", [p.exitcode for p in ps])

    bad = mp.Process(target=boom)
    bad.start()
    bad.join()
    print("出错的子进程退出码:", bad.exitcode)

    sleeper = mp.Process(target=time.sleep, args=(30,))
    sleeper.start()
    sleeper.terminate()
    sleeper.join()
    print("被掐掉的子进程退出码:", sleeper.exitcode, "| 还活着吗", sleeper.is_alive())
工人-0 启动,进程号有值吗 True
工人-1 启动,进程号有值吗 True
工人-2 启动,进程号有值吗 True
回收 3 条,编号 [0, 1, 2]
都是自己人: True
退出码 [0, 0, 0]
出错的子进程退出码: 1
被掐掉的子进程退出码: -15 | 还活着吗 False

三点。

继承的时候改的是 run(),不是 target。run() 里写的就是子进程要干的事。

__init__ 里第一句一定要 super().__init__(),把父类的初始化走完。

子进程里抛异常,不会让主进程崩溃,异常信息打到 stderr,退出码变成 1。这个特性有点坑,子进程干砸了,主进程默认不知道。要么检查 exitcode,要么让子进程把错误放进队列传回来。

terminate() 是硬掐,不给清理的机会,退出码是 -15。文件写了一半、事务没提交,都可能留下烂摊子。能好好退出的场景,用队列或 Event 通知它自己收工。

2.6 进程池 Pool

一个一个 Process 手动管,任务多了就累。进程池是把一批进程开好放在那儿,你只管往里扔任务。

import multiprocessing as mp

def square(n):
    return n * n

if __name__ == "__main__":
    print("CPU 核数", mp.cpu_count())
    with mp.Pool(4) as pool:
        print("map     ", pool.map(square, range(6)))
        print("分块 map", pool.map(square, range(6), chunksize=2))
        print("apply   ", pool.apply(square, (9,)))
        r = pool.apply_async(square, (7,))
        print("异步    ", r.get(), "| 好了吗", r.ready(), "| 成功吗", r.successful())
        print("imap    ", list(pool.imap(square, range(6))))
        print("starmap ", pool.starmap(pow, [(2, 3), (3, 2), (2, 10)]))
CPU 核数 32
map      [0, 1, 4, 9, 16, 25]
分块 map [0, 1, 4, 9, 16, 25]
apply    81
异步     49 | 好了吗 True | 成功吗 True
imap     [0, 1, 4, 9, 16, 25]
starmap  [8, 9, 1024]

几个方法的区别。

方法干什么什么时候用
map(func, 序列)一个函数吃一堆参数,返回结果列表,顺序跟输入一致最常用,批量处理
imap(func, 序列)跟 map 一个用法,但返回迭代器,出一个拿一个想边算边显示进度
apply(func, 参数)单个任务,阻塞等结果偶尔一个
apply_async单个任务,立刻返回 AsyncResult,.get() 取结果不想等,或者要并发跑多个
starmap(func, 序列)参数是元组,自动拆开传给函数函数有多个参数

Pool(4) 里的 4 是进程数,不传的话默认等于 cpu_count()。不是越多越好,CPU 密集的任务开到核数就够了,再多也是排队。

with 语句用它的时候,退出会自动关池子。手写的话记得 pool.close() 再 pool.join()。

传给池子的函数必须是模块顶层定义的,不能是 lambda,也不能是某个函数里面嵌套定义的。因为 spawn 是靠名字去找函数的,找不着就没法在子进程里重建它。

2.7 什么时候别用多进程

前面说过起一个空进程要 0.45 秒。这个成本决定了,任务太小的时候多进程是负收益。

import multiprocessing as mp
import threading
import time

def nothing(_=None):
    pass

def once(n):
    t = time.perf_counter()
    ps = [mp.Process(target=nothing) for _ in range(n)]
    for p in ps:
        p.start()
    for p in ps:
        p.join()
    return time.perf_counter() - t

if __name__ == "__main__":
    t1 = once(1)
    t8 = once(8)
    print("8 个一起起,不比 1 个慢一倍:", t8 < t1 * 2)
    t = time.perf_counter()
    ths = [threading.Thread(target=nothing) for _ in range(8)]
    for x in ths:
        x.start()
    for x in ths:
        x.join()
    tt = time.perf_counter() - t
    print("线程比进程便宜两个数量级:", tt * 100 < t8)
8 个一起起,不比 1 个慢一倍: True
线程比进程便宜两个数量级: True

再看一个更直观的,任务本身只要 0.05 秒,开 8 个进程去做。

import multiprocessing as mp
import time

def slow(seconds):
    time.sleep(seconds)
    return seconds

if __name__ == "__main__":
    t = time.perf_counter()
    for _ in range(8):
        slow(0.05)
    t1 = time.perf_counter() - t

    t = time.perf_counter()
    ps = [mp.Process(target=slow, args=(0.05,)) for _ in range(8)]
    for x in ps:
        x.start()
    for x in ps:
        x.join()
    t2 = time.perf_counter() - t

    print("八个进程各睡 0.05 秒,比串行还慢:", t2 > t1)
八个进程各睡 0.05 秒,比串行还慢: True

串行是 0.40 秒,八个进程是 0.53 秒左右。多花了三成,还搭上八个进程的内存。

结论挺简单。单个任务要跑够久,才值得为它开进程。 具体多久,看你机器上的启动成本。0.45 秒这条线,任务至少要跑满一两秒,多进程才回本。任务很多的时候用进程池,把启动成本摊薄,一次启动反复用。


3 通过队列实现进程间通信

3.1 全局变量改了没用

两个进程内存是隔开的,你在子进程里改全局变量,主进程一点感觉都没有。

import multiprocessing as mp
import threading

VALUE = 0

def bump_process(q):
    global VALUE
    VALUE += 100
    q.put(VALUE)

def bump_thread():
    global VALUE
    VALUE += 1

if __name__ == "__main__":
    q = mp.Queue()
    p = mp.Process(target=bump_process, args=(q,))
    p.start()
    p.join()
    print("子进程里 VALUE 变成", q.get())
    print("父进程里 VALUE 还是", VALUE)

    ts = [threading.Thread(target=bump_thread, name="加-%d" % i) for i in range(5)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    print("五个线程各加一次,VALUE =", VALUE)
子进程里 VALUE 变成 100
父进程里 VALUE 还是 0
五个线程各加一次,VALUE = 5

同一段代码,进程改了等于没改,线程改了真改了。这是进程和线程最本质的区别,也是这一节存在的理由。

子进程要往回传东西,得走通道。multiprocessing.Queue 是最常用的那一条。

3.2 队列怎么用

队列就是一根管子,一头塞一头掏,先进去的先出来。

  生产者进程                                     消费者进程
  ┌──────────┐   put(数据)   ┌──────────┐  get()  ┌──────────┐
  │ 造数据的  │ ───────────► │   队列    │ ──────► │ 处理的    │
  └──────────┘               └──────────┘         └──────────┘
import multiprocessing as mp
import queue

if __name__ == "__main__":
    q = mp.Queue(maxsize=2)
    print("刚建好,空吗", q.empty(), "| 现有几条", q.qsize())
    q.put("一")
    q.put("二")
    print("塞满了吗", q.full(), "| 现有几条", q.qsize())
    try:
        q.put_nowait("三")
    except queue.Full:
        print("put_nowait 满了会报 queue.Full")
    try:
        q.put("三", timeout=0.3)
    except queue.Full:
        print("put 带 timeout 也一样")
    print("取出来:", q.get(), q.get())
    try:
        q.get_nowait()
    except queue.Empty:
        print("get_nowait 空了会报 queue.Empty")
    try:
        q.get(timeout=0.3)
    except queue.Empty:
        print("get 带 timeout 也一样")
刚建好,空吗 True | 现有几条 0
塞满了吗 True | 现有几条 2
put_nowait 满了会报 queue.Full
put 带 timeout 也一样
取出来: 一 二
get_nowait 空了会报 queue.Empty
get 带 timeout 也一样

常用方法就这几个。

方法行为
put(数据)塞一条,队列满了就等着
put(数据, timeout=秒)等着,超时报 queue.Full
put_nowait(数据)满了立刻报 queue.Full,不等
get()掏一条,队列空了就等着
get(timeout=秒)等着,超时报 queue.Empty
get_nowait()空了立刻报 queue.Empty
qsize()现在有几条(这个数不准,别的进程可能正在改)
empty() / full()空了吗 / 满了吗,同样只是参考
close()关掉,之后不能再塞

两个异常都来自标准库的 queue 模块,不是 multiprocessing 的。import queue 才能捕获。

maxsize 不传就是无限大,塞多少都行,代价是内存。传了就是有界队列,满了生产者会阻塞,这其实是好事,能防止生产太快把内存吃光。

3.3 生产者消费者的标准套路

一个进程造数据,一个进程处理数据,中间用队列连起来。

import multiprocessing as mp

def producer(q, n):
    for i in range(n):
        q.put("数据-%d" % i)
    q.put("END")            # 干完了发个结束信号

def consumer(q, out):
    while True:
        item = q.get()
        if item == "END":
            break
        out.append(item)

if __name__ == "__main__":
    q = mp.Queue(maxsize=3)
    out = mp.Manager().list()
    p = mp.Process(target=producer, args=(q, 6))
    c = mp.Process(target=consumer, args=(q, out))
    p.start()
    c.start()
    p.join()
    c.join()
    print("收到", len(out), "条")
    print("内容:", sorted(out))
    print("队列空了吗:", q.empty())
收到 6 条
内容: ['数据-0', '数据-1', '数据-2', '数据-3', '数据-4', '数据-5']
队列空了吗: True

那个 "END" 叫哨兵,用来告诉消费者"后面没有了"。没有它,消费者会在空队列上一直 get() 等下去,永远不结束。几个消费者就发几个哨兵,一人一个。

out 用的是 mp.Manager().list(),一个能在进程之间共享的列表。普通列表传进子进程会被复制一份,子进程往里加东西,主进程这边还是空的。

3.4 一个能把程序卡死的写法

这个坑我见过太多次,单独拿出来说一遍。

import multiprocessing as mp

def sender(q):
    for i in range(6):
        q.put("第%d块" % i)

if __name__ == "__main__":
    q = mp.Queue(maxsize=2)
    p = mp.Process(target=sender, args=(q,))
    p.start()
    p.join(timeout=1)
    print("等了 1 秒,子进程还在吗:", p.is_alive())
    got = [q.get() for _ in range(6)]
    p.join(timeout=2)
    print("把 6 块都取走之后:", got)
    print("这回还在吗:", p.is_alive(), "| 退出码:", p.exitcode)
等了 1 秒,子进程还在吗: True
把 6 块都取走之后: ['第0块', '第1块', '第2块', '第3块', '第4块', '第5块']
这回还在吗: False | 退出码: 0

join() 写在取数据之前,程序就死了。原因是个死结,子进程塞到第三个的时候队列满了,卡在 put() 上;主进程卡在 join() 上等子进程结束。两边都在等对方,谁也不动。

   子进程                              主进程
   ┌──────────────┐                  ┌──────────────┐
   │ put 第 3 块   │ ──队列满了──►    │              │
   │ 卡住,等着     │                 │ join()       │
   │              │                  │ 卡住,等子进程 │
   └──────────────┘                  └──────────────┘
        ▲                                    │
        └──────────── 互相等 ────────────────┘

规矩是,先取数据,再 join()。join() 永远放在所有队列操作之后。

演示里我给 join 加了 timeout=1,所以它没真的卡死,一秒之后返回,让我能打印出"还在吗"。真实代码里写 p.join() 不带超时,那就是永久卡住,只能强制结束。

3.5 队列之外的几条路

队列是最通用的,但不是唯一选择。

import multiprocessing as mp

def addone(val, arr, lock, times):
    for _ in range(times):
        with lock:
            val.value += 1
            arr[0] += 1

if __name__ == "__main__":
    val = mp.Value("i", 0)
    arr = mp.Array("i", [0, 0])
    lock = mp.Lock()
    ps = [mp.Process(target=addone, args=(val, arr, lock, 100)) for _ in range(4)]
    for x in ps:
        x.start()
    for x in ps:
        x.join()
    print("Value 结果:", val.value, "(期望 400)")
    print("Array 结果:", arr[:], "(期望 [400, 0])")

    a, b = mp.Pipe()
    a.send({"name": "张三", "age": 30})
    print("Pipe 收到:", b.recv())
    b.send("回信")
    print("Pipe 是双向的:", a.recv())

    with mp.Manager() as m:
        d = m.dict()
        d["进度"] = 0
        d["总数"] = 100
        print("Manager 字典:", dict(d))
Value 结果: 400 (期望 400)
Array 结果: [400, 0] (期望 [400, 0])
Pipe 收到: {'name': '张三', 'age': 30}
Pipe 是双向的: 回信
Manager 字典: {'进度': 0, '总数': 100}

四条路,各有各的用处。

通道传什么特点
Queue任意能被 pickle 的对象最通用,支持多生产者多消费者
Pipe也能传 pickle 对象只能两头,快一点,适合一对一
Value / Array数字和数组共享内存,最快,但类型受限,要自己加锁
Manager 的 dict / list容器用起来跟普通字典列表一样,最慢,因为背后跑着一个服务进程

Value 和 Array 用的是共享内存,改起来快,但不支持普通 Python 对象。而且 val.value += 1 这种操作本身不是原子的,多个进程同时加还是要配一把 mp.Lock(),上面那段代码里就带上了。

Manager 最省事,代价是慢,每次读写都要跟一个专门的进程通信。存进度、存配置这种低频操作用它,高频的数据传递别用。


4 什么是线程

4.1 线程住在进程里面

进程是操作系统分配资源的最小单位,线程是 CPU 调度的最小单位。这句话背下来没用,看图更直观。

┌───────────────── 一个进程 ─────────────────┐
│                                            │
│   共享区(所有线程都能看见)                  │
│   全局变量、打开的文件、数据库连接             │
│                                            │
│   ┌──────────┐ ┌──────────┐ ┌──────────┐   │
│   │ 主线程    │ │  线程 A   │ │  线程 B   │   │
│   │ 有自己    │ │  有自己   │ │  有自己   │   │
│   │ 的栈和    │ │  的栈和   │ │  的栈和   │   │
│   │ 寄存器    │ │  寄存器   │ │  寄存器   │   │
│   └──────────┘ └──────────┘ └──────────┘   │
│                                            │
└────────────────────────────────────────────┘

进程和进程之间,中间那堵墙是实心的
线程和线程之间,共享区是公用的

一个进程里至少有一个线程,就是主线程。你再开几个,它们跟主线程共用同一块内存,同时各自有一小份私有的东西(栈、寄存器、当前执行到哪一行)。

共享内存是线程最大的优势,也是它最大的麻烦。传数据不用走队列,一个全局变量就搞定。但两个线程同时改一个变量,谁先谁后没个准,结果就可能对不上。

4.2 主线程和它的兄弟们

>>> import threading
>>> threading.current_thread() is threading.main_thread()
True
>>> threading.current_thread().name
'MainThread'
>>> threading.active_count()
1
>>> len(threading.enumerate())
1
>>> [t.name for t in threading.enumerate()]
['MainThread']

enumerate() 列出所有还活着的线程,active_count() 是它们的数量。刚启动的程序只有主线程一个,所以是 1。

这两个是排查问题时的好帮手。程序明明该退出了却退不掉,多半是还有线程活着。打一句 threading.enumerate(),名字和数量都出来了。

4.3 进程和线程怎么挑

对比项进程线程
内存各有一份,互相隔离共享同一份
传数据得走队列、管道、共享内存直接读写全局变量
启动成本高,我这里实测 0.45 秒低,0.0013 秒,差两个数量级
崩一个不影响别的整个进程跟着完蛋
能用到多个 CPU 核吗能CPU 密集的任务不能(GIL 挡着)
适合的任务计算密集,要真并行等待密集,等网络、等磁盘、等用户
调试难度麻烦,日志会混在一起更麻烦,问题还不一定能复现

一句话版本。要真提速,用进程。要不卡界面,用线程。


5 创建线程

线程用 threading 模块,标准库自带。

5.1 第一个多线程程序

import threading
import time

log = []
lock = threading.Lock()

class Counter(threading.Thread):
    def __init__(self, name, n):
        super().__init__(name=name)
        self.n = n

    def run(self):
        time.sleep(0.1)
        for i in range(self.n):
            with lock:
                log.append("%s-%d" % (self.name, i))

def plain(n):
    time.sleep(0.1)
    with lock:
        log.append("%s-%d" % (threading.current_thread().name, n))

if __name__ == "__main__":
    ts = [Counter("甲", 3), Counter("乙", 3),
          threading.Thread(target=plain, args=(1,), name="丙")]
    for t in ts:
        t.start()
    print("刚启动,三个都活着吗", [t.is_alive() for t in ts])
    for t in ts:
        t.join()
    print("都结束了", [t.is_alive() for t in ts])
    print("日志条数", len(log))
    print("甲 写了", [x for x in log if x.startswith("甲")])
    print("乙 写了", [x for x in log if x.startswith("乙")])
    print("丙 写了", [x for x in log if x.startswith("丙")])
刚启动,三个都活着吗 [True, True, True]
都结束了 [False, False, False]
日志条数 7
甲 写了 ['甲-0', '甲-1', '甲-2']
乙 写了 ['乙-0', '乙-1', '乙-2']
丙 写了 ['丙-1']

用法跟 Process 几乎一模一样,Thread() 造对象,start() 开跑,join() 等它,is_alive() 查状态。

继承的时候同样改 run(),同样要 super().__init__()。

代码里那把 lock 是给 log.append 加的。列表的 append 本身是线程安全的,这里加锁是为了让输出整齐,不然顺序会更乱。

5.2 Thread 的常用参数和属性

参数作用
target线程要执行的函数
args参数元组,注意是元组
kwargs关键字参数字典
name线程名字,调试时救命用的
daemon是不是守护线程,默认 False
属性 / 方法含义
start()开跑,每个对象只能调一次
run()线程体,直接调它就是普通函数调用,不开新线程
join(timeout)等它结束
is_alive()还活着吗
name名字
ident / native_id线程编号,没启动是 None
daemon是不是守护线程

有个容易忽略的点,t.run() 和 t.start() 不是一回事。直接调 run() 就是在当前线程里跑这个函数,跟并发没关系。要开新线程必须 start(),它内部会去调 run()。

5.3 args 是个元组,这个坑我踩过

单个参数的时候特别容易写错。

>>> def greet(name):
...     return "你好" + name
>>> greet("张三")
'你好张三'
>>> args = "张三"
>>> list(args)
['张', '三']
>>> greet(*args)
TypeError: greet() takes 1 positional argument but 2 were given

args=("张三") 里的括号不是元组,它只是个普通的圆括号,值还是字符串 "张三"。传进去之后 Python 会把它拆开当参数列表用,于是 "张三" 被拆成了 "张" 和 "三" 两个参数。

正确的写法是加个逗号。

>>> args = ("张三",)
>>> greet(*args)
'你好张三'
>>> type(("张三")), type(("张三",))
(<class 'str'>, <class 'tuple'>)

单个元素的元组必须有那个尾随逗号。这个坑在 Process 和 Pool 上一样存在,写法完全一样。

5.4 join 和守护线程

join() 的意思跟进程那边一样,是等。不等的话主程序可能先跑完,把还在干活的线程一起带走。

import threading
import time

def tick(n):
    for _ in range(n):
        time.sleep(0.05)

if __name__ == "__main__":
    d = threading.Thread(target=tick, args=(100,), name="守护线程", daemon=True)
    n = threading.Thread(target=tick, args=(3,), name="普通线程")
    d.start()
    n.start()
    print("守护线程是 daemon 吗", d.daemon, "| 普通线程呢", n.daemon)
    n.join()
    print("普通线程收工后,守护线程还活着吗", d.is_alive())
    print("主程序不等它,直接往下走")
守护线程是 daemon 吗 True | 普通线程呢 False
普通线程收工后,守护线程还活着吗 True
主程序不等它,直接往下走

守护线程的意思是"主程序退出我就跟着死",不给它清理的机会。默认 False,主程序会等所有非守护线程结束才退出。

上面这段跑完,守护线程还有 4 秒多才轮到它结束,但主程序直接退出了,它就被扔在那儿了。文件只写了一半、事务没提交,都是这么来的。

要设守护线程,得确认它手里没有"必须完成"的活。定时清理缓存可以,写订单不行。

5.5 定时器

想让某件事过一会儿再做,用 Timer,它本身就是个线程。

import threading

fired = []

if __name__ == "__main__":
    t = threading.Timer(0.2, lambda: fired.append("定时器响了"))
    t.start()
    print("Timer 本身是个线程吗", isinstance(t, threading.Thread), "| 活着吗", t.is_alive())
    t.join()
    print("到点了:", fired)

    t2 = threading.Timer(0.2, lambda: fired.append("不该出现"))
    t2.start()
    t2.cancel()
    t2.join()
    print("取消掉之后:", fired)
Timer 本身是个线程吗 True | 活着吗 True
到点了: ['定时器响了']
取消掉之后: ['定时器响了']

cancel() 能在响之前把它掐掉,已经响了就掐不掉了。

Timer 是一次性的。要周期性地做,要么每次重新建一个 Timer,要么自己写个循环加 Event.wait(秒),后者更好控制,第四节会用到。

5.6 线程池

跟进程池一个道理,把线程开好放着,你只管扔任务。

import time
from concurrent import futures

def job(n):
    time.sleep(0.05)
    return n * n

if __name__ == "__main__":
    with futures.ThreadPoolExecutor(max_workers=4) as pool:
        print("map      ", list(pool.map(job, range(6))))
        f = pool.submit(job, 7)
        print("submit   ", f.result(), "| 好了吗", f.done())
        fs = [pool.submit(job, i) for i in range(4)]
        print("谁先好谁先来", sorted(f.result() for f in futures.as_completed(fs)))
map       [0, 1, 4, 9, 16, 25]
submit    49 | 好了吗 True
谁先好谁先来 [0, 1, 4, 9]

map 按输入顺序给结果,as_completed 谁先完成谁先出,做进度显示的时候用后者。

concurrent.futures 里还有一个 ProcessPoolExecutor,接口一模一样,把 ThreadPoolExecutor 换个名字就变成多进程。

# pool_swap.py
import time
from concurrent.futures import ProcessPoolExecutor

def job(n):
    time.sleep(0.05)
    return n * n

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as pool:
        print(list(pool.map(job, range(6))))

这是我最推荐的写法。同一套代码,改一行就能在线程和进程之间切换,方便你实测哪个快。它同样要遵守那道 if __name__ == "__main__": 守卫。


6 线程间通信

线程之间传数据有两条路,共享变量和队列。前者方便但有雷,后者麻烦点但稳。

6.1 共享变量,能改但不保险

import threading
import time

counter = 0

def add(n):
    global counter
    for _ in range(n):
        tmp = counter        # 第一步,读出来
        time.sleep(0)        # 强制让出 CPU,让别的线程有机会插进来
        counter = tmp + 1    # 第二步,写回去

def run(threads, times):
    global counter
    counter = 0
    ts = [threading.Thread(target=add, args=(times,)) for _ in range(threads)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    return counter

if __name__ == "__main__":
    want = 1000
    print("期望值", want, "(5 个线程,每个加 200 次)")
    print("对得上吗:", run(5, 200) == want)
期望值 1000 (5 个线程,每个加 200 次)
对得上吗: False

结果对不上,而且每次跑出来还不一样。我连跑三次,拿到的是 462、355、345,一次比一次离谱。

原因在"读出来、加一、写回去"这三步上。它不是一步完成的,中间随时可能被切走。

   线程甲                        线程乙                    counter
   ─────────                    ────────                  ───────
   读到 0                                                    0
                                读到 0                       0
   算 0 + 1                     算 0 + 1                     0
   写回 1                                                    1
                                写回 1                       1   ← 两次加法只加了 1

两个线程都读到 0,都算出 1,都写回 1。加了两次,结果只涨了 1。

time.sleep(0) 那行是我故意加的,它会让当前线程立刻让出 CPU,把这种切换放大到每次都发生。去掉这行,错误还是会发生,只是概率低得多,一万个线程里错一两次。这种"偶尔错一次"的问题最要命,测试的时候全过,上线之后隔三差五出一次。

要复现的话,把循环次数加大、线程数加大,time.sleep(0) 保留。

6.2 用队列传,最省心

queue.Queue 是线程安全的,内部已经把锁加好了。生产者往里塞,消费者往外掏,不用你自己管同步。

import queue
import threading
import time

def producer(q, n):
    for i in range(n):
        q.put("任务-%d" % i)
        time.sleep(0.001)
    q.put(None)          # 干完了发个哨兵

def consumer(q, out):
    while True:
        item = q.get()
        if item is None:
            break
        out.append(item)
        q.task_done()

if __name__ == "__main__":
    q = queue.Queue(maxsize=5)
    out = []
    p = threading.Thread(target=producer, args=(q, 8), name="生产者")
    c = threading.Thread(target=consumer, args=(q, out), name="消费者")
    p.start()
    c.start()
    p.join()
    c.join()
    print("消费了", len(out), "条")
    print("顺序对吗:", out == ["任务-%d" % i for i in range(8)])
    print("队列空了吗:", q.empty())
消费了 8 条
顺序对吗: True
队列空了吗: True

跟 multiprocessing.Queue 的用法几乎一样,连 Full 和 Empty 两个异常都是同一个 queue 模块里的。区别是这个是给线程用的,不能跨进程。

几个消费者的时候,哨兵要一人发一个,不然有的消费者会在空队列上一直等。

除了 Queue,queue 模块还有两个变体。

>>> import queue
>>> lq = queue.LifoQueue()          # 后进先出,是个栈
>>> lq.put(1)
>>> lq.put(2)
>>> lq.get()
2
>>> pq = queue.PriorityQueue()      # 按优先级出,元素得能比较大小
>>> pq.put((2, "普通"))
>>> pq.put((1, "紧急"))
>>> pq.get()
(1, '紧急')

6.3 Event,一个信号灯

有时候线程之间不需要传数据,只需要一个"可以开始了"或者"该停了"的信号。

import threading
import time

ready = threading.Event()
state = []

def waiter():
    state.append("等着")
    ready.wait(timeout=1)
    state.append("收到 %s" % ready.is_set())

if __name__ == "__main__":
    t = threading.Thread(target=waiter, name="等待者")
    t.start()
    time.sleep(0.1)
    print("主线程先看到的:", state)
    ready.set()
    t.join()
    print("set 之后:", state)
    ready.clear()
    print("clear 之后信号灯是:", ready.is_set())
    print("等不到的 wait 返回:", threading.Event().wait(timeout=0.1))
主线程先看到的: ['等着']
set 之后: ['等着', '收到 True']
clear 之后信号灯是: False
等不到的 wait 返回: False

Event 就三个动作,set() 点亮,clear() 熄灭,wait(超时) 等着被点亮。返回值是 True 或 False,表示到底等到了没有。

它最常见的用处是让后台线程能优雅退出。线程体写成 while not stop.is_set():,主程序想停的时候就 stop.set(),线程把手上的活干完自己出来。比 daemon=True 硬掐体面得多。

6.4 加锁,把那三步变成一步

回到 6.1 那个计数器。问题出在读和写之间能被插队,那就用一把锁把这段护起来。

import threading
import time

counter = 0
lock = threading.Lock()

def add(n):
    global counter
    for _ in range(n):
        with lock:               # 进来拿锁,出去放锁
            tmp = counter
            time.sleep(0)
            counter = tmp + 1

def run(threads, times):
    global counter
    counter = 0
    ts = [threading.Thread(target=add, args=(times,)) for _ in range(threads)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    return counter

if __name__ == "__main__":
    want = 1000
    print("期望值", want, "(5 个线程,每个加 200 次)")
    print("对得上吗:", run(5, 200) == want)
    print("再来一遍还对吗:", run(5, 200) == want)
期望值 1000 (5 个线程,每个加 200 次)
对得上吗: True
再来一遍还对吗: True

一把锁,结果就对了。

锁的几个用法。

>>> lock = threading.Lock()
>>> lock.locked()
False
>>> with lock:
...     print("进来了,锁着吗", lock.locked())
进来了,锁着吗 True
>>> lock.locked()
False
>>> lock.acquire(timeout=0.2)       # 拿锁,成功返回 True
True
>>> lock.acquire(timeout=0.2)       # 已经被自己拿着,再拿就等着
False
>>> lock.release()
>>> lock.locked()
False

with lock: 最省事,进来拿出去放,中间抛异常也会放。手写 acquire() 和 release() 就一定要配 try ... finally,不然异常一抛锁就不放了。

拿不到锁的时候,acquire(timeout=秒) 会等到超时,返回 False。不传 timeout 就一直等,等到天荒地老。给个超时是好习惯,至少不会让程序彻底卡死。

还有个 RLock,可重入锁,同一个线程可以拿好几次。

>>> r = threading.RLock()
>>> with r:
...     with r:
...         print("同一把锁能进两次")
同一把锁能进两次

普通 Lock 这么写就自己把自己锁死了。一个方法拿了锁又要调另一个也要拿同一把锁的方法,就得用 RLock。

6.5 每个线程存一份自己的数据

有些数据天生就该是线程私有的,比如数据库连接、当前用户信息。用 threading.local(),各存各的。

import threading
import time

local = threading.local()
result = {}

def show(name):
    local.name = name
    time.sleep(0.05)
    result[threading.current_thread().name] = local.name

if __name__ == "__main__":
    ts = [threading.Thread(target=show, args=("甲",), name="甲线程"),
          threading.Thread(target=show, args=("乙",), name="乙线程")]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    print("甲线程看到:", result["甲线程"])
    print("乙线程看到:", result["乙线程"])
    print("互不干扰:", result["甲线程"] == "甲" and result["乙线程"] == "乙")
甲线程看到: 甲
乙线程看到: 乙
互不干扰: True

同一个 local.name,两个线程写进去不同的值,读出来还是各自的。它内部按线程 id 分开存,你不用管。

Web 框架里那个"当前请求"对象就是这么实现的。一个全局变量,每个请求线程看到的都是自己那一份。


7 关于线程需要注意的两点

这两点是多线程最容易翻车的地方,前面都埋过伏笔,这里摆到明面上。

7.1 GIL,那把看不见的大锁

CPython 解释器里有一把全局锁,叫 GIL(Global Interpreter Lock)。同一时刻,一个进程里只有一个线程能执行 Python 字节码。

┌─────────────── 一个 Python 进程 ───────────────┐
│                                                │
│   ┌──────────────────────────────────────┐     │
│   │              GIL                     │     │
│   │   拿着它的线程才能执行 Python 代码     │     │
│   └──────────────────────────────────────┘     │
│                                                │
│   线程甲 ► 跑了 5 毫秒 ► 交锁                   │
│   线程乙 ► 等着       ► 接手 ► 跑 5 毫秒        │
│   线程丙 ► 等着                                │
│                                                │
└────────────────────────────────────────────────┘

它存在的原因很实际,CPython 的内存管理不是线程安全的,引用计数那套东西要是被多个线程同时改就乱了。加锁是最省事的解法,代价就是上面这张图。

切换的间隔默认是 5 毫秒,可以查也可以改。

>>> import sys
>>> sys.getswitchinterval()
0.005

说了这么多,跑一遍最直观。四个一模一样的纯计算任务,分别用串行、四个线程、四个进程来做。

import multiprocessing as mp
import threading
import time

N = 6_000_000

def burn(_=None):
    s = 0
    for i in range(N):
        s += i * i
    return s

def serial(times):
    t = time.perf_counter()
    for _ in range(times):
        burn()
    return time.perf_counter() - t

def threaded(times):
    t = time.perf_counter()
    ts = [threading.Thread(target=burn) for _ in range(times)]
    for x in ts:
        x.start()
    for x in ts:
        x.join()
    return time.perf_counter() - t

if __name__ == "__main__":
    t1 = serial(4)
    t2 = threaded(4)
    t = time.perf_counter()
    with mp.Pool(4) as pool:
        pool.map(burn, range(4))
    t3 = time.perf_counter() - t
    print("4 个线程比串行快:", t2 < t1 * 0.8)
    print("4 个进程比串行快:", t3 < t1 * 0.8)
4 个线程比串行快: False
4 个进程比串行快: True

我这台机器上跑出来,串行 1.8 秒,四个线程 1.87 秒,四个进程 0.9 秒。线程不但没快,还慢了一点点,因为多了切换的开销。进程快了一倍,四个核真的在同时算。

结论。

CPU 密集的任务(解析、压缩、加密、图像处理、大量计算),多线程没用,要提速只能上多进程。

等待密集的任务(网络请求、磁盘读写、等用户输入),多线程有用。 因为等待的时候线程会把 GIL 放掉,别的线程正好接着跑。

   CPU 密集                              等待密集

   线程A ████████████                    线程A ██░░░░██░░░░
   线程B ████████████   ← 排队用 GIL      线程B ░░██░░░░██░░   ← 等的时候让出 GIL
   线程C ████████████                    线程C ░░░░██░░░░██
   总时间 = 三份加起来                    总时间 ≈ 一份

   ░ 表示在等,██ 表示在用 CPU

顺带说一句,time.sleep()、文件读写、网络请求这些操作都会释放 GIL,所以它们是"能并发"的。反过来看,一个自己写的纯 Python 循环,就是在死占着 GIL。

要用 C 写的库绕过 GIL 也行,numpy 的大矩阵运算就是在锁外头算的,那时候多线程也能提速。前提是那个库自己放开了 GIL。

7.2 锁是好东西,用错了就是死锁

6.1 那个计数器告诉我们共享数据要加锁。但两把锁碰在一起,可能出现更麻烦的事。

import threading
import time

def hold(name, first, second, out, timeout):
    with first:
        time.sleep(0.05)
        got = second.acquire(timeout=timeout)
        out[name] = got
        if got:
            second.release()

if __name__ == "__main__":
    a, b = threading.Lock(), threading.Lock()
    out = {}
    t1 = threading.Thread(target=hold, args=("线程一", a, b, out, 2.0))
    t2 = threading.Thread(target=hold, args=("线程二", b, a, out, 0.2))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print("线程一(愿意等 2 秒)拿到第二把锁了吗", out["线程一"])
    print("线程二(只等 0.2 秒)拿到第二把锁了吗", out["线程二"])
    print("两个都拿到了吗:", all(out.values()))
线程一(愿意等 2 秒)拿到第二把锁了吗 True
线程二(只等 0.2 秒)拿到第二把锁了吗 False
两个都拿到了吗: False

线程一拿着 A 想要 B,线程二拿着 B 想要 A,两个都在等对方放手。这就是死锁。

   线程一                              线程二
   ┌──────────┐                      ┌──────────┐
   │ 拿着锁 A  │                      │ 拿着锁 B  │
   │ 想要锁 B  │ ────► 等 ◄────      │ 想要锁 A  │
   └──────────┘                      └──────────┘
        ▲                                  │
        └────────── 谁也不放手 ────────────┘

演示里线程二只愿意等 0.2 秒,超时就放弃并松开了 B,线程一这才拿到 B 继续往下走。要是两个都写 acquire() 不设超时,程序就永远停在那儿了。

几条实操经验。

acquire 一律带 timeout。 锁不上的时候至少能报错,不至于让整个程序挂死。

所有线程按同一个顺序拿锁。 上面那个例子,两个线程都先拿 A 再拿 B,就不会死锁。

能不加锁就不加。 用队列传数据、用局部变量、让每个线程处理自己那一份,最后再汇总,比中途抢一把锁清爽得多。

锁的范围要小。 只把真正要保护的那几行圈进去。锁里头调网络请求、读大文件,整个程序的并发度就被这一把锁拖没了。

7.3 顺带一提,别在子线程里碰界面

这条跟上一章直接相关,GUI 库基本都不是线程安全的,wxPython、Qt、tkinter 都一样。子线程里直接改界面上的文字,运气好没事,运气不好整个程序闪退。

正确的做法是子线程只干活,要更新界面就把它交给主线程。wxPython 里用 wx.CallAfter。

# gui_worker.py
# 接第十一章的待办清单,只留了跟线程有关的那几行

class BackupFrame(wx.Frame):
    def __init__(self):
        super().__init__(None, title="待办清单", size=(420, 320))
        self.panel = wx.Panel(self)
        self.btn = wx.Button(self.panel, label="开始备份")
        self.btn.Bind(wx.EVT_BUTTON, self.on_backup)
        self.text = wx.TextCtrl(self.panel, style=wx.TE_MULTILINE | wx.TE_READONLY)
        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(self.btn, 0, wx.ALL, 8)
        sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 8)
        self.panel.SetSizer(sizer)

    def on_backup(self, event):
        self.btn.Disable()                      # 主线程的活,直接改
        threading.Thread(target=self.do_backup, daemon=True).start()

    def do_backup(self):
        for n in range(1, 4):                   # 子线程,只干活
            time.sleep(0.5)
            wx.CallAfter(self.log, "第 %d 份备份完成" % n)
        wx.CallAfter(self.btn.Enable)           # 要改界面,交给主线程

    def log(self, msg):
        self.text.AppendText(msg + "\n")        # 这个函数跑在主线程里

wx.CallAfter 会把你要做的事排进事件循环的队列,等主线程空了再执行。上一章 5.9 节留的那个尾巴,正解就是这个。


8 实战

两个例子,一个用进程,一个用线程,都是能直接跑的完整脚本。

8.1 批量压缩日志(多进程 + 队列报进度)

日志归档是典型的 CPU 密集活,压缩算法一直在算,CPU 打满,正好适合多进程。

import multiprocessing as mp
import os
import queue
import random
import shutil
import time
import zlib

WORK = "logs"
LEVELS = ["INFO", "WARN", "ERROR", "DEBUG"]
MODULES = ["订单", "支付", "库存", "用户", "网关"]
WORDS = ["登录", "下单", "支付", "退款", "查询", "超时", "缓存", "数据库"]
REPORT = None

def make_logs(n_file, n_line):
    """造一批日志文件,真项目里这一步是运维已经给你准备好的。"""
    shutil.rmtree(WORK, ignore_errors=True)
    os.makedirs(WORK)
    paths = []
    for i in range(n_file):
        rnd = random.Random(1000 + i)
        path = os.path.join(WORK, "app-%02d.log" % i)
        with open(path, "w", encoding="utf-8", newline="\n") as f:
            for _ in range(n_line):
                f.write("2026-10-08 %02d:%02d:%02d %s %s模块 %s 用户%04d 耗时%03dms\n" % (
                    rnd.randrange(24), rnd.randrange(60), rnd.randrange(60),
                    rnd.choice(LEVELS), rnd.choice(MODULES), rnd.choice(WORDS),
                    rnd.randrange(9000), rnd.randrange(500)))
        paths.append(path)
    return paths

def init_worker(q):
    """池子里每个进程启动时跑一次,把队列挂到全局变量上。"""
    global REPORT
    REPORT = q

def archive(path):
    idx = int(os.path.basename(path).split("-")[1].split(".")[0])
    data = open(path, "rb").read()
    packed = zlib.compress(data, 9)
    with open(path + ".zz", "wb") as f:
        f.write(packed)
    REPORT.put((idx, len(data), len(packed), data.count(b"\n")))
    return idx

def collect(q):
    """把队列里攒的进度全掏出来,按文件序号排好。"""
    got = []
    while True:
        try:
            got.append(q.get(timeout=2))
        except queue.Empty:
            break
    got.sort()
    return got

if __name__ == "__main__":
    paths = make_logs(8, 24000)
    print("待归档 %d 个文件,共 %d 字节" % (len(paths), sum(os.path.getsize(p) for p in paths)))

    q = mp.Queue()
    t = time.perf_counter()
    with mp.Pool(4, initializer=init_worker, initargs=(q,)) as pool:
        done = pool.map(archive, paths)
    par = time.perf_counter() - t

    for p in paths:
        os.remove(p + ".zz")
    t = time.perf_counter()
    for p in paths:
        data = open(p, "rb").read()
        zlib.compress(data, 9)
    ser = time.perf_counter() - t

    lines = collect(q)
    print("完成的文件编号:", done)
    print()
    print("序号     原始字节      压缩后     压缩率")
    for idx, raw, packed, count in lines:
        print("  %02d   %10d   %9d     %5.1f%%" % (idx, raw, packed, packed * 100 / raw))
    print()
    print("总行数:", sum(x[3] for x in lines))
    print("八个文件都报了进度:", len(lines) == 8)
    print("4 个进程比串行快:", par < ser * 0.8)

    shutil.rmtree(WORK, ignore_errors=True)
待归档 8 个文件,共 13223696 字节
完成的文件编号: [0, 1, 2, 3, 4, 5, 6, 7]

序号     原始字节      压缩后     压缩率
  00      1652612      282097      17.1%
  01      1653000      281953      17.1%
  02      1653056      281942      17.1%
  03      1653115      282019      17.1%
  04      1652986      281989      17.1%
  05      1652876      282177      17.1%
  06      1653071      281901      17.1%
  07      1652980      282152      17.1%

总行数: 192000
八个文件都报了进度: True
4 个进程比串行快: True

几个关键点。

队列不能直接当 pool.map 的参数传,会报"队列只能通过继承共享"。得用 initializer 在进程启动时把它挂到全局变量上,这是标准做法。

collect 里用 timeout=2 加异常退出,取不到就认为没了。别用 qsize() 判断,那个数字在多进程下不准。

先取数据再算别的,pool.map 已经等所有进程结束了,队列里的数据肯定是齐的。

make_logs 开头那句 rmtree 是必须的。不然第二次跑,目录下会残留上次的文件,结果就对不上了。造数据的脚本一定记得先清场。

我这里跑出来串行 1.5 秒、四进程 0.9 秒。八个文件一共 1300 万字节,压到 220 万,压缩率 17% 左右。

8.2 后台自动备份(线程 + 队列 + 信号灯)

接上一章那个待办清单。现在给它加个后台备份,点一下按钮就隔一会儿备份一次,界面上还能看到进度,中间不影响你继续添加待办。

import os
import queue
import shutil
import sqlite3
import threading

DB = "todo.db"
BACKUP = "backups"

def setup_db():
    if os.path.exists(DB):
        os.remove(DB)
    con = sqlite3.connect(DB)
    con.execute("CREATE TABLE todo (id INTEGER PRIMARY KEY AUTOINCREMENT,"
                " title TEXT NOT NULL, done INTEGER DEFAULT 0)")
    con.executemany("INSERT INTO todo (title) VALUES (?)",
                    [("买牛奶",), ("写周报",), ("交电费",)])
    con.commit()
    con.close()

def count(path):
    con = sqlite3.connect(path)
    n = con.execute("SELECT COUNT(*) FROM todo").fetchone()[0]
    con.close()
    return n

class BackupThread(threading.Thread):
    """隔一会儿把数据库备份一份,干满 times 次或者收到停止信号就收工。"""

    def __init__(self, db, outdir, report, stop, times=3, interval=0.05):
        super().__init__(name="备份线程", daemon=True)
        self.db = db
        self.outdir = outdir
        self.report = report
        self.stop = stop
        self.times = times
        self.interval = interval

    def run(self):
        for n in range(1, self.times + 1):
            if self.stop.is_set():
                break
            dst = os.path.join(self.outdir, "backup_%03d.db" % n)
            src = sqlite3.connect(self.db)
            out = sqlite3.connect(dst)
            src.backup(out)          # 第十章讲过的在线备份
            out.close()
            src.close()
            self.report.put((n, count(dst)))
            self.stop.wait(self.interval)

if __name__ == "__main__":
    shutil.rmtree(BACKUP, ignore_errors=True)
    os.makedirs(BACKUP)
    setup_db()
    print("库里有", count(DB), "条待办")

    report = queue.Queue()
    stop = threading.Event()
    worker = BackupThread(DB, BACKUP, report, stop)
    worker.start()
    print("备份线程启动,活着吗", worker.is_alive(), "| 是守护线程吗", worker.daemon)

    seen = []
    while len(seen) < 3:
        n, rows = report.get(timeout=5)
        seen.append(n)
        print("收到第 %d 份备份,里面有 %d 条" % (n, rows))
        print("  主线程没被卡住,顺手查了一下,现在库里", count(DB), "条")

    stop.set()
    worker.join(timeout=5)
    print("停止信号发出后,线程还在吗", worker.is_alive())
    print("备份文件:", sorted(os.listdir(BACKUP)))
    print("最后一份跟原库一致吗", count(os.path.join(BACKUP, "backup_003.db")) == count(DB))

    shutil.rmtree(BACKUP, ignore_errors=True)
    os.remove(DB)
库里有 3 条待办
备份线程启动,活着吗 True | 是守护线程吗 True
收到第 1 份备份,里面有 3 条
  主线程没被卡住,顺手查了一下,现在库里 3 条
收到第 2 份备份,里面有 3 条
  主线程没被卡住,顺手查了一下,现在库里 3 条
收到第 3 份备份,里面有 3 条
  主线程没被卡住,顺手查了一下,现在库里 3 条
停止信号发出后,线程还在吗 False
备份文件: ['backup_001.db', 'backup_002.db', 'backup_003.db']
最后一份跟原库一致吗 True

拆开看。

BackupThread 继承 Thread,run() 里是循环。每次先做一份备份,然后把"第几份、里面几条"塞进队列。self.stop.wait(间隔) 既是等待又是睡觉,收到停止信号会立刻醒过来。

主线程那边,用 report.get(timeout=5) 一条条收消息。收到消息的同时它还在查数据库,说明界面没被堵住。放到 wxPython 里,把 print 换成 wx.CallAfter 去更新进度条就行。

停止用的是 Event,不是 daemon=True 硬掐。备份写到一半被掐掉会留下半个文件,用信号灯让它自己收工,文件是完整的。

src.backup(out) 是 SQLite 自带的在线备份接口,第十章讲过。它比直接 shutil.copy 稳,备份过程中有别人在写也没事,拿到的是某一时刻的完整快照。

这个例子虽然用的是 SQLite,换成"每隔五分钟拉一次报表""后台上传日志文件"是一回事。骨架就这三样,一个继承 Thread 的类、一个 queue.Queue 传消息、一个 Event 管停止。


9 小结

9.1 这章说了什么

进程是跑起来的程序,各占一块内存,要传数据得走队列、管道、共享内存。线程住在进程里,共享内存,传数据一个全局变量就行,但容易引起竞争。

multiprocessing 管进程,threading 管线程,两者接口设计得几乎一样。开进程用 Process 或 Pool,开线程用 Thread 或 ThreadPoolExecutor,都是 start() 开跑、join() 等、is_alive() 查状态。

GIL 决定了 CPU 密集的活线程帮不上忙,要提速得用进程。等待密集的活反过来,线程便宜又好使。

9.2 挑选的依据

你的情况选什么
任务在算,CPU 打满多进程,或者 ProcessPoolExecutor
任务在等,网络或磁盘多线程,或者 ThreadPoolExecutor
只想让界面不卡一个后台线程就够了
任务太小,几十毫秒就完事别并发,直接串行跑
要传大量数据线程(共享内存),进程的话开销全在序列化上
任务之间要完全隔离,一个崩了不能连累别人多进程

还有一个通用的建议。先写串行版本,跑通了再改并发。 并发的调试难度是串行版本的几倍,业务逻辑本身有 bug 的时候,并发只会让你更难找到它。

9.3 十个容易踩的坑

  1. Windows 上忘了写 if __name__ == "__main__":,子进程无限套娃,报 bootstrapping 那个错
  2. join() 写在取队列之前,父子进程互相等,程序永久卡死
  3. 传给子进程的函数是 lambda 或者嵌套定义的,spawn 找不到它
  4. args=("张三") 忘了逗号,字符串被拆成好几个参数
  5. 以为子进程改了全局变量主进程能看见,改了等于没改
  6. 以为多线程能给 CPU 密集的活提速,被 GIL 挡着,一点用没有
  7. 共享数据不加锁,偶尔错一次,上线才暴露
  8. acquire() 不带 timeout,一死锁就是永久
  9. 开了进程池或线程池不关,用 with 语句最省事
  10. 在子线程里直接改界面,GUI 库不是线程安全的,用 CallAfter 之类的方法交回主线程

9.4 四道小题

第一题,下面这段代码跑完,result 里是什么。

>>> import threading
>>> result = []
>>> def work(n):
...     result.append(n * 2)
>>> ts = [threading.Thread(target=work, args=(i,)) for i in range(3)]
>>> for t in ts:
...     t.start()
>>> for t in ts:
...     t.join()
>>> sorted(result)
[0, 2, 4]

result 里是 [0, 2, 4],顺序不一定,所以我用了 sorted。三个线程都往同一个列表里加,列表的 append 是线程安全的,不会丢。

第二题,给 6.1 那个计数器加锁,让 100 个线程各加一次,结果应该是 100。

>>> import threading
>>> counter = 0
>>> lock = threading.Lock()
>>> def safe_add():
...     global counter
...     with lock:
...         counter += 1
>>> ts = [threading.Thread(target=safe_add) for _ in range(100)]
>>> for t in ts:
...     t.start()
>>> for t in ts:
...     t.join()
>>> counter
100

第三题,这段代码有什么问题。

# wrong_order.py
import multiprocessing as mp

def sender(q):
    for i in range(100):
        q.put(i * i)

if __name__ == "__main__":
    q = mp.Queue()
    p = mp.Process(target=sender, args=(q,))
    p.start()
    p.join()          # 问题在这一行
    data = q.get()
    print(data)

join() 在取数据之前。子进程的队列要是没被及时取走,塞满了就卡在 put() 上,join() 永远等不到它结束。把 join() 挪到所有 get() 之后。

第四题,一个脚本要处理 200 个文件,每个文件处理要 20 毫秒。用多进程合适吗。

不合适。总共才 4 秒的活,起进程的开销有零点几秒,而且 200 个进程的内存占用不小。要么串行跑完这 4 秒,要么用 ProcessPoolExecutor(max_workers=8) 把进程复用起来,只付一次启动成本。