9 分钟阅读

FastAPI 事件循环、Python GIL 与 Go 协程:并发模型深度对比

如果你用过 FastAPI,一定被它的性能数据震惊过——一个 Python 框架,居然能在 TechEmpower 基准测试里吊打大多数 Java 框架。但你可能也听说过"Python 有 GIL,天生不适合并发"。

这两件事怎么能同时为真?

答案就藏在事件循环、协程、和操作系统 I/O 模型里。


一、从操作系统说起:同步 I/O vs 异步 I/O

1.1 同步阻塞 I/O

线程发起 read() 系统调用
        ↓
内核:数据还没到,你先睡觉
        ↓
线程挂起(进入 WAITING 状态)
        ↓
数据到了,内核唤醒线程

一个请求一个线程,等待 I/O 时什么都干不了。并发 1000 个请求就要 1000 个线程——内存爆炸,上下文切换开销巨大。

1.2 I/O 多路复用(epoll)

Linux 的 epoll 允许单个线程监听多个文件描述符

单线程注册 1000 个 socket 到 epoll
        ↓
epoll_wait() 等待
        ↓
某个 socket 有数据了
        ↓
epoll 返回"就绪列表"
        ↓
线程依次处理就绪的 socket,继续 epoll_wait()

这就是事件驱动的本质:不是"我去等数据",而是"数据来了通知我"。Node.js、Nginx、Redis、Python asyncio、Go runtime 都基于 epoll。


二、Python GIL:并发的枷锁

2.1 GIL 是什么

GIL(全局解释器锁)是 CPython 中的一把互斥锁,任意时刻只允许一个线程执行 Python 字节码

为什么要有 GIL? CPython 的内存管理基于引用计数,多线程同时修改同一对象的 ob_refcnt 会出现竞态条件。GIL 是最简单粗暴的解决方案——一把大锁,保证引用计数操作的原子性。

2.2 GIL 的影响范围

被 GIL 限制的操作:

  • CPU 密集型任务(纯 Python 计算、循环)
  • Python 对象的创建和销毁

不受 GIL 影响的操作:

  • I/O 操作:CPython 在发起系统调用前会主动释放 GIL
  • C 扩展(NumPy、pandas 的核心计算)
  • 子进程(multiprocessing 每个进程有独立 GIL)
# CPython 内部逻辑(简化)
def socket_recv(sock, bufsize):
    Py_BEGIN_ALLOW_THREADS  # 释放 GIL
    result = OS_recv(sock.fd, buf, bufsize)  # 系统调用
    Py_END_ALLOW_THREADS    # 重新获取 GIL
    return result

结论:GIL 对 I/O 密集型 Web 服务影响极小。 Web 服务器大部分时间在等数据库、等网络——这些时候 GIL 是释放的,其他线程可以正常运行。

2.3 Python 3.13 的 No-GIL 实验

Python 3.13 引入了实验性 No-GIL 模式(PEP 703)。引用计数改为原子操作,单线程性能下降约 10%。短期内 GIL 不会消失,生产环境还需依赖异步模型。


三、Python asyncio 事件循环原理

3.1 协程是什么

协程是可以暂停和恢复的函数。与线程不同,协程的切换是用户态的、主动的、无需内核介入。

async def fetch_data(url):
    # 遇到 await,当前协程暂停,把控制权交还给事件循环
    response = await http_client.get(url)
    # 数据回来了,事件循环恢复这个协程
    return response.json()

3.2 事件循环核心机制

asyncio 事件循环是单线程调度器,内部维护三个结构:

  1. 就绪队列:可以立即执行的回调
  2. 等待集合:注册到 epoll、等待 I/O 的协程
  3. 定时器堆:按时间排序的定时回调
┌───────────────────────────────────────────┐
│              Event Loop                    │
│                                            │
│  就绪队列: [coro_A] [coro_C]              │
│                                            │
│  执行 coro_A → await I/O → 注册到 epoll   │
│  执行 coro_C → await I/O → 注册到 epoll   │
│                                            │
│  epoll_wait() ← 阻塞直到有 I/O 就绪       │
│  把就绪协程加回队列,继续循环              │
└───────────────────────────────────────────┘
# 事件循环简化伪代码
while True:
    while ready_queue:
        callback = ready_queue.popleft()
        callback()                       # 执行协程直到下一个 await
 
    events = selector.select(timeout)    # epoll_wait
    for key, mask in events:
        ready_queue.append(key.callback) # I/O 就绪,重新入队

3.3 await 的底层:生成器协议

Python 协程基于生成器实现,await 本质是 yield from

  • 协程执行到 awaityield 出一个 Future 对象
  • 事件循环接收 Future,把对应 I/O 注册到 epoll
  • 去执行其他就绪协程
  • I/O 完成,设置 Future 结果,协程重新入队,从 yield 点恢复

3.4 FastAPI 为什么快

FastAPI = Starlette + Uvicorn + uvloop

uvloop 是用 Cython 写的 asyncio 替代品,底层基于 libuv(和 Node.js 同款),比标准 asyncio 快 2~4 倍

# async def 路由:直接在事件循环线程执行
@app.get("/users/{id}")
async def get_user(id: int):
    user = await db.fetch_one("SELECT * FROM users WHERE id = $1", id)
    return user
 
# def 路由:FastAPI 自动扔进线程池,不阻塞事件循环
@app.get("/report")
def generate_report():
    return heavy_computation()  # CPU 密集,放线程池

最常见的坑:async def 里调用同步阻塞函数(time.sleep()、同步 ORM),会阻塞整个事件循环,所有请求全部卡住。


