Go 部落格

Go Playground 內部

Andrew Gerrand
2013 年 12 月 12 日

引言

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

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

如果您是一名 Go 程式設計師,那麼您可能已經透過直接使用Go Playground、體驗Go 之旅或執行 Go 文件中的可執行示例來使用過 playground。

您可能還透過點選 go.dev/talks 上幻燈片演示中的“執行”按鈕或本部落格上的文章(例如最近關於字串的文章)使用過它。

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

概述

playground 服務包含三個部分

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

後端

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

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

(這個特殊工具鏈已合併到 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 客戶端(執行在使用者的網頁瀏覽器中)使用提供的延遲間隔來回放事件。對使用者而言,看起來程式正在即時執行。

偽造檔案系統

使用 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 之旅中的檔案。

實現可以在 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 處理程式。該處理程式期望一個帶有 body 欄位(要執行的 Go 程式)和一個可選 version 欄位(對於大多數客戶端,這應該是 "2")的 POST 請求。

當前端收到編譯請求時,它首先檢查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 之旅演示工具都可以離線執行。這對於網際網路連線有限的人或在會議上不能(也不應該)依賴正常網際網路連線的演示者來說非常有用。

為了離線執行,這些工具在本地機器上執行它們自己的 playground 後端版本。該後端使用正常的 Go 工具鏈,沒有前面提到的任何修改,並使用 WebSocket 與客戶端通訊。

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

其他客戶端

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

結論

從 godoc 到 Go 之旅再到這篇部落格,playground 已成為我們 Go 文件體系中不可或缺的一部分。隨著最近增加了偽造檔案系統和網路棧,我們很高興能夠擴充套件我們的學習材料以涵蓋這些領域。

但是,歸根結底,playground 只是冰山一角。隨著計劃在 Go 1.3 中支援 Native Client,我們期待看到社群能夠用它做些什麼。

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

下一篇文章:Go on App Engine:工具、測試與併發
上一篇文章:The cover story
部落格索引