# 前言
这两天又研究了下协程,之前虽然用过,也稍微想过,但是并没有深入研究,这次就想把流程仔细走一遍。
# 原理
其实也是一个思维方式问题,协程的原理其实很简单:利用迭代器
# 迭代器
我们都知道,实现了 IEnumerable
接口、或者说拥有 IEnumerator GetEnumerator()
的类可以被 foreach
所迭代。
其重点就在于 IEnumerator
这个接口。
数组
、 List
、 字典
等数据结构,迭代时都是通过返回自己创建的一个实现了 IEnumerator
的 Warp
类进行。
为什么一个方法,标记返回 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 |
执行完毕后,最后通过指令跳转返回 0
或 1
作为 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 null
及 yield return 数字
的情况,若为 null
等待一秒,若为 数字
则等待指定数字秒数。
根据打印时间判断,逻辑是正确生效了的。
同时如果还有其它等待逻辑,则增加判断即可,如 Unity
提供的各种协程等待方法: WaitForEndOfFrame
、 WaitForSeconds
、 WaitForFixedUpdate
等等,我怀疑就是这样处理的,不过由于 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
值进行操作。因此如果是主线程调用,其中有死循环就会卡死主线程 - 协程本质上依然只是主线程上的一个调用,消耗的是主线程的时间,同时每个协程方法会被编译成一个新类,因此也不能滥用,顶多某些时候可以更方便地分帧处理