# 前言

这两天又研究了下协程,之前虽然用过,也稍微想过,但是并没有深入研究,这次就想把流程仔细走一遍。

# 原理

其实也是一个思维方式问题,协程的原理其实很简单:利用迭代器

# 迭代器

我们都知道,实现了 IEnumerable 接口、或者说拥有 IEnumerator GetEnumerator() 的类可以被 foreach 所迭代。

其重点就在于 IEnumerator 这个接口。

数组List字典 等数据结构,迭代时都是通过返回自己创建的一个实现了 IEnumeratorWarp 类进行。

为什么一个方法,标记返回 IEnumerator 直接就能被迭代?

例如:

public class TestEnumerator
{
    public void DoTest()
    {
        foreach (var item in this)
        {
        }
    }
    public IEnumerator GetEnumerator()
    {
        Debug.Log("迭代");
        yield return null;
    }
}

上述代码是不会报错的,如果执行也会正常调用:

迭代
UnityEngine.Debug:Log(Object)
<GetEnumerator>c__Iterator0:MoveNext() (at Assets/TestEnumerator.cs:37)
TestEnumerator:DoTest() (at Assets/TestEnumerator.cs:29)
Test:Start() (at Assets/Test.cs:15)

# 从 IL 代码分析

想过为什么会这样吗?毕竟这只是一个方法而已,为何就能被迭代了?

实际上, IEnumerator + yield 该是属于 C# 的法糖,因为底层编译器会为此生成一个新的类,这个新类直接实现了 IEnumerator 接口,而原本的方法则会在被调用时,创建这个新类并返回。

