Unity 中的异步

最近在使用Unity的过程中,涉及了一些高并发状态下的异步调用出现的各种问题,于是对于Unity中的异步产生了一些疑问,不过最终,首先抛出一个最重要的结论,异步不等于多线程。

首先我们要明白一点,unity中的C#与我们平时所说的C#不同,我们平时所说的C# 他的运行虚拟机是.Net Framework,而Unity的是Mono VM,所以二者的语法看起来相同,但是实际的原理可能并不相同,比如本文后面要理解的await,而且Mono对C#的语法支持只到2.8,有些新的C#语法并不能使用。具体的二者对比以及IL,IL2CPP是什么,可以参考这篇文章:Unity将来时:IL2CPP

其实一开始看到这两个关键字的时候,身为一个有点Web前端开发经验的人,我第一反应就是和JS进行比较,在JS中,这两个关键字是在JS的主线程上起了新的协程。

那么在dotnet中和unity中是不是这样?它们是协程吗?是主线程的协程吗?

如果你不理解协程是什么,可以自己搜索一下,我用比较官方的语言简单(好吧,看起来也不是很简单)解释下进程,线程,协程之间的关系,就是:

  • 进程是除CPU之外的计算机资源调度的单位,你申请一块内存,一台设备,都是以进程为单位的,所以进程之间的交换信息需要特殊的处理,因为它们之间是完全隔离的,这种过程叫做进程间通信。

  • 当进程申请好资源之后,它是是线程的方式去申请CPU资源的,理论上,单核CPU一个进程在同一时刻只有一个线程在运行,所谓的多线程,指的是一段时间内CPU可能在通过某种调度算法选择某个线程进行执行,这种切换是操作系统自动的,所以需要系统自动维护之前执行的线程的上下文,如上一次执行到哪一行了。但是统一进程的多线程是共享内存的,所以没有所谓的线程间通信。

  • 上面那种切换需要系统帮忙维护线程的上下文,消耗比较大,所以就有了一种线程内部自己维护上下文然后去切换执行内容的方式,这个东西就叫做协程。比如现在CPU把时间片分配给我了,我还没用完,但是当前任务因为需要等待什么东西被阻塞了,我又不想让出时间片,那我就内部切换。这个过程不需要系统参与。

协程

关键词 IEnumerator

这个关键词不是在Unity中特有,unity也是来自c#,所以找一个c#的例子来理解比较合适。首先看看IEnumerator的定义:

1
2
3
4
5
6
public interface IEnumerator
{
bool MoveNext();
void Reset();
Object Current{get;}
}

从定义可以理解,一个迭代器,三个基本的操作:Current/MoveNext/Reset, 这儿简单说一下其操作的过程。在常见的集合中,我们使用foreach这样的枚举操作的时候,最开始,枚举数被定为在集合的第一个元素前面,Reset操作就是将枚举数返回到此位置。

迭代器在执行迭代的时候,首先会执行一个 MoveNext, 如果返回true,说明下一个位置有对象,然后此时将Current设置为下一个对象,这时候的Current就指向了下一个对象。当然c#是如何将这个IEnumrator编译成一个对象示例来执行,下面会讲解到。

关键词 Yield

c#中的yield关键词,后面有两种基本的表达式:

1
2
yield return <expresion>
yiled break

yield break就是跳出协程的操作,一般用在报错或者需要退出协程的地方。

yield return是用的比较多的表达式,具体的expresion可以以下几个常见的示例:

1
2
3
4
WWW : 常见的web操作,在每帧末调用,会检查isDone/isError,如果true,则 call MoveNext
WaitForSeconds: 检测间隔时间是否到了,返回true, 则call MoveNext
null: 直接 call MoveNext
WaitForEndOfFrame: 在渲染之后调用, call MoveNext

好了,有了对几个关键词的理解,接下来我们看看c#编译器是如何把我们写的协程调用编译生成的。

C#对协程的编译结果

1
2
3
4
5
6
7
8
9
10
class Test
{
static IEnumerator GetCounter()
{
for(int count = 0; count < 10; count++)
{
yiled return count;
}
}
}

