Lua 5.2 最重大的改進,末過於 "yieldable pcall and metamethods" 。這需要克服一個難題:如何在 C 函數調用中,正確的 yield 回 resume 調用的位置。
resume 的發起總是通過一次lua_resume的調用,在 Lua 5.1 以前,yield 的調用必定結束於一次lua_yield調用,而調用它的 C 函數必須立刻返回。中間不能有任何 C 函數執行到中途的狀態。這樣,Lua VM 才能正常工作。
(C)lua_resume -> Lua functions -> coroutine.yield
-> (C)lua_yield -> (C) return
在這個流程中,無論 Lua functions 有多少層,都被 lua state 中的 lua stack 管理。所以當最後 C return 返回到最初 resume 點 ,都不存在什麼問題,可以讓下一次 resume 正確繼續。也就是說,在 yield 時,lua stack 上可以有沒有執行完的 lua 函數,但不可以有沒有執行完的 C 函數。
如果我們寫了這麼一個 C 擴展,在 C function 裡回調了傳入的一個 Lua 函數。情況就變得不一樣了。
(C)lua_resume -> Lua function -> C function
-> (C) lua_call -> Lua function
-> coroutine.yield -> (C)lua_yield
C 通過lua_call調用的 Lua 函數中再調用 coroutine.yield 會導致在 yield 之後,再次 resume 時,不再可能從lua_call的下一行繼續運行。lua 在遇到這種情況時,會拋出一個異常 "attempt to yield across metamethod/C-call boundary" 。
在 5.2 之前,有人試圖解決這個問題,去掉 coroutine 的這些限制。比如Coco這個項目。它用操作系統的協程來解決這個問題 (例如,在 Windows 上使用Fiber)。即給每個 lua coroutine 真的附在一個 C 協程上,獨立一個 C 堆棧。
這樣的方案開銷較大,且依賴平台特性。到了 Lua 5.2 中,則換了一個更徹底的方案解決這個問題。
其實,需要解決的問題是在 C 和 Lua 的邊界時,如果在 yield 之後,resume 如何繼續運行 C 邊界之後的 C 代碼。
當只有一個 C 堆棧時,只能從調用深處跳出來(使用 longjmp),卻無法回到那個位置(因為一旦跳出,堆棧就被破壞)。Lua 5.2 想了一個巧妙的方法來解決這個問題。
C 進入 Lua 的邊界一共有四個 API :lua_call,lua_pcall,lua_resume和lua_yield。其中要解決的關鍵問題在於 call 一個 lua function 有兩條返回路徑。
lua function 的正常返回應該執行lua_call調用後面的 C 代碼,而中途如果 yield 發生,回導致執行序回到前面lua_resume調用處的下一行 C 代碼執行。對於後一種,在後續的某次lua_resume發生後,lua coroutine 結束,還需要回到lua_call之後完成後續的 C 執行邏輯。C 語言是不允許這樣做的,因為當初的 C 堆棧已經不存在了。
Lua 5.2 提供了新的 API :lua_callk來解決這個問題。既然無法在 yield 之後,C 的執行序無法回到lua_callk的下一行代碼,那麼就讓 C 語言使用者自己提供一個 Continuation 函數 k 來繼續。
我們可以這樣理解 k 這個參數:當lua_callk調用的 lua 函數中沒有發生 yield 時,它會正常返回。一旦發生 yield ,調用者要明白,C 代碼無法正常延續,而 lua vm 會在需要延續時調用 k 來完成後續工作。
k 會得到正確的 L 保持正確的 lua state 狀態,看起來就好像用一個新的 C 執行序替代掉原來的 C 執行序一樣。
典型的用法就是在一個 C 函數調用的最後使用 callk :
lua_callk(L, 0, LUA_MULTRET, 0, k);
return k(L);
也就是把 callk 後面的執行邏輯放在一個獨立 C 函數 k 中,分別在 callk 後調用它,或是傳遞給框架,讓框架在 resume 後調用。
這裡,lua 狀態機的狀態被正確保存在 L 中,而 C 函數堆棧會在 yield 後被破壞掉。如果我們需要在 k 中得到延續點前的 C 函數狀態怎麼辦呢?lua 提供了 ctx 用於輔助記錄 C 中的狀態。
在 k 中,可以通過lua_getctx獲得最近一次邊界調用時傳入的 k 。lua_getctx返回兩個參數,分別是 k 和當前所處的執行位置。是原始函數(沒有被 yield 打斷的),還是在被 yield 打斷後的延續點函數中。這有一點點像 setjmp 或 fork 的接口設計。
作者:雲風