协程

协程可以说是类似进程和线程的一种概念,代表一条执行序列,但又有所区别。协程所包含的执行序列无法供系统内核调度,反而是以特殊API函数调用的方式来协作完成该执行序列。一般高级程序设计语言都会以某种方式提供协程的概念,然而,上手就直接讲协程云云,难免流于表面,这里先从进程、线程、C语言入手。


用户态多线程

基本概念:

  1. C语言开发的程序或OS内核,都是直接编译成机器汇编指令,其执行上下文依赖于机器(上下文包括栈空间、CPU寄存器的值等)
  2. 进程是系统资源分配的基本单位,比如虚拟内存、文件描述符之类的,每一个进程都会拥有一个主线程
  3. 线程是CPU调度的基本单位,每一条线程都有自己的执行上下文
  4. 一般操作系统调度线程的原理:通过定时器中断从硬件层次切断当前线程的执行,并在中断响应函数中做三步操作,一是保存当前线程的执行上下文,二是载入其他线程的执行上下文,三是通过ret指令返回到新线程中去执行

系统内核对于线程的调度依赖底层硬件中断,线程内任意两条执行指令之间都可能被中断打断,这种行为对于用户态是无感知的,用户甚至不知道在自己线程执行过程是否被打断过。(也就是说,即使某条线程写了for死循环,通过OS的中断调度,这条线程依然无法百分百占用CPU)

所有计算机资源都归OS内核管理,在用户态进程空间中,程序无法控制硬件中断,自然无法天然的中断某条线程的执行,因此,当我们想要实现多条用户态线程对应一条内核态线程的时候,这多条用户态线程虽然拥有各自的上下文,却只能通过协同的方式进行切换,这就是C语言层次的协程,也称为用户态多线程。

设计实现协程,我们需要完成如下任务:

  1. 构造多个执行上下文(用于支持执行序列)
  2. 当前执行流的让出操作(yield)
  3. 唤醒其他执行流的操作(resume)

用户态如何构造多个执行上下文?关于这个问题,网上已经有多种实现:基于ucontext的协程(makecontext设置新的上下文和新的栈空间)利用pthread创建新的上下文和新的栈空间利用信号处理函数构造新的上下文和新的栈空间

另外,关于C/C++的协程实现,腾讯开源了一个协程库libco,还有语法糖式的有趣实现ProtoThread

C语言层次的协程实现,网上已经有非常多的资料说明,这里不赘述,相反,本文想深入说明的,是抽象层次的协程。一条协程说白了就是拥有一些内部状态(执行上下文)和相关操作(yield和resume之类的)。通过高级语言的封装,我们可以模拟并提供协程的语义。


C语言迭代器

假设我有一个远程编译的服务端任务,需要分3个步骤来完成,每个步骤执行各自的特殊内容(互相不一定一样),需要按顺序执行:

  1. 获取参数
  2. 执行编译
  3. 上传编译结果

最简单的做法是编写三个函数,按顺序去调用:

void getPara(){
    printf("In getPara Now...\n");
    // do something
}
void doCompile(){
    printf("In doCompile Now...\n");
    // do something
}
void uploadResult(){
    printf("In uploadResult Now...\n");
    // do something
}
void remoteCompile(){
    getPara();
    doCompile();
    uploadResult();
}

此时,只要调用remoteCompile,便可以一次性执行完三个步骤。好,那有没有办法,让使用者决定何时去分段的进行这个流程,而不必知道流程内部的细节(使用者不直接调用getPara doCompile uploadResult这些接口,而是调用一个统一的接口),既然不让使用者知道内部细节,那么就应该要有内部变量去记录当前执行到了哪个环节,因此可实现如下:

static int procCnt = 0;
bool doNext(){
    switch(procCnt){
        case 0:
            getPara();
            procCnt++;
            return true;
        case 1:
            doCompile();
            procCnt++;
            return true;
        case 2:
            uploadResult();
            procCnt++;
            return false;
        default:
            // error, now the process is finish
            fprintf(stderr, "The process has finished\n");
            return false;
    }
}
// for user:
while(doNext());

使用者直接循环调用三次doNext()函数,即可执行三个步骤,最后一个步骤执行完返回false,标志着该流程的步骤已经走完。这就是一个协程:

  1. 有这样一个可以人为分段的执行序列
  2. 协程内部有记录当前协程执行的状态(执行上下文)
  3. 使用者无需知道协程内部细节,而是调用一个统一的接口来执行协程的一个片段

