说实话,写业务代码写了几年 Python,我一直觉得这语言就是个“温和的老好人”:语法友好、生态丰富、怎么折腾都不容易崩溃。直到有一天线上服务出现诡异的内存暴涨,多线程跑了半天 CPU 占用却上不去,我才醒悟——如果不懂 Python 底层那点事,你永远只能在一个很浅的层面写代码。
这也就是今天这篇长文的由来。我想把 Python 里最容易让开发者困惑、也最值得花时间搞明白的三件事一次性讲透:指针到底是怎么回事、GIL 到底是朋友还是敌人、异步内核究竟怎么运作。这三者看起来是独立的知识点,但其实是同一条主线上的三个站点。当你把它们串起来看的时候,很多“为什么我这样写不行”“为什么换了个方案就快了 10 倍”的问题,都会有答案。
这篇文章适合谁?刚学完 Python 基础、想往深处走的初学者,写爬虫或脚本时经常被内存和并发问题困扰的开发者,以及准备面试、需要把底层机制讲清楚的朋友。你不用是 C/C++ 高手,我会用尽量简单的方式把这些概念拆开揉碎,再配上可以直接实操的代码和排查思路。
1. 内容整体设计与思路拆解
1.1 为什么选这三个关键词作为解剖对象
把“指针”“GIL”“异步内核”放在一起,看起来跨度很大,但我自己在梳理的时候发现它们恰好回答了 Python 编程里三个最关键的追问:数据在内存里是怎么存的?代码在有多核 CPU 的机器上是怎么跑的?当事件变多、系统变忙时,我们怎么让单线程的程序也能扛住高并发?
先说指针。Python 官方文档里从来没说过 Python 有指针,但实际开发中你几乎每时每刻都在和“指针”打交道。比如你写 a = [1, 2, 3],这个 a 存的并不是列表本身,而是指向列表对象的引用。函数传参、列表拷贝、可变对象修改,这些行为的底层全都是引用语义。我以前带过几个刚转 Python 的同事,他们最容易踩的坑就是默认参数用可变对象、赋值后两个变量互相影响,这些都是没搞懂引用的后果。所以第一部分我决定把 Python 的“指针”讲清楚,不是教 C 语言那种语法层面的指针,而是讲 Python 对象在内存里的真实存在方式。
再说 GIL。Global Interpreter Lock,全局解释器锁,这个名词几乎是 Python 并发编程的“原罪”。很多新手学多线程时信心满满,结果写了 8 个线程去跑 CPU 密集型计算,发现速度还不如单线程,第一反应就是 Python 废物。但 GIL 不是不可理解的魔咒,它是一个非常具体的机制,只要理解了它的调度逻辑、它在什么情况下是瓶颈、什么情况下根本无感,你就知道该用线程、进程还是协程。
最后是异步内核。Python 的异步编程在 asyncio 出现之后变得正规且强大,但很多开发者学了好久 async/await 还是觉得玄乎,不知道事件循环到底在循环什么,Task 和 Future 到底有什么区别。本质上看,协程实际上是“用户态线程切换”,而 Python 的 asyncio 则是那个帮你做切换的调度器。理解这一层,写起异步代码来心里会踏实很多。
1.2 整体叙述逻辑:从内存到并发再到解决方案
这篇文章的编排是有递进关系的。先讲内存层的引用模型,这是理解一切的基础;再讲 GIL,解释 Python 底层并行性的天然约束;最后讲异步内核,讨论「在这种约束下 Python 怎么实现高并发」。三章内容就像修房子:先打地基,再搭框架,最后装修。
另外我在每章都会穿插一些实战排查经验。比如你用 sys.getrefcount 查看引用计数、用 faulthandler 排查死锁、用 asyncio.run 时遇到的事件循环冲突,这些都是我实际工作中踩过的坑。我会把问题现象、排查过程、解决方式全部写出来,而不是只给一个空洞的概念定义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 初探 Python 的内存模型:一切皆对象,变量皆引用
如果你是从 C/C++ 转过来的,第一个要纠正的思维是:Python 变量不是一个“能装数据的盒子”,而是一个“贴着对象名字的标签”。在 C 里,你写 int x = 10,意味着在栈上分配一块 4 字节的内存,把值 10 放进去,变量 x 就是那块内存的别名。在 Python 里,你写 x = 10,解释器会先创建一个整数对象 10,然后把变量名 x 指向这个对象。
这个差异可以用一个生活场景来理解:C 的变量像你手里抱着的一个盒子,你把东西放在自己怀里;Python 的变量像一张便利贴,你可以把它贴到很多东西上,也可以撕下来换一张贴。对象本身存放在一个叫做“堆”的区域里,由 Python 内存管理器统一分配和回收。
python复制import sys
a = [1, 2, 3]
b = a
print(sys.getrefcount(a)) # 输出 3,因为 a、b 和 getrefcount 参数各引用了一次
print(id(a), id(b)) # 两个 id 完全相同,说明 a 和 b 指向的是同一个列表对象
上面这段代码里,b = a 并没有复制列表,而是让 b 这个便利贴也贴到了同一个列表对象上。所以当你修改 b[0] 时,a[0] 也会变,因为它们根本就是同一个东西。
注意:
sys.getrefcount(a)返回值比实际引用数多 1,因为传入函数时也会产生一次临时引用。这个细节在面试里很容易被问到。
这一节想强调的核心是:Python 里的变量绑定本质就是“指针操作”,只不过被包装成了对用户友好的语法。你在 Python 里写的 =,既可能是“重新贴标签”,也可能是“修改对象内容”,具体是哪种语义取决于右边的表达式和对象的可变性。
2.2 可变对象与不可变对象:引用语义的关键分叉点
Python 对象按照是否可变分为两大类。不可变对象包括 int、float、str、tuple、frozenset,你一旦创建,内容就不能变化。可变对象包括 list、dict、set、自定义类实例,你可以原地修改它们的内容。
这两者的区别直接决定了上面说的 = 的语义。看一段我经常拿来测试学员的代码:
python复制def add_item(item, container=[]):
container.append(item)
return container
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2],而不是 [2]
print(add_item(3)) # [1, 2, 3]
很多新手看到这个结果会懵。原因就是:默认参数 container=[] 在函数定义时只求值一次,创建了一个空列表对象,之后的调用都复用了这个同一个列表对象。container.append(item) 是在原地修改这个对象,所以上一次调用的数据一直累积在里面。这个问题的修复方式很简单,改成 container=None,函数内再判断。
那如果你真的想复制一个对象呢?那就得用 copy 模块。浅拷贝 copy.copy 会创建一个新的容器对象,但容器内的元素还是原对象的引用;深拷贝 copy.deepcopy 则会递归复制所有嵌套对象。
python复制import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
shallow[0].append(99) # original[0] 也会变
deep[0].append(88) # original 不受影响
实际开发里我一般遵循一个原则:如果数据不需要被多个地方同时修改,尽量用不可变对象;如果必须共享可变数据,要么显式用深拷贝,要么明确设计好所有权和修改接口。这样能省掉大量诡异的 bug。
2.3 Python 引用与 C 指针的对照速查表
虽然 Python 的引用在底层实现上和 C 指针有相似之处,但语言层面的语义有很多关键差异。我整理了一张速查表,方便大家对照理解。
| 维度 | C 指针 | Python 引用 |
|---|---|---|
| 基本操作 | *p 解引用访问目标 |
变量直接访问目标对象 |
| 空值 | NULL,可以判断 | None,身份判断用 is None |
| 指针运算 | 支持 p+1、p++ 等 | 不支持,无法对引用做算术 |
| 类型声明 | 需要声明指针类型 | 动态绑定,变量无固定类型 |
| 内存管理 | 手动 malloc/free | 自动引用计数 + 垃圾回收 |
| 引用传递 | 显式传指针才能修改外部变量 | 可变对象天然按引用共享 |
| 重新指向 | 可以随意指向别的地址 | 变量可以重新绑定,但不影响原对象 |
从上表能看到,Python 引用是“安全简化版”的指针:保留了指针共享可变对象的能力,但去掉了指针运算和手动内存管理这些容易出错的部分。这也是为什么我说“Python 没有指针,但处处都是引用”。
2.4 引用计数与循环引用:内存管理的底层逻辑
CPython 的内存回收机制以引用计数为主,标记清除和分代回收为辅。每当一个对象被引用,它的 ob_refcnt 就加一;引用解除时减一。当引用计数降到 0,对象会被立即回收,内存归还给 Python 内存分配器。
这种机制的一个经典缺点是循环引用。两个对象互相持有引用,并且没有被外部引用时,它们的引用计数永远不会降到 0,就会造成内存泄漏。看一个常见例子:
python复制class Node:
def __init__(self, name):
self.name = name
self.parent = None
self.children = []
a = Node("parent")
b = Node("child")
a.children.append(b)
b.parent = a
这段代码创建了 a 和 b 两个节点,它们互相引用。如果删除外部变量 a 和 b 的引用,这两个对象依然因为互相引用而计数不为零,不会被立刻回收。CPython 的垃圾回收器会定期执行标记清除来找到并回收这种不可达的循环引用,但回收时机不确定,所以如果你在服务里高频创建这种循环结构,内存占用会肉眼可见地慢慢涨。
实操建议:尽量避免显式构造循环引用。如果确实需要,可以使用弱引用
weakref.ref或者weakref.WeakRef。双向链表、树结构里的 parent 回指,我都倾向于改成弱引用。
3. 实操过程与核心环节实现
3.1 GIL 到底是什么:一个从全局视角切入的思考
GIL 的英文全称是 Global Interpreter Lock,全局解释器锁。它本质上是 CPython 解释器主循环里的一把互斥锁,规定“同一时刻只能有一个线程在解释器里执行 Python 字节码”。这意味着即使在多核 CPU 上,多个 Python 线程也没法真正并行执行 Python 代码。
为什么会有这个东西?CPython 的内存管理并不是线程安全的。对象的引用计数 ob_refcnt 是一个共享变量,如果两个线程同时修改它,可能引发内存损坏。解决办法要么给每个对象单独加锁,要么全局加一把锁。CPython 选择的是后者,因为全局锁实现简单、性能可接受,而且能保证 C 扩展模块不用考虑线程安全问题。
放到实际业务里,GIL 的影响要看你的任务类型。如果你做的是网络请求、文件读写、数据库查询这类 IO 密集型任务,线程在等待 IO 时会主动释放 GIL,所以多线程能显著提升吞吐量。但如果是纯计算的任务,比如解压图片、跑循环处理数据,GIL 会限制多线程的并行性,导致多线程不比单线程快,甚至更慢——因为线程切换本身有开销。
3.2 用实验看穿 GIL:单线程/多线程/多进程对比
理论说了半天,不如直接跑一次实验。我用一个非常经典的 CPU 密集型函数做对比:计算斐波那契数列第 32 项,分别用单线程、多线程和多进程执行 4 次,记录耗时。
python复制import time
import threading
import multiprocessing
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
def run_in_threads():
threads = []
for _ in range(4):
t = threading.Thread(target=fib, args=(32,))
threads.append(t)
t.start()
for t in threads:
t.join()
def run_in_processes():
processes = []
for _ in range(4):
p = multiprocessing.Process(target=fib, args=(32,))
processes.append(p)
p.start()
for p in processes:
p.join()
start = time.time()
for _ in range(4):
fib(32)
print("单线程耗时: %.2fs" % (time.time() - start))
start = time.time()
run_in_threads()
print("多线程耗时: %.2fs" % (time.time() - start))
start = time.time()
run_in_processes()
print("多进程耗时: %.2fs" % (time.time() - start))
在我的机器上(8 核 CPU,Python 3.11)输出大概是这样的:
code复制单线程耗时: 2.84s
多线程耗时: 8.45s
多进程耗时: 1.02s
这个结果非常直观。多线程反而是最慢的,因为四个线程都在抢同一把 GIL,同时还有上下文切换的开销;多进程最快,每个进程都有独立的解释器和独立的内存空间,不再被 GIL 束缚。
注意:如果你用的是 Python 3.14 或更高版本,可能情况有所不同,因为官方正在逐步推进移除 GIL 的试验性版本。但绝大多数生产环境还在用 3.8~3.12,GIL 依然是绕不开的机制。
3.3 绕开 GIL 的几种常用姿势
既然 GIL 限制了 CPU 密集型多线程的并行度,那怎么绕开?我这里分享几个实际项目里用过的方案,按推荐程度排序。
第一种方案:用多进程。multiprocessing 模块是标准库自带的,用法和 threading 很像。但要注意,进程间不能直接共享对象,需要通过 multiprocessing.Queue、Pipe 或者在创建进程时传入参数来传递数据。序列化通信有开销,所以任务量足够大时才划算。
python复制def worker(x):
return x * x
if __name__ == "__main__":
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(worker, range(10))
print(results)
第二种方案:用 C 扩展把计算密集部分下放到 C 语言层面。C 扩展可以在持有 GIL 时做并行计算,或者使用 Py_BEGIN_ALLOW_THREADS 宏暂时释放 GIL。比如调用 NumPy 做矩阵运算时,底层 C 代码会释放 GIL,所以几个线程同时做 NumPy 计算是可以并行的。
第三种方案:换解释器。PyPy 虽然也有 GIL,但 JIT 提升很大;Jython 和 IronPython 没有 GIL,但生态不完整,只适合特定场景。实际项目里最靠谱的还是多进程 + C 扩展。
还有个容易被忽略的点:用 concurrent.futures 模块配合 ProcessPoolExecutor 可以更方便地管理进程池,它的接口和 ThreadPoolExecutor 几乎一样。如果你不确定该用线程还是进程,可以直接把 Executor 类换一下,基本不用改业务代码。
python复制from concurrent.futures import ProcessPoolExecutor
def square(x):
return x * x
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(square, i) for i in range(10)]
results = [f.result() for f in futures]
print(results)
3.4 协程与事件循环:异步内核的真正主角
把 GIL 的问题聊清楚之后,终于可以进入异步内核的部分了。先抛出一个关键观点:Python 的 asyncio 是基于协程的事件驱动模型,它解决的是 IO 密集型任务的并发问题,而不是 CPU 密集型任务的并行问题。
所谓事件循环,就是一个人在单线程里不断循环做三件事:检查有没有新任务、执行可以在当前时刻执行的任务、把等待 IO 的任务挂起等数据来了再唤醒。这个“任务”在 asyncio 里就是协程(coroutine)。
协程是一种可以被暂停和恢复的函数。普通函数一旦调用就会从头执行到尾,但协程可以在 await 处把控制权交回事件循环,等数据准备好了再回来继续执行。
python复制import asyncio
async def fetch_data(name, delay):
print(f"开始抓取 {name}")
await asyncio.sleep(delay)
print(f"抓取完成 {name}")
return f"{name} 数据"
async def main():
task1 = asyncio.create_task(fetch_data("网站A", 2))
task2 = asyncio.create_task(fetch_data("网站B", 1))
results = await asyncio.gather(task1, task2)
print(results)
asyncio.run(main())
这段代码里 asyncio.create_task 会创建 Task 对象,Task 会负责把协程封装成可调度的对象。await asyncio.gather 会同时等待两个任务完成。整个程序只有一个线程,但两个“看起来并发”的任务在交替执行:网站 A 的协程在 await asyncio.sleep(2) 处挂起,事件循环立刻让网站 B 的协程开始跑;B 在 sleep(1) 后先醒来完成,A 再醒来完成。
这个机制最神奇的地方在于:挂起的是什么?挂起的是协程的执行帧,包括局部变量、指令指针,Python 会把当前状态保存下来,等再次唤醒时从暂停处继续。这就很像操作系统切换线程,但它是完全在用户态完成的,切换成本比线程小得多。
3.5 自己动手写一个极简事件循环
为了真正理解 asyncio 的内核,我建议你手动实现一个极简事件循环。虽然功能不完整,但能帮你把“事件循环是怎么调度协程的”这个问题想透。
以下是一个只支持 sleep 的迷你事件循环:
python复制import time
from collections import deque
class MiniEventLoop:
def __init__(self):
self.tasks = deque()
self.sleeping = [] # (醒来时间, 协程)
def create_task(self, coro):
self.tasks.append(coro)
def run(self):
while self.tasks or self.sleeping:
if self.tasks:
coro = self.tasks.popleft()
try:
op, arg = coro.send(None)
except StopIteration:
continue
if op == "sleep":
wakeup = time.time() + arg
self.sleeping.append((wakeup, coro))
self.sleeping.sort(key=lambda x: x[0])
# 检查有没有到时间的 sleep
now = time.time()
while self.sleeping and self.sleeping[0][0] <= now:
_, coro = self.sleeping.pop(0)
self.tasks.append(coro)
def mini_sleep(seconds):
yield "sleep", seconds
def task(name, delay):
print(f"任务 {name} 开始")
yield from mini_sleep(delay)
print(f"任务 {name} 结束")
loop = MiniEventLoop()
loop.create_task(task("A", 0.2))
loop.create_task(task("B", 0.1))
loop.run()
这个极简实现里,协程通过 yield 把“我要谁等多久”告诉调度器,调度器把协程保存起来,等时间到了再放回任务队列。真实的 asyncio 远比这复杂——它有完善的任务调度、IO 事件通知、futures、信号处理等——但核心逻辑就是这个套路:遇到等待就挂起,条件满足就唤醒。
我在带新人时总结了一个比喻:事件循环就像一名身兼数职的餐厅唯一服务员。他给每桌客人点完单后,不是站在原地等菜做好,而是立即去服务下一桌;等某道菜做好的时候(IO 完成),他再回到那桌继续上菜。单线程也照样能服务很多客人,瓶颈只在调度是否高效。
4. 常见问题与排查技巧实录
4.1 引用与内存问题排查:内存泄漏和意外共享
我在生产环境里最常遇到的 Python 内存问题,不是真的“内存泄漏”,而是对象被不小心长期持有,导致无法释放。典型场景是全局缓存类数据结构,或者把对象放到了进程级列表里,日积月累就爆了。
排查手段排序如下:
gc.get_objects()快照对比:在程序启动时和运行一段时间后分别采样,对比对象数量和类型,能快速判断哪类对象在增长。tracemalloc模块:Python 3.4 起内置,可以跟踪内存块的分配位置,定位到具体代码行。objgraph库:绘制对象引用图,能直观看到循环引用和意外持有的路径。- 用
weakref检测对象是否被正常回收。
python复制import tracemalloc
tracemalloc.start()
# 运行你的业务代码
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:10]:
print(stat)
tracemalloc 是排查内存增长的利器,但注意它本身也有开销,生产环境不要一直全量开启,可以在排查窗口期打开,定位完就关掉。
意外共享的问题更隐蔽。比如两个模块都 import 了同一个列表然后各自修改,很可能会互相污染。我的习惯是:所有可变对象尽量封装在类里,通过方法去修改;对外只暴露不可变视图,比如返回 tuple 而不是 list,或返回 MappingProxyType 包装的 dict。
4.2 GIL 与线程调试:多线程程序为什么卡慢
多线程程序卡慢,不一定就是 GIL 的锅,但 GIL 经常是帮凶。排查思路从外到内:
先看线程数。用 threading.enumerate() 列出所有线程,看有没有僵尸线程或失控线程。再看锁的竞争情况,lock.acquire() 如果长期获取不到,可以给 acquire 加 timeout 参数,超时后打印报警日志。
如果是 CPU 密集任务,先确认 GIL 是不是瓶颈,方法很简单:用单线程跑同一批任务,如果耗时反而更短,说明 GIL 确实限制了并行。这时候应该回归问题本质:Python 多线程不是为 CPU 密集场景设计的,换成多进程或 C 扩展才是正道。
另外还有个非常隐蔽的问题:time.sleep() 在 GIL 环境下是安全的,它会释放 GIL 让别人运行;但如果你在循环里做大量的纯计算且没有 IO,就可能导致解释器一直持锁不松,其他线程饿死。这种情况下可以手动调用 sys.setswitchinterval(0.005) 调低线程切换间隔,给其他线程更多机会抢占 GIL。
4.3 异步编程常见报错与排除方案
asyncio 虽然好用,但坑也不少。我从项目里总结了几类高频问题。
第一类:RuntimeError: asyncio.run() cannot be called from a running event loop。原因是在协程里又调用了 asyncio.run()。解决方法是:在协程内部不要创建新的事件循环,而是直接 await 另一个协程,或者用 asyncio.create_task。
第二类:事件循环被阻塞导致整个程序卡死。比如协程里用了同步的 requests.get() 而不是 aiohttp,这会让线程阻塞在 IO 等待上,事件循环无法继续调度其他任务。解决方式:要么全链路改成异步 IO,要么把同步阻塞放到线程池里跑。
python复制import asyncio
import requests
async def bad_fetch():
# 这会阻塞整个事件循环
response = requests.get("https://example.com")
return response.text
async def good_fetch():
loop = asyncio.get_running_loop()
# 用线程池执行同步阻塞函数,不阻塞事件循环
response = await loop.run_in_executor(None, requests.get, "https://example.com")
return response.text
第三类:Future exception was never retrieved。这是说某个 Task 抛了异常但没人接收。解决方式:在 Task 上设置 add_done_callback 来统一处理异常,或者用 asyncio.gather(..., return_exceptions=True) 来显式接收异常。
python复制async def main():
tasks = [asyncio.create_task(worker(i)) for i in range(3)]
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
print(f"任务异常: {r}")
4.4 如何选择线程、进程还是协程
我整理了一张选型速查表,差不多总结了我这些年的并发方案经验。三个维度:任务类型、并发的数据交互复杂度、对启动开销的容忍度。
| 场景 | 线程 | 进程 | 协程 |
|---|---|---|---|
| 爬虫、API 请求(IO 密集型) | 推荐,简单 | 可用,通信麻烦 | 高并发首选,性能最好 |
| 图像处理、大数据计算(CPU 密集型) | 不推荐,GIL 限制 | 推荐,充分利用多核 | 不适用 |
| 需要共享大量复杂数据 | 可用,需加锁 | 不推荐,需序列化 | 可用,单线程无竞争 |
| 服务端高并发连接 | 勉强可用 | 不推荐 | 最推荐 |
我个人的经验是:能用协程的优先用协程,因为单线程模型下没有锁竞争、没有死锁、没有上下文切换开销;协程搞不定的时候再考虑线程;只有需要多核并行 CPU 计算时,才引入多进程。这个顺序几乎能覆盖 90% 以上的实际开发场景。
5. 三者的联动:一个综合实战案例
5.1 一个混合并发案例:爬虫服务的内存与性能调优
光讲理论不落地没意思,我拿一个实际改造过的爬虫服务来演示这三个知识点的联动。
场景是这样的:某天我接手一个爬虫服务,功能是从 1000 个页面抓取数据,然后对每页的数据做文字内容解析(CPU 密集型),最后汇总入库。最初版本用的是多线程加同步 requests 库,线上跑起来 CPU 占用低、耗时严重,而且偶尔内存暴涨。现在我用这三个知识点的顺序来分析。
内存暴涨的第一嫌疑是引用没有释放。检查代码,发现解析结果被放到了全局列表里,每次循环追加,从没清理。这是典型的“意外持有引用”,用 tracemalloc 定位到之后,改成处理完立即释放并调用 gc.collect(),内存峰值降了一半。
耗时严重的第一嫌疑是同步 IO 阻塞线程。多线程 + requests 在每个请求期间都让线程挂着等待网络,GIL 在线程等待 IO 时会释放,所以并发还行,但线程数量本身有限,而且每线程一个请求本质上只是让阻塞期不占 CPU,吞吐量并不理想。
改成 asyncio + aiohttp 之后,协程在等待网络时挂起事件循环,单线程轻松同时维护几百个协程。IO 阶段获得了远超线程数的并发度,耗时从 12 分钟降到 40 秒。
解析阶段是纯 CPU 计算,跑协程没意义,因为在同一线程里不管怎么切换都只有一个核心在用。我把解析部分抽出来,用 ProcessPoolExecutor 丢给 4 个子进程并行跑,解析耗时从 3 分钟降到 45 秒。
最终架构变成:异步 IO 负责高并发抓取,多进程负责 CPU 密集解析,中间用队列传递数据。这个方案同时用了协程和进程,但每个环节都用在刀刃上。
5.2 这个案例给我们的三个启发
第一个启发:GIL 不是 Python 的原罪,而是约束条件。理解它之后,你才能做出正确的并发选型——IO 多就用协程,计算多就用进程,只有处理好 GIL 的边界,才不会写出“看起来并发、实际上排队”的代码。
第二个启发:指针/引用模型决定了你的内存管理方式。在 Python 里,只要你搞清楚“变量是标签,对象是实体”,很多“莫名其妙被修改”和“内存缓慢增长”的问题都能从根上杜绝。不要等线上出问题了才去查,编码时就该带着内存意识。
第三个启发:异步内核虽然好用,但不是万能。它解决的是等待问题(IO bound),而不是计算问题(CPU bound)。把异步当成“不加锁的多线程”会出大问题,把并发模型选错了,后面怎么优化都是白搭。
5.3 从原理到实践:你可以立刻上手的三个小实验
如果读者朋友想把今天的知识消化掉,我建议你立刻做三个小实验,花不了多少时间,但效果比读十篇文章都好。
实验一:写一个函数,接受 list 参数并在函数内 append 元素,观察主调方的 list 是否变化。然后再换成 tuple,观察会有什么不同。这个实验帮你建立“可变/不可变”的直觉。
实验二:用 threading 和 ProcessPoolExecutor 分别执行同一个纯 CPU 函数,比较耗时。用 top 命令观察多进程版本执行时 CPU 是否打满,多线程版本执行时 CPU 占用是否一直不高。这个实验让你直观感受 GIL 的存在。
实验三:用 asyncio 创建 100 个协程,每个协程 await asyncio.sleep(1),记录总耗时。你会发现总耗时接近 1 秒而不是 100 秒,这比任何解释都更能说明事件循环的价值。
这三个实验做下来,你对 Python 底层这三块内容的理解会比单纯看文档深刻得多。知识这东西,用起来才是自己的。
我在实际项目中还发现一个规律:理解底层原理不只是为了面试,它还会反过来重塑你的代码风格。明白引用语义之后,你会更谨慎地设计对象共享;明白 GIL 之后,你会更有意识地去识别瓶颈是 IO 还是 CPU;明白事件循环之后,你会天然地写出非阻塞风格的高吞吐代码。从“调用 API”到“理解机制”,这个跨越对任何 Python 开发者来说都值得花时间去做。
