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://go-lang.org.cn/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://go-lang.org.cn/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 故事
博客索引