Python 并发编程:IO 等待用线程/asyncio,CPU 计算才用进程

Python 并发编程:IO 等待用线程/asyncio,CPU 计算才用进程

一句话记住:IO 等待用线程(或 asyncio),CPU 计算才用进程。

这是 Python 并发编程里非常重要的一条经验。

很多人刚开始接触 Python 并发时,会陷入一个误区:

"并发性能不好,是不是直接多开几个进程就行?"

其实不是。

到底应该使用 线程、asyncio 还是多进程,关键不在于代码看起来复杂不复杂,而在于:

你的程序大部分时间是在"等待",还是在"计算"?


一、先搞懂 IO 和 CPU

一个程序运行的时候,大致会遇到两类工作。

1. IO 密集型

IO 就是 Input / Output,也就是程序需要和外部世界交互。

例如:

  • HTTP 请求
  • 数据库查询
  • Redis 操作
  • 读取文件
  • 写入文件
  • 调用第三方 API
  • 网络 Socket 通信

例如:

python 复制代码
response = requests.get("https://example.com")

发送请求以后,CPU 并不会一直忙着计算。

程序真正做的事情更像:

text 复制代码
发送请求
   ↓
等待网络
   ↓
等待服务器处理
   ↓
等待数据返回
   ↓
继续执行

大量时间都浪费在"等"。

这种情况就是:

IO 密集型任务


二、CPU 密集型又是什么?

CPU 密集型任务则相反。

程序的大部分时间都在进行计算。

例如:

python 复制代码
def calculate():
    total = 0

    for i in range(100_000_000):
        total += i * i

    return total

这个程序没有等待数据库,也没有等待网络。

CPU 会一直执行:

text 复制代码
计算
计算
计算
计算
计算
...

这种就是:

CPU 密集型任务

典型场景包括:

  • 大量数学计算
  • 图片处理
  • 视频编码
  • 数据压缩
  • 加密计算
  • 大规模数据计算
  • 机器学习中的部分 CPU 计算

三、为什么 IO 等待适合线程?

先看一个简单例子:

python 复制代码
import requests

def get_data(url):
    return requests.get(url)

假设请求一个接口需要 1 秒。

如果顺序执行:

python 复制代码
get_data(url1)
get_data(url2)
get_data(url3)

那么大约需要:

text 复制代码
1s + 1s + 1s = 3s

但这些请求之间实际上没有太强的依赖关系。

我们可以让多个线程同时发请求:

python 复制代码
from concurrent.futures import ThreadPoolExecutor
import requests

urls = [
    "https://example.com/1",
    "https://example.com/2",
    "https://example.com/3",
]

def get_data(url):
    return requests.get(url).text

with ThreadPoolExecutor(max_workers=3) as executor:
    results = list(executor.map(get_data, urls))

执行过程变成:

text 复制代码
线程1 ── 请求 ───────── 等待 ───── 返回
线程2 ── 请求 ───────── 等待 ───── 返回
线程3 ── 请求 ───────── 等待 ───── 返回

三个请求可以同时进行。

因此:

text 复制代码
IO 密集型
    ↓
大量时间在等待
    ↓
线程可以在等待的时候干其他事情
    ↓
提高整体吞吐量

这就是线程非常适合 IO 密集型任务的原因。


四、那为什么 CPU 计算不推荐线程?

这就涉及 Python 中非常经典的:

GIL(Global Interpreter Lock,全局解释器锁)

在传统 CPython 实现中,同一进程内的多个 Python 线程不能真正同时执行 Python 字节码。

例如:

python 复制代码
def cpu_task():
    total = 0

    for i in range(100_000_000):
        total += i

    return total

如果使用多个线程:

text 复制代码
线程1:CPU计算 █████████
线程2:CPU计算 █████████
线程3:CPU计算 █████████

并不会简单地变成:

text 复制代码
CPU核心1:线程1
CPU核心2:线程2
CPU核心3:线程3

多个线程会受到 GIL 的限制。

所以:

线程非常适合等待,但不适合用来解决纯 Python CPU 密集型计算的并行问题。


五、CPU 密集型任务应该使用进程

进程和线程最大的区别之一:

text 复制代码
线程:
同一个进程里的多个线程
共享进程资源

进程:
每个进程拥有独立的 Python 解释器

