Go 部落格
Go Playground 內幕
簡介
注意:本文並未說明目前版本的 Go Playground。
2010 年 9 月,我們介紹了 Go Playground,這是一個編譯並執行任意 Go 程式碼和回傳程式輸出項目的網路服務。
如果您是 Go 程式設計師,您可能已經使用 Go Playground 直接透過使用它、進行 Go Tour 或執行 Go 文件中的可執行範例。
您可能也曾在 go.dev/talks 上的簡報中按一下某個「執行」按鈕或はこの部落格上某篇文章(例如最近一篇文章 Strings)中按一下某個「執行」按鈕而使用過此服務。
在本文中,我們將深入了解 Playground 如何建置和與這些服務整合。建置包含多變的作業系統環境和執行階段,而且本文假設您對使用 Go 進行系統程式設計相當熟悉。
概觀

Playground 服務有三部分
- 於 Google 伺服器上執行的後端執行緒。它會接收到 RPC 要求,使用 gc 工具鏈編譯使用者程式,執行使用者程式,並將程式輸出 (或編譯錯誤) 以 RPC 反應的方式回傳。
- 在 Google App Engine 上執行的前端執行緒。它會接收到客戶端的 HTTP 要求,並對後端執行緒提出相對應的 RPC 要求。它也會執行一些快取。
- 實作使用者介面的 JavaScript 執行緒,並對前端執行緒提出 HTTP 要求。
後端執行緒
後端程式本身是微不足道的,所以我們在此處不會討論其實作。有趣的部分是在於我們如何在仍然提供時間、網路和檔案系統等核心功能的同時,在安全環境中安全地執行任意使用者程式碼。
為了將使用者程式與 Google 的基礎架構隔離,後端執行緒會在 Native Client (或「NaCl」) 的保護之下執行這些程式,這項技術是由 Google 所開發,用於在網路瀏覽器中安全執行 x86 程式。後端執行緒會使用 gc 工具鏈的特別版本來產生 NaCl 可執行檔。
(此特別工具鏈已併入到 Go 1.3。如需深入瞭解,請參閱 設計文件。))
NaCl 會限制程式可耗用的 CPU 和 RAM 使用量,並防止程式存取網路或檔案系統。然而,這會造成一個問題。Go 的並行運算和網路支援是其主要功能,而存取檔案系統對於許多程式而言至關重要。為了有效地展示並行運算,我們需要時間,而為了展示網路和檔案系統,顯然我們需要網路和檔案系統。
儘管今日已支援所有這些事項,但遊戲場的第一個版本是在 2010 年發佈,當時這些事項皆未支援。目前時間固定在 2009 年 11 月 10 日,time.Sleep
沒有任何效果,而 os
和 net
套件的大部分函式皆會被標註為返回 EINVALID
錯誤。
一年前,我們在遊戲場中 實作了假造時間,讓進行休眠的程式能正確運作。遊戲場最近的更新中引入了假造網路堆疊和假造檔案系統,使遊戲場的工具鏈類似於一般的 Go 工具鏈。後續各節將針對這些功能進行說明。
假造時間
遊樂場程式在可使用 CPU 時間和記憶體的量上受到限制,但它們也能在可使用的真實時間量上受到限制。這是因為每一個執行的程式都會消耗後端資源,以及它和客戶端之間的任何有狀態基礎架構。限制每個遊樂場程式的執行時間,會讓我們的服務更可預測,並抵禦拒絕服務攻擊。
但在執行使用時間的程式時,這些限制會變得窒礙難行。Go 並行模式 演講中透過使用計時功能來展示並行,例如 time.Sleep
和 time.After
。在早期版本的遊樂場下執行時,這些程式的睡眠不會產生任何效果,而且它們的行為會很奇怪 (有時是錯誤的)。
透過使用一種技巧,我們可以讓 Go 程式認為它正在睡眠,然而事實上這些睡眠根本不需要任何時間。為了解這個技巧,我們首先需要了解排程器是如何管理睡眠的 goroutine。
當 goroutine 呼叫 time.Sleep
(或類似函式) 時,排程器會將計時器加入堆疊的待處理計時器中,並讓 goroutine 休眠。同時,一個特殊的計時器 goroutine 來管理堆疊。當計時器 goroutine 啟動時,它會告訴排程器,在堆疊中下一個待處理的計時器準備好觸發時叫醒它,然後進行休眠。當它醒來時,它會檢查有哪些計時器已過期,喚醒適當的 goroutine,然後再次進行休眠。
技巧在於改變喚醒計時器 goroutine 的條件。我們修改排程器,讓它等待一個僵局,而非在特定時間後喚醒它;僵局是指所有 goroutine 都遭到封鎖的狀態。
遊樂場版的執行時間維護其自己的內部時鐘。當修改後的排程器偵測到僵局時,它會檢查是否有任何待處理的計時器。如果有,它會將內部時鐘推進到最早計時器的觸發時間,然後喚醒計時器 goroutine。執行作業會持續,而且程式會認為時間已經過去了,儘管事實上睡眠幾乎是瞬間發生的。
對排程器的這些變更可以在 proc.c
和 time.goc
中找到。
假的時間修正了後端資源耗盡的問題,但程式輸出呢?看到一個睡眠的程式正確執行而沒有花費任何時間,會是一件奇怪的事。
下列程式每秒列印一次目前時間,然後在三秒後結束執行。請嘗試執行它。
func main() { stop := time.After(3 * time.Second) tick := time.NewTicker(1 * time.Second) defer tick.Stop() for { select { case <-tick.C: fmt.Println(time.Now()) case <-stop: return } } }
這是如何運作的?是後端、前端和客戶端之間的合作。
我們擷取每個寫入標準輸出和標準錯誤的時間,並提供給用戶端。然後,用戶端可以依照正確時間「回放」寫入,讓輸出顯示就像程式執行在本地一般。
遊樂場的 runtime
套件提供了特殊 write
函式,在每次寫入之前都包含小型「回放標頭」。回放標頭包含魔法字串、目前時間以及寫入資料長度。包含回放標頭的寫入具有以下結構:
0 0 P B <8-byte time> <4-byte data length> <data>
上述程式的原始輸出如下所示:
\x00\x00PB\x11\x74\xef\xed\xe6\xb3\x2a\x00\x00\x00\x00\x1e2009-11-10 23:00:01 +0000 UTC
\x00\x00PB\x11\x74\xef\xee\x22\x4d\xf4\x00\x00\x00\x00\x1e2009-11-10 23:00:02 +0000 UTC
\x00\x00PB\x11\x74\xef\xee\x5d\xe8\xbe\x00\x00\x00\x00\x1e2009-11-10 23:00:03 +0000 UTC
前端會將此輸出剖析為一連串事件,並以 JSON 物件的形式傳回事件清單給用戶端。
{
"Errors": "",
"Events": [
{
"Delay": 1000000000,
"Message": "2009-11-10 23:00:01 +0000 UTC\n"
},
{
"Delay": 1000000000,
"Message": "2009-11-10 23:00:02 +0000 UTC\n"
},
{
"Delay": 1000000000,
"Message": "2009-11-10 23:00:03 +0000 UTC\n"
}
]
}
隨後,JavaScript 用戶端(執行在使用者的網路瀏覽器中)會使用提供的延遲間隔回放這些事件。對使用者來說,就像程式是即時執行一般。
偽造檔案系統
以 Go 的 NaCl 工具鏈建立的程式無法存取本地電腦的檔案系統。相反地,syscall
套件的檔案相關函式(Open
、Read
、Write
等)運作於 syscall
套件本身實作的記憶體內檔案系統。由於 syscall
套件是 Go 程式和作業系統核心之間的介面,因此使用者程式會以與真實檔案系統完全相同的方式看到檔案系統。
以下範例程式會寫入資料到檔案中,然後將其內容複製到標準輸出。試著執行看看。(也可以編輯看看!
func main() { const filename = "/tmp/file.txt" err := ioutil.WriteFile(filename, []byte("Hello, file system\n"), 0644) if err != nil { log.Fatal(err) } b, err := ioutil.ReadFile(filename) if err != nil { log.Fatal(err) } fmt.Printf("%s", b) }
處理序啟動時,檔案系統會在 /dev
下載入一些裝置,以及空的 /tmp
目錄。程式可以像平常一樣操控檔案系統,但是當處理序結束時,檔案系統的任何變更都會消失。
在初始化時也有提供載入 zip 檔案到檔案系統的程式(請參閱 unzip_nacl.go
)。到目前為止,我們只使用 unzip 功能來提供執行標準函式庫測試所需的資料檔案,但是我們打算針對遊樂場程式提供可以在文件範例、部落格文章和 Go Tour 中使用的檔案組。
實作可以在 fs_nacl.go
和 fd_nacl.go
檔案(根據其 _nacl
字尾,只有當 GOOS
設為 nacl
時才會建置到 syscall
套件中)中找到。
檔案系統本身由 fsys
結構 表示,其全域實例(命名為 fs
)是在初始化時建立的。然後各種與檔案相關的函數在 fs
上執行,而不是進行實際的系統呼叫。例如,以下是 syscall.Open
函數
func Open(path string, openmode int, perm uint32) (fd int, err error) {
fs.mu.Lock()
defer fs.mu.Unlock()
f, err := fs.open(path, openmode, perm&0777|S_IFREG)
if err != nil {
return -1, err
}
return newFD(f), nil
}
檔案描述符由名為 files
的全域區塊追蹤。每個檔案描述符對應到一個 file
,而每個 file
會提供實作 fileImpl
介面的值。介面有幾個實作
- 一般檔案和裝置(例如
/dev/random
)由fsysFile
表示, - 標準輸入、輸出和錯誤是
naclFile
的實例,它使用系統呼叫與實際檔案互動(這些是遊樂場程式與外界互動的唯一方式), - 網路通訊端點有自己的實作,如下一章所述。
偽造網路
與檔案系統類似,遊樂場的網路堆疊是 syscall
套件實作的程序內偽造。它允許遊樂場專案使用環回介面(127.0.0.1
)。對其他主機的請求將會失敗。
以下是一個可執行的範例程式。它會在 TCP 埠等待,等待一個連線,將該連線的資料複製到標準輸出,然後結束程式。在另一個 goroutine 中,它會連線到監聽埠,將字串寫入連線並關閉它。
func main() { l, err := net.Listen("tcp", "127.0.0.1:4000") if err != nil { log.Fatal(err) } defer l.Close() go dial() c, err := l.Accept() if err != nil { log.Fatal(err) } defer c.Close() io.Copy(os.Stdout, c) } func dial() { c, err := net.Dial("tcp", "127.0.0.1:4000") if err != nil { log.Fatal(err) } defer c.Close() c.Write([]byte("Hello, network\n")) }
網路的介面比檔案的介面複雜,所以偽造網路的實作比偽造檔案系統更長更大。它必須模擬讀取和寫入超時、不同的位址類型和協定等等。
說明文件請參閱 net_nacl.go
。入門閱讀建議從 netFile
開始,它就是網路插座fileImpl
介面的說明文件實作。
前端
程式碼互動操場的前端也是個簡單的程式 (少於 100 行)。它接收客戶端的 HTTP 要求,執行對後端的 RPC 要求,並進行一些快取。
前端在 https://go.dev.org.tw/compile
執行 HTTP 處理程式。處理程式會預期 POST 要求內含 body
欄位 (要執行的 Go 程式),和一個選填的 version
欄位 (大多數客戶端的 version
應該為 "2"
)。
接收編譯要求時,前端會先查看 memcache,找出先前是否已快取過該程式原始碼編譯後的結果。如果找到,它會傳回快取的回應。這個快取機制可以防止熱門程式 (例如 Go 首頁 上的程式) 讓後端過載。如果沒有快取回應,前端會執行對後端的 RPC 要求,將回應儲存在 memcache,解析回放事件,並以 JSON 物件回傳給客戶端,也就是上述的 HTTP 回應。
客戶端
許多使用操場的網站各自分享一些設定使用者介面的通用 JavaScript 程式碼 (程式碼和輸出方塊、執行按鈕,等等) 並與操場前端進行通訊。
此實作位在 go.tools
儲存庫的 playground.js
檔案中,可以從 golang.org/x/tools/godoc/static
套件匯入。其中,有些部分簡潔明瞭,有些部分則有點凌亂,因為它是將分散的數個客戶端程式碼實作統合起來。
playground
函式會取得一些 HTML 元素,並將其轉換成互動式操場小工具。如果您想在自己的網站上放置操場,請使用此函式 (請參閱下列的「其他客戶端」)。
Transport
界面(並沒有正式定義,因為這是 JavaScript)將使用者介面從與 Web 前端通訊的方法中抽象出來。HTTPTransport
是實作 Transport
,會使用前面說明的根據 HTTP 協定的實作。SocketTransport
是另一種實作,會使用 WebSocket(請參閱下方的「離線播放」)。
為了遵守 同源政策,各種 Web 伺服器 (例如 godoc) 都會將對 /compile
的請求代理傳送到 https://go.dev.org.tw/compile
上的 playground 服務。常見的 golang.org/x/tools/playground
套件會做這項代理工作。
離線播放
Go Tour
和 展示工具 都可以離線執行。這非常適合網路連線不穩定的使用者,或是在無法 (且不應該) 依賴正常網路連線的會議演示者。
若要離線執行,這些工具會在本地電腦上執行屬於自己的 playground 後端版本。後端會使用一般的 Go 工具鏈,而且不會做上述任何修改,並會使用 WebSocket 與用戶端通訊。
WebSocket 後端實作可以在 golang.org/x/tools/playground/socket
套件中找到。Inside Present
演講中有詳細說明這段程式碼。
其他用戶端
playground 服務不僅是由官方 Go 專案 (Go by Example 就是另一個實例) 使用,而且我們也非常歡迎您在自己的網站上使用。我們唯一的要求是,請您先 聯絡我們、在請求中使用獨特的使用者代理(這樣我們才能識別您的身分),以及您的服務對 Go 社群有益。
結論
從 godoc 到導覽到本部落格,playground 已成為我們 Go 文件故事中不可或缺的一部分。隨著最近新增的偽檔案系統和網路堆疊,我們非常興奮地要將我們的學習教材擴充到這些領域。
不過,playground 最終來說還只是冰山一角。隨著 Native Client 支援排定於 Go 1.3 上線,我們期待看到社群可以發揮它的哪些功用。
本文為 Go Advent Calendar系列的第 12 篇,是為期 12 月的每日部落格文章。
下一篇文章:Go on App Engine:工具、測試和並發處理
前一篇文章:頭條新聞
部落格索引