如果你熟悉迭代器或者状态机,可能会发现它们其实是一个思想!

按照这个概念,我们发现,常用文件IO操作也有类似的概念:

  1. read系统调用,类似这里的doNext函数,用于驱动操作的执行
  2. 每次调用read会返回读到的字节数,类似doNext返回true,当文件读完了,read系统调用就返回0来告诉我们触发了EOF,这类似doNext返回了false
  3. 内核中文件表记录的文件读取位置,类似这里的静态变量procCnt。也就是这一条读序列的内部状态

状态机无处不在。(闭包、迭代器和协程都只是状态机一定程度上的抽象)

对于用C语言迭代器抽象出来的协程,这里思考一个问题:C语言中的流程步骤,我们是通过每个步骤用一个函数来实现,doNext的时候通过switch来决定执行哪个函数,这种方法并不能很好的体现这三个函数是属于同一个流程的关系,那么,有没有什么办法能让这个switch去掉?比如说,多加一层编译,写出来的代码经过这一层编译之后,就得到上述的switch那种代码,这总可以吧(类似C++预处理那种)。这里,我自己定义一个关键字,叫suspend,中止的意思,那么代码应该如下:

bool doNext(){
    suspend getPara();
    suspend doCompile();
    uploadResult();
}

做这一层编译的时候,每次suspend,则返回一个true,遇到函数尾部了,就返回一个false,这很容易理解,高级语言的协程,就基本是这种形式了,只不过,一般是将这里的suspend关键字换成了大家都在用的yield关键字

理解了C语言实现的迭代流程,下面来看下高级语言是如何应用这种思想。


Lua协程用法

lua的实现中,每一个协程对应一个lua_State,代表一条执行序列。lua_State中包含了lua栈、函数调用顺序等,而这些都是存放在C层次的堆空间中。

lua中创建协程的C语言API函数原型为:

lua_State *lua_newthread (lua_State *L);

显然,常规意义上的协程在lua中被称为线程,这也难免,因为Lua虚拟机本身仅仅是单线程的,不存在多线程相关的操作。

同样,用Lua实现上述流程的版本如下:

print("Start...")
local function getPara()
    print("In getPara Now...")
    -- do something
    coroutine.yield()
end
local function doCompile()
    print("In doCompile Now...")
    -- do something
    coroutine.yield()
end
local function uploadResult()
    print("In uploadResult Now...")
    -- do something
end
local function remoteCompile()
    getPara()
    doCompile()
    uploadResult()
end
local coro = coroutine.create(remoteCompile)
while true do
    coroutine.resume(coro)
    print("Check done now...")
    local status = coroutine.status(coro)
    if status == "dead" then
        break
    end
end
print("End")

关于Lua脚本语言的编写,可以参考书籍Lua程序设计

Lua语言本身不提供协程的概念,反而是提供了coroutine标准库,这里只用到了其中的4个API,分别是create、yield、resume、status(Lua标准库的每一个函数,都是一个C语言实现的、能访问Lua虚拟机内部数据的函数,Lua源码可以从Lua官网下载,也可以从我的Github下载Lua5.3.5版本,我自己加了一些注释)

lua的所有代码都执行于虚拟机中,每创建一个协程,相当于创建了一个状态机,coroutine库中的函数就是该状态机的相关状态切换操作。

总结一下:

  1. Lua的协程可以通过两个不同的角度来审视,一是Lua语言的层次,二是C语言的层次
  2. Lua语言层次,多个lua_State就存在多个执行序列,多个Lua栈,多个Lua函数调用序列等
  3. C语言层次,则是状态机之间的切换

关于一些coroutine库的实现细节:

  1. coroutine.resume函数在C层次上就是一个带有setjmp的函数调用,coroutine.yield函数在C层次上是一个longjmp解开栈帧回滚
  2. lua虚拟机保证运行时的所有数据都存放在C层次上的堆空间中,因此,采用longjmp回退丢弃的栈帧中,并没有包含有效数据
  3. C层次存在于栈空间的局部变量一般都是指向某个堆空间的地址,可以通过lua_State重新获得,无需C层次栈空间去保存
  4. 需要注意的是,lua的error错误也是使用longjmp进行回滚

对于Lua虚拟机官方实现中,C语言层次的setjmp+longjmp结构是被作为两种功能的实现机制,第一是协程,第二是Lua错误回滚,Lua虚拟机中通过一个L->errorJmp->status变量,指代了当前longjmp的触发原因。


