使用 lua 的時候有時候會遇到這樣的報錯:“attempt to yield across a C-call boundary”。
1. 網絡上的解釋
可以在網上找到一些關于這個問題的解釋。
1.1 解釋一
這個 issue:一個關于 yield across a C-call boundary 的問題,云風的解釋是:
C (skynet framework)->lua (skynet service) -> C -> lua
最后這個 lua 里如果調用了 yield 就會產生。
云風這樣解釋沒問題,但太簡了,只說了這樣會導致報錯,但沒具體說為什么會報錯。
1.2 解釋二
這篇文章 lua中并不能隨意yield 提到:
流程:
coroutine --> c --> coroutine --> yield ===> 報錯
為什么這種情況下lua會給出這種報錯呢?主要是因為在從c函數調回到coroutine中yield時,coroutine當前的堆棧情況會被保存在lua_State中,因此在調用resume時,lua可以恢復yield時的場景,并繼續執行下去。但c函數不會因為coroutine的yield被掛起,它會繼續執行下去,函數執行完后堆棧就被銷毀了,所以無法再次恢復現場。而且因為c函數不會被yield函數掛起,導致c和lua的行為也不一致了,一個被掛起,一個繼續執行完,代碼邏輯很可能因此出錯。
這個接近于胡說了。
1.3 解釋三
這篇文章 深入Lua:在C代碼中處理協程Yield 提到:
原因是Lua使用longjmp來實現協程的掛起,longjmp會跳到其他地方去執行,使得后面的C代碼被中斷。l_foreach函數執行到lua_call,由于longjmp會使得后面的指令沒機會再執行,就像這個函數突然消失了一樣,這肯定會引起不可預知的后果,所以Lua不允許這種情況發生,它在調用coroutine.yield時拋出上面的錯誤。
作者點出了問題的關鍵: “由于longjmp會使得后面的指令沒機會再執行”,但講得不夠細,對于問題產生的條件沒有講清楚。
1.4 小結
以上解釋,感覺都沒有把這個問題說清楚,需要深入到 lua vm 的工作機制才能解釋清楚,所以有了這篇文章。
2. 從原理上分析問題
問題的關鍵就在于:
-
lua 是通過 setjmp/longjmp 實現 resume/yield 的。
-
lua 函數只操作 lua 數據棧,而 c 函數不止操作 lua 數據棧,還會操作 c 棧(即操作系統線程的棧)。
-
每個 lua 協程都有一個獨立的 lua 數據棧,但每個系統線程只有一個公共的 c 棧。
-
在協程的函數調用鏈中,會有 lua 函數也會有 c 函數,如果調用鏈中有 c 函數,并且在更后續的調用中出現 yield,就會 longjmp 回到 resume (setjmp) 之處,從而導致 c 函數依賴的 c 棧被恢復執行的協程的 c 函數調用給覆蓋掉。
setjmp/longjmp 示意圖:
c 棧從棧底向棧頂生長 棧底| || ||-----| co1 resume (setjmp) <-| | | | co2 | ||stack| ||-----| co2 yield (longjmp) ->棧頂
不懂 setjmp / longjmp 怎么工作的,可以參考這篇文章,講得很細了: setjmp是怎么工作的 。