因此可以利用多个 CPU 核心。

例如:

python 复制代码
from concurrent.futures import ProcessPoolExecutor

def calculate(n):
    total = 0

    for i in range(n):
        total += i * i

    return total

with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(
        executor.map(calculate, [10_000_000] * 4)
    )

可以理解成:

text 复制代码
CPU Core 1 ── Process 1
CPU Core 2 ── Process 2
CPU Core 3 ── Process 3
CPU Core 4 ── Process 4

这样才能真正利用多核 CPU 的计算能力。


六、asyncio 又是什么?

很多 Python 开发者看到这里会问:

"那 asyncio 是不是比线程更快?"

不能这么简单理解。

asyncio 的核心思想不是:

创建更多线程。

而是:

一个线程管理大量 IO 任务,在任务等待 IO 时切换到其他任务。

例如:

python 复制代码
import asyncio

async def task(name):
    print(f"{name} 开始")

    await asyncio.sleep(1)

    print(f"{name} 完成")

async def main():
    await asyncio.gather(
        task("任务1"),
        task("任务2"),
        task("任务3"),
    )

asyncio.run(main())

执行过程大概是:

text 复制代码
任务1:开始 → 等待 IO
任务2:开始 → 等待 IO
任务3:开始 → 等待 IO

等待结束

任务1:继续
任务2:继续
任务3:继续

所以 asyncio 的核心是:

text 复制代码
IO 等待
   ↓
await
   ↓
把执行权交给其他任务
   ↓
IO 完成
   ↓
继续执行

七、线程和 asyncio 怎么选?

两者都适合 IO 密集型任务,但使用场景有所不同。

可以简单理解:

场景 推荐
少量 IO 并发 线程
大量网络 IO asyncio
已经是异步项目 asyncio
第三方库只有同步接口 线程
CPU 密集计算 多进程
简单脚本 线程通常更方便
FastAPI 异步接口 asyncio
FastAPI 调用同步阻塞库 线程池
纯 Python 大量计算 多进程

八、FastAPI 中尤其容易踩这个坑

例如:

python 复制代码
from fastapi import FastAPI
import time

app = FastAPI()

@app.get("/test")
async def test():
    time.sleep(5)

    return {"message": "ok"}

很多人看到:

python 复制代码
async def

就认为:

"这是异步代码。"

其实不是。

time.sleep() 是同步阻塞操作。

所以:

python 复制代码
async def
    ↓
time.sleep(5)
    ↓
阻塞事件循环

这就会产生问题。

正确的异步方式应该是:

python 复制代码
import asyncio

@app.get("/test")
async def test():
    await asyncio.sleep(5)

    return {"message": "ok"}

这样等待期间,事件循环可以去处理其他请求。


九、FastAPI 中同步代码怎么办?

现实项目中不可能所有库都是异步的。

例如你使用一个同步数据库驱动:

python 复制代码
def query_database():
    return db.query(...)

或者:

python 复制代码
requests.get(url)

这种情况下,如果直接放进异步函数:

python 复制代码
async def test():
    requests.get(url)

就可能阻塞事件循环。

可以考虑把同步阻塞操作放到线程池。

例如:

python 复制代码
from fastapi.concurrency import run_in_threadpool

@app.get("/test")
async def test():
    result = await run_in_threadpool(query_database)

    return result

理解成:

text 复制代码
FastAPI
   │
   ├── asyncio 事件循环
   │
   └── 阻塞任务 → 线程池

这样可以避免同步 IO 阻塞整个事件循环。


十、一个非常重要的判断方法

以后看到一个任务,不要第一反应:

"用线程还是进程?"

应该先问:

这个任务到底在干什么?

例如:

场景 1:请求 100 个 HTTP 接口

text 复制代码
主要时间:
等待网络

结论:
IO 密集型

推荐:
asyncio / 线程

场景 2:查询数据库

text 复制代码
主要时间:
等待数据库返回

结论:
IO 密集型

推荐:
asyncio / 线程

场景 3:读取大量文件

text 复制代码
主要时间:
等待磁盘 IO

结论:
IO 密集型

推荐:
根据场景使用异步 IO / 线程

场景 4:计算 10 亿次循环

text 复制代码
主要时间:
CPU 运算

结论:
CPU 密集型

推荐:
多进程

