改進的協程補丁

lua-users home
維基

這是一個用於改進 Lua 5.0.2 中協程支援的補丁。其主要目的是盡可能地消除在元方法或 C 函數中產生協程的限制,同時不引入作業系統依賴性或對執行緒庫或 C 堆疊配置等作業系統功能的依賴。

補丁如下:檔案:wiki_insecure/power_patches/5.0/ejcoro.patch

以下是自述檔案的簡略維基化版本...

-Eric Jacobs


它向後相容,除了那些尋找特定錯誤訊息「嘗試跨越元方法/C 呼叫邊界產生」的程式,該訊息已被更改為更準確,並說明使用了哪個與產生不相容的 Lua API 呼叫。

運作原理

此補丁使用與 Lua 在標準情況下(Lua 函數呼叫 Lua 函數)略有不同的策略來允許產生。在標準情況下,Lua 為使用普通函數呼叫呼叫的任意數量的 Lua 框架(CallInfo)維護一組 C 堆疊框架。這些 C 堆疊框架中最底層的是直譯器的主要迴圈 luaV_execute()。從 Lua 到 Lua 的呼叫是在不重新進入 luaV_execute() 的情況下完成的;而是將新的 Lua 框架新增或移除堆疊,並執行「goto」以在適當的位置重新啟動 luaV_execute() C 框架。

                                  /<+------------+
                                 /  |  luaFunc3  |
                                /   |            |
                               /    +------------+
                              /     |  luaFunc2  |
                             /      | CI_CALLING |
                            /       +------------+
            +--------------+  1:n   |  luaFunc1  |
            | luaV_execute |        | CI_CALLING |
            +--------------+<======>+------------+
                C stack              Lua CallInfo's
此圖說明了在 Lua 函數呼叫 Lua 函數的情況下,C 堆疊和 Lua CallInfo 框架堆疊如何對齊。luaV_execute() 的單個 C 框架映射到任意數量的 Lua 框架。請注意,CI_CALLING 標誌設置在以此方式進行呼叫的函數中。

當元方法或 C 函數回調到標準 Lua 時,會重新進入 luaV_execute() 函數,並且禁止產生。

                                                  
                                    +------------+
                                    |  luaFunc2  |
            +--------------+        |            |
            | luaV_execute |<======>|            |
  ..........+--------------+........+------------+  
            |   callTMxxx  |        |  luaFunc1  |
            +--------------+        |            |
            | luaV_execute |<======>|            |
            +--------------+        +------------+
                C stack              Lua CallInfo's

                                    +------------+
                                    |  luaFunc2  |
            +--------------+        |            |
            | luaV_execute |<======>|            |
  ..........+--------------+........+------------+  
            |    cFunc     |        |   cFunc   C|
            +--------------+        +------------+
            | luaV_execute |<======>|  luaFunc1  |
            +--------------+        +------------+
                C stack              Lua CallInfo's
此圖顯示了在呼叫 Lua 程式碼的元方法或 C 函數的情況下,C 堆疊和 Lua CallInfo 如何對齊。在這種情況下,未設置 CI_CALLING 標誌。虛線表示 C 堆疊如何分為兩組 C 框架,每個 Lua CallInfo 一組。這條線正是標準 Lua 不允許您跨越的邊界。

消除元方法的此限制的一種策略是使元方法呼叫的行為類似於 Lua 的普通函數呼叫;也就是說,它們不會重新進入 luaV_execute(),而只是「goto」到已在執行的 luaV_execute() C 框架的開頭。雖然對於純 Lua 元方法來說可能是可行的,但由於以下兩個原因,這種方法不適用於 C 函數

C 函數通常將局部變數存儲在 C 堆疊上。為了維護 luaV_execute() 的單個實例,必須將這些變數從 C 堆疊移到其他地方(例如,Lua 堆疊),以便讓 C 堆疊展開。這對於 Lua 函數來說不是問題,因為 Lua 函數將其局部變數存儲在 Lua 堆疊上。但是,對於 C 函數,必須展開堆疊才能回調到 Lua。這是 C 函數的不良要求。儘管 C 函數必須能夠這樣做才能充分利用協程,但並非每次都必須這樣做,才能回調到 Lua。如果被呼叫的程式碼從未產生,那麼來回傳輸變數只是浪費 CPU 時間。