如果想要证实这个猜测,这就要从编译后的代码说起了,通过 dnSpy 反编译,选择 IL 语言 (C# 模式会还原我们的代码,所以看不出来),就可以查看到具体信息,一部分代码如下:

// Token: 0x0200019E RID: 414
.class public auto ansi beforefieldinit TestEnumerator
	extends [mscorlib]System.Object
{
	// Nested Types
	// Token: 0x02000215 RID: 533
	.class nested private auto ansi sealed beforefieldinit '<GetEnumerator>c__Iterator0'
		extends [mscorlib]System.Object
		implements [mscorlib]System.Collections.IEnumerator,
		           [mscorlib]System.IDisposable,
		           class [mscorlib]System.Collections.Generic.IEnumerator`1<object>
	{
		.custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
			01 00 00 00
		)
		// Fields
		// Token: 0x04000C77 RID: 3191
		.field assembly object $current
		// Token: 0x04000C78 RID: 3192
		.field assembly bool $disposing
		// Token: 0x04000C79 RID: 3193
		.field assembly int32 $PC
		
		//============ 略 ================

我们只有一个 TestEnumerator 类,但是 IL 代码中却在 TestEnumerator 中又额外包含了一个 <GetEnumerator>c__Iterator0 新类。

再看原本的 GetEnumerator() 方法:

// Token: 0x06000E23 RID: 3619 RVA: 0x00059D54 File Offset: 0x00058154
.method public hidebysig
    instance class [mscorlib]System.Collections.IEnumerator GetEnumerator () cil managed 
{
    .custom instance void [mscorlib]System.Diagnostics.DebuggerHiddenAttribute::.ctor() = (
        01 00 00 00
    )
    // Header Size: 12 bytes
    // Code Size: 15 (0xF) bytes
    // LocalVarSig Token: 0x11000353 RID: 851
    .maxstack 1
    .locals init (
        [0] class TestEnumerator/'<GetEnumerator>c__Iterator0',
        [1] class [mscorlib]System.Collections.IEnumerator
    )
    /* 0x00058160 736E110006   */ IL_0000: newobj    instance void TestEnumerator/'<GetEnumerator>c__Iterator0'::.ctor()
    /* 0x00058165 0A           */ IL_0005: stloc.0
    /* 0x00058166 06           */ IL_0006: ldloc.0
    /* 0x00058167 0B           */ IL_0007: stloc.1
    /* 0x00058168 3800000000   */ IL_0008: br        IL_000D
    /* 0x0005816D 07           */ IL_000D: ldloc.1
    /* 0x0005816E 2A           */ IL_000E: ret
} // end of method TestEnumerator::GetEnumerator

该方法创建并返回了 <GetEnumerator>c__Iterator0 类的实例。

# MoveNext 原理

那么,协程中又是如何实现分块执行 yield 分割的代码呢?

在我之前的想像中,是觉得有一个 容器盛放 这些代码块,通过 yield 将原本函数代码分割成一个个更小的代码块,然后维护一个下标,通过 MoveNext 递增下标来执行实现。

看了 IL 代码后,虽然跟实际表现有差异,不过想得感觉倒是没大错:确实是被编译成了更细小的代码块,同时以一个下标维护着代码块执行进度。

首先是三个字段:

// Fields
// Token: 0x04000C77 RID: 3191
.field assembly object $current
// Token: 0x04000C78 RID: 3192
.field assembly bool $disposing
// Token: 0x04000C79 RID: 3193
.field assembly int32 $PC

因为之前的代码太简单,不好看出结构,这里我多加了一点代码:

public IEnumerator GetEnumerator()
{
    Debug.Log("迭代1");
    yield return null;
    Debug.Log("迭代2");
    yield return null;
    Debug.Log("迭代3");
}

GetEnumerator() 被编译为新类的 MoveNext() 方法块,结构如下:

// Token: 0x0600116F RID: 4463 RVA: 0x00059D78 File Offset: 0x00058178
.method public final hidebysig newslot virtual 
    instance bool MoveNext () cil managed 
{
    // Header Size: 12 bytes
    // Code Size: 133 (0x85) bytes
    // LocalVarSig Token: 0x1100007D RID: 125
    .maxstack 2
    .locals init (
        [0] uint32
    )
    /* 0x00058184 02           */ IL_0000: ldarg.0
    /* 0x00058185 7B790C0004   */ IL_0001: ldfld     int32 TestEnumerator/'<GetEnumerator>c__Iterator0'::$PC
    /* 0x0005818A 0A           */ IL_0006: stloc.0
    /* 0x0005818B 02           */ IL_0007: ldarg.0
    /* 0x0005818C 15           */ IL_0008: ldc.i4.m1
    /* 0x0005818D 7D790C0004   */ IL_0009: stfld     int32 TestEnumerator/'<GetEnumerator>c__Iterator0'::$PC
    /* 0x00058192 06           */ IL_000E: ldloc.0
    /* 0x00058193 4503000000050000002B00000050000000 */ IL_000F: switch    (IL_0025, IL_004B, IL_0070)
    /* 0x000581A4 385C000000   */ IL_0020: br        IL_0081
    /* 0x000581A9 00           */ IL_0025: nop
    /* 0x000581AA 7230AE0070   */ IL_0026: ldstr     "迭代1"
    /* 0x000581AF 28F700000A   */ IL_002B: call      void [UnityEngine.CoreModule]UnityEngine.Debug::Log(object)
    /* 0x000581B4 02           */ IL_0030: ldarg.0
    /* 0x000581B5 14           */ IL_0031: ldnull
    /* 0x000581B6 7D770C0004   */ IL_0032: stfld     object TestEnumerator/'<GetEnumerator>c__Iterator0'::$current
    /* 0x000581BB 02           */ IL_0037: ldarg.0
    /* 0x000581BC 7B780C0004   */ IL_0038: ldfld     bool TestEnumerator/'<GetEnumerator>c__Iterator0'::$disposing
    /* 0x000581C1 2D07         */ IL_003D: brtrue.s  IL_0046
    /* 0x000581C3 02           */ IL_003F: ldarg.0
    /* 0x000581C4 17           */ IL_0040: ldc.i4.1
    /* 0x000581C5 7D790C0004   */ IL_0041: stfld     int32 TestEnumerator/'<GetEnumerator>c__Iterator0'::$PC
    /* 0x000581CA 3838000000   */ IL_0046: br        IL_0083
    /* 0x000581CF 7238AE0070   */ IL_004B: ldstr     "迭代2"
    /* 0x000581D4 28F700000A   */ IL_0050: call      void [UnityEngine.CoreModule]UnityEngine.Debug::Log(object)
    /* 0x000581D9 02           */ IL_0055: ldarg.0
    /* 0x000581DA 14           */ IL_0056: ldnull
    /* 0x000581DB 7D770C0004   */ IL_0057: stfld     object TestEnumerator/'<GetEnumerator>c__Iterator0'::$current
    /* 0x000581E0 02           */ IL_005C: ldarg.0
    /* 0x000581E1 7B780C0004   */ IL_005D: ldfld     bool TestEnumerator/'<GetEnumerator>c__Iterator0'::$disposing
    /* 0x000581E6 2D07         */ IL_0062: brtrue.s  IL_006B
    /* 0x000581E8 02           */ IL_0064: ldarg.0
    /* 0x000581E9 18           */ IL_0065: ldc.i4.2
    /* 0x000581EA 7D790C0004   */ IL_0066: stfld     int32 TestEnumerator/'<GetEnumerator>c__Iterator0'::$PC
    /* 0x000581EF 3813000000   */ IL_006B: br        IL_0083
    /* 0x000581F4 7240AE0070   */ IL_0070: ldstr     "迭代3"
    /* 0x000581F9 28F700000A   */ IL_0075: call      void [UnityEngine.CoreModule]UnityEngine.Debug::Log(object)
    /* 0x000581FE 02           */ IL_007A: ldarg.0
    /* 0x000581FF 15           */ IL_007B: ldc.i4.m1
    /* 0x00058200 7D790C0004   */ IL_007C: stfld     int32 TestEnumerator/'<GetEnumerator>c__Iterator0'::$PC
    /* 0x00058205 16           */ IL_0081: ldc.i4.0
    /* 0x00058206 2A           */ IL_0082: ret
    /* 0x00058207 17           */ IL_0083: ldc.i4.1
    /* 0x00058208 2A           */ IL_0084: ret
} // end of method '<GetEnumerator>c__Iterator0'::MoveNext

其中 $PC 字段就代表了当前细分代码块进度,然后在 MoveNext() 方法调用时,通过一个 IL_000F: switch (IL_0025, IL_004B, IL_0070) —— 也就是 Switch case 判断执行当前应该执行哪个细分代码块了。

执行完细分代码块后,将 yield 返回值赋值给 $current :

/* 0x000581B4 02           */ IL_0030: ldarg.0
/* 0x000581B5 14           */ IL_0031: ldnull
/* 0x000581B6 7D770C0004   */ IL_0032: stfld     object TestEnumerator/'<GetEnumerator>c__Iterator0'::$current

并将 $PC 替换为对应的 index:

/* 0x000581C3 02           */ IL_003F: ldarg.0
/* 0x000581C4 17           */ IL_0040: ldc.i4.1
/* 0x000581C5 7D790C0004   */ IL_0041: stfld     int32 TestEnumerator/'<GetEnumerator>c__Iterator0'::$PC

执行完毕后,最后通过指令跳转返回 01 作为 bool 变量,用于调用者判断是否已结束:

/* 0x00058205 16           */ IL_0081: ldc.i4.0
/* 0x00058206 2A           */ IL_0082: ret
/* 0x00058207 17           */ IL_0083: ldc.i4.1
/* 0x00058208 2A           */ IL_0084: ret

原来指令集中 bool 确实是作为 int 处理的?难怪之前在研究 Bool字节对齐 字节对齐的时候,通过 Marshal.SizeOf 取出来是 4 个字节。

# 实现自己的协程

有了上述基本原理理解之后,实现一个自己的 协程 也就是非常简单的事了。

这里以一个简单的多线程为例:

public class TestEnumerator
{
    private SynchronizationContext Current;
    public TestEnumerator()
    {
        Current = SynchronizationContext.Current;
    }
    public void DoTest()
    {
        IEnumerator em = TestMyEnumerator();
        ThreadPool.QueueUserWorkItem((x) =>
        {
            int wait = 1;
            while (em.MoveNext())
            {
                wait = 1;
                if (em.Current == null)
                {
                    Thread.Sleep(wait * 1000);
                }
                else if (int.TryParse(em.Current.ToString(), out wait))
                {
                    Thread.Sleep(wait * 1000);
                }
                else
                {
                    Log("发现未知迭代对象:" + em.Current);
                }
            }
        });
    }
    public IEnumerator TestMyEnumerator()
    {
        Log("迭代1,Time:" + DateTime.Now);
        yield return null;
        Log("迭代2,Time:" + DateTime.Now);
        yield return 2;
        Log("迭代3,Time:" + DateTime.Now);
        yield return new WaitForEndOfFrame();
    }
    private void Log(string str)
    {
        Current.Post((x) => Debug.Log(str), null);
    }
}

如上图所示,我们自己实现的 协程 ,只处理了 yield return nullyield return 数字 的情况,若为 null 等待一秒,若为 数字 则等待指定数字秒数。

根据打印时间判断,逻辑是正确生效了的。

同时如果还有其它等待逻辑,则增加判断即可,如 Unity 提供的各种协程等待方法: WaitForEndOfFrameWaitForSecondsWaitForFixedUpdate 等等,我怀疑就是这样处理的,不过由于 Unity StartCoroutine 协程最终实际生效是调用的 extern 函数,C# 反编译跟不进去了:

[MethodImpl(MethodImplOptions.InternalCall)]
private extern Coroutine StartCoroutineManaged2(IEnumerator enumerator);

因此真实逻辑就不得而知了,只能猜测大约是使用类似方式,毕竟迭代器逻辑就这样。

# Unity 协程执行时机

官方文档 ExecutionOrder 有对应的流程图,其正常等待位于 Update 之后, LateUpdate 之前。

例外的有比如: WaitForFixedUpdate 位于 Update 之前, WaitForEndOfFrame 位于一帧最后。

# 补充

(2023.2.24)

# 调用了类成员函数的协程

例如:

public class TestEnumerator
{
    private int _counter;
    public IEnumerator GetEnumerator()
    {
        Debug.Log("迭代");
        _counter++;
        yield return null;
    }
}

为协程编译的新类字段中会对本类产生引用:

// Fields
.field private int32 '<>1__state'
.field private object '<>2__current'
.field public class TestEnumerator '<>4__this'

初始化的地方是原本主类的 GetEnumerator 方法(实例化迭代类时将自身实例传进去):

// Fields
.field private int32 _counter
// Methods
.method public hidebysig 
    instance class [mscorlib]System.Collections.IEnumerator GetEnumerator () cil managed 
{
    .custom instance void [mscorlib]System.Runtime.CompilerServices.IteratorStateMachineAttribute::.ctor(class [mscorlib]System.Type) = (
        01 00 22 54 65 73 74 45 6e 75 6d 65 72 61 74 6f
        72 2b 3c 47 65 74 45 6e 75 6d 65 72 61 74 6f 72
        3e 64 5f 5f 31 00 00
    )
    // Method begins at RVA 0x2151
    // Header size: 1
    // Code size: 14 (0xe)
    .maxstack 8
    IL_0000: ldc.i4.0
    IL_0001: newobj instance void TestEnumerator/'<GetEnumerator>d__1'::.ctor(int32)
    IL_0006: dup
    IL_0007: ldarg.0
    IL_0008: stfld class TestEnumerator TestEnumerator/'<GetEnumerator>d__1'::'<>4__this'
    IL_000d: ret
} // end of method TestEnumerator::GetEnumerator

注:后续反编译发现就算协程没有调用主类字段,初始化内嵌类时依然会将主类实例出传递进去 (为什么上面的记录似乎没有?)。

# 总结

  • 反编译 IL 代码显示为自动创建一个实现了 IEnumerator 的新类,调用存在 yield 的原方法自动 newObj 这个新类
    • 协程的局部变量会被编译为新类的成员变量
  • 根据 yield 原方法逻辑会被编译成一段一段更细粒度的的代码块,并挪到 MoveNext 方法中
  • 维护一个下标,每次调用返回的 IEnumerator MoveNext() 就往下挪一位
  • 等待逻辑后再调用下一个小方法块,可以由调用者判断 Current 值进行操作。因此如果是主线程调用,其中有死循环就会卡死主线程
  • 协程本质上依然只是主线程上的一个调用,消耗的是主线程的时间,同时每个协程方法会被编译成一个新类,因此也不能滥用,顶多某些时候可以更方便地分帧处理