致并发的新大陆03

Python协程体系踹门式入门

Posted by Miso on May 8, 2026

0. 前言


手动建立许多个线程再利用回调函数构造调用链的时代已经结束了。

他们这么说。

协程体系通常被人们称为“并发编程的最终形态”。它完全避免了手动处理多个线程和复杂的回调函数嵌套,封装了一切关于回调的底层细节,使得我们能够用同步函数的思维来撰写异步函数。

本文旨在快速打通你的Python协程编程思维,以一种最贴合直觉的方式建立程序员对async/await语法的理解,服务于大家的Vibe Coding。

我们出发吧。

01. 概念快速入门


异步函数 | Async Func

1
2
3
async def func(spam: str)
	result = await another_async_func(spam)
	return result

在Python中,所谓异步函数是使用 async def 定义的函数。它的某些性质和使用方式与一般def的同步函数不同,使之可以在进行耗时等待操作时挂起自身并把CPU线程转移至其它函数和语句。也就是说,程序员可以通过一些特殊的写法(非常关键的await关键字的各种排布),使得原本单箭头的执行顺序分叉为两个甚至多个箭头,并控制其延伸的走向和合并的次序。

使用圆括号把实参表写在函数名右方进行调用时,与直接返回值的同步函数不同,异步函数返回协程对象而非立即执行。需要通过非常关键的await关键字来获取其返回值。

1
2
3
func: Callable[[str], Coroutine]      # 异步函数本身
coro: coroutine = func(spam = "spam") # 传参后返回协程对象
result: str = await coro              # await协程对象来获取结果

至于为什么要搞出这么一堆奇妙的语法,特别是非常关键的await关键字,下文会详细说明。

协程对象 | Coro

如前文所说,协程对象是异步函数进行圆括号传参后的返回值。Coro在建立后不会自动开始异步执行异步函数内的逻辑。我们一般把Coro传入asyncio.create_task()等函数之中进行管理,也可以直接使用await来执行该Coro内部的逻辑并获取返回值。

Coro只是一个概念,在一般情况下不需要被变量显式持有。result = await asyncFunc(raw)和task = asyncio.create_task(asyncFunc(raw)); result = await task足够解决大多数情况。

事件循环 | Event Loop

Python事件循环是异步编程的底层调度引擎,本质是一个在单线程内无限运行的协调调度器。它会在异步函数运行到await时挂起当前函数,并将执行路线切换到其它函数中。

从底层实现的角度看,事件循环维护着任务队列与就绪的集合,在每一次循环时检查并执行可运行的协程或回调。它通过多路复用等底层机制监控文件描述符、套接字和计时器,当协程运行到 await 挂起时,循环立即切换执行其他就绪任务,待 IO 事件或超时发生后恢复暂停的协程。

它确保了单线程内的并发性,驱动着 asyncio 生态的运行。

任务对象 | Task

Task是将Coro传入asyncio.create_task()等函数后的返回值。一旦Task被创建,其内部异步函数逻辑会立刻被事件循环进行评估并开始执行。之后,事件循环将自动识别该函数内部的await点来挂起Task把线程重新分配到创建Task的主分支。之后,与await Coro相似,主分支可以通过await Task来获取任务对象内部异步函数的返回值。

示例见下。

02. 干货在此 | 并发编程的常见模式


Fire-And-Forget | 聊聊何为分支

Fire-And-Forget 是最简单的异步编程模型之一,常用于日志记录、错误输出等不希望阻塞主线程、也不关心返回值的场景。我将在此场景中展示如何编写Async Func、用asyncio.create_task()建立分支,以及await Task来确保分支完成。

设想以下场景:

1
2
3
4
5
6
7
* raw = get_data()
| 
* log(raw)
| 
* result = process(data)
|
* end

上图展示了以同步形式调用log()的调度流程图。其缺点在于,如果log()是一个IO耗时函数(如文件、数据库的读写),后方process(data)会被直接阻塞,直到log()执行完毕。

