第一次接触协程的概念是在18年学习Unity的时候。对这个概念陌生的我误以为它就是Unity的低配版多线程(当时Unity不支持多线程,现在不知道怎么样了),因此对协程这种设计感到怪异且难用。直到昨天翻看c++20发现其新增了对协程的支持,我这才打算探索一番:协程是什么?它解决了编程中的哪些痛点?
协程是什么
相比子函数只有起始处一个入口点、一次执行完返回的特点,协程是一种有多个入口、可多次返回值的子函数。因此它能够“协同其他函数”自己执行一半,主动将控制权让出(yield),待下一个函数让出控制权时,继续执行(resume)。
以C#的生成器模型为例:
public class PowersOf2
{
static void Main()
{
// Display powers of 2 up to the exponent of 8:
foreach (int i in Power(2, 8))
{
Console.Write("{0} ", i);
}
}
public static System.Collections.Generic.IEnumerable Power(int number, int exponent)
{
int result = 1;
for (int i = 0; i < exponent; i++)
{
result = result * number;
yield return result;
}
}
// Output: 2 4 8 16 32 64 128 256
}
在foreach
中,Power
并非一次返回了所有的平方数。控制权初次从Main
转移到Power
函数并将参数传递,Power
计算出一个平方数后将控制权让出给Main,执行Console.Write
输出,接着foreach
会通过调用Power
返回的IEnumerable<int>
接口让控制权再次进入Power
计算下一个平方数,如此重复直到Power
结束为止。这就是“多个入口、多次返回值”。
协程的应用
事件驱动程序中的麻烦
Unity manual中这样引入协程:
调用函数时,函数将运行到完成状态,然后返回。这实际上意味着在函数中发生的任何动作都必须在单帧更新内发生;函数调用不能用于包含程序性动画或随时间推移的一系列事件。例如,假设需要逐渐减少对象的 Alpha(不透明度)值,直至对象变得完全不可见。
void Fade() { for (float ft = 1f; ft >= 0; ft -= 0.1f) { Color c = renderer.material.color; c.a = ft; renderer.material.color = c; } }
就目前而言,Fade 函数不会产生期望的效果。为了使淡入淡出过程可见,必须通过一系列帧降低 Alpha 以显示正在渲染的中间值。但是,该函数将完全在单个帧更新中执行。这种情况下,永远不会看到中间值,对象会立即消失。
在Unity中,引擎在每一帧通过update
事件将控制权转移给开发者,此时想要编写如上Fade功能就得自己在全局维护alpha变量、在事件中对alpha递减并赋值给相应对象,非常麻烦。
这种麻烦在事件驱动的程序中经常出现。例如(在一些比较落后的桌面UI框架中),用户点击删除一张图片,你希望冒出一个红色删除按钮,让用户确认删除。此时不得不将“删除”动作写在red_button_click
事件中,而且还需要维护一个全局变量picture_will_remove
来得知要删除的是哪张图片,类似这样:
Picture picture_will_remove;
void picture1_click(){
picture_will_remove=picture1;
}
void red_button_click(){
delete_picture(picture_will_remove);
}
这种麻烦的本质是,原本连续的逻辑被打散,程序员不得不使用全局变量去维护这串流程的状态。这种做法不仅不符合封装的理念(暴露了不必要的变量),而且还容易出错、增加调试的难度。
如果用协程来封装,代码就清晰的多了:
//使用StartCoroutine(Fade())启动淡出
IEnumerator Fade()
{
for (float ft = 1f; ft >= 0; ft -= 0.1f)
{
/*
将ft赋值给相应对象的操作
*/
yield return new WaitForSeconds(.1f);//暂停0.1秒
}
}
还可以传入参数,省去维护状态的麻烦。例如,指定一个对象淡出屏幕:
//调用:StartCoroutine(Fade(something));
IEnumerator Fade(GameObject obj)
{
for (float ft = 1f; ft >= 0; ft -= 0.1f)
{
/*
将ft赋值给obj
*/
yield return new WaitForSeconds(.1f);//暂停0.1秒
}
}
假如我上面说的落后的UI框架也能使用协程的思想,那么同样的逻辑代码将是这样:
//伪代码
void picture1_click(){
remove_picture(picture1);
}
void remove_picture(Picture pic){
WaitForClick(red_button);
delete_picture(pic);
}
程序开发的一大矛盾是,你要用控制流去完成逻辑流。也就是说,你要用指令的执行来完成逻辑链条的前因后果。但是问题是中间有些过程是不能立即得到结果的,程序为了等结果就会阻塞。这种情况多见于一些io操作。为了提升效率,我们可以使用异步的api,通过回调/通知函数来响应操作结果,同时接着执行下一轮的逻辑。异步回调/通知的问题在于,它把原本统一的逻辑流拆开成了几个阶段,这样控制流和逻辑流就不等价了。为了保证逻辑数据的传递,需要自己来维护状态,阅读起来也比较头疼。
多任务调度
在线程这种抢占式多任务的调度方式中,人们发明了各种锁来解决不同线程使用同一个资源的冲突。而协程这种协作式调度方式则没有锁的困扰:程序知道何时执行哪项任务,哪些代码能够拆开执行。
协作式多任务不需要抢占式多任务的时间片分配,时钟滴答,抢占式上下文切换等一系列开销,根据实现方式的不同,任务切换时甚至可能不需要保存现场。另外,协作式多任务中的每项任务都不需要担心被抢占,因而具有更低的通信和同步开销。而相比抢占式多任务的透明性,协作式多任务反而提供了更精细和可控的调度。
参考&引用
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/yield
https://docs.unity3d.com/cn/2019.4/Manual/Coroutines.html
https://www.zhihu.com/question/50185085/answer/183463734
https://zh.wikipedia.org/wiki/%E5%8D%8F%E7%A8%8B
https://www.zhihu.com/question/50185085/answer/1290717605
真鸡儿复杂,快来用Go
草 go你太美