协程学习小结:协程能给编程带来什么样的改变?

第一次接触协程的概念是在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

0

Leave a Reply