两个线程算两遍循环,为什么只快了一点点不是两倍?GIL连环追问

「Python 进阶之路」系列 Day34

写在前面

模块四(Day16-21)讲过 GIL、threadingmultiprocessingasyncio 各自的基础用法,但面试官真正想验证的往往是几个更细的连环追问:threading 面对 CPU 密集型任务到底是"完全没用"还是"效果有限"?asyncio 号称并发,是不是真的只用了一个线程?取消一个协程任务,为什么有时候怎么取消都取消不掉?这几个问题背后都是同一件事------GIL 到底挡住了什么、没挡住什么,今天用实测数据把这几层追问一次讲透。


一、是什么:GIL 挡住的只是"同一时刻执行字节码"这一件事

GIL(全局解释器锁)保证的是:任意时刻,只有一个线程能执行 Python 字节码 。这句话里最容易被忽略的是"执行字节码"这个限定词------它挡的是纯计算这部分,不挡线程在等待 IO(网络、磁盘、time.sleep)期间的等待时间,因为发起 IO 调用之后线程会主动释放 GIL,把执行权让给别的线程,等 IO 完成再重新申请 GIL 回来接着跑。

flowchart TB T1[线程一申请GIL] --> G{GIL当前被谁持有} T2[线程二申请GIL] --> G G -->|线程一持有| R1[线程一执行字节码<br/>直到切换点或者遇到IO主动释放] R1 --> G G -->|线程二持有| R2[线程二执行字节码<br/>直到切换点或者遇到IO主动释放] R2 --> G

这也是为什么"GIL 导致 Python 多线程没用"这句话本身就不准确------它只对 CPU 密集型任务成立,对 IO 密集型任务,threading不但没有失效,反而是官方推荐的方案。


二、为什么 threading 面对 CPU 密集型任务是"效果有限"而不是"完全无效"

这是最容易被问错的一点。实测拿一段纯计算循环(for _ in range(n): count += 1)跑 3 万次,对比"单线程串行跑两次"和"两个 threading 线程并发跑":

python 复制代码
def cpu_bound(n):
    count = 0
    for _ in range(n):
        count += 1
    return count

N = 30_000_000
# 单线程串行跑两次:约 1.25s ~ 1.39s(3轮测量)
# 两个线程并发跑:约 1.08s ~ 1.10s(3轮测量)

三轮重复测量,两线程版本稳定比串行快了 13%~22%,确实变快了,但远远达不到两倍 。作为对照,同样的计算量换成multiprocessing起两个进程:

python 复制代码
# 单进程串行跑两次:约 1.09s
# 两个进程并发跑:约 0.69s(提速约 1.57 倍,远超threading的提升幅度)

multiprocessing的提速幅度明显更接近线程数量倍数,因为每个子进程都有自己独立的解释器和独立的 GIL,彼此之间没有互斥关系,是真正利用了多核。而threading的这一点点提升更微妙------GIL 依然把"同一时刻执行字节码"锁死成单线程,两个线程理论上不应该有任何计算上的重叠;这一点点提升更可能来自线程调度、内存分配等 C 层面操作和 GIL 切换窗口带来的系统级差异,而不是真的有并行计算发生。结论应该是"threading 对 CPU 密集型任务收益远低于线程数、天花板很低",而不是"threading 对 CPU 密集型任务完全没有任何效果",两者是有区别的表述,面试时说绝对化的后者反而是不准确的。


三、怎么用

1. IO 密集型任务:threading 确实有效

python 复制代码
def io_bound():
    time.sleep(0.5)

# 单线程串行跑两次:约 1.008s
# 两个线程并发跑:约 0.505s(几乎正好是一次sleep的时间)

time.sleep触发的是操作系统级别的等待,Python 线程在进入等待前会释放 GIL,让另一个线程趁机拿到 GIL 去执行,这正是 IO 密集型场景下threading真正发挥作用的地方。

2. asyncio 全程只用一个线程,靠 await 主动让出控制权

python 复制代码
async def task(name):
    print(f"{name} 开始, 线程id={threading.get_ident()}")
    await asyncio.sleep(0.3)
    print(f"{name} 结束, 线程id={threading.get_ident()}")

async def main():
    await asyncio.gather(task("A"), task("B"))

实测两个 task 打印出来的线程 id 完全相同,总耗时约 0.3s 而不是 0.6s------没有用到任何多线程,"并发"完全是靠 A 在await asyncio.sleep(0.3)这一行主动把控制权交还给事件循环,事件循环转手去跑 B,等 A 的等待时间到了再切回来。这也解释了为什么协程里一旦误用了阻塞调用会直接拖垮整个事件循环:

python 复制代码
async def bad_task(name):
    time.sleep(0.3)   # 阻塞调用,不会释放控制权,也不会触发切换

async def main_bad():
    await asyncio.gather(bad_task("A"), bad_task("B"))
# 耗时约0.611s,退化成了完全串行------A的time.sleep把唯一的线程死死攥住,
# 事件循环根本没有机会去调度B,B只能等A彻底执行完才能开始

只有await asyncio.sleep(...)这种支持协程协作的调用才会真正把控制权交出去,普通的time.sleep只是让当前唯一的线程傻等,谁都跑不了。

3. Task.cancel():取消信号只在下一个挂起点生效,不是立刻打断

python 复制代码
async def worker():
    try:
        await asyncio.sleep(1)
    except asyncio.CancelledError:
        print("worker: 捕获到CancelledError,做清理")
        raise

async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(0.1)
    task.cancel()
    await task   # 这里会重新抛出 CancelledError