JS迭代器和生成器

关于JavaScript的Generator,这里推荐一篇好文,这篇文章中还讲述了Generator与JS异步回调之间的恩恩怨怨,挺有意思的,对JS有兴趣的同学建议精读。

Generator,又称semi-coroutine,准确来讲是半协程,不过半协程与协程的特性基本差不多,比较大的区别在于半协程只能在Main函数中进行yield,无法在其他调用函数中进行yield,完整的协程没有这个限制,如Lua提供的是非对称完整协程。

我们这里不涉及JS的异步回调部分,只讲协程,先看一下用Nodejs来实现协程:

console.log("Start...");
function* remoteCompile() {
    console.log("In getPara Now...");
    // do something
    yield null;
    console.log("In doCompile Now...");
    // do something
    yield null;
    console.log("In uploadResult Now...");
    // do something
    return null;
}
var iter = remoteCompile();
for(;;){
    var status = iter.next();
    console.log("Check done now...");
    if(status.done){
        break;
    }
}
console.log("End");

通过node命令去执行的结果如下:

Start...

In getPara Now...

Check done now...

In doCompile Now...

Check done now...

In uploadResult Now...

Check done now...

End

跟我们所设想的,将多个步骤写到一块,并用yield关键字标志着执行的暂时中止,是不是完全一样?这里很明显,JS的生成器,就类似常规意义上的协程,生成器被调用所返回的迭代器,就是代表了协程本身。


C#迭代器

C#的迭代器,也提供了yield关键字:

public static void Main()
{
    Console.WriteLine("Start...");
    var itera = remoteCompile();
    for (; ; )
    {
        var status = itera.MoveNext();
        Console.WriteLine("Check done now...");
        if (status == false)
        {
            break;
        }
    }
    Console.WriteLine("End");
}
static IEnumerator remoteCompile()
{
    Console.WriteLine("In getPara Now...");
    // do something
    yield return null;
    Console.WriteLine("In doCompile Now...");
    // do something
    yield return null;
    Console.WriteLine("In uploadResult Now...");
    // do something
}

单纯看这点C#代码,可能看不出什么端倪,我们这里先用Mono提供的C#编译器mcs将这个C#文件编译成exe,然后,通过dnspy对这个exe进行反编译(注意,在dnspy的菜单:调试==>选项==>反编译器 中,不要勾选反编译枚举器(yield return),否则反编译回yield语句,就得不到我们想看的内部实现),反编译结果如下:

// Token: 0x06000002 RID: 2 RVA: 0x00002058 File Offset: 0x00000258
public static void Main()
{
    Console.WriteLine("Start...");
    IEnumerator enumerator = CSharpCoroutine.doCompile();
    bool flag;
    do
    {
        flag = enumerator.MoveNext();
        Console.WriteLine("Check done now...");
    }
    while (flag);
    Console.WriteLine("End");
}
// Token: 0x06000003 RID: 3 RVA: 0x000020A8 File Offset: 0x000002A8
[DebuggerHidden]
private static IEnumerator doCompile()
{
    return new CSharpCoroutine.<doCompile>c__Iterator0();
}
// Token: 0x02000003 RID: 3
[CompilerGenerated]
private sealed class <doCompile>c__Iterator0 : IEnumerator, IDisposable, IEnumerator<object>
{
    // Token: 0x06000004 RID: 4 RVA: 0x000020BC File Offset: 0x000002BC
    [DebuggerHidden]
    public <doCompile>c__Iterator0()
    {
    }
    // Token: 0x06000005 RID: 5 RVA: 0x000020C4 File Offset: 0x000002C4
    public bool MoveNext()
    {
        uint num = (uint)this.$PC;
        this.$PC = -1;
        switch (num)
        {
        case 0u:
            Console.WriteLine("In getPara Now...");
            this.$current = null;
            if (!this.$disposing)
            {
                this.$PC = 1;
            }
            return true;
        case 1u:
            Console.WriteLine("In doCompile Now...");
            this.$current = null;
            if (!this.$disposing)
            {
                this.$PC = 2;
            }
            return true;
        case 2u:
            Console.WriteLine("In uploadResult Now...");
            this.$PC = -1;
            break;
        }
        return false;
    }
    // Token: 0x17000001 RID: 1
    // (get) Token: 0x06000006 RID: 6 RVA: 0x00002154 File Offset: 0x00000354
    object IEnumerator<object>.Current
    {
        [DebuggerHidden]
        get
        {
            return this.$current;
        }
    }
    // Token: 0x17000002 RID: 2
    // (get) Token: 0x06000007 RID: 7 RVA: 0x0000215C File Offset: 0x0000035C
    object IEnumerator.Current
    {
        [DebuggerHidden]
        get
        {
            return this.$current;
        }
    }
    // Token: 0x06000008 RID: 8 RVA: 0x00002164 File Offset: 0x00000364
    [DebuggerHidden]
    public void Dispose()
    {
        this.$disposing = true;
        this.$PC = -1;
    }
    // Token: 0x06000009 RID: 9 RVA: 0x00002174 File Offset: 0x00000374
    [DebuggerHidden]
    public void Reset()
    {
        throw new NotSupportedException();
    }
    // Token: 0x04000001 RID: 1
    internal object $current;
    // Token: 0x04000002 RID: 2
    internal bool $disposing;
    // Token: 0x04000003 RID: 3
    internal int $PC;
}