此補丁通過使用樂觀策略來解決這些問題。當從 C 函數呼叫 Lua 程式碼時,首先假設 Lua 程式碼不會產生,並且 C 堆疊會像在標準 Lua 中那樣遞迴地建立。如果被呼叫的函數在沒有產生的情況下完成,則情況與標準 Lua 完全相同。但是假設 Lua 程式碼反而產生了

                +-----------------+        +------------+
            /---| coroutine.yield |        |  luaFunc2  |
            |   +-----------------+        |            |
       -1   |   |  luaV_execute   |<======>|            |  <-
      Yield |   +-----------------+        +------------+    \
      return|   |     cFunc       |        |   cFunc   C|  <-- CI_CALLING flag becomes set
      value |   +-----------------+        +------------+    /
            |   |  luaV_execute   |<======>|  luaFunc1  |  <-
            v   +-----------------+        +------------+
                     C stack                Lua CallInfo's
yield 函數產生返回值 -1。此時,直譯器知道樂觀策略不起作用,並開始展開 C 堆疊。隨著 yield 返回值向下傳播,每個 C 框架都必須將其狀態保存在相應的 Lua 框架中,設置 CI_CALLING 標誌並返回 -1,否則拋出錯誤,說明無法完成 yield。正是在這一點上,將發生關於非法產生的錯誤。

如果 yield 成功,則 C 堆疊返回其展開狀態,並且 Lua 堆疊包含所有狀態資訊。通過等到出現 -1 yield 返回值後再要求 C 函數保存狀態,我們優化了不發生 yield 的情況,同時讓 C 函數有機會在發生 yield 時處理它。

恢復協程時,會像在當前 Lua 中一樣檢查頂部 CallInfo。由於堆疊已成功展開,因此設置了 CI_CALLING 標誌。這表明完成函數呼叫的程式碼(對 luaD_poscall 的呼叫)沒有在 C continuation 上等待,我們需要根據 CallInfo 狀態找出如何完成函數呼叫並恢復函數。對於 Lua 函數,這涉及查看 (ci->savedpc - 1) 處指令的操作碼。在一個名為 luaV_return() 的新函數中有一個開關可以做到這一點。對於 C 函數,它涉及再次呼叫 C 函數以要求它恢復自身。在 CallInfo 結構中有一個新的使用者定義的整數/指標聯合欄位,C 函數可以使用該欄位來做到這一點。這也由 luaV_return() 處理。

以下是補丁更改的摘要

錯誤訊息指示使用了哪個無法處理 yield 的 API 呼叫。

新的 API 函數

int lua_call_yp (lua_State *L, int nargs, int nresults, int tailcall);

此函數與 lua_call() 的含義相同,只是它不會阻止被呼叫的程式碼產生。如果發生 yield,則返回值為 -1。否則,返回值為實際結果數。

如果此呼叫是 C 函數要執行的最後一個操作,則 tailcall 參數應設置為 1。在這種情況下,如果程式碼產生,則當它恢復時,被呼叫的函數返回的結果將成為 C 函數的結果。在這種情況下,可以像這樣簡單地呼叫 API

return lua_call_yp(..., 1);

如果 tailcall 參數設置為 0,並且程式碼產生,則當它恢復時,將重新呼叫 C 函數。在這種情況下,lua_call_yp() 返回 -1,並且 C 函數將需要保存其局部變數和其他狀態的狀態,以便在重新呼叫 C 函數時能夠重建它們。它可以使用 Lua 堆疊來做到這一點;但是,它_不能_將值壓入 Lua 堆疊,因為被呼叫的函數已直接佔用了堆疊上方的空間。

對於許多 C 函數來說,最簡單的方法可能是在堆疊中保留幾個插槽,這些插槽可以用數字或使用者資料填充(如果需要清理,則設置 __gc 元方法。)

當重新呼叫 C 函數時,Lua 堆疊就像原始 lua_call_yp() 成功時的樣子,並且可以再次將值壓入堆疊。

void *lua_get_frame_state (lua_State *L);

