把 Go 放到 Nginx C module 之中

spacewander發表於2022-11-28

最近一段時間,我在做一件有趣的事情,讓一個 Nginx C module 透過 Go 程式碼來訪問 gRPC 服務。不得不感慨 Go 真的很流行,讓人無法拒絕。之前我做 wasm-nginx-module 時就試圖把 tinygo 跑在 Nginx 裡面,這次則是把正宗的 Go 跑在 Nginx 裡面。這就是無法迴避的命運嗎?!

作為一個想要挑戰 C 語言歷史地位的後起之秀,Go 提供了和 C 的緊密結合的功能,方便使用者更好地過渡到新世界當中。這個功能有個樸素的名字,就是 CGO。當然,由於 Go 自己的野望,它並不甘心於複用 C 的那一套,結果就是 CGO 的呼叫效能不如人意。當然本文不是關乎呼叫效能和細節的,而是講講 Nginx C module 整合 Go 時遇到的一些挑戰。

一開始,我計劃透過靜態連結把 Go 程式碼直接編譯到 Nginx 裡面,這樣就不需要額外再打包程式碼了。編譯是成功了,但是在開發過程中,我發現對應的 Go 程式碼並沒有執行。透過 dlv 檢視發現,goroutine 是建立出來了,卻沒有被排程到。花了些時間查詢資料,我這才發現由於 Go 是多執行緒應用,而在 fork 之後,子程式並不會繼承父程式的所有執行緒,結果到了子程式那裡就沒有執行緒可以排程 goroutine 了。對於一般的 C 專案可能不是大問題,但不巧的是 Nginx 是 master-worker 架構的。如果走靜態連結的方式, master 上的 Go 執行緒將無法複製到 worker 上。為了解決這個問題,我把 Go 程式碼編譯成動態連結庫,再在 worker 程式上完成載入。

為了協調 Go 協程和 Lua 協程,我們實現了一個任務佇列機制。當 Lua 程式碼從 gRPC 發起一個 IO 操作時,它會向 Go 端提交一個任務,然後掛起自己。這個任務由 Go 協程執行,其結果被寫入佇列中。在 Nginx 端有一個後臺執行緒,消耗任務執行的結果,並重新安排相應的 Lua 協程繼續執行 Lua 程式碼。這樣,在 Lua 程式碼看來,gRPC 的 IO 操作與普通的 socket 操作沒有什麼區別。

後臺執行緒由 Nginx 的 thread_pool 指令配置。它會阻塞在消費任務執行結果佇列中,直到返回至少一個結果。但是在開發中,我發現 Nginx 在退出時,會等待所有 thread_pool 裡的執行緒完成任務。這意味著如果後臺執行緒無限期地阻塞在結果佇列中,Nginx 將無法退出。所以我增加了一個等待時間,一旦超過給定時間後,仍未消費到任務結果,後臺執行緒會直接返回。如果此時 Nginx 尚無退出,且還有任務沒有完成,那麼後臺執行緒會重新嘗試消費結果佇列。

在進行 gRPC 操作時,我遇到了另一個難題。通常我們在 Go 中使用 gRPC,會基於 .proto 檔案生成對應的 Go 程式碼,然後編譯到專案中。Go 結構體和二進位制的 Protobuf 間的轉換,是在生成的程式碼裡完成的。但是我做的是一個 Nginx C module,而且要求在執行時能夠載入 gRPC 的 .proto 檔案,自然沒辦法事先生成好 Go 程式碼並編譯進來。這麼一來,我得探索一條別出心裁的道理。憑藉閱讀 grpc-go 的程式碼,我發現透過 encoding.RegisterCodec 這個介面,可以註冊自定義的 Marshal/Unmarshal 來覆蓋掉內建的處理。 於是我自定義了對應的方法,允許直接傳二進位制形式的 Protobuf 而不是某個 Go 的結構體。這麼搞之後,我們就可以在 Lua 程式碼裡面動態載入 .proto 檔案,根據載入的 schema 完成 Protobuf 的編解碼操作。

由於 gRPC 互動包含了若干 IO 操作(比如先 connect,接著 send,然後 read),所以我們需要多次向 Go 端提交任務,而這些任務要共享一個上下文。每次 IO 操作之後,我們需要保留這個上下文,後面相關的操作時會帶上。整個過程是這樣的:

  1. Nginx 端建立一個上下文(物件 C)
  2. 以物件 C 的地址作為 key,到 Go 端的對應 sync.Map 裡面查詢是否有 Go 端的上下文
  3. 如果沒有,那麼建立一個上下文(物件 G)。物件 G 是物件 C 在 Go 端的化身。
  4. 使用這個上下文來完成 gRPC 操作
  5. 後續圍繞物件 C 的一系列 IO 操作,都會由物件 G 在 Go 端完成。
  6. 當我們銷燬物件 C 時,到 Go 端來 delete 掉 sync.Map 的引用,釋放物件 G。

上述流程把 C 和 G 繫結了起來,確保如果物件 C 的生命週期是正確的,那麼物件 G 的生命週期也是正確的。當我們用 ASAN 等機制保證了物件 C 的分配沒有記憶體洩漏,那麼物件 G 也不會被束縛在 sync.Map 裡。

目前該 Nginx C module 已經支援 gRPC 全部四種請求型別:

  1. unary
  2. client stream
  3. server stream
  4. bidirectional stream

有些細節部分還需要打磨,不過功能已經基本可用了。

我們已經把它開源出來:https://github.com/api7/grpc-...
歡迎大家試用,並參與到專案的開發中來。

番外篇:是否可以使用 Go 擴充 Nginx?

由於 Go 並不是作為嵌入式語言設計的,所以把 Go 嵌入到 Nginx 裡面,會受一些限制:

  1. Nginx 許多 API 不是執行緒安全的,比如基本的 log 操作。Go 作為多執行緒的後臺元件,沒辦法保證呼叫 Nginx 時的執行緒安全。舉個例子,Go 裡面呼叫 ngx.log 時,Nginx 也可能正在呼叫 ngx.log,兩者會有 race。所以在開發 grpc-client-nginx-module 時,我沒辦法讓 Go 側的程式碼直接打日誌到 Nginx 的 error.log 中,給除錯增加了難度。後面我另外在 Go 側程式碼裡實現了一套用於 debug 的 log 機制。
  2. Go 的 recover 只能捕獲自己所在的 goroutine 裡面的崩潰,救不了被呼叫的函式里面起的 goroutine 導致的崩潰。一旦崩潰跨越語言的邊界,就會帶著 Nginx worker 程式一起掛掉。

在是否決定引入 Go 程式碼到你的 Nginx 專案之前,可以衡量下它帶來的好處能否超過對應的侷限。

相關文章