Skip to content

Latest commit

 

History

History
386 lines (310 loc) · 18.2 KB

ch09-协程.md

File metadata and controls

386 lines (310 loc) · 18.2 KB

###协程 ####概念介绍 协程的英文单词是coroutine,从单词来看,"routine"是"例程"的意思,所以"coroutine"就是"协作的例程".协程与线程有类似的作用,同样每个协程中都有自己的堆栈,局部变量等.

但是与线程不同的是,协程是由用户控制其执行以及挂起操作的,而线程的调度则是由操作系统内核来调度执行,这对对用户是透明的.

####相关的API 来看看Lua提供了哪些协程方面的API.协程相关的操作集中在coroutine库中,里面提供了以下的API:

API 传入参数 返回值 说明
create(f) 函数,做为协程运行的主函数 返回创建好的协程 该函数只负责创建协程,而如果要运行协程,还需要执行resume操作
resume(co,[val1,..]) 传入的第一个参数是create函数返回的协程,剩下的参数是传递给协程运行的参数 分两种情况,resume成功的情况下返回true以及上一次yield函数传入的参数;失败的情况下返回false以及错误信息 第一次执行resume操作时,会从create传入的函数开始执行,之后会在该协程主函数调用yield的下一个操作开始执行,直到这个函数执行完毕.调用resume操作必须是在主线程中.
running 返回当前正在执行的协程,如果是在主线程中被调用将返回nil
status 返回当前协程的状态,有dead/runnning/suspend/normal
wrap 与create类似,传入协程运行的主函数 返回创建好的协程 wrap函数相当于结合了create和resume函数,所不同的是,wrap函数返回的是创建好的协程,下一次直接传入参数调用该协程即可,无需调用resume函数.
yield 变长参数,这些是返回给此次resume函数的返回值 返回下一个resume操作传入的参数值 挂起当前协程的运行,调用yield操作必须是在协程中.

从以上可以看到,这里面最难理解的两个API,就是resume和yield这两个函数,两者的关系密切,这里再列举一下两者的联系:

  1. yield在协程中执行,用于挂起当前协程的执行,同时调用yield函数时的参数将做为下一次调用resume参数的返回值之一返回,而yield函数的返回值是下一次调用resume函数的传入参数.
  2. resume在主线程中执行,用于第一次执行协程或者从上一次调用yield被挂起的地方继续执行.不同时候针对同一个协程,调用resume函数时传入不同的参数,将做为上一次yield函数的返回值.

换言之,yield/resume除了常见的挂起/执行操作之外,两者之间还可以通过调用时传入的参数分别做为对方的返回值.

在列举协程的例子中,常见的是生产-消费者模型,但是这种例子只能单方面的看到通过yield函数向另一个协程传递参数,这里举另外一个不那么常见的例子,来看看yield/resume两者之间互相传递参数的情况:

  1 function foo (a)
  2   print("foo", a)  -- foo 2
  3   return coroutine.yield(2 * a)
  4 end
  5
  6 co = coroutine.create(function (a , b)
  7   print("co-body", a, b)
  8   local r = foo(a + 1)
  9
 10   print("co-body2", r)
 11   local r, s = coroutine.yield(a + b, a - b)
 12
 13   print("co-body3", r, s)
 14   return b, "end"
 15 end)
 16
 17 print("main", coroutine.resume(co, 1, 10)) -- true, 4
 18 print("------")
 19 print("main", coroutine.resume(co, "r")) -- true 11 -9
 20 print("------")
 21 print("main", coroutine.resume(co, "x", "y")) -- true 10 end
 22 print("------")
 23 print("main", coroutine.resume(co, "x", "y")) -- false cannot resume dead coroutine
 24 print("------")

我们结合协程的API描述,来具体分析这段代码的执行:

  • 首先,在第6行程序创建了协程co,但是此时该协程并没有开始运行,需要执行resume操作之后才会运行.
  • 然后,在17行,调用resume函数开始执行前面创建的协程.在这里,传入的参数是1,10.在协程主函数中,在第8行调用foo函数,在此函数中,协程将调用yield函数将执行权让出,而在让出时传入的参数是2*a(也就是4),这也就是前面resume函数执行完毕之后返回的第二个参数(第一个参数是true,表示协程执行成功,见前面API的注释).因此,在18行第一次打印横线进行标记之前的输出为:
