Skip to content

协程是什么?如何理解?

核心定义

协程(Coroutine) 是一种可暂停执行、之后又能从暂停处恢复执行的函数或方法。它不像普通函数那样"一口气"跑完,而是能在中途主动让出控制权(yield),待条件满足后精确回到断点继续执行,同时保持所有内部状态不变。


关键特征:三个"可"

1. 可暂停(Suspendable)

协程在代码中显式标记的"挂起点"主动暂停,通常是遇到 awaityield 等关键字时。这不是被操作系统强制中断,而是协作式的主动让权。

python
# Python示例
def simple_coroutine():
    print("开始")
    value = yield 1          # 暂停,返回1给调用者
    print(f"恢复时收到: {value}")
    yield 2                  # 再次暂停

2. 可恢复(Resumable)

暂停后,协程的完整状态(局部变量、执行位置、调用栈)被妥善保存。当异步操作完成或数据到达时,调度器能精确唤醒它,仿佛从未离开过。

3. 有状态(Stateful)

普通函数每次调用都是全新的,而协程实例能"记住"上一次执行到哪里,数据是什么。这让它特别适合流水线式的数据加工分阶段的异步任务


直观类比:断点续传的"故事章节"

想象你在听一部有声小说APP:

  • 普通函数:必须一口气听完一章,中途不能停,如果断网就得从头重听。
  • 协程:听到第15分钟时,APP自动暂停并标记进度。你可以去干别的,等网络恢复后点击"续播",它精确从15:00开始,人物的台词、背景音乐状态都完好无损。

协程的"暂停"不是为了休息,而是等待某个条件(网络IO、文件读取、用户输入),等待期间不占用CPU,让系统去处理其他任务。


与线程的本质区别

维度线程协程
调度方式操作系统抢占式调度(强制切换)程序自身协作式调度(主动让权)
切换成本高(需保存寄存器、栈等,~μs级)极低(仅需保存几个状态字段,~ns级)
内存占用独立栈空间(MB级)共享堆内存,仅保存必要状态(KB级)
并发模型多核并行单线程并发(配合事件循环)
适用场景CPU密集型计算IO密集型任务(网络、文件、定时器)

核心口诀线程是"操作系统管"的,协程是"程序员管"的。


跨语言示例

JavaScript (async/await)

javascript
// 看似同步,实则暂停-恢复
async function fetchData() {
  console.log("请求前");
  const data = await fetch('/api');  // 暂停,释放线程
  console.log("请求后", data);       // 恢复,data可用
}

Python (生成器/async)

python
# 生成器式协程(旧)
def read_file():
    with open('data.txt') as f:
        for line in f:
            yield line  # 暂停,返回一行

# 原生协程(新)
async def fetch_data():
    response = await aiohttp.get('https://...')  # 暂停
    return await response.text()                  # 恢复

C# (async/await)

csharp
// 编译器生成状态机保存状态
async Task<string> LoadConfigAsync()
{
    await Task.Delay(100);    // 暂停,Timer到期后恢复
    return "config-data";     // 状态机保证_context变量还在
}

为什么需要协程?解决两大痛点

1. 消灭"回调地狱"

异步代码不用写成嵌套金字塔:

javascript
// ❌ 回调地狱
getData(function(a) {
  getMoreData(a, function(b) {
    getMoreData(b, function(c) {
      console.log(c);
    });
  });
});

// ✅ 协程拯救
const a = await getData();
const b = await getMoreData(a);
const c = await getMoreData(b);
console.log(c);

2. 降低心智负担

用同步的思维写异步代码:try/catch 正常捕获异常,循环和条件语句按直觉工作,不再需要手动管理复杂的回调链。


理解协程的"魔法"本质

