原理对比
Coroutine | UniTask | |
本质 | IEnumerator 的协作调度器 | async/await 状态机(IAsyncStateMachine) |
调度方式 | Unity 内部调用 MoveNext() | 自建 PlayerLoopRunner 控制状态推进 |
内存管理 | 引用类型,频繁分配 GC | 结构体 UniTask,低 GC 压力 |
多线程支持 | 主线程限制 | 可结合多线程(但默认仍在主线程) |
工具组合能力 | 弱 | 强(如 WhenAll, WithCancellation) |
调用方式
协程
1.不使用StartCoroutine调用时,可通过编译,但是无法启动,协程进不去
2.使用StartCoroutine可正确执行协程逻辑,正常执行等待,对当前函数体无要求
3.可使用yield return等待协程执行完毕,需要当前函数体有IEnumerator关键字标识
4.可使用await关键字等待协程执行完毕,需要当前函数体有async关键字标识
示例代码如下
void CorTest()
{Test();Debug.Log("Coroutine 3");StartCoroutine(Test());Debug.Log("Coroutine 4");
}IEnumerator Test()
{Debug.Log("Coroutine 1");yield return new WaitForSeconds(1.1f);Debug.Log("Coroutine 2");
}
当调用CorTest后,输出结果为
可以看到,先输出了"Coroutine 3",而没有输出"Coroutine 1",表示没有使用StartCoroutine启动时,协程是进不去的。使用了StartCoroutine后,"Coroutine 2"在"Coroutine 1"及"Coroutine 4"一秒后输出。await关键字情况同下面UniTask调用
UniTask
1.UniTask无论是否使用await关键字,都可以正确进入逻辑,正常执行等待,对当前函数体无要求
2.可使用await等待UniTask执行完毕,需要当前函数体有async关键字标识
示例代码如下
async void TaskTest()
{Test2();Debug.Log("UniTask 3");await Test2();Debug.Log("UniTask 4");
}async UniTask Test2()
{Debug.Log("UniTask 1");await UniTask.Delay(1100);Debug.Log("UniTask 2");
}
当调用TaskTest后,输出结果为
可以看到,两次调用Test2均正常进入,且正常执行了等待逻辑,两次"UniTask 2"输出均在"UniTask 1"后,而"UniTask 4"输出也是在"UniTask 2"后执行的。函数体如果没有async关键字时,内部是无法使用await的,编译不通过。
性能测试
yield return null VS UniTask.Yield()
测试代码
public int times = 1000;void CorProTest()
{StartCoroutine(CorProEnum());
}IEnumerator CorProEnum()
{for (int i = 0; i < times; i++){yield return null;}
}void UniTaskProTest()
{UniTaskProTask();
}async UniTask UniTaskProTask()
{for (int i = 0; i < times; i++){await UniTask.Yield();}
}
由于无法抓取一段时间内的纯Profiler数据,所以只取一帧的数据,每帧数据都是一致的。
可以看到,两个对GC都没有影响,因为协程本身并没有新建对象,所以不存在分配内存。可以理解成等价的。
yield return new WaitForSeconds VS UniTask.Delay
测试代码
public int times = 1000;void CorProTest()
{StartCoroutine(CorProEnum());
}IEnumerator CorProEnum()
{for (int i = 0; i < times; i++){yield return new WaitForSeconds(0.01f);}
}void UniTaskProTest()
{UniTaskProTask();
}async UniTask UniTaskProTask()
{for (int i = 0; i < times; i++){await UniTask.Delay(10);}
}
同上,抓取某一帧的数据
可以看到,调用yield return new WaitForSeconds(0.01f)时,有20B的内存分配,这是因为创建了引用对象WaitForSeconds,所以必定会有内存分配。调用await UniTask.Delay(10)没有内存分配,是因为UniTask内部使用的是结构体,而不是类。
yield return new WaitUntil VS UniTask.WaitUntil
测试代码
public int times = 1000;void CorProTest()
{StartCoroutine(CorProEnum());
}IEnumerator CorProEnum()
{bool value = true;for (int i = 0; i < times; i++){yield return new WaitUntil(() => value);}
}void UniTaskProTest()
{UniTaskProTask();
}async UniTask UniTaskProTask()
{bool value = true;for (int i = 0; i < times; i++){await UniTask.WaitUntil(() => value);}
}
同上,抓取某一帧数据
可以看到 ,调用yield return new WaitUntil时,有24B的内存分配,这是因为创建了引用对象WaitUntil,所以必定会有内存分配。调用await UniTask.WaitUntil没有内存分配,是因为UniTask内部使用的是结构体,而不是类。
总结
测试了这三种常用的用法,可以看到,协程除了null没有GC产生(因为没有创建对象)外,其他两种用户均产生了GC,只是量比较小,而UniTask三种用法都没有GC产生。
如果只考虑GC方面的差异,在项目使用过程中,如果量比较大,使用比较频繁,建议使用UniTask。而对于一般用量来讲,差距可以忽略不计。而GC是可以使用对象池来优化的,可以一定程度上降低GC的分配。对象池参考另一篇博客从CPU缓存出发对引用池进行优化。
然而,最终选择使用哪一种,需要结合其他情况考虑,协程使用起来比较方便,而UniTask也有一些比较好的功能,比如UniTask支持带返回值的异步,封装了多任务同时进行、等待以及其他功能。