此函數返回指向 Lua 堆疊框架中變數的指標,C 函數可以使用該指標在 C 函數的多次呼叫之間保留狀態。返回值可以轉換為 int * 或 void **。當第一次呼叫 C 函數時,框架狀態的值始終為 0。在返回 -1 以防產生 API 呼叫之前,C 函數應將框架狀態設置為非零值,以便它在下次被呼叫時能夠識別它正在被恢復。

框架狀態值的可能用途是:簡單迴圈的數字計數器、存儲變數的堆疊索引或指向使用者資料的指標(當然,使用者資料必須存儲在堆疊中的某個位置,以防止它被收集。)

使用 YP API
  table.foreachi()
  table.foreach()
  tostring()
  print()
  dofile()
  string.gsub()
因此,從這些函數呼叫的回調函數能夠產生。

內部變更

這些操作碼是

  
      OP_CALL
      OP_TAILCALL
      OP_GETTABLE
      OP_GETGLOBAL
      OP_SELF
      OP_ADD
      OP_SUB
      OP_MUL
      OP_DIV
      OP_POW
      OP_CONCAT
      OP_UNM
      OP_LT
      OP_LE
      OP_EQ
      OP_SETTABLE
      OP_SETGLOBAL
      OP_TFORLOOP
luaV_return() 在執行 OP_RETURN 期間從 luaV_execute() 呼叫,並在恢復協程時從 resume() 呼叫。

0 = 此 CallInfo 存在一個活動的 C 堆疊框架,

它正在等待下一個更高的
CallInfo 的返回值通過 C-return 返回給它,並且
將使用該返回值執行 luaD_poscall() 以
停用下一個更高的 CallInfo
參考:luaD_call、luaD_call_yp、resume、luaV_execute
1 = 此
CallInfo 不存在活動的 C 堆疊框架。當下一個更高的 CallInfo 返回時,
必須使用指向
此 CallInfo 的指標呼叫 luaV_return() 函數才能恢復它。luaV_return()
處理 luaD_poscall() 以停用下一個
更高的 CallInfo
使用此描述,很容易看出必須在哪裡設置 CI_CALLING。它應該由呼叫 luaD_precall() 並且即將呼叫 luaD_poscall() 的函數設置,但由於被呼叫的函數產生(luaD_precall() > L->top 或 luaV_execute() 返回 NULL)並且該函數必須返回,因此它將沒有機會這樣做。

CI_CALLING 標誌設置在 -

lvm.c 中,在執行 OP_CALL 和 OP_TAILCALL 期間,當

它們呼叫產生 yield 的 C 函數時;
lvm.c 中,在執行呼叫 Lua 函數的 OP_CALL 期間;

lvm.c 中,在執行呼叫元方法的操作碼期間

獲取值,並且元方法產生。
(通過 SETOBJ2S_YP 巨集,它也保存 PC。)
lvm.c 中,在執行呼叫元方法的操作碼期間
但不需要值,並且元方法產生。
(通過也保存 PC 的 YP 巨集。)
lvm.c 中,在執行 OP_UNM、OP_EQ、OP_LE、OP_LT 期間
操作碼的操作方式類似,但不使用巨集。
在 lapi.c 中,當 C 函式呼叫 lua_call_yp() 時,若
被呼叫的函式發生 yield。在這種情況下,沒有 PC
可以儲存;C 函式需要負責儲存自己的
狀態。但是,堆疊指標和從 lua_call_yp() 要求的
結果數量會被儲存
到 CallInfo 中,以便將來可以完成呼叫。

nCcalls 計數器不再用於禁止 yield 的發生。相反,無效 yield 的錯誤是由無法處理 yield 的 API 呼叫產生的。nCcalls 計數器仍然會被維護;但是,它的值可能沒有那麼有用,因為它會計算已暫停(從 C 堆疊中移除)的 C 函式;因此它可能與 C 堆疊的大小沒有任何關係。

  lua_gettable_yp()
  lua_settable_yp()
  ...
  etc

完整的 YP API 函式集



尋找一種方法讓 C 函式能夠在需要暫停時分配堆疊空間?這可能非常困難,因為下一個較高的函式已經在我們之上。目前,C 函式必須預先分配它們可能需要的任何堆疊空間。