编译成C++之后的结果是:

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
internal class Test 
{
// GetCounter获得结果就是返回一个实例对象
private static IEnumerator GetCounter()
{
return new <GetCounter>d__0(0);
}

// Nested type automatically created by the compiler to implement the iterator
[CompilerGenerated]
private sealed class <GetCounter>d__0 : IEnumerator<object>, IEnumerator, IDisposable
{
// Fields: there'll always be a "state" and "current", but the "count"
// comes from the local variable in our iterator block.
private int <>1__state;
private object <>2__current;
public int <count>5__1;

[DebuggerHidden]
public <GetCounter>d__0(int <>1__state)
{
//初始状态设置
this.<>1__state = <>1__state;
}

// Almost all of the real work happens here
//类似于一个状态机,通过这个状态的切换,可以将整个迭代器执行过程中的堆栈等环境信息共享和保存
private bool MoveNext()
{
switch (this.<>1__state)
{
case 0:
this.<>1__state = -1;
this.<count>5__1 = 0;
while (this.<count>5__1 < 10) //这里针对循环处理
{
this.<>2__current = this.<count>5__1;
this.<>1__state = 1;
return true;
Label_004B:
this.<>1__state = -1;
this.<count>5__1++;
}
break;

case 1:
goto Label_004B;
}
return false;
}

[DebuggerHidden]
void IEnumerator.Reset()
{
throw new NotSupportedException();
}

void IDisposable.Dispose()
{
}

object IEnumerator<object>.Current
{
[DebuggerHidden]
get
{
return this.<>2__current;
}
}

object IEnumerator.Current
{
[DebuggerHidden]
get
{
return this.<>2__current;
}
}
}
}

所以我们在执行开启一个协程的时候,其本质就是返回一个迭代器的实例,然后在主线程中,每次update的时候,都会更新这个实例,判断其是否执行MoveNext的操作,如果可以执行(比如文件下载完成),则执行一次MoveNext,将下一个对象赋值给Current(MoveNext需要返回为true, 如果为false表明迭代执行完成了)。

通过这儿,可以得到一个结论,协程并不是多线程的,其本质还是在Unity的主线程中执行,每次update的时候都会触发是否执行MoveNext。

Unity Coroutine 官方文档

这个是unity官方对于它的协程的文档,有这么几个关键点:

  • 在 yield 语句之间可以正确保留任何变量或参数。

  • 默认情况下,协程将在执行 yield 后的帧上恢复,但也可以使用 WaitForSeconds 来引入时间延迟

  • 游戏中的许多任务需要定期执行,最容易想到的方法是将任务包含在 Update 函数中。但是,通常情况下,每秒将多次调用该函数。不需要以这样的频繁程度重复任务时,可以将其放在协程中来进行定期更新,而不是每一帧都更新

  • 协程类似于一种生命周期函数,在固定的时间调用,不能理解为操作系统的协程

  • 一旦一个协程所在的gameobject变为inactive了,协程就会被中断,而且即使再次变为active,协程也不会继续

可以使用 StopCoroutineStopAllCoroutines 来停止协程。 当用 SetActive(false) 禁用某个协程所附加到的游戏对象时,该协程也将停止。调用 Destroy(example)(其中 example 是一个 MonoBehaviour 实例)会立即触发 OnDisable,并会处理协程,从而有效地停止协程。最后,在帧的末尾调用 OnDestroy

通过在 MonoBehaviour 实例上将 enabled 设置为 false 来禁用 MonoBehaviour 时,协程不会停止。

从多角度分析Unity中的协程

Task + async/await

C#中的Task

https://docs.microsoft.com/zh-cn/dotnet/standard/asynchronous-programming-patterns/

https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.task?view=net-6.0

简单来说,Task只是表明了这个东西是异步的,他并不一定会另起一个线程去做

而且这个Task无论是使用还是内部状态的维护方式,都很像JS的Promise

await与Task配合使用,await的是Task,而async则是表明这个函数里面会有await。

Unity中的Task

相比C#的Task,Unity中的Task有一个特性是,await前后的上下文都是主线程的上下文,保证可以调用到Unity的API,所以也带来了一些问题

https://zhuanlan.zhihu.com/p/86168785