1
2
3
4
5
6
7
* data = get_data()
| \
|  * log(data)
|  
* processed_data = process_data(data)
| 
* end

上图展示了以异步形式调用log()的调度流程图。图中可见,在得到raw后,程序执行逻辑分为了两个不同的分支,主分支执行运算逻辑,侧分支执行日志记录。

我们尝试用Python实现这三个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def get_data():
    with open('data.txt', mode='r') as f:
	    data = f.read()
	return data

def process_data(data):
	time.sleep(1)
    return f"processed {data}"

async def log(data):
    f = await aiofiles.open('app.log', mode='a')
    await f.write(f"logged: {data}\n")
    # 出于教学(偷懒?)目的,我没有使用async with语句,也没有使用try-finally
    await f.close()

请看注释。请不要在任何地方这么写文件读写操作。如果你的Agent这么写,纠正它。

主分支的两个函数可以写成同步函数,而侧分支的log()写成了异步函数。async def说明调度器可以在内部任何一个await点(本例中有三个)将控制权切出该函数回到主分支。

三个await点所等待的三个函数由aiofiles提供,它们都是异步函数。异步函数传参后返回Coro, 而await Coro保证函数完成并返回该函数的返回值。

值得注意的是,await这一切出控制的特性反过来决定了使用await的函数必须是异步函数。同步函数不支持控制权的切出,因此,任何使用await的函数必须使用async def。试图在同步函数或程序顶层使用await会报SyntaxError语法错误。

习惯上,我们使用async def main()来做顶层封装,再交给asyncio.run(main())来执行。这多少弥补了Python没有main()的缺憾(雾)

1
2
3
4
5
6
async def main():
	data = get_data()                     # 书写主分支
	task = asyncio.create_task(log(data)) # 侧分支启动
	processed_data = process_data(data)   # 继续写主分支
	print(processed_data)
	await task

虽然我们不关心log()的返回值,但是我们需要确保它执行完毕。若不加上最后的await task,在主分支执行完毕时侧分支可能未能结束,此时该侧分支将会被中断。我们通过这种方法确保分支的合并,在end之前把侧分支并回主分支。

1
2
3
4
5
6
7
* data = get_data()
| \
|  * log(data)
|  |
* processed_data = process_data(data)
| /
* end

Fork-Join | 从分支中取得值

Fork-Join 是能够展现全套async/await语法的最简单模式。很简单,相比起Fire-And-Forget,Fork-Join 多了从分支中取值这一步。

我们先来让log返回值。

1
2
3
4
5
6
7
8
async def log(data):
	try:
		async with aiofiles.open('app.log', mode='a') as f: # 字面意思
			await f.write(f"logged: {data}\n")
		return True
	except Exception as e:
		print(e)
		return False

aiofiles.open异步上下文管理器语法async with,其语法与

1
2
3
4
5
6
7
async def main():
	data = get_data()                     # 书写主分支
	task = asyncio.create_task(log(data)) # 侧分支启动
	processed_data = process_data(data)   # 继续写主分支
	print(processed_data)
	is_ok = await task
	print("ok" if is_ok else "err")

Fan-In-Fan-Out | 批量展开分支并取值

Fan-In-Fan-Out 是 Fork-Join 的进阶模式,适用于需要同时发起多个独立 IO 任务、等待所有任务完成后再汇总结果的场景。我将在此场景中展示如何用 asyncio.gather() 同时启动多个协程,以及如何从多个分支中收集返回值。

设想以下场景:

1
2
3
4
5
6
7
8
9
* raw = fetch_data()
|
* result_a = process_a(raw)    
|
* result_b = process_b(raw)
|
* combined = combine(result_a, result_b)
|
* end

上图展示了以同步形式调用 process_a() 和 process_b() 的调度流程图。其缺点在于,如果 process_a() 和 process_b() 都是IO 耗时函数(如调用不同的外部 API),它们会串行执行,总耗时等于两者之和。

