Go 部落格

Go Playground 內部

Andrew Gerrand
2013 年 12 月 12 日

引言

注意:本文件不描述 Go Playground 的當前版本。

2010 年 9 月,我們 釋出了 Go Playground,這是一個編譯和執行任意 Go 程式碼並返回程式輸出的 Web 服務。

如果您是 Go 程式設計師,那麼您可能已經透過直接使用 Go Playground、學習 Go Tour 或執行 Go 文件中的 可執行示例 來使用過 Playground。

您也可能透過點選 go.dev/talks 上的幻燈片簡報或本文(例如,關於字串的近期文章)中的“執行”按鈕來使用過它。

在本文中,我們將探討 Playground 的實現方式以及它如何與這些服務整合。實現涉及一個特殊的作業系統環境和執行時,我們的描述假定您熟悉使用 Go 進行系統程式設計。

概覽

Playground 服務有三個部分:

  • 一個後端,執行在 Google 的伺服器上。它接收 RPC 請求,使用 gc 工具鏈編譯使用者程式,執行使用者程式,並將程式輸出(或編譯錯誤)作為 RPC 響應返回。
  • 一個前端,執行在 Google App Engine 上。它接收來自客戶端的 HTTP 請求,並向後端發出相應的 RPC 請求。它還進行一些快取。
  • 一個 JavaScript 客戶端,實現了使用者介面並向前端傳送 HTTP 請求。

後端

後端程式本身很簡單,所以我們不在此討論其實現。有趣的部分在於我們如何在安全的環境中安全地執行任意使用者程式碼,同時仍然提供時間、網路和檔案系統等核心功能。

為了將使用者程式與 Google 的基礎設施隔離,後端在 Native Client(簡稱“NaCl”)下執行它們。NaCl 是 Google 開發的一項技術,允許在 Web 瀏覽器內部安全地執行 x86 程式。後端使用一個特殊版本的 gc 工具鏈,該工具鏈生成 NaCl 可執行檔案。

(這個特殊工具鏈已合併到 Go 1.3 版本中。要了解更多資訊,請閱讀設計文件。)

NaCl 限制了程式可以消耗的 CPU 和 RAM 量,並阻止程式訪問網路或檔案系統。然而,這帶來了一個問題。Go 的併發和網路支援是其關鍵優勢之一,而對檔案系統的訪問對於許多程式至關重要。為了有效地演示併發,我們需要時間;而為了演示網路和檔案系統,我們顯然需要網路和檔案系統。

儘管所有這些功能今天都已支援,但 Playground 的第一個版本(於 2010 年釋出)卻沒有這些功能。當前時間固定為 2009 年 11 月 10 日,time.Sleep 沒有效果,osnet 包的大部分函式都被佔位符替換為返回 EINVALID 錯誤。

一年前,我們在 Playground 中實現了偽造時間,以便休眠的程式能夠正確執行。最近一次 Playground 更新引入了一個偽造的網路堆疊和一個偽造的檔案系統,使得 Playground 的工具鏈類似於正常的 Go 工具鏈。這些功能將在以下各節中進行介紹。

偽造時間

Playground 程式在 CPU 時間和記憶體使用量上受到限制,但它們在實際可用時間上也受到限制。這是因為每個正在執行的程式都會消耗後端以及它與客戶端之間的任何有狀態基礎設施上的資源。限制每個 Playground 程式的執行時間可以使我們的服務更具可預測性,並防禦拒絕服務攻擊。

但這些限制在執行使用時間的程式時會變得令人沮喪。Go 併發模式講座透過使用諸如 time.Sleeptime.After 等時間函式作為示例來演示併發。在早期版本的 Playground 中執行這些程式時,它們的休眠將不起作用,行為將很奇怪(有時甚至是錯誤的)。

透過一個巧妙的技巧,我們可以讓 Go 程式 *認為* 它在休眠,而實際上休眠根本不花費時間。為了解釋這個技巧,我們首先需要理解排程程式是如何管理休眠的 goroutine 的。

當一個 goroutine 呼叫 time.Sleep(或類似函式)時,排程程式會將一個計時器新增到待處理計時器的堆中,並將 goroutine 置於休眠狀態。同時,一個特殊的計時器 goroutine 管理著該堆。當計時器 goroutine 啟動時,它會告訴排程程式在下一個待處理計時器準備好觸發時喚醒它,然後休眠。當它醒來時,它會檢查哪些計時器已過期,喚醒相應的 goroutine,然後再次休眠。

這個技巧是改變喚醒計時器 goroutine 的條件。我們不讓它在特定時間段後喚醒,而是修改排程程式等待死鎖;即所有 goroutine 都被阻塞的狀態。

Playground 版本的執行時維護著自己的內部時鐘。當修改後的排程程式檢測到死鎖時,它會檢查是否有待處理的計時器。如果有,它會將內部時鐘推進到最早計時器的觸發時間,然後喚醒計時器 goroutine。執行繼續,程式認為時間已過,而實際上休眠幾乎是瞬時的。

這些對排程程式的更改可以在 proc.ctime.goc 中找到。

偽造時間解決了後端資源耗盡的問題,但程式的輸出呢?看到一個休眠的程式在不花費任何時間的情況下正確執行完成,這似乎有些奇怪。

以下程式每秒列印一次當前時間,然後在三秒後退出。嘗試執行它。


package main

import (
    "fmt"
    "time"
)


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
        }
    }
}

這如何工作?這是後端、前端和客戶端協同工作的成果。