co-body	1	10
foo	2
main	true	4
  • 接下来,在19行,程序再一次调用resume函数来执行协程.与上一次不同的是,这一次不是从协程主函数开始执行,而是从上一次协程执行yield操作让出执行权的地方继续执行.在这里传递进行的参数是字符串"r",这就是协程重新获得执行权之后yield函数的返回值.之后,协程执行第10,11两行代码,其中11行再次调用yield函数让出执行权,此时传入的参数是a+b(也就是11),和a-b(也就是-9).可见,虽然协程co曾经调用yield函数让出执行权,但是该协程的执行环境,表现在这里就是主函数的局部参数a,b都还是保持原样的,并没有因此发生改变. 从前面的分析可知,第一,二次横线之间打印输出的是:
co-body2	r
main	true	11	-9
  • 接下来,程序在第21行再次调用resume函数唤醒协程,这次传入的参数是"x","y",因此在第11行协程重新唤醒时yield函数的返回值就是"x","y".协程主函数继续往下执行,这次没有再次yield让出执行权,而是执行完协程的主函数,并且返回10,"end"两个参数.因此,在第二,三次横线之间打印输出的是:
co-body3	x	y
main	true	10	end
  • 此时协程co已经执行完毕,因此在第23行再次调用resume函数试图唤醒该协程继续执行时,将返回false表示执行失败,以及失败的错误信息,因此这一次的输出是:
main	false	cannot resume dead coroutine

从以上例子,我们再重温总结一下协程运行的几个关键点:

  • 协程可以自由的由操作者执行(调用resume函数)或者挂起让出执行权(yield),这是协程与操作系统级别的线程最大的不同,协程的运行可以由用户自行操作,而线程的调度是由操作系统内核来完成的,对用户并不可见.

  • 协程运行的两个主要函数就是resume和yield,协程调用者和协程之间可以通过这两个函数的参数来互相通信.具体的规则小结如下:

    1. 协程创建后,首次执行resume操作时,传入resume函数的参数是协程主函数的参数.
    2. 调用yield操作让出执行权时,传入yield函数的参数,作为协程调用者执行resume函数的返回值.注意,这里的第一个返回值是true/false,表示协程是否执行成功.
    3. 再次调用resume函数唤醒协程(非首次调用)时传入resume函数的参数,作为协程环境中调用yield函数的返回值.

####实现 有了前面的准备,已经大致对协程有所了解,接下来可以来看看Lua协程的实现,还是首先从相关的数据结构来入手.

#####数据结构 在Lua代码中,使用的是Lua_State结构体来表示协程,这与Lua虚拟机用的是同一个数据结构.这一点,可以从创建Lua协程的函数lua_newthread中看出来,唯一有区别的是,Lua协程的类型是LUA_TTHREAD.换言之,在Lua源码的处理中,Lua协程与Lua虚拟机的表现形式,并没有太大差异,也许这样做是为了实现的方便吧.前面提到过,一个协程有自己私有的环境,不会因为协程的切换而发生改变.

#####协程之间通信 接下来来看看如何在不同的协程之间进行通信,或者说Lua协程间数据的交换,前面提到过resume/yield函数的参数就是用来做协程数据交换的,现在来看看里面的实现.奥秘都在函数lua_xmove中:

(lapi.c)
 110 LUA_API void lua_xmove (lua_State *from, lua_State *to, int n) {
 111   int i;
 112   if (from == to) return;
 113   lua_lock(to);
 114   api_checknelems(from, n);
 115   api_check(from, G(from) == G(to));
 116   api_check(from, to->ci->top - to->top >= n);
 117   from->top -= n;
 118   for (i = 0; i < n; i++) {
 119     setobj2s(to, to->top++, from->top + i);
 120   }
 121   lua_unlock(to);
 122 }

这段代码做的事情,就是从from协程中,移动n个数据到to协程中,当然在移动之前,数据要在from协程的栈顶上准备好.

#####创建协程 创建协程在函数luaB_cocreate中进行:

	(lbaselib.c)
	576 static int luaB_cocreate (lua_State *L) {
	577   lua_State *NL = lua_newthread(L);
	578   luaL_argcheck(L, lua_isfunction(L, 1) && !lua_iscfunction(L, 1), 1,
	579     "Lua function expected");
	580   lua_pushvalue(L, 1);  /* move function to top */
	581   lua_xmove(L, NL, 1);  /* move function from L to NL */
	582   return 1;
	583 }

明白了前面的内容,理解创建协程的过程就不难,这里主要做几件事情:

  • 调用lua_newthread创建lua_State结构体.
  • 检查当前栈顶的元素是不是一个函数对象,因为需要一个函数做为协程开始运行时的主函数.
  • 将协程主函数压入当前lua_State中的栈中,然后调用lua_xmove将该函数从当前的lua_State移动到新创建的协程的lua_State栈中.