上圖中:
1、co1 resume 了 co2,co2 開始執行,co2 的 callinfo 調用鏈中有 lua 也有 c 函數,其中的 c 函數會操作 lua 數據棧和 c 棧,c 棧在圖中就是 “co2 c stack” 那一塊內存。
2、co2 yield 的時候,co2 停止執行,co1 從上次 resume 處恢復。
3、co1 繼續往下執行,必然會有 c 函數調用,co1 的 c 函數會把 “co2 c stack” 這塊內存覆蓋掉,這意味著 co2 那些還沒執行完成的 c 函數的 c 棧被破壞了,即使 co2 再次被 resume,也無法正常運行了。
3. 從代碼上分析問題
其實講完原理就夠了,但是 lua 在 yield 這個問題上會選擇性不報錯,所以還是有必要從源碼上講一講。
以下分析使用的 lua 版本是 5.3.6(lua-5.2 跟 lua-5.4 也是差不多的)。
lua-5.3.6 官方下載鏈接: https://lua.org/ftp/lua-5.3.6.tar.gz 。
筆者的 github 也有 lua-5.3.6 源碼的 copy: https://github.com/antsmallant/antsmallant_blog_demo/tree/main/3rd/lua-5.3.6 。
下文展示的 demo 代碼都在此(有 makefile,可以直接跑起來):https://github.com/antsmallant/antsmallant_blog_demo/tree/main/blog_demo/lua-co-yield 。
3.1 情況一:lua 調用 c,在 c 中直接 yield
結果
yield 時不會報錯,但實際上也沒能正常工作。
不報錯的原因
這是 lua 官方的設定,lua 調用 c 函數或者其他什么函數,都是被編譯成 OP_CALL 指令,而 OP_CALL 并不會設一個標志位導致后面有 yield 的時候報錯;而 c 調用 lua 是用 lua_call 這個 api,它會設置一個標志位,后面 yield 時判斷到標志位就報錯: “attempt to yield across a C-call boundary”。
沒能正常工作的原因
上面分析過了,yield 之后,協程的 c 棧被恢復執行的協程覆蓋掉了。
上代碼吧。
lua 代碼:test_co_1.lua
-- test_co_1.lualocal co = require "coroutine"
local clib = require "clib"local co2 = co.create(function()clib.f1()
end)-- 第一次 resume
local ok1, ret1 = co.resume(co2)
print("in lua:", ok1, ret1)-- 第二次 resume
local ok2, ret2 = co.resume(co2)
print("in lua:", ok2, ret2)
c代碼:clib.c
// clib.c#include <stdlib.h>
#include <stdio.h>
#include <lua.h>
#include <lauxlib.h>static int f1(lua_State* L) {printf("clib.f1: before yield\n");lua_pushstring(L, "yield from clib.f1");lua_yield(L, 1);printf("clib.f1: after yield\n");return 0;
}LUAMOD_API int luaopen_clib(lua_State* L) {luaL_Reg funcs[] = {{"f1", f1},{NULL, NULL}};luaL_newlib(L, funcs);return 1;
}
輸出:
clib.f1: before yield
first time return: true yield from clib.f1
second time return: true nil
clib.f1 的這句代碼 printf("clib.f1: after yield\n");
在第二次 resume 的時候沒有被執行,但代碼也沒報錯,跟開頭說的結果一樣。lua 大概是認為沒有人會這樣寫代碼吧。
這種情況,如果要讓 clib.f1 能執行 yield 之后的,需要把 lua_yield 換成 lua_yieldk,然后把 yield 之后要執行的邏輯放到另一個函數里,類似這樣:
int f2_after_yield(lua_State* L, int status, lua_KContext ctx) {printf("clib.f2: after yield\n");return 0;
}static int f2(lua_State* L) {printf("clib.f2: before yield\n");lua_pushstring(L, "yield from clib.f2");lua_yieldk(L, 1, 0, f2_after_yield);return 0;
}
3.2 情況二:c 調用 lua,lua 后續調用出現 yield
結果
yield 時會報錯 “attempt to yield across a C-call boundary”。
原因
上面原理的時候分析過了,源碼實現上,c 調用 lua 是用的 lua_call 這個 api,它會設置一個標志位,在后續調用鏈中(無論隔了多少層,無論是 c 還是 lua)只要執行了 yield,都會判斷標志位,然后觸發報錯。
上代碼吧。
lua 代碼: test_co_3.lua
-- test_co_3.lualocal co = require "coroutine"
local clib = require "clib"function lua_func_for_c()print("enter lua_func_for_c")co.yield()print("leave lua_func_for_c")
endlocal co2 = co.create(function()print("enter co2")clib.f3()print("leave co2")
end)local ok, err = co.resume(co2)
print(ok, err)
c 代碼:clib.c
// clib.c#include <stdlib.h>
#include <stdio.h>
#include <lua.h>
#include <lauxlib.h>static int f3(lua_State* L) {printf("enter f3\n");lua_getglobal(L, "lua_func_for_c"); lua_call(L, 0, 0); // 調用 lua 腳本里定義的 lua 函數: lua_func_for_cprintf("leave f3\n");return 0;
}LUAMOD_API int luaopen_clib(lua_State* L) {luaL_Reg funcs[] = {{"f3", f3},{NULL, NULL}};luaL_newlib(L, funcs);return 1;
}
輸出:
enter co2
enter f3
enter lua_func_for_c
false attempt to yield across a C-call boundary
clib 里的 c 函數 f3,通過 lua_call 調用 lua 腳本里面定義的 lua 函數 lua_func_for_c,而 lua_func_for_c 里面會 yield,所以這種情況下 yield 就直接報錯了。
3.3 lua_call 是如何阻止后續 yield 的?
直接看 lua 源碼,lua_callk 會調用到 luaD_callnoyield,而 luaD_callnoyield 設置了標志位:
L->nny++;
而在 lua_yieldk 中,判斷了標志位:
if (L->nny > 0) {if (L != G(L)->mainthread)luaG_runerror(L, "attempt to yield across a C-call boundary");elseluaG_runerror(L, "attempt to yield from outside a coroutine");}
4. 問題總結 & 解決辦法
4.1 問題總結
經過上面分析,可以看到,問題的核心在于 lua 的多個協程共用一個 c 棧,而協程里面 c 函數調用又會依賴 c 棧,如果在它返回之前就 yield 了,則它依賴的 c 棧會被其他協程覆蓋掉,也就無法恢復運行了。按照 luajit 的說法,lua 官方實現不是一種 “fully resumable vm”。
這里面 yield 又分兩種情況:
情況 | 癥狀 | 原因 |
---|---|---|
lua調c | 不報錯,但也不正常工作 | lua 里調用函數(無論 lua 或 c),都是編譯成 OP_CALL 指令,這個指令的實現不會設置讓 yield 報錯的標志位 |
c調lua | 報錯 | 用的是 lua_call,它會設置讓 yield 報錯的標志位 |
4.2 解決辦法
4.2.1 lua-5.2 及以上
lua 對于此問題的解決方案是引入 lua_callk / lua_pcallk / lua_yieldk,要求使用者把 yield 之后要執行的東西放到一個單獨的函數 (類型為 lua_KFunction) 里,k 意為 continue,把這個 k 函數作為參數傳給 lua_callk / lua_pcallk / lua_yieldk,這個 k 函數會被記錄起來,等 yield 返回的時候調用它。
顯然,lua 官方的這種方案有點操蛋,但也不失為一種辦法。
不過悲催的是,lua 5.2 才引入 kfunction 的,所以 lua-5.1 要用其他的辦法。
4.2.2 lua-5.1
lua-5.1 有兩個辦法,都與 luajit 相關。
方法一:使用 luajit
直接使用 luajit ( https://luajit.org/luajit.html ),luajit 支持 “Fully Resumable VM”[1]:
The LuaJIT VM is fully resumable. This means you can yield from a coroutine even across contexts, where this would not possible with the standard Lua 5.1 VM: e.g. you can yield across pcall() and xpcall(), across iterators and across metamethods.
方法二:使用 lua-5.1.5 + coco 庫
coco 庫是 luajit 下面的一個子項目 ( https://coco.luajit.org/index.html ),它可以獨立于 luajit 之外使用的,但它只能用于 lua-5.1.5 版本。
它的介紹[2]:
Coco is a small extension to get True C Coroutine semantics for Lua 5.1. Coco is available as a patch set against the standard Lua 5.1.5 source distribution.
Coco is also integrated into LuaJIT 1.x to allow yielding for JIT compiled functions. But note that Coco does not depend on LuaJIT and works fine with plain Lua.
coco 庫能做到從 c 調用中恢復,是因為它為每個協程準備了專用的 c 棧:“Coco allows you to use a dedicated C stack for each coroutine”[2]。
所以,如果不使用 luajit,就使用官方的 lua-5.1.5,再 patch 上這個 coco 庫就可以了。
5. 總結
-
lua 官方實現的 vm 不是 “fully Resumable” 的,原因在于多個協程共用 c 棧,會導致協程的函數調用鏈中有 c 函數的情況下,yield 報錯或工作不正常。
-
lua 提供的函數中,有些使用了 lua_call/lua_pcall,容易導致 yield 報錯,比如 lua 函數:require,c 函數:luaL_dostring、luaL_dofile。
-
lua 提供的函數中,有些使用了 lua_callk/lua_pcallk 規避 yield 報錯,比如 lua 函數:dofile。
-
lua-5.2 及以上的,可以使用 lua_callk / lua_pcallk / lua_yieldk 來規避 yield 報錯問題。
-
lua-5.1 可以使用 luajit 或 lua-5.1.5官方版本+coco庫的方法來解決 yield 報錯問題。
6. 參考
[1] luajit. extensions. Available at https://luajit.org/extensions.html.
[2] luajit. Coco — True C Coroutines for Lua. Available at https://coco.luajit.org/index.html.