我們捕獲每次寫入標準輸出和標準錯誤的計時資訊,並將其提供給客戶端。然後,客戶端可以“回放”寫入資訊,並保持正確的計時,以便輸出看起來就像程式在本地執行一樣。

Playground 的 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 客戶端(在使用者 Web 瀏覽器中執行)隨後使用提供的延遲間隔回放這些事件。對使用者而言,程式看起來就像在即時執行。

偽造檔案系統

使用 Go 的 NaCl 工具鏈構建的程式無法訪問本地機器的檔案系統。相反,syscall 包中與檔案相關的函式(OpenReadWrite 等)操作的是由 syscall 包本身實現的一個記憶體檔案系統。由於 syscall 包是 Go 程式碼與作業系統核心之間的介面,使用者程式看到的檔案系統與真實檔案系統完全一樣。

以下示例程式將資料寫入檔案,然後將其內容複製到標準輸出。嘗試執行它。(您也可以編輯它!)


package main

import (
    "fmt"
    "io/ioutil"
    "log"
)


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)。到目前為止,我們僅使用解壓縮功能來提供執行標準庫測試所需的資料檔案,但我們打算為 Playground 程式提供一組檔案,這些檔案可用於文件示例、部落格文章和 Go Tour。

實現可以在 fs_nacl.gofd_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 的例項,它使用系統呼叫與實際檔案互動(這是 Playground 程式與外部世界互動的唯一方式);
  • 網路套接字有自己的實現,將在下一節討論。

偽造網路

與檔案系統一樣,Playground 的網路堆疊也是一個程序內的偽造實現,由 syscall 包實現。它允許 Playground 專案使用環回介面(127.0.0.1)。對其他主機的請求將失敗。

例如,執行以下程式。它監聽一個 TCP 埠,等待傳入連線,將來自該連線的資料複製到標準輸出,然後退出。在另一個 goroutine 中,它連線到監聽埠,向連線寫入一個字串,然後關閉它。


package main

import (
    "io"
    "log"
    "net"
    "os"
)


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 介面的網路套接字實現。

前端

Playground 的前端是另一個簡單的程式(不到 100 行)。它接收來自客戶端的 HTTP 請求,向後端發出 RPC 請求,並進行一些快取。

前端在 https://golang.org.tw/compile 上提供一個 HTTP 處理程式。該處理程式期望一個 POST 請求,其中包含一個 body 欄位(要執行的 Go 程式)和一個可選的 version 欄位(對於大多數客戶端,應為 "2")。

當前端收到編譯請求時,它首先檢查 memcache,檢視是否已快取了先前對此原始碼的編譯結果。如果找到,它將返回快取的響應。快取可以防止像 Go 主頁上的流行程式那樣,使後端過載。如果沒有快取的響應,前端將向後端發出 RPC 請求,將響應儲存在 memcache 中,解析回放事件,然後將 JSON 物件作為 HTTP 響應返回給客戶端(如上所述)。

客戶端

使用 Playground 的各個網站都共享一些通用的 JavaScript 程式碼,用於設定使用者介面(程式碼和輸出框、執行按鈕等)以及與 Playground 前端通訊。

此實現位於 go.tools 倉庫的 playground.js 檔案中,可以從 golang.org/x/tools/godoc/static 包匯入。其中一些程式碼很乾淨,有些則有些粗糙,因為它是合併了幾個不同的客戶端程式碼實現的結果。

playground 函式接收一些 HTML 元素,並將它們變成一個互動式 Playground 小部件。如果您想在自己的網站上放置 Playground(請參閱下面的“其他客戶端”),則應該使用此函式。

Transport 介面(在 JavaScript 中不是正式定義的)將使用者介面與與 Web 前端通訊的方式分離。HTTPTransportTransport 的一個實現,它使用上面描述的基於 HTTP 的協議。SocketTransport 是另一個實現,它使用 WebSocket(請參閱下面的“離線執行”)。

為了遵守同源策略,各種 Web 伺服器(例如 godoc)會將 /compile 的請求代理到 https://golang.org.tw/compile 上的 Playground 服務。通用的 golang.org/x/tools/playground 包執行此代理。

離線執行

Go TourPresent Tool 都可以離線執行。這對於網際網路連線有限的人或在會議上演講者(他們無法且*不應*依賴工作中的網際網路連線)來說非常方便。

為了離線執行,這些工具會在本地機器上執行自己的 Playground 後端版本。該後端使用普通的 Go 工具鏈,沒有任何上述修改,並使用 WebSocket 與客戶端通訊。

WebSocket 後端實現可以在 golang.org/x/tools/playground/socket 包中找到。Inside Present 講座詳細討論了這些程式碼。

其他客戶端

Playground 服務不僅被官方 Go 專案使用(Go by Example 是另一個例子),我們也歡迎您在自己的網站上使用它。我們只要求您先聯絡我們,在您的請求中使用唯一的 User-Agent(以便我們能識別您),並且您的服務對 Go 社群有益。

結論

從 godoc 到 Tour 再到本文,Playground 已成為我們 Go 文件故事的重要組成部分。隨著最近添加了偽造的檔案系統和網路堆疊,我們很高興能夠擴充套件我們的學習材料以涵蓋這些領域。

但最終,Playground 只是冰山一角。隨著 Native Client 支援計劃在 Go 1.3 中釋出,我們期待看到社群將如何利用它。

本文是 Go Advent Calendar 的第 12 部分,該系列在整個 12 月份釋出每日部落格文章。

下一篇文章:App Engine 上的 Go:工具、測試和併發
上一篇文章:Cover 故事
部落格索引