回顾Unity的Coroutine

虽然现在的实习已经不再使用Unity了,但是回过头看,至少在异步编程这方面,还是有了不少体会,也更好的理解了Unity的Coroutine的设计。

之前提到了使用UnityWebRequest来跟网关进行通信,因为是个典型的异步场景所以使用了Coroutine。在C#里面,一般都是用Task<T>来表达异步的语义,这一点和JavaScript类似,而且也可以很方便的用上async/await的语法糖。但是Unity的主流用法还是Coroutine,很大程度上还是因为Coroutine绑定在了Monobehaviour类型的生命周期上。

其实Coroutine和Task还是有点相似的,yield returnawait看起来也有点像。只不过,async/await的返回值是函数的返回值本身,而Unity的yield return返回的则是控制流。使用IEnumerator来表示一个持续很多个时间切片单位的过程。每次yield都是把控制权交还给Unity引擎,然后等待下次切回来。

在高版本有async/await语法糖的C#里面,Coroutine和Task也可以互换,例如使用这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
public static IEnumerator AsIEnumerator([NotNull] this Task task)
{
while (!task.IsCompleted)
{
yield return null;
}

if (task.IsFaulted)
{
throw task.Exception ?? new Exception("Null exception in faulted task while converting to IEnumerator");
}
}

​ 在我的实习过程中,也应用了这样的互相转换,大概是以下的逻辑:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
private void InitRemoteCall()
{
...
IEnumerator LoadingDisplayStatus()
{
while (!loadCompleted)
{
StatusText = "Loading,\nplease wait";
yield return new WaitForSeconds(0.2f);
for (var _ = 0; _ < 3; _++)
{
if (loadCompleted)
{
break;
}
StatusText = StatusText + ".";
yield return new WaitForSeconds(0.2f);
}
}
}

IEnumerator HaltingEventListener(IEnumerator eventToHalt)
{
while (true)
{
if (_halted)
{
StopCoroutine(eventToHalt);
... // 回滚场景并扫尾
break;
}

if (loadCompleted)
{
... // 完成渲染
break;
}

yield return null;
}
}

...

switch (_grpcConfig)
{
case IGrpcConfig.OneWayCall():
{
async Task OneWayCallInnerTask()
{
try
{
... // 准备渲染前的任务,并且构造一个名叫 streaming的 GrpcClient 实例
var scene = await _grpcClient.SetAddress(remoteAddress).FetchEntireScene(filename);
... // 一次性获得返回值后完成渲染
}
catch (Exception e)
{
...
}
}

var coroutine = OneWayCallInnerTask().AsIEnumerator();

StartCoroutine(LoadingDisplayStatus());
StartCoroutine(coroutine);
StartCoroutine(HaltingEventListener(coroutine));
break;
}
case IGrpcConfig.Streaming(var size):
{
async Task StreamingInnerTask(int fragSize)
{
... // 准备渲染前的任务,并且构造一个名叫 streaming的 GrpcClient 实例
while (await streaming.ResponseStream.MoveNext(CancellationToken.None))
{
... // 分步提取 streaming 的回应并且处理
}
... // 完成渲染工作
}

var coroutine = StreamingInnerTask(size).AsIEnumerator();

StartCoroutine(coroutine);
StartCoroutine(HaltingEventListener(coroutine));
break;
}
}
}

​ 这段代码声明一个叫LoadingDisplayStatus的Coroutine和一个叫HaltingEventListener的构造Coroutine的高阶函数,根据当前配置选择一次性下载整张地图或者开启一个streaming管道,并且把对应的处理过程从Task<T>转化为Coroutine,然后在开启下载过程的同时,也开启一个监听这个过程并且在下载被中断的时候停止任务清扫现场的Coroutine。

这也是一个试图把并发的处理结构化的例子,当然了,因为实习的环境是WebGL,所以没有使用到多线程,否则整个应用会直接卡死。

不过相比之下,Task比起Unity的Coroutine,提供了更完善的组合、链接和管理异步任务的机制,包括Cancellation Token这样的设计也让跨过不同协程/进程的任务调度更容易实现,是一个完整的异步框架。一般来说,C#的Task其实更接近JVM平台上高版本的CompletableFuture,或者比它更完善。相比之下,Coroutine就显得原始多了。

总的来说,在「延迟一个计算过程」这件事上,Coroutine和Task是相似的,得益于yield关键字,Coroutine也避免了某些语言里异步库不得不写成回调地狱的情况。实际上,yieldIEnumerator的组合也是一种很经典的异步模式:生成器模式。一般认为只要一个语言有了生成器模式就可以实现轻量级的异步支持了。

Coroutine最大的特点大概就是绑定在Monobehaviour上面这件事情了。这既是优点也是缺点,所以在一个Unity项目里,Coroutine和Task的用途分别也是蛮明显的。

  • 在处理游戏逻辑,比如动画、延迟等待、监听事件的时候,Coroutine是比较合适的选择
  • 在处理IO操作,例如远程调用或者下载资源的时候,Task提供了更接近通用.NET编程的体验

不过无论哪种情况,阻塞线程和线程安全都是需要注意的问题了。一般来说,在Task里面处理这些会相对简单一些,而在Unity的Coroutine里,就可能需要特别设计游戏的相关机制了。


回顾Unity的Coroutine
http://inori.moe/2023/05/25/review-unity-coroutines/
作者
inori
发布于
2023年5月25日
许可协议