从反编译代码中我们可以看到,yield return语句根本就是一个语法糖,并无实际IL与之对应,而是编译成其他语句,编译器帮我们实现了一个迭代器,并且,在MoveNext函数中,正是使用了switch结构进行当前运行状态的判断,这跟我们采用C语言实现的版本如出一辙,看到这里,你应该对于协程有一个自己的见解了吧!


Unity3D中的摊帧

在上述的C#迭代器中,我们使用yield return关键字进行人为的流程切割,从而在多次调用MoveNext的时候,每一次执行一段流程。也就是说,每次调用的接口是一样的,执行的却是流程的不同部分,这种机制在游戏中的应用颇为普遍,比如一个动画的实现,每一帧移动一点位置,每一帧增加一点颜色或亮度等,Unity3D中要实现这些,你所要做的,就是写一个C#迭代器,并在Update函数中调用该迭代器的MoveNext函数,这样就实现了每一帧运行一点,分多帧执行完毕。代码如下:

public GameObject cube;
private Material mate;
private Color tmpColor;
private IEnumerator tmpIterator;
// Use this for initialization
void Start()
{
    mate = cube.GetComponent<MeshRenderer>().sharedMaterial;
    tmpColor = new Color(0.0f, 0.0f, 0.0f, 1.0f);
    tmpIterator = changeColor();
    // StartCoroutine(tmpIterator);
}
// Update is called once per frame
void Update()
{
    tmpIterator.MoveNext();
}
private IEnumerator changeColor()
{
    for (; ; )
    {
        for (int i = 0; i < 100; i++)
        {
            yield return null;
            tmpColor += new Color(0.01f, 0.01f, 0.01f, 0.0f);
            mate.SetColor("_MainColor", tmpColor);
        }
        for (int i = 0; i < 100; i++)
        {
            yield return null;
            tmpColor -= new Color(0.01f, 0.01f, 0.01f, 0.0f);
            mate.SetColor("_MainColor", tmpColor);
        }
    }
}

这样的功能在游戏编程中太普遍了,以至于Unity3D专门为这种需求给出来StartCoroutine接口,接口的参数正是一个IEnumerator迭代器:

public Coroutine StartCoroutine(IEnumerator routine);

通过StartCoroutine注册的迭代器(只需要调用一次注册进去),会在Unity3D的脚本生命周期(也就是游戏循环)中自动被Unity3D引擎调用,从而不需要用户自己去管理这些迭代器的迭代和结束迭代时的判断。

更多关于Unity3D中C#协程的更多内容,请参考书籍Unity3D脚本编程第八章。

细心的读者可以发现,上述的C语言协程中,有一个话题一直被忽略,那就是传参。我们将实现分段流程的迭代器本身称为协程,而将调用迭代器doNext函数的一方称为协程的使用者,那么,能否在协程和协程的使用者之间做一些参数传递(不通过全局或静态变量)?

这其实是可以的,此时doNext的函数原型不再是bool (*)(void),而变成Struct1* (*)(Struct2*),协程的使用者传递Struct2结构体的指针,告诉协程一些参数,协程执行片段完毕,则返回Struct1结构体的指针,反馈一些信息给调用者。