1
2
3
4
5
6
7
8
* raw = fetch_data()
| \                           \
|  * result_a = process_a(raw) * result_b = process_b(raw)
|  |                           |
| /                           /
* combined = combine(result_a, result_b)
|
* end

上图展示了以异步形式调用 process_a() 和 process_b() 的调度流程图。图中可见,在得到 raw 后,程序执行逻辑分为了两个并行的分支,两个分支同时执行各自的处理逻辑。当两个分支都完成后,主分支将两个结果合并。

我们尝试用 Python 实现这三个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def fetch_data():
    with open('data.txt', mode='r') as f:
        data = f.read()
    return data

async def process_a(data):
    await asyncio.sleep(2)  # 模拟调用外部 API A
    return f"processed by A: {data}"

async def process_b(data):
    await asyncio.sleep(3)  # 模拟调用外部 API B
    return f"processed by B: {data}"

def combine(result_a, result_b):
    return f"{result_a} | {result_b}"

两个处理函数都写成了异步函数,因为它们的耗时操作(网络请求、数据库查询等)可以被 await 挂起,让出控制权给事件循环。asyncio.gather() 会同时启动所有传入的协程,并等待它们全部完成后,按传入顺序返回结果列表。

1
2
3
4
5
6
7
8
async def main():
    data = fetch_data()                                    # 获取原始数据
    result_a, result_b = await asyncio.gather(             # 同时启动两个分支
        process_a(data),
        process_b(data)
    )
    combined = combine(result_a, result_b)                 # 合并结果
    print(combined)

asyncio.gather() 接收多个协程对象(或可等待对象),将它们全部注册到事件循环中并发执行。当所有协程都完成时,返回一个包含所有返回值的元组。若某个协程抛出异常,gather() 默认会立即抛出该异常,其他协程仍会继续执行但结果会被丢弃。

1
2
3
4
5
6
7
8
9
* data = fetch_data()
| \
|  * result_a = process_a(data)  * result_b = process_b(data)
|  |                             |
|  * await asyncio.gather() 等待所有分支完成
| /
* combined = combine(result_a, result_b)
|
* end

此图由deepseek-v4-flash生成

可以通过for语句或列表推导式批量生成Coro,并将Coro列表通过星号解包的方式传入asyncio.gather()来批量启动任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async def main():
    data = fetch_data()  # 获取原始数据

    # 使用列表推导式批量生成 Coro
    coros = [process_a(data) for _ in range(5)] # 展开五个分支
	# Coro 的内部逻辑未开始运行
	
    # 使用星号解包将 Coro 列表传入 gather
    results = await asyncio.gather(*coros) # 相当于同时await五个Coro
    # Coro 的内部逻辑此时才开始运行

    # 处理所有结果
    for i, result in enumerate(results):
        print(f"Task {i}: {result}")

也可以传入Task。其优点在于除了启动任务外,额外地留出了主分支的空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async def main():
    data = fetch_data()  # 获取原始数据

    # 使用列表推导式批量创建 Task
    tasks = [asyncio.create_task(process_a(data)) for _ in range(5)]
    # Task的内部逻辑立即开始运行

	# *在这里放一些你希望在得到result之前执行的代码*

    # 将 Task 列表传入 gather
    results = await asyncio.gather(*tasks)
    # 起到确保完成的作用

    # 处理所有结果
    for i, result in enumerate(results):
        print(f"Task {i}: {result}")

为了展示传入Task的用途,摘录一段我自己工程的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    async def run(self, items: list):
        self.self_check()

        # 部署所有机器到已有流水线
        tasks = []
        for machine in self.machines:
            tasks += [
                asyncio.create_task(machine.run())
                for _ in range(machine.num)
            ]


        # 向输入队列中添加所有项目
        for item in items:
            self.assembly_lines[0].put_nowait(item)

        # 等待所有流水线完成
        for line in self.assembly_lines:
            await line.join()

        # 向机器任务发送停止侦听信号
        for task in tasks:
            task.cancel()

        # 等待所有机器任务完成
        await asyncio.gather(*tasks, return_exceptions=True)

        # 从输出队列中获取所有结果
        out = []
        while not self.output_queue.empty():
            out.append(await self.output_queue.get())
        return out

