强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

异步与协程精讲 / 第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 统一监听     │
└─────────────────────────────────────────────┘
平台系统调用特点
LinuxepollO(1) 事件通知,支持边缘触发(ET)和水平触发(LT)
macOS / BSDkqueue类似 epoll,支持文件、信号、进程等多种事件
WindowsIOCP (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 asynciogoroutine / coroutine运行时I/O 密集型
线程池Java、C#线程OS混合型
多进程Erlang、Nginx进程OSCPU 密集型 + 容错

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-2000sC10K 问题(单机万级并发)epoll、kqueue、IOCP
2009-2015Web 实时应用兴起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)——异步编程的心脏,理解它如何用单线程处理成千上万的并发连接。


扩展阅读