####resume操作

	(lbaselib.c)
	543 static int luaB_coresume (lua_State *L) {
	544   lua_State *co = lua_tothread(L, 1);
	545   int r;
	546   luaL_argcheck(L, co, 1, "coroutine expected");
	547   r = auxresume(L, co, lua_gettop(L) - 1);
	548   if (r < 0) {
	549     lua_pushboolean(L, 0);
	550     lua_insert(L, -2);
	551     return 2;  /* return false + error message */
	552   }
	553   else {
	554     lua_pushboolean(L, 1);
	555     lua_insert(L, -(r + 1));
	556     return r + 1;  /* return true + `resume' returns */
	557   }
	558 }

可以看到,这里主要做几件事情:

  • 检查当前栈顶元素是不是协程指针.
  • 调用辅助函数auxresume进行实际的resume操作.
  • 根据auxresume的返回值来做不同的处理.当返回值小于0时,说明resume操作出错,并且此时出错信息在栈顶,因此压入"false"以及出错消息;否则,auxresume的返回值表示执行resume时返回的参数数量,这种情况下压入"true"以及这些返回参数.

auxresume函数的实现:

518 static int auxresume (lua_State *L, lua_State *co, int narg) {
519   int status = costatus(L, co);
520   if (!lua_checkstack(co, narg))
521     luaL_error(L, "too many arguments to resume");
522   if (status != CO_SUS) {
523     lua_pushfstring(L, "cannot resume %s coroutine", statnames[status]);
524     return -1;  /* error flag */
525   }
526   lua_xmove(L, co, narg);
527   lua_setlevel(L, co);
528   status = lua_resume(co, narg);
529   if (status == 0 || status == LUA_YIELD) {
530     int nres = lua_gettop(co);
531     if (!lua_checkstack(L, nres + 1))
532       luaL_error(L, "too many results to resume");
533     lua_xmove(co, L, nres);  /* move yielded values */
534     return nres;
535   }
536   else {
537     lua_xmove(co, L, 1);  /* move error message */
538     return -1;  /* error flag */
539   }
540 }

它主要做如下的操作:

  • 数据的合法性检查.
  • 将参数通过lua_xmove函数传递到待启动的协程中,调用lua_resume函数执行协程代码.
  • 当lua_resume函数返回,说明该协程已经执行完毕,通过lua_xmove函数将yield传入的参数传递给启动该协程的协程.

auxresume函数中最终会调用resume函数来执行实际的resume操作:

	(ldo.c)
	383 static void resume (lua_State *L, void *ud) {
	384   StkId firstArg = cast(StkId, ud);
	385   CallInfo *ci = L->ci;
	386   if (L->status == 0) {  /* start coroutine? */
	387     lua_assert(ci == L->base_ci && firstArg > L->base);
	388     if (luaD_precall(L, firstArg - 1, LUA_MULTRET) != PCRLUA)
	389       return;
	390   }
	391   else {  /* resuming from previous yield */
	392     lua_assert(L->status == LUA_YIELD);
	393     L->status = 0;
	394     if (!f_isLua(ci)) {  /* `common' yield? */
	395       /* finish interrupted execution of `OP_CALL' */
	396       lua_assert(GET_OPCODE(*((ci-1)->savedpc - 1)) == OP_CALL ||
	397                  GET_OPCODE(*((ci-1)->savedpc - 1)) == OP_TAILCALL);
	398       if (luaD_poscall(L, firstArg))  /* complete it... */
	399         L->top = L->ci->top;  /* and correct top if not multiple results */
	400     }
	401     else  /* yielded inside a hook: just continue its execution */
	402       L->base = L->ci->base;
	403   }
	404   luaV_execute(L, cast_int(L->ci - L->base_ci));
	405 }

这个函数做了以下的事情:

  • 如果当前协程的状态是0,那么说明它是第一次执行resume操作,此时调用luaD_precall做函数调用前的准备工作. luaD_precall函数如果返回值不是PCRLUA,说明是在C函数中进行resume操作的,此时并不需要后面的luaV_execute函数,因此就直接返回了.
  • 否则就是从之前的YIELD状态中继续执行,首先将协程的状态置为0,其次判断此时ci的类型,如果不是Lua函数,说明之前是被中断的函数调用,此时调用luaD_poscall函数继续完成未完的函数操作;否则只需要调整base指针即可.
  • 以上的几种情况,最终都会调用luaV_execute函数来进入Lua虚拟机中执行.在这里可以看到,由于使用了同样的结构Lua_State来表示Lua虚拟机和Lua协程,在表达Lua虚拟机的执行和协程的执行上,两者都是统一的使用LuaV_execute函数,方便了实现.