所有分支的异步函数是侦听性质的。主分支需要做出一些调度操作,被分支上的异步函数侦听到,进而执行内部逻辑。

由此可见,由于前者能够在扇出时额外留出主分支的代码操作空间,且能对各个任务进行精细处理,asyncio.gather(*tasks)可以处理的情况是asyncio.gather(*coros)的超集。而后者的好处是代码较少,task由gather内部封装并管理。

在Coding时,你可以根据需求跟你的Agent提一嘴。

Wait-For-First | 筛选最快返回的分支

Wait-For-First 与 Fan-In-Fan-Out有一定的相似性,其区别在于:Wait-For-First里所有分支的返回值是等价的,只需拿到一个返回值就可以放弃之后的所有分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
async def fetch_from_cache():
    await asyncio.sleep(0.5)          # 模拟缓存较慢
    return "cache_data"

async def fetch_from_db():
    await asyncio.sleep(0.1)          # 模拟数据库较快
    return "db_data"

async def fetch_from_api():
    await asyncio.sleep(0.8)          # 模拟 API 最慢
    return "api_data"

async def main():
    # 创建任务
    tasks = [
        asyncio.create_task(fetch_from_cache()),
        asyncio.create_task(fetch_from_db()),
        asyncio.create_task(fetch_from_api())
    ]

    # 等待第一个完成
    done, pending = await asyncio.wait(
        tasks, 
        return_when=asyncio.FIRST_COMPLETED
    )

    # 取第一个完成的结果
    first_result = done.pop().result()
    print(f"首个完成的任务返回: {first_result}")

    # 取消剩余任务(避免资源浪费)
    for task in pending:
	    task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
	        await task

asyncio.run(main())

此模式直接依赖于asyncio.wait()。与 gather() 不同,wait() 提供了更加精准的控制手段,允许你指定放弃其余分支(如果需要)并返回的时间点。如等待所有任务完成,等待第一个完成,或是等待第一个异常抛出。通过return_when 指定,支持三种模式:

  • asyncio.ALL_COMPLETED:等待所有任务完成(默认)
  • asyncio.FIRST_COMPLETED:只要有任意一个任务完成就返回
  • asyncio.FIRST_EXCEPTION:只要有任意一个任务抛出异常就返回(若没有异常则等同 ALL_COMPLETED)

asyncio.wait() 返回两个Task集合:done(已完成的任务)和 pending(未完成的任务)。应当手动task.cancel()并pass可能出现的asyncio.CancelledError来避免资源浪费。

3. 总结 我们学到了什么


我们一路从最基础的概念走到了并发编程的常见模式。

从异步函数与协程对象的区分开始,我们理解了 async def 定义的是函数本身,而圆括号传参后得到的 Coro 只是一个等待执行的对象,需要 await 才能真正驱动它。

事件循环作为单线程内的调度引擎,在每一个 await 点挂起当前协程、切换执行其他就绪任务,从而实现了看似并发的效果。

任务对象则是对协程的进一步封装,一旦通过 asyncio.create_task() 创建,其内部逻辑便会立即被事件循环调度执行,让我们能够在主分支继续工作的同时让侧分支并行推进。

在模式层面,我们从最简单的 Fire-And-Forget 出发,理解了如何用 create_task 建立分支、用 await task 合并分支,即使不关心返回值也要确保分支完成。

接着是 Fork-Join,在 Fire-And-Forget 的基础上增加了从分支中取值的步骤,展示了 await 获取返回值的完整链路。

Fan-In-Fan-Out 则进一步扩展了分支的数量,通过 asyncio.gather() 批量启动多个协程并等待全部完成,配合列表推导式和星号解包,能够优雅地处理任意数量的并行任务。

最后有 Wait-For-First,它展示了 asyncio.wait() 的灵活控制能力,在多个等价分支中去获取最快返回的结果,并主动取消剩余任务以避免资源浪费。