这段代码里task.cancel()能立刻取消成功,是因为worker当时正卡在await asyncio.sleep(1)这个挂起点上,取消信号能在这个点直接被"注入"成一个CancelledError异常抛出来。但如果任务当时根本没有处在任何await挂起点上,而是在跑一段纯 CPU 循环,情况就完全不同:

python 复制代码
async def busy_worker():
    total = 0
    for i in range(200_000_000):
        total += 1
    return total

async def main2():
    task = asyncio.create_task(busy_worker())
    await asyncio.sleep(0.05)
    task.cancel()
    result = await task
    print("busy_worker正常跑完了,没有真正被取消:", result)   # 实测确实是这个分支

实测busy_worker完整跑完了 2 亿次循环,取消完全没生效------因为取消信号本质上是"下次这个协程挂起时把 CancelledError 抛给它",而这段纯 CPU 循环里没有任何await语句,协程从开始到结束一次都没有把控制权交还给事件循环,取消信号自然找不到注入的时机:

flowchart TB A[外部调用task点cancel] --> B[给task内部打上取消标记] B --> C{task当前<br/>处于什么状态} C -->|正卡在纯CPU循环没有await挂起点| D[标记先挂着循环跑完才有机会检查] C -->|正处于await挂起点| E[挂起点直接抛出CancelledError] D --> F[循环结束后下一次挂起才真正被取消]

这也是协程编程里一个真实的坑:协程里如果写了一大段没有任何await的纯计算逻辑,这段时间里它既不能被其他协程打断,也无法被外部取消 ,想让一个耗时的协程能被及时取消,得主动在计算逻辑里穿插await asyncio.sleep(0)这样的让出点。


四、面试追问

Q1:GIL 存在的情况下,Python 的多线程是不是完全没用?

不是。GIL 保证的是"同一时刻只有一个线程执行字节码",但线程在等待 IO(网络、磁盘、sleep)期间会主动释放 GIL。所以threading对 IO 密集型任务是真正有效的官方推荐方案;对 CPU 密集型任务效果确实很有限(实测两线程只比串行快了 13%~22%,远达不到两倍),但也不能说完全没有任何效果,准确的说法是"收益远低于线程数、有一个很低的天花板",而不是"完全无效"。

Q2:既然threading对CPU密集型任务效果有限,为什么multiprocessing能接近线性加速?

因为multiprocessing创建的是独立的操作系统进程,每个进程都有自己独立的 Python 解释器实例和独立的 GIL,进程之间互不影响、互不互斥,是真正利用了多核 CPU 并行计算。而threading的多个线程共享同一个解释器和同一把 GIL,同一时刻依然只有一个线程能执行 Python 字节码,天然没有真正的计算并行。

Q3:asyncio 的并发到底是不是多线程实现的?

不是,asyncio全程运行在同一个线程里(实测多个 task 打印出的线程 id 完全相同),并发效果完全依赖协程在await语句处主动把控制权交还给事件循环,事件循环再去调度其他就绪的协程。这也是为什么协程里一旦误用了阻塞调用(比如time.sleep而不是await asyncio.sleep),会直接卡住整个事件循环------没有任何其他协程能获得执行机会,退化成完全串行。

Q4:为什么有时候task.cancel()调用了却没有真正取消掉任务?

因为取消信号本质上是"在协程下一次挂起(遇到await)时抛出一个CancelledError",而不是立刻中断协程的执行。如果协程当时正处在一段没有任何await语句的纯 CPU 计算逻辑里,取消信号找不到注入的时机,只能等这段计算逻辑自己跑完、协程下一次挂起时才会真正被取消。想让耗时的协程能被及时响应取消,需要在计算逻辑里主动穿插await asyncio.sleep(0)这样的让出点。

Q5:time.sleepawait asyncio.sleep在协程里的本质区别是什么?

await asyncio.sleep会把控制权交还给事件循环,让事件循环去调度其他就绪的协程,等待期间不占用线程;time.sleep是阻塞调用,会让当前唯一的线程原地傻等,期间事件循环完全没有机会调度任何其他任务。在协程函数里误用time.sleep是一个隐蔽但很致命的坑,实测会让原本应该并发的多个任务退化成完全串行。


下一篇预告

Day35 是 Python 模块七(面试高频题串讲)也是整个专栏的收官篇,讲内存管理与垃圾回收连环追问:引用计数、循环引用、分代回收之间到底是怎么配合工作的,以及几个容易被问倒的细节。

相关推荐
重生之小比特1 小时前
【Java SE】程序逻辑控制
java·开发语言·python
小灰灰搞电子1 小时前
Python 函数参数分隔符 *:Keyword-Only Arguments 原理与实践
开发语言·python
2601_962297251 小时前
Authlib 0.13通用Python认证授权库wheel安装包(支持Python 2/3)
python·jwt·oauth2.0·authlib·openidconnect
SamChan901 小时前
大文件多语言PDF翻译性能实测:300页文档的耗时、内存占用与失败率分析
python·ai·pdf·机器翻译
只爱喝胡辣汤1 小时前
04-JVM 性能调优参数
后端
GIS数据转换器1 小时前
遥感GIS一体化技术应用平台
大数据·人工智能·python·安全·数据挖掘
Bs_MoneyMagnet1 小时前
基于springboot+vue的家居生活商城平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
爱勇宝1 小时前
《道德经》第 11 章:真正有用的,常常是你没写出来的那部分
前端·后端·产品
微三云 - 廖会灵 (私域系统开发)1 小时前
Spring Boot实战:五级分销定价与自动分佣系统设计与实现
java·spring boot·后端