场景 5:图片压缩

如果使用的库主要在 C/C++ 层执行计算,并且能够释放 GIL,那么线程也可能有效。

所以不要简单地认为:

"计算 = 一定不能用线程。"

更准确的说法是:

纯 Python CPU 密集型计算通常应该优先考虑多进程;如果底层库释放 GIL,线程也可能实现并行。

这是实际工程中非常重要的区别。


十一、可以记住这张图

text 复制代码
                 Python 并发任务
                       │
                       ▼
                主要是在等待吗?
                 /            \
               是              否
               │                │
               ▼                ▼
             IO型             CPU型
               │                │
        ┌──────┴──────┐         │
        │             │         │
        ▼             ▼         ▼
     asyncio        线程      多进程
        │             │         │
        │             │         │
     异步库          同步库    多核 CPU

进一步考虑:

text 复制代码
IO 密集型
    │
    ├── 异步库 → asyncio
    │
    └── 同步阻塞库 → 线程池


CPU 密集型
    │
    ├── 纯 Python 计算 → 多进程
    │
    └── C/C++ 扩展且释放 GIL → 线程也可能有效

十二、为什么不能所有东西都用多进程?

因为进程不是免费的。

创建进程、进程间通信、数据序列化、内存占用,都有成本。

例如:

python 复制代码
ProcessPoolExecutor()

如果任务本身只有几毫秒:

text 复制代码
创建/调度进程的成本
        >
真正计算的成本

那么使用多进程反而可能更慢。

线程同样有成本。

asyncio 也不是万能的。

所以并发模型应该根据任务特点选择,而不是盲目追求"异步"。


十三、最终形成一个工程上的判断口诀

可以把整篇文章浓缩成:

先判断任务是在"等"还是在"算"。

如果是在等:

text 复制代码
IO 等待
  ↓
asyncio / 线程

如果是在算:

text 复制代码
CPU 计算
  ↓
多进程

如果是:

text 复制代码
同步阻塞库 + 异步程序

那么:

text 复制代码
asyncio
   ↓
线程池

如果是:

text 复制代码
纯 Python CPU 密集计算

那么:

text 复制代码
多进程

十四、最后总结

Python 并发并不是简单的:

asyncio > 线程 > 进程

也不存在一个"性能最强"的并发模型。

正确的思路应该是:

text 复制代码
                 任务类型
                    │
          ┌─────────┴─────────┐
          │                   │
       IO 密集型            CPU 密集型
          │                   │
     ┌────┴────┐              │
     │         │              │
   异步库     同步库           │
     │         │              │
 asyncio     线程池           │
                               │
                               ▼
                             多进程

因此最值得记住的不是复杂的 API,而是这一句话:

IO 等待用线程(或 asyncio),CPU 计算才用进程。

掌握这个判断逻辑之后,面对 FastAPI、数据库、Redis、HTTP 请求、消息队列、文件 IO、数据处理等实际场景,就能快速判断应该采用哪一种并发模型。

相关推荐
这个DBA有点耶1 小时前
VLDB 2026核心议题解读:当负载被AI改写,数据库的内核该往哪走?
数据库·架构·aigc
2601_962284501 小时前
Python 和Java 哪个更适合做自动化测试?
java·自动化测试·python·接口测试·性能测试
liliangcsdn1 小时前
因子权重矩阵处理-滞回缓冲带+降频稳定化动态重选
开发语言·python·算法
泡泡鱼(敲代码中)2 小时前
Python语法技术学习笔记
开发语言·笔记·python·学习·pycharm
敲代码的嘎仔2 小时前
自己设计了一个兑换码算法:自增ID + Base32转码 + 按位加权签名 + 异或混淆,面试被追问细节时终于不用慌了
java·数据库·mysql·算法·微服务·面试·职场和发展
CTA终结者2 小时前
量化实现难,先看交易想法有没有说清
人工智能·python
hqyjzsb2 小时前
Python 技术人转型 AI:CAIE Level I、Level II 对比怎么选
开发语言·人工智能·python·金融·数据挖掘·数据分析·aigc
shirsl2 小时前
数据开发实时项目问题整理
数据库·sql·big data
zhangzeyuaaa2 小时前
Python asyncio 事件循环演进:从手动管理到现代化实践
java·服务器·python