异步与协程精讲 / 第1章:同步 vs 异步 —— 基础概念
第1章:同步 vs 异步 —— 基础概念
1.1 为什么要学习异步编程?
想象一个餐厅的服务场景:
- 同步模式:服务员接到桌 A 的菜单,送到厨房,然后站在厨房门口等,直到菜做好才端给桌 A,再去服务桌 B。
- 异步模式:服务员接到桌 A 的菜单,送到厨房,立刻去服务桌 B,厨房做好后按铃通知,服务员再端菜。
显然,异步模式能让服务员同时服务更多客人。在计算机世界中,“服务员"就是 CPU 或线程,“客人"就是请求(Request),“厨房"就是 I/O 设备或外部服务。
核心直觉:异步的本质不是"更快”,而是**“不傻等”**——在等待 I/O 的时候去做别的事情。
1.2 同步 vs 异步
定义
| 概念 | 定义 | 关注点 |
|---|---|---|
| 同步(Synchronous) | 调用发出后,调用方必须等待结果返回才能继续执行 | 调用方的行为 |
| 异步(Asynchronous) | 调用发出后,调用方不等待,通过回调/通知/轮询等方式获取结果 | 调用方的行为 |
代码对比
Python — 同步版本:
import requests
def fetch_all(urls: list[str]) -> list[str]:
results = []
for url in urls:
resp = requests.get(url) # 阻塞等待
results.append(resp.text)
return results
# 3 个 URL,每个耗时 1 秒 → 总耗时约 3 秒
urls = ["https://api.example.com/a", "https://api.example.com/b", "https://api.example.com/c"]
data = fetch_all(urls)
Python — 异步版本:
import asyncio
import aiohttp
async def fetch_all(urls: list[str]) -> list[str]:
async with aiohttp.ClientSession() as session:
tasks = [session.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [await r.text() for r in responses]
# 3 个 URL 并发请求 → 总耗时约 1 秒
urls = ["https://api.example.com/a", "https://api.example.com/b", "https://api.example.com/c"]
data = asyncio.run(fetch_all(urls))
JavaScript — 异步版本:
async function fetchAll(urls) {
const responses = await Promise.all(
urls.map(url => fetch(url))
);
return Promise.all(responses.map(r => r.text()));
}
const urls = ["https://api.example.com/a", "https://api.example.com/b", "https://api.example.com/c"];
fetchAll(urls).then(data => console.log(data));
Go — 并发版本:
func fetchAll(urls []string) []string {
results := make([]string, len(urls))
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1)
go func(i int, url string) { // goroutine 并发
defer wg.Done()
resp, _ := http.Get(url)
body, _ := io.ReadAll(resp.Body)
results[i] = string(body)
}(i, url)
}
wg.Wait()
return results
}
1.3 阻塞 vs 非阻塞
这两个概念与同步/异步经常被混淆,但它们描述的是不同的层面。
| 概念 | 定义 | 关注点 | 层面 |
|---|---|---|---|
| 阻塞(Blocking) | 调用后线程被挂起(Suspend),让出 CPU,直到操作完成 | 线程的状态 | I/O 层面 |
| 非阻塞(Non-blocking) | 调用后立即返回,无论操作是否完成 | 线程的状态 | I/O 层面 |
组合矩阵
同步/异步和阻塞/非阻塞是两个独立维度,可以组合出四种模型:
| 阻塞(Blocking) | 非阻塞(Non-blocking) | |
|---|---|---|
| 同步 | 最常见:read() 系统调用,线程挂起等待数据 | read() 立即返回,需轮询检查数据是否就绪 |
| 异步 | 较少见:发起异步操作,但调用方仍阻塞等待通知 | 最高效:io_uring / aio_read(),发起后去做别的事 |
常见误区:“非阻塞 = 异步” 是错误的。非阻塞只意味着"不会卡住线程”,但获取结果的方式仍然可以是同步轮询。
I/O 多路复用(I/O Multiplexing)
在实际工程中,非阻塞 I/O 通常与 I/O 多路复用配合使用:
┌─────────────────────────────────────────────┐
│ 单线程事件循环 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Socket A │ │ Socket B │ │ Socket C │ │
│ │ (可读) │ │ (等待中) │ │ (可写) │ │
│ └────┬────┘ └─────────┘ └────┬────┘ │
│ │ │ │
│ ▼ ▼ │
│ 读取数据并处理 写入响应数据 │
│ │
│ epoll_wait / kqueue / select 统一监听 │
└─────────────────────────────────────────────┘
| 平台 | 系统调用 | 特点 |
|---|---|---|
| Linux | epoll | O(1) 事件通知,支持边缘触发(ET)和水平触发(LT) |
| macOS / BSD | kqueue | 类似 epoll,支持文件、信号、进程等多种事件 |
| Windows | IOCP (I/O Completion Ports) | Proactor 模型,内核完成 I/O 后通知 |
| 通用 | select / poll | 兼容性好,但 O(n) 扫描,fd 数量受限 |
1.4 并发 vs 并行
| 概念 | 定义 | 关键特征 |
|---|---|---|
| 并发(Concurrency) | 同一时间段内处理多个任务(可以交替执行) | 逻辑上的同时 |
| 并行(Parallelism) | 同一时刻执行多个任务(需要多核 CPU) | 物理上的同时 |
形象比喻
- 并发:一个厨师在多个灶台之间来回切换,每次做一个菜的一部分。
- 并行:多个厨师同时在各自的灶台上做菜。
并发编程模型对比
并发模型光谱:
单线程 协程/纤程 线程池 多进程
─────┼──────────┼──────────┼──────────┼────────→
低开销 高开销
无竞态 需 IPC
事件循环 真并行
Node.js Go/Python Java/.NET Erlang/nginx
| 模型 | 代表语言/运行时 | 并发单位 | 调度方 | 适用场景 |
|---|---|---|---|---|
| 单线程 + 事件循环 | Node.js | 回调/Promise | 运行时 | I/O 密集型 |
| 协程 | Go、Python asyncio | goroutine / coroutine | 运行时 | I/O 密集型 |
| 线程池 | Java、C# | 线程 | OS | 混合型 |
| 多进程 | Erlang、Nginx | 进程 | OS | CPU 密集型 + 容错 |
1.5 I/O 密集型 vs CPU 密集型
选择并发模型的关键在于理解任务的性质:
| 类型 | 特征 | 瓶颈 | 典型场景 | 推荐模型 |
|---|---|---|---|---|
| I/O 密集型 | 大量时间花在等待 I/O | 网络延迟、磁盘读写 | Web 服务、数据库查询、API 调用 | 异步/协程 |
| CPU 密集型 | 大量时间花在计算 | CPU 算力 | 图像处理、加密、科学计算 | 多线程/多进程 |
注意:异步编程的主要优势体现在 I/O 密集型场景。对于 CPU 密集型任务,异步并不能带来性能提升,反而可能因为上下文切换增加开销。
1.6 历史演进
异步编程的发展并非一蹴而就,而是经历了数十年的演进:
时间线:
1960s ── 协程概念诞生(Melvin Conway)
1970s ── UNIX 进程模型,select() 系统调用
1980s ── CSP 模型(Tony Hoare),Actor 模型(Hewitt)
1990s ── Java 绿色线程,POSIX 线程标准
2000s ── epoll (Linux)、kqueue (BSD)、IOCP (Windows)
2004 ── Node.js 前身开始酝酿
2009 ── Node.js 发布,事件循环 + 回调模型流行
2012 ── Go 1.0 发布,goroutine + Channel 成为经典
2015 ── ES2015 引入 Promise,Python 3.5 引入 async/await
2018 ── Rust async/await 提案,Tokio 生态崛起
2019 ── C++20 引入协程
2020 ── Java Project Loom 启动(虚拟线程)
2023 ── Java 21 正式发布虚拟线程(Virtual Threads)
2024 ── io_uring 成为 Linux 主流异步 I/O 接口
各时期的关键驱动力
| 时期 | 驱动力 | 代表技术 |
|---|---|---|
| 1990s-2000s | C10K 问题(单机万级并发) | epoll、kqueue、IOCP |
| 2009-2015 | Web 实时应用兴起 | Node.js、WebSocket |
| 2012-2020 | 微服务 + 云原生 | Go goroutine、Kubernetes |
| 2018-至今 | 零成本抽象 + 安全并发 | Rust async、Java 虚拟线程 |
1.7 C10K 与 C10M 问题
C10K(2000 年代)
Dan Kegel 在 1999 年提出:单台服务器能否同时处理 10,000 个并发连接?
传统的一线程一连接(Thread-per-Connection)模型无法扩展到万级连接,因为:
- 每个线程占用约 1MB 栈空间,10,000 线程需要 ~10GB 内存
- 线程上下文切换开销巨大
select()的 O(n) 扫描成为瓶颈
解决方案:
| 方案 | 描述 | 代表 |
|---|---|---|
| 事件驱动(Event-driven) | 单线程 + I/O 多路复用 | Nginx、Node.js |
| 协程 | 轻量级并发单位,用户态调度 | Go、Erlang |
| 异步 I/O | 内核完成 I/O 后通知 | Windows IOCP、Linux io_uring |
C10M(2010 年代)
C10K 已被完美解决后,新的目标是 C10M:单机千万级并发连接。这推动了:
io_uring(零系统调用开销)- DPDK(绕过内核的网络栈)
- XDP(eXpress Data Path,内核态高速包处理)
1.8 本章小结
| 要点 | 说明 |
|---|---|
| 同步/异步 | 描述调用方是否等待结果 |
| 阻塞/非阻塞 | 描述线程是否被挂起 |
| 并发/并行 | 逻辑同时 vs 物理同时 |
| I/O 密集型 | 异步/协程的优势场景 |
| CPU 密集型 | 多线程/多进程的优势场景 |
| C10K → C10M | 推动异步编程模型不断进化 |
下一章预告:我们将深入事件循环(Event Loop)——异步编程的心脏,理解它如何用单线程处理成千上万的并发连接。
扩展阅读
- The C10K Problem — Dan Kegel 的经典文章
- Rob Pike — Concurrency is not Parallelism — 必看演讲
- What is Async, What is Blocking? — Armin Ronacher
- The Art of Unix Programming — Chapter on Concurrency — Eric Raymond
- Hugo 系列文章 — 作者博客