####yield操作

	(ldo.c)
	443 LUA_API int lua_yield (lua_State *L, int nresults) {
	444   luai_userstateyield(L, nresults);
	445   lua_lock(L);
	446   if (L->nCcalls > L->baseCcalls)
	447     luaG_runerror(L, "attempt to yield across metamethod/C-call boundary");
	448   L->base = L->top - nresults;  /* protect stack slots below */
	449   L->status = LUA_YIELD;
	450   lua_unlock(L);
	451   return -1;
	452 }

yield函数做的事情相比起来就简单很多了,就是将协程执行状态至为YIELD,这样可以终止luaV_execute函数的循环.

将前面的总结起来,如下图所示. lua_coroutine

####对称协程和非对称协程 (以下内容主要参考自Lua的多任务机制——协程(coroutine))

Lua的协程实现是非对称(asymmetric)协程,这种机制之所以被称为非对称的,是因为协程之间的关系并不对等,它提供了两种让出协程控制权的操作,其一是通过调用协程(在Lua中就是resume操作),其二是通过挂起当前协程的执行将控制权让出来给协程调用者的方式(在Lua中就是yield操作).这样的关系,很像例程与其调用者之间的关系.

与之相比,对称(symmetric)协程只有一种传递程序控制权的方式,就是直接将控制权传递给指定的协程.

对称协程也可以使用非对称协程来实现,下面就来看看如果在Lua中使用非对称的协程实现对称协程,以及分析两者的优缺点.

(coro.lua)
-- coro.main用来标识程序的主函数
coro = {}
coro.main = function() end
-- coro.current变量用来标识拥有控制权的协程,
-- 也即正在运行的当前协程
coro.current = coro.main

-- 创建一个新的协程
function coro.create(f)
   return coroutine.wrap(function(val)
                            return nil,f(val)
                         end)
end

-- 把控制权及指定的数据val传给协程k
function coro.transfer(k,val)
   if coro.current ~= coro.main then
      return coroutine.yield(k,val)
   else
      -- 控制权分派循环
      while k do
         coro.current = k
         if k == coro.main then
            return val
         end
         k,val = k(val)
      end
      error("coroutine ended without transfering control...")
   end
end

来看看它的使用者是如何实现的:

require("coro")

function foo1(n)
   print("1: foo1 received value "..n)
   n = coro.transfer(foo2,n + 10)
   print("2: foo1 received value "..n)
   n = coro.transfer(coro.main,n + 10)
   print("3: foo1 received value "..n)
   coro.transfer(coro.main,n + 10)
end

function foo2(n)
   print("1: foo2 received value "..n)
   n = coro.transfer(coro.main,n + 10)
   print("2: foo2 received value "..n)
   coro.transfer(foo1,n + 10)
end

function main()
   foo1 = coro.create(foo1)
   foo2 = coro.create(foo2)
   local n = coro.transfer(foo1,0)
   print("1: main received value "..n)
   n = coro.transfer(foo2,n + 10)
   print("2: main received value "..n)
   n = coro.transfer(foo1,n + 10)
   print("3: main received value "..n)
end

这段代码中,重点是理解main函数以及coro.transfer操作.姑且可以认为,这里的main函数作为一个标记,表示程序当前在主协程中运行,而transfer函数做的工作,可以认为是一个协程调度器的工作:

  • 如果此时current函数不是main函数,那么认为在非主协程中运行,直接调用yield函数让出执行权.
  • 否则,当前current函数为main函数,此时在主协程中运行,那么就将current函数至为该协程运行的主函数,同时启动一个循环不停的执行协程主函数,仅在协程主函数重新切换回main函数时终止循环的执行.

为什么需要main函数这个标记函数?因为对称协程之间,虽然关系是平等的,可是也还是需要主函数主循环,否则程序就马上退出了.

明白了以上几点,很容易的就知道测试代码的输出为:

1: foo1 received value 0
1: foo2 received value 10
1: main received value 20
2: foo2 received value 30
2: foo1 received value 40
2: main received value 50
3: foo1 received value 60
3: main received value 70

而协程的执行顺序为:

main->foo1->foo2->main->foo2->foo1->main->foo1->main

从上面也可以看到,对称协程是可以直接指定要转换执行权的目的协程的,如前面的foo1函数代码中写的:

n = coro.transfer(foo2,n + 10)

这里将foo1协程的执行权转换给foo2协程了.这么看来,对称协程可以实现协程的"指哪打哪"功能,类似于C语言中的Goto调用.但是这么做的坏处在于,程序的模块性遭到了破坏,不敢想象如果Goto代码满天飞是个什么样的场景.

所以,在Lua中实现的非对称协程,而可以使用非对称协程来模拟对称协程,区别在于对称协程太过自由,容易写出混乱的代码来.

####参考资料