深入浅出Unity协程

发表于2017-05-15
评论0 1.17w浏览

 

引言

    Unity的协程使用起来比较方便,但是由于其封装和隐藏了太多细节,使其看起来比较神秘。比如协程是否是真正的异步执行?协程与线程到底是什么关系?本文将从语义角度来分析隐藏在协程背后的原理,并使用C++来实现一个简单的协程,以解开协程的神性面纱

一、什么是协程

    简单来说,协程是一个有多个返回点的函数。一般来说一个函数只有一个返回点,函数的调用者调用一次后,函数的生命周期就结束了。而对于协程来说,其生命周期由调用者来决定,可以通过返回值来决定如何进行一次调用以及如何结束调用。

    由于协程返回的是一系列的值,每一个yield return对应一次返回。用迭代器作为返回类型是比较好的选择,可以简单的认为每一个yield return对于了迭代器中的一个元素。

    Unity的C#代码中一个协程的返回值通常是IEnumerator类型,IEnumerator 接口有两个方法,分别是 Current 和 MoveNext。我们可以简单的认为:协程就是一个返回迭代器的函数,一开始迭代器的Current指向函数的开头,每执行一次MoveNext,Current就指向下一个yield return 返回的值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  
using System;
using System.Collections;
class Program
{
    static IEnumerator coroutine()
    {
        yield return 1;
        yield return 2;
        yield return 5;
    }
    static void Main(string[] args)
    {
        IEnumerator iter = coroutine();
        while(iter.MoveNext())
        {
            Console.WriteLine(iter.Current);
        }
        // Output: 1 2 5
    }
}
  

 

二、协程的核心,神奇的yield

我们来看看上而代码示例中的函数coroutine:

1
2
3
4
5
6
  
static IEnumerator coroutine()
{
    yield return 1;
    yield return 2;
    yield return 5;
}
  

    上面代码神奇的地方在于关键词yield,为什么可以调用多次yield return返回一组值,并且该函数的返回值变成了Enumerator类型?这其实就是隐藏在协程背后的核心原理。有很多关于协程的核心原理的分析及实现的文章,大多都是从系统底层的角度来分析,使用了汇编,goto语句或者是 C 语言的 setjmp 和 longjmp来实现了协程,虽然分析得比较透彻,但对读者所掌握的知识要求比较高,其中的一些概念比较晦涩,难于理解。

    实际上从语义角度来看,协程的工作原理比较简单,任何支持闭包的语言都可以实现协程。下面我们从语义的角度来解释协程:

  1. 每一个yield return被包装成一个函数(简称为Y函数),如果该函数使用了外层的局部变量,将形成一个闭包
  2. Y函数的返回值为yield传入的对象和下一个yield return所包装成的函数
  3. 对迭代器的MoveNext调用等价于对Y函数的调用
  4. 最后一个yield return包装成的Y函数返回yield传入的对象和一个空函数以代表迭代结束(即调用MoveNext时返回false)

三、简单协程的C++实现

    以下是根据协程在语义上的解释,用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
  
#include "stdafx.h"
#include
#include
#include
#include
class CoroutineNode
{
public:
  typedef std::shared_ptr Ptr;
  typedef std::function NextDelegate;
public:
  CoroutineNode(int v, NextDelegate n):value(v), next(n) {}
public:
  int value;
  NextDelegate next = nullptr;
};
//1.每一个yield return被包装为一个函数(后面表示为Y),Y函数接收两个函数,参数1为object类型,参数2为一个Y函数
CoroutineNode::Ptr yield(int v, CoroutineNode::NextDelegate next = nullptr)
{
  return std::make_shared(v,next);
}
class IEnumerator
{
  CoroutineNode::Ptr _node;
  int _current;
public:
  IEnumerator(CoroutineNode::Ptr node) :_node(node) {}
  int Current(){return _current;}
  //迭代器的MoveNext调用即调用了一次Y函数,该函数返回一个object,另一个Y函数或者空
  bool MoveNext()
  {
    if (_node != nullptr)
    {
      _current = _node->value;
      _node = _node->next != nullptr ? _node->next() : nullptr;
      return true;
    }
    //如果返回的Y函数为空,则迭代结束
    return false;
  }
};
IEnumerator coroutine()
{
  return yield(1, [=] {
  return yield(2, [=] {
  return yield(5);
  });
  });
}
int main()
{
  IEnumerator iter = coroutine();
  while (iter.MoveNext())
  {
    std::cout << iter.Current() << std::endl;
  }
  // Output: 1 2 5
  return 0;
}
  

 一共不到60行代码,就实现了简单的协程。对于上示例中的coroutine函数,除了语法上有一些差异,语义已经和C#的协程完全一协了,甚至我们可以引入一个宏让其长得更像C#的协程,例如:

1
2
3
4
5
6
7
8
9
  
#define  yield_return(x) return yield(x, [=]() {
IEnumerator coroutine()
{
  yield_return(1);
  yield_return(2);
  return yield(5);
  });
  });
}
  

     虽然以上代码段看起来已经比较像Unity的协程了,但是遗憾的是代码还不够简洁,有一点点语法噪音(函数的结尾部分多了一些没意义的括号)语法也不够统一(最后一行是return yield)。

四、For循环

    第三节我们讲了协程的简单的实现,但是如果我们如果在for循环中使用yield还存在问题,比如以下代码段:

1
2
3
4
5
6
7
  
IEnumerator coroutine1()
{
  for (int i = 0; i < 10; ++i)
  {
    return yield(i+1);
  }
}
  

 coroutine1函数返回的迭代器中只包含一个值1,而不是我们期望的1~10。因此对于协程中的循环我们需要特殊处理。分析以上代码,我们期望的是对于循环中的每一个i,调用yield生成一个coroutine node,然后将这些node合并成一个链表返回。因此我们可以将协程中的for语句翻译成一个For函数,代码如下:

1
2
3
4
5
6
7
8
9
10
11
  
CoroutineNode::Ptr Combine(CoroutineNode::Ptr a, CoroutineNode::Ptr b);
typedef std::functionint)> YieldDelegate;
CoroutineNode::Ptr For(int min, int max, YieldDelegate y)
{
  CoroutineNode::Ptr node = nullptr;
  for (int i = min; i < max; ++i)
  {
    node = Combine(node, y(i));
  }
  return node;
}int)>
  

 Combine函数实现将两个Coroutine Node连接在一起合并成一个链表返回,其定义代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  
CoroutineNode::Ptr Combine(CoroutineNode::Ptr a, CoroutineNode::Ptr b)
{
  if (a == nullptr)
  {
    return b;
  }
  else if (b == nullptr)
  {
    return a;
  }
  else
  {
    CoroutineNode::NextDelegate next = a->next;
    if (next == nullptr)
    {
      next = [=](){ return b; };
    }
    else
    {
      next = [=](){ return Combine(a->next(), b); };
    }
    return std::make_shared(a->value, next);
  }
}
  

 有了For函数,可以将coroutine1实现可以改成如下:

1
2
3
4
5
6
7
  
#define  _for(min,max,i) return For(min,max,[](int i)
IEnumerator coroutine1()
{
  _for(0, 10, i){
    return yield(i);
  });
}
  

 虽然在语法上该函数有一些丑,但是是从语义上完全是等价以下C#代码的:

1
2
3
4
5
6
7
  
IEnumerator coroutine1()
{
  for(int i = 0;i<10;++i)
  {
    yield return i;
  }
}
  

以下是一个更加复杂的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  
IEnumerator coroutine2()
{
  int x = 10;
  yield_return(x);
  int y = x + 100;
  yield_return(y);
  int z = y + 1100;
  yield_return(z);
  return Combine(
    For(0, 5, [](int i) {return yield(5); }),
    For(2, 10, [](int i) {
      return For(0, 3, [=](int j) {
        return yield(i + j);
      });
    })
  );
  });
  });
  });
}
  

 语义上等价的C#代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  
IEnumerator coroutine2()
{
  int x = 10;
  yield return x;
  int y = x + 10;
  yield return y;
  int z = y + 1100;
  yield return z;
  for(int i = 0;i<5;++i)
  {
    yield return i;
  }
  for(int i = 0;i<5;++i)
  {
    for(int j = 0;j<3;++j)
    {
        yield return (i + j);
    }
  }
}
  

五、总结

   本文从语义的角度来解释了协程的运行机制,并使用C++实现了简单的协程(代码已上传github),但仍有部分内容未讨论到,比如怎样在For循环中实现break,yield break等,其实要实现这些并不困难,感兴趣的同学可以自行去研究。

    回到引言中提到的两个问题:

  1. 协程是否为异步执行?严格意义上来讲,协程并不是异步执行的,但是调用者可以分时间片去执行每一个yield,让程序看起来像是异步的。
  2. 协程和线程是什么关系?协程与线程之间没有严格的对应关系,但是可以结合使用,隐藏一些不必要的细节以简化编程,比如unity的WWW。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引