系列文章

Python 从入门到精通

第 33 / 36 篇

从环境安装和基础语法出发,逐步学习工程实践、自动化、数据处理与 Web 开发。

  1. 01
    Python 怎么装、怎么用
  2. 02
    变量、数字和字符串
  3. 03
    列表:把一组数据放在一起
  4. 04
    元组、集合和字典
  5. 05
    条件判断:让程序做选择
  6. 06
    循环:重复的事交给程序
  7. 07
    函数:把代码整理成可复用的块
  8. 08
    模块与包:拆分你的程序
  9. 09
    输入、输出与字符串格式化
  10. 10
    文件读写:保存程序的数据
  11. 11
    异常处理:程序出错时怎么办
  12. 12
    基础阶段练习:命令行记账本
  13. 13
    类和对象:面向对象入门
  14. 14
    继承、组合与特殊方法
  15. 15
    迭代器与生成器
  16. 16
    列表推导式与生成器表达式
  17. 17
    装饰器:给函数增加能力
  18. 18
    上下文管理器与 with
  19. 19
    类型标注与 dataclass
  20. 20
    正则表达式:从文本中找规律
  21. 21
    日期、时间与时区
  22. 22
    日志与调试
  23. 23
    虚拟环境与依赖管理
  24. 24
    测试:让修改不再提心吊胆
  25. 25
    网络请求:用 Python 调用 API
  26. 26
    网页解析与合规采集
  27. 27
    操作 Excel、CSV 与批量文件
  28. 28
    SQLite:给程序加一个数据库
  29. 29
    数据分析入门:NumPy 与 Pandas
  30. 30
    画图:把数据变得直观
  31. 31
    Flask 入门:做一个小网站
  32. 32
    异步编程:同时处理多项任务
  33. 33
    线程、进程与并发选择正在阅读
  34. 34
    性能分析与优化
  35. 35
    项目结构、配置与发布
  36. 36
    综合项目:从需求到上线

查看整个系列 →

上一篇学了异步编程,处理”等得多”的任务。但还有一类任务它管不了:CPU 密集——大量计算、图像处理、数据转换,这类任务需要多个 CPU 核心同时干活。这一篇讲线程和进程,以及怎么选。

三个概念先分清

  • 进程(process):一个正在运行的程序,有自己的内存空间。多进程 = 多个程序并行,能用到多核 CPU
  • 线程(thread):进程里的执行单元,共享进程内存。多线程 = 一个程序里多条”流水线”
  • GIL(全局解释器锁):CPython 的机制,同一时刻只有一个线程在执行 Python 代码。这是理解并发选型的钥匙

多线程:threading

先看线程怎么用(最简方式,用 ThreadPoolExecutor 线程池):

from concurrent.futures import ThreadPoolExecutor
import time

def work(name, seconds):
    time.sleep(seconds)      # 模拟 IO 等待
    return f"{name} 完成"

start = time.time()
with ThreadPoolExecutor(max_workers=3) as pool:
    results = list(pool.map(work, ["任务A", "任务B", "任务C"], [1, 2, 3]))
print(results)   # ['任务A 完成', '任务B 完成', '任务C 完成']
print(f"耗时 {time.time() - start:.2f} 秒")   # ≈3 秒(并发执行)

ThreadPoolExecutor 是线程池:max_workers 控制同时几个线程,pool.map(函数, 参数列表) 批量派活。比手写 threading.Thread 简洁得多。

GIL:为什么线程算不快

关键实验:多线程跑 纯计算 任务:

from concurrent.futures import ThreadPoolExecutor
import time

def heavy_calc(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

start = time.time()
with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(heavy_calc, [5_000_000] * 4))
print(f"4 线程纯计算耗时:{time.time() - start:.2f} 秒")

# 对比:单线程
start = time.time()
for _ in range(4):
    heavy_calc(5_000_000)
print(f"单线程耗时:{time.time() - start:.2f} 秒")

结果会让你意外:4 线程 ≈ 单线程,甚至更慢(线程切换有开销)。原因就是 GIL——Python 解释器同一时刻只让一个线程跑字节码,多线程抢一个锁,纯计算根本没法并行。