可以看到,在C语言的实现中,参数类型和数量其实是固定的(C语言的不定参数其实也依赖于调用双方的使用协议,采用数据类型字段+联合体数据是另一种形式),这样用结构体的指针传递数据,就显得很蹩脚,然而,在Lua、JS等弱类型动态脚本语言中,变量不与数据类型挂钩,而是数据与数据类型挂钩,这种情况下就可以很完美的实现每次doNext传递和返回完全不同的数据类型(Lua和JS传递参数的协程用法,就留给读者去探索了)。

回到Unity3D的StartCoroutine中,既然StartCoroutine只是让Unity3D引擎在每帧固定时机(Mono生命周期)去调用iterator.MoveNext()函数而已,那,有没有某种方式来控制一下这个迭代器的调用时机?比如说,我想过Unity3D引擎在两秒之后再继续调用下一个协程片段。哈哈,答案当然是有方法的,具体做法就是利用MoveNext()方法的返回值。通过StartCoroutine将迭代器注册之后,迭代器的MoveNext()是由Unity3D引擎去调用的,所以,我们可以通过MoveNext()函数返回不同的对象,告诉Unity3D引擎下一次再调用MoveNext()的时机。(MoveNext()函数的返回值,其实就是yield return后面所带的东西了)

  1. yield return null; 正常协程的片段执行结束,下一帧继续执行下一个片段
  2. yield return new WaitForEndOfFrame(); 告诉Unity,这一帧结束之前,不要调用本迭代器的MoveNext
  3. yield return new WaitForFixedUpdate(); 告诉Unity,下一个FixedUpdate生命周期被调用之前,不要调用本迭代器的MoveNext
  4. yield return new WaitForSeconds(5); 告诉Unity,游戏时间的5秒内,不要调用本迭代器的MoveNext
  5. yield return new WaitForSecondsRealtime(5); 告诉Unity,真实时间的5秒内,不要调用本迭代器的MoveNext
  6. yield return new WaitUntil(() => {return true; // return false;}); WaitUntil构造方法传入一个函数,Unity每帧在MonoBehaviour.Update和MonoBehaviour.LateUpdate这两个生命周期之间去调用这个函数,在这个函数返回true之前,Unity不会再调用本迭代器的MoveNext
  7. yield return new WaitWhilel(() => {return true; // return false;}); 类似WaitUntil,只是这回传入的函数返回false之后Unity才继续调用迭代器的MoveNext

除了Unity3D定义好的WaitFor*系列对象可以用于yield return来控制下一次调用迭代器的时机,Unity3D也提供通用的方法让我们可以自定义对象,这给了我们更大的操作空间来使用迭代器。自定义yield return对象我们只需要将我们设计的类继承自CustomYieldInstruction类,并覆盖实现其bool类型的keepWaiting属性,每一帧Unity3D都会检查keepWaiting属性(Update之后LateUpdate之前),当该属性为false的时候,Unity3D就会再次调用对应迭代器。代码如下:

using System.Collections;
using UnityEngine;
public class MyCoroutine : MonoBehaviour
{
    private bool isRunning = false;
    private WaitForFrames myCo = null;
    // Use this for initialization
    void Start()
    {
        StartCoroutine(RunInFrames(3));
        this.isRunning = true;
    }
    // Update is called once per frame
    void Update()
    {
        myCo.IncreaseFrame();
        if (isRunning)
        {
            Debug.Log("MyCoroutine is Running Now");
        }
    }
    // FixedUpdate ==> yield WaitForFixedUpdate ==> Update ==> Other Yield ==> LateUpdate ==> yield WaitForEndOfFrame
    // Now: Start==>Update==>keepWaiting
    private IEnumerator RunInFrames(int num)
    {
        myCo = new WaitForFrames(num);
        yield return myCo;
        this.isRunning = false;
        Debug.Log("End of MyCoroutine");
    }
}
public class WaitForFrames : CustomYieldInstruction
{
    private int frames = 0;
    private int currFrames = 0;
    public WaitForFrames(int num)
    {
        frames = num;
    }
    public void IncreaseFrame()
    {
        currFrames++;
    }
    public override bool keepWaiting
    {
        get
        {
            Debug.Log("Access keepWaiting Now:" + currFrames);
            return currFrames < frames;
        }
    }
}

总结

各种高级语言的编程抽象体往往代表着某种思维方式或方法,协程也不例外,进程、线程、协程、闭包等多个层次多个角度的抽象,给了编程人员极大的自由和灵活性来设计相应程序。深刻理解编程语言抽象概念及其底层实现原理,做到知其然也知其所以然,写代码也能体验运筹帷幄决胜千里。(示例代码可在我的Github上获取)