四、Go 并发模型:Goroutine + GMP

4.1 Goroutine

// 启动一个 goroutine,仅需 ~2KB 初始栈
go fetchData("https://api.example.com")
 
// 可以轻松启动百万个
for i := 0; i < 1_000_000; i++ {
    go worker(i)
}
对比项 系统线程 Goroutine
初始栈大小 1~8 MB ~2 KB(动态增长)
切换代价 内核态,~1μs 用户态,~100ns
调度者 OS 内核 Go runtime
并发数量 数千 数百万

4.2 GMP 调度模型

Go runtime 实现 M:N 调度(M 个 Goroutine 映射到 N 个系统线程):

  • G(Goroutine):用户代码执行单元
  • M(Machine):系统线程
  • P(Processor):逻辑处理器,持有本地 Goroutine 队列
┌───────────────────────────────────────────────┐
│                 Go Runtime                     │
│                                                │
│  Global Queue: [G5] [G6] [G7]                 │
│                                                │
│  P0 (core0)           P1 (core1)              │
│  Local: [G1][G2]      Local: [G3][G4]         │
│  M0 running G1        M1 running G3           │
│                                                │
│  Network Poller (epoll)                        │
│  等待 I/O 的 G: [G8→fd1] [G9→fd2]            │
└───────────────────────────────────────────────┘

P 的数量 = CPU 核数(GOMAXPROCS),Go 程序可以真正多核并行,完全没有 GIL 的限制。

4.3 抢占式调度(Go 1.14+)

Go 通过 SIGURG 信号实现异步抢占,即使纯 CPU 循环也会被强制切换:

// Go 1.14+ 之前:这会导致其他 goroutine 饿死
// Go 1.14+ 之后:runtime 发信号强制抢占,完全透明
go func() {
    for { x++ }  // 纯 CPU 循环,不需要任何让出代码
}()

Python asyncio 是协作式的,你得手动 await asyncio.sleep(0) 让出;Go 是抢占式的,runtime 负责公平调度。

4.4 Channel:CSP 并发模型

Go 推崇通过通信共享内存,而不是通过共享内存通信:

func main() {
    ch := make(chan int, 5)
 
    go func() {
        for i := 0; i < 10; i++ {
            ch <- i  // 发送,满了就让出调度
        }
        close(ch)
    }()
 
    for v := range ch {
        fmt.Println(v * v)
    }
}

Channel 阻塞时 goroutine 立刻让出,不浪费 CPU。


五、全面对比

5.1 核心差异

维度 Python asyncio Go Goroutine
并发单元 协程 Goroutine
调度方式 协作式(显式 await) 抢占式(Go 1.14+)
执行线程 单线程(默认) 多线程(GOMAXPROCS)
真正并行 ❌(受 GIL 限制)
I/O 并发
CPU 并发 ❌(需 multiprocessing)
内存占用 协程几 KB Goroutine ~2KB
可运行数量 数万~数十万 数百万

5.2 协作式 vs 抢占式的实际影响

# Python:这个循环会霸占事件循环,其他所有请求全部卡住
async def bad_handler():
    result = sum(range(100_000_000))  # 纯 CPU,无 await
    return result
 
# 必须手动让出控制权
async def good_handler():
    result = 0
    for i in range(100_000_000):
        result += i
        if i % 10000 == 0:
            await asyncio.sleep(0)  # 主动让出
    return result
// Go:runtime 自动抢占,不需要手动让出
func handler() int {
    result := 0
    for i := 0; i < 100_000_000; i++ {
        result += i
        // 不用写任何让出代码
    }
    return result
}

5.3 并发查询写法对比

# Python:asyncio.gather 并发执行
async def get_user_full(user_id: int):
    user, orders = await asyncio.gather(
        db.fetchrow("SELECT * FROM users WHERE id = $1", user_id),
        db.fetch("SELECT * FROM orders WHERE user_id = $1", user_id),
    )
    return {"user": dict(user), "orders": [dict(o) for o in orders]}
// Go:errgroup 并发执行
func getUserFull(ctx context.Context, userID int) (*UserData, error) {
    var user User
    var orders []Order
 
    g, ctx := errgroup.WithContext(ctx)
    g.Go(func() error { return queryUser(ctx, userID, &user) })
    g.Go(func() error { return queryOrders(ctx, userID, &orders) })
 
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return &UserData{User: user, Orders: orders}, nil
}

六、选型建议

选 FastAPI 的场景:

  • 团队 Python 背景深,快速上手
  • AI/ML 集成(PyTorch、HuggingFace 原生 Python)
  • 数据处理密集(NumPy/pandas 生态无可替代)
  • I/O 密集型 API 服务,性能完全够用

选 Go 的场景:

  • 高并发低延迟(gRPC 服务、实时推送、游戏后端)
  • 基础设施组件(代理、中间件、CLI 工具)
  • CPU 密集型服务(图像处理、密码学、数据编解码)
  • 内存受限环境,单二进制部署运维友好

实际大型系统往往两者共存:

Go API Gateway(高并发路由)
    ├── Go 微服务(核心业务,高性能)
    └── Python FastAPI(AI 推理、数据分析)

总结

问题 结论
GIL 会让 FastAPI 变慢吗? I/O 密集型几乎无影响,Web 服务绝大部分时间在等 I/O
asyncio 是真正的并行吗? 不是,是单线程并发,靠 I/O 切换提高吞吐
Go goroutine 强在哪? 多核并行 + 抢占调度 + 极低内存占用
FastAPI 能用于生产吗? 完全可以,Instagram、Uber 的 Python 服务也在跑

理解底层模型,根据业务场景选型,才是正道。