但 IO 等待时线程会释放 GIL(sleep、网络、磁盘等待都不占锁),所以上一篇那种”等网络”的任务,多线程也能提速。结论:

  • IO 密集:线程/异步都能提速(等的时候让出 GIL)
  • CPU 密集:线程没用,得上多进程(每个进程有自己的 GIL)

多进程:multiprocessing

多进程让每个进程独立跑 Python,绕过 GIL,真正用上多核:

from concurrent.futures import ProcessPoolExecutor
import time

def heavy_calc(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

start = time.time()
with ProcessPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(heavy_calc, [5_000_000] * 4))
print(f"4 进程耗时:{time.time() - start:.2f} 秒")   # 明显快于单线程(多核并行)

用法和线程池几乎一样,把 ThreadPoolExecutor 换成 ProcessPoolExecutor 即可。

注意事项:

  • 进程间不共享内存,参数和返回值会被序列化传递(pickle)——传大对象很慢
  • Windows 上必须把入口代码放 if __name__ == "__main__": 里,否则递归报错

进程间通信:Queue

进程不共享变量,要传递结果用队列:

import multiprocessing

def producer(q, name):
    for i in range(3):
        q.put(f"{name}-{i}")     # 放数据

def consumer(q):
    while not q.empty():
        print("收到:", q.get())   # 取数据

if __name__ == "__main__":
    q = multiprocessing.Queue()
    p1 = multiprocessing.Process(target=producer, args=(q, "A"))
    p2 = multiprocessing.Process(target=consumer, args=(q,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()

日常用 ProcessPoolExecutor 就够,手写 Process + Queue 了解即可。

共享变量的坑:竞态条件

多线程共享内存,多个线程同时改一个变量会出乱子:

import threading

counter = 0

def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1    # 不是原子操作!读-改-写三步,线程间会互相打断

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

print(counter)   # 应该是 5_000_000,实际经常少一些(数据竞争)

解决:用锁(Lock)保护共享区域:

import threading

counter = 0
lock = threading.Lock()      # 锁

def increment():
    global counter
    for _ in range(1_000_000):
        with lock:           # 加锁:同时只有一个线程能进
            counter += 1

# ... 同上启动线程 ...
print(counter)   # 5000000 正确了

经验:多线程里尽量别共享可变状态;必须共享就加锁;能避免就避免(用线程池的返回值代替共享变量)。

并发选型总表

场景 工具 原因
IO 密集(网络/文件/数据库等待) asyncio 或 ThreadPoolExecutor 等待时让出 GIL,单线程扛大量并发
CPU 密集(计算/图像/数据处理) ProcessPoolExecutor / multiprocessing 多进程绕过 GIL,用满多核
两者混合 异步 + to_thread / 进程池 各取所长

一句话:等得多用异步/线程,算得多用多进程

新手坑

坑 1:CPU 任务用线程,发现没提速。GIL 卡着,换 ProcessPoolExecutor。

坑 2:多线程共享变量出诡异结果。竞态条件,加锁或改用返回值传递。

坑 3:进程池传超大对象。序列化开销比计算还大,慢得离谱。大数据用共享内存或文件。

小结

  • 进程有独立内存、能用多核;线程共享内存、受 GIL 限制
  • GIL:同一时刻只有一个线程执行 Python 代码;IO 等待释放 GIL,纯计算不释放
  • IO 密集 → 异步/线程;CPU 密集 → 多进程(ProcessPoolExecutor)
  • 共享变量要加锁(with lock:),最好别共享
  • 并发选型一句话:等得多用异步/线程,算得多用多进程

练习

  1. 用 ThreadPoolExecutor 并发下载 5 个文件(模拟 sleep),和串行对比耗时
  2. 用 ProcessPoolExecutor 并行计算 4 个大数的质数判断,对比单线程耗时
  3. 思考:为什么 GIL 存在的情况下,多线程处理网络请求依然能提速?”等待”和”计算”在 GIL 面前有什么区别?