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.ac.cn/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.ac.cn/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
博客索引