协程的"魔法"不在语言层面,而在编译器/运行时的状态保存与恢复机制

  1. 暂停时:将当前执行位置(比如第15行)、局部变量调用栈打包成一个状态对象(C#的状态机、JavaScript的Promise链)
  2. 等待时:释放线程去做其他事(处理其他请求、渲染UI)
  3. 恢复时:从状态对象还原上下文,跳转到之前保存的执行位置,变量值原封不动

这就像一个带书签和记事本的函数:书签标记读到哪里,记事本记录当时的所有变量,下次打开时"穿越"回当时的状态。


总结

协程是用户态的轻量级线程,它打破了"函数必须一口气执行完"的约束,赋予代码在指定位置暂停、在未来精确恢复的超能力。这种协作式多任务模式,让异步IO密集型应用能以极低成本实现高并发,同时保持代码的线性和可读性。

C# 中的协程与 async/await 实现机制

一、C#中的协程概念

C# 并没有官方"协程"(Coroutine)术语,但 async/await 本质上是协程的一种实现形式。协程的核心特征是能暂停执行(yield)并在未来恢复执行,同时保留方法的完整状态——这正是 async/await 的底层行为。

与传统线程不同,这种"暂停-恢复"是协作式而非抢占式的:方法只在遇到 await 时主动让出控制权,异步操作完成后又精确回到断点继续执行,期间不阻塞线程,而是利用状态机保存所有局部变量和执行位置。


二、async/await 的实现原理:编译器生成的状态机

当你写一个 async 方法时,编译器会将其彻底重写为一个状态机类/结构体,这是理解实现的关键。

2.1 编译器转换的整体流程

假设你有如下代码:

csharp
public async Task<decimal> GetStockPriceAsync(string companyId)
{
    await InitializeIfNeededAsync();
    return _prices[companyId];
}

编译后大致等价于(简化版):

csharp
// 原始方法被替换为状态机启动器
public Task<decimal> GetStockPriceAsync(string companyId)
{
    var stateMachine = new __GetStockPriceAsync_StateMachine
    {
        __this = this,
        companyId = companyId,
        __builder = AsyncTaskMethodBuilder<decimal>.Create(),
        __state = -1
    };
    stateMachine.__builder.Start(ref stateMachine);
    return stateMachine.__builder.Task;
}

2.2 生成的状态机结构

状态机(Debug模式生成类,Release模式生成结构体)实现 IAsyncStateMachine 接口,核心成员包括:

成员作用
__state当前执行状态(0=在第一个await处暂停,-1=继续执行,-2=完成)
__builderAsyncTaskMethodBuilder<T>,管理Task生命周期和结果
__awaiterTaskAwaiter,包装被等待的Task并处理续体回调
所有局部变量被提升为字段,确保跨暂停-恢复时状态不丢失
csharp
struct __GetStockPriceAsync_StateMachine : IAsyncStateMachine
{
    public StockPrices __this;
    public string companyId;
    public AsyncTaskMethodBuilder<decimal> __builder;
    public int __state;
    private TaskAwaiter __awaiter;

    public void MoveNext()  // 核心方法:每次恢复都从此进入
    {
        decimal result;
        try
        {
            TaskAwaiter awaiter;
            if (__state != 0)  // State 0 表示从第一个await恢复
            {
                // 1. 执行到第一个await之前的代码
                awaiter = __this.InitializeIfNeededAsync().GetAwaiter();
                
                // 2. 热路径优化:如果任务已完成,直接继续
                if (!awaiter.IsCompleted)
                {
                    __state = 0;
                    __awaiter = awaiter;
                    __builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;  // 暂停!控制权交回调用方
                }
            }
            else
            {
                // 3. 恢复执行:从awaiter获取结果
                awaiter = __awaiter;
                __awaiter = default;
                __state = -1;
            }

            awaiter.GetResult();  // 取异步结果(或抛异常)
            result = __this._prices[companyId];
        }
        catch (Exception ex)
        {
            __state = -2;
            __builder.SetException(ex);  // Task转为Faulted状态
            return;
        }

        __state = -2;
        __builder.SetResult(result);  // Task完成并返回结果
    }
}

三、关键组件与协作机制

3.1 AsyncTaskMethodBuilder:Task的制造工厂

AsyncTaskMethodBuilder<T> 承担三大职责:

  1. 启动状态机Start<TStateMachine>() 方法开始第一次 MoveNext() 调用
  2. 管理Task:内部维护一个 TaskCompletionSource<T>,最终返回的 Task 从此获取
  3. 调度续体AwaitUnsafeOnCompleted() 将状态机注册为Task的回调

3.2 TaskAwaiter:包装与调度

TaskAwaiter 虽小但至关重要:

  • IsCompleted:检查任务是否同步完成(热路径优化关键)
  • GetResult():提取结果或"解包"异常(若Task失败,抛出首个异常而非 AggregateException
  • UnsafeOnCompleted(Action):注册状态机的 MoveNext 作为Task完成时的回调

3.3 ExecutionContext:跨线程的隐式数据流

异步方法可能在不同线程执行,但 ExecutionContext 确保线程本地数据(如 AsyncLocal<T>、安全上下文)能自动流动。流程如下:

  1. AsyncTaskMethodBuilder 在暂停时捕获当前 ExecutionContext
  2. 创建 MoveNextRunner 包装状态机和Context
  3. Task完成时,MoveNextRunner.Run() 在捕获的Context中调用 MoveNext()
csharp
// 这能工作是因为ExecutionContext自动流动
static async Task DemoAsyncLocal()
{
    var local = new AsyncLocal<int>();
    local.Value = 42;
    await Task.Delay(100);  // 可能切换到线程池线程
    Console.WriteLine(local.Value);  // 仍然输出42
}

四、热路径优化:同步完成的性能

若被等待的Task已同步完成(如缓存命中),状态机全程在栈上执行,不堆分配、不注册回调、不切换上下文。这是高性能异步代码的关键:

csharp
// 如果 InitializeIfNeededAsync 瞬间完成,没有额外开销
await InitializeIfNeededAsync();

五、限制与约束

由于状态机转换的复杂性,以下场景禁止使用 await

  • lock 块内:可能导致锁在未释放时暂停
  • catch/finally 块内:破坏异常处理语义
  • unsafe 区域:指针状态无法跨暂停保留
  • ref/out 参数的方法:参数需在同步返回时赋值

总结

C# 的 async/await 通过编译器生成的状态机TaskAwaiter调度ExecutionContext流动,在语言层面实现了协程的所有核心能力。这种设计让异步代码看起来像同步代码,同时保持高性能(热路径优化)和正确的语义(异常、上下文)。

协程与yield的关系

在 C# 里,“迭代器(iterator)”“异步协程(async/await)” 是两条完全不同的编译器魔法,但它们都依靠 “把方法拆成状态机 + 在特定点挂起/恢复” 这一招。
yield 只在前一条赛道里出现,和 async/await 没有语法交叉,却共享了同一套“状态机”思想。


  1. 两条赛道一眼看清
特性迭代器(yield)异步协程(async/await)
关键词yield return/breakasync / await
返回类型IEnumerable / IEnumerator (含泛型)Task / Task<T> / ValueTask
挂起点遇到 yield return/break遇到 await
状态机名字<Name>d__0 并实现 IEnumerator<Name>d__0 并实现 IAsyncStateMachine
驱动方式MoveNext()foreach/while 手工调用MoveNext()Task.ContinueWith 或线程池回调
用途按需流式生产数据异步等待 IO/任务完成
是否捕获 ExecutionContext

  1. 迭代器状态机长什么样?

源码

csharp
static IEnumerable<int> CountDown()
{
    for (int i = 3; i >= 0; --i)
        yield return i;
}

编译器生成(极度简化)

csharp
private sealed class <CountDown>d__0 : IEnumerable<int>, IEnumerator<int>
{
    private int _state;   // 0~N:走到第几个 yield
    private int _current; // 当前 yield 出来的值

    bool IEnumerator.MoveNext()
    {
        switch (_state)
        {
            case 0: _state = 1; goto start;
            case 1: _state = 2; goto afterYield0;
            case 2: _state = 3; goto afterYield1;

        }

      start:
        for (int i = 3; i >= 0; --i)
        {
            _current = i;
            return true;          // 挂起,等待下一次 MoveNext
          afterYield0:;           // 恢复时直接跳到这里
        }
        return false;             // 迭代结束
    }

    int IEnumerator<int>.Current => _current;

}

调用方 foreach (var x in CountDown()) … 只是不断调 MoveNext(),状态机就一步一步从挂起点继续跑。
这就是“同步协程”——协作式挂起,但始终在同一线程同步推进。


  1. async/await 状态机长什么样?

前面已经详述,再提炼一句:
遇到 await 时,如果操作没完成,就把 MoveNext 注册成回调,然后立即返回;操作完成时由线程池/IOCP 把 MoveNext 再扔回线程(可能换线程),状态字段告诉我们该跳到哪一段代码。
这是“异步协程”——协作式挂起,但可能跨线程、跨时间片。


  1. 二者共同点与差异

共同点

  • 都是编译器把“顺序方法”拆成“状态机 + MoveNext”
  • 都用 int _state 做“断点行号”,用字段保存局部变量
  • 都在特定词法点(yield/await)“暂停”,之后可“恢复”

根本差异

  • 调度权:迭代器由用户代码手动 MoveNext();异步状态机由运行时(Task 回调、线程池)自动调度
  • 线程模型:迭代器不会自己切换线程;async 方法遇到真正的异步 IO 时线程会被复用或切换
  • 上下文:async 状态机自动捕获/还原 ExecutionContextyield 迭代器不管这些

  1. 一句话总结

yield“同步协程” 的挂起指令,用来 按需生产数据
await“异步协程” 的挂起指令,用来 不阻塞地等待结果
它们背后都是“C# 编译器帮你写状态机”,但跑在两条互不交叉的轨道上。

yield 的本质

yield本质只有一句话:

编译器把“顺序写的生成代码”变成实现了 IEnumerator/IEnumerable状态机类**,在每次 MoveNext() 调用时从上次“退出的位置”继续往下跑,直到再遇到 yield return/break 或方法结束。**

换句话说,yield 本身不是 CLR 指令,也不是线程魔法,而是 C# 编译器提供的“语法糖 + 状态机代码生成” 技术。它让“写起来像同步顺序代码”,运行时却表现为“可逐段迭代、可暂停/继续”的协程行为。


  1. 编译器到底生成了什么?

源码

csharp
static IEnumerable<int> Foo()
{
    Console.WriteLine("A");
    yield return 1;
    Console.WriteLine("B");
    yield return 2;
    Console.WriteLine("C");
}

编译器生成(极度简化版)

csharp
[CompilerGenerated]
private sealed class <Foo>d__0 : IEnumerable<int>, IEnumerator<int>
{
    private int _state;      // 0=初始,1=第一次yield后,2=第二次yield后…
    private int _current;    // 当前yield出来的值
    private int _initialThreadId;

    bool IEnumerator.MoveNext()
    {
        switch (_state)
        {
            case 0:
                _state = -1;
                Console.WriteLine("A");
                _current = 1;
                _state = 1;
                return true;   // 第一次暂停

            case 1:
                _state = -1;
                Console.WriteLine("B");
                _current = 2;
                _state = 2;
                return true;   // 第二次暂停

            case 2:
                _state = -1;
                Console.WriteLine("C");
                return false;  // 迭代结束

            default: return false;
        }
    }

    int  IEnumerator<int>.Current => _current;
    void IEnumerator.Reset() => throw new NotSupportedException();

}

Foo() 本身被替换成

csharp
static IEnumerable<int> Foo()
{
    return new <Foo>d__0(0) { _initialThreadId = Environment.CurrentManagedThreadId };
}

  1. 运行期发生了什么?

  2. foreach (var x in Foo()) { … }

  3. 先取 IEnumerable 对象(就是状态机实例)

  4. 每次循环调 IEnumerator.MoveNext()

  5. 状态机根据 _state 跳转到对应 case,执行代码到下一个 yield return

  6. yield return 的值写入 _current 并返回 true → 消费者拿到值

  7. 下一次 MoveNext() 再次进入,继续 switch,直到 return false 结束迭代

整个过程中没有线程切换、没有堆栈捕获,纯粹是“手工推进的断点续跑”。


  1. 一句话提炼

yield本质 =
“C# 编译器帮你把方法拆成 MoveNext() 状态机,使代码可以‘分段执行’,从而用同步语法实现同步协程。”