Go 博客

Go 语言游乐场内部

Andrew Gerrand
2013 年 12 月 12 日

介绍

注意:本文没有描述 Go 语言游乐场的当前版本。

2010 年 9 月,我们发布了 Go 语言游乐场,这是一项网络服务,可以编译和执行任意 Go 代码,并返回程序输出。

如果您是 Go 程序员,那么您可能已经通过以下方式使用过游乐场:直接使用Go 语言游乐场、参加Go 语言之旅,或运行 Go 文档中的可执行示例

您也可以通过点击go.dev/talks 上的幻灯片或本博客中的文章(例如关于字符串的最新文章)中的“运行”按钮之一来使用它。

在本文中,我们将探讨游乐场的实现方式以及它与这些服务的集成方式。实现涉及一个变体操作系统环境和运行时,我们在这里的描述假设您对使用 Go 进行系统编程有一定了解。

概述

游乐场服务包含三个部分

  • 后端在 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 没有效果,并且 osnet 包的大多数函数都已存根以返回 EINVALID 错误。

一年前,我们在游乐场中实现了虚拟时间,因此休眠的程序将表现正确。游乐场的最新更新引入了虚拟网络堆栈和虚拟文件系统,使游乐场的工具链类似于普通的 Go 工具链。这些功能将在以下部分中介绍。

虚拟时间

游乐场程序在可以使用的 CPU 时间和内存方面受到限制,但它们在可以使用的实际时间方面也受到限制。这是因为每个正在运行的程序都会消耗后端资源,以及它与客户端之间的任何有状态基础设施。限制每个游乐场程序的运行时间可以使我们的服务更可预测,并防御拒绝服务攻击。

但是,当运行使用时间的代码时,这些限制变得令人窒息。在Go 并发模式 演讲中,使用计时函数(如time.Sleeptime.After)的示例演示了并发性。在游乐场的早期版本中运行时,这些程序的休眠将没有效果,其行为将变得奇怪(有时是错误的)。

通过使用一个巧妙的技巧,我们可以让 Go 程序认为它正在休眠,而实际上休眠几乎没有时间。为了解释这个技巧,我们首先需要了解调度程序如何管理休眠的 goroutine。

当 goroutine 调用 time.Sleep(或类似函数)时,调度程序会将计时器添加到一个待处理计时器堆中,并将 goroutine 置于休眠状态。同时,一个特殊的计时器 goroutine 管理该堆。当计时器 goroutine 启动时,它会告诉调度程序在下一个待处理计时器准备好触发时唤醒它,然后休眠。当它醒来时,它会检查哪些计时器已过期,唤醒相应的 goroutine,然后返回休眠状态。

这个技巧是改变唤醒计时器 goroutine 的条件。我们修改调度程序,而不是在特定时间段后唤醒它,而是等待死锁;所有 goroutine 都被阻塞的状态。

游乐场版本的运行时维护自己的内部时钟。当修改后的调度程序检测到死锁时,它会检查是否有任何计时器处于待处理状态。如果有,它会将内部时钟提前到最早计时器的触发时间,然后唤醒计时器 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
        }
    }
}

这是如何工作的?它是后端、前端和客户端之间的协作。

我们捕获每次写入标准输出和标准错误的时间,并将其提供给客户端。然后客户端可以以正确的时间“回放”写入,因此输出看起来就像程序在本地运行一样。

游乐场的 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 目录。程序可以照常操作文件系统,但是当进程退出时,对文件系统的任何更改都会丢失。

还有一种规定是在 init 时间将 zip 文件加载到文件系统中(参见unzip_nacl.go)。到目前为止,我们只使用 unzip 功能来提供运行标准库测试所需的 Data 文件,但我们打算为游乐场程序提供一组文件,这些文件可以在文档示例、博客文章和 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 的实例,它使用系统调用与实际文件进行交互(这些是游乐场程序与外部世界交互的唯一方式),
  • 网络套接字有自己的实现,将在下一节讨论。

模拟网络

与文件系统类似,游乐场的网络栈是 syscall 包实现的进程内模拟。它允许游乐场项目使用回环接口(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 接口的网络套接字实现。

前端

游乐场前端是另一个简单的程序(不到 100 行)。它接收来自客户端的 HTTP 请求,向后端发出 RPC 请求,并进行一些缓存。

前端在 https://golang.ac.cn/compile 上提供 HTTP 处理程序。该处理程序预期 POST 请求包含 body 字段(要运行的 Go 程序)和可选的 version 字段(对于大多数客户端,这应该是 "2")。

当前端收到编译请求时,它首先检查 memcache 以查看它是否已缓存该源代码之前编译的结果。如果找到,它将返回缓存的响应。缓存可防止诸如 Go 主页 上的流行程序等程序过载后端。如果没有缓存响应,前端将向后端发出 RPC 请求,将响应存储在 memcache 中,解析回放事件,并将 JSON 对象作为 HTTP 响应返回给客户端(如上所述)。

客户端

使用游乐场的各个网站都共享一些用于设置用户界面(代码和输出框、运行按钮等)以及与游乐场前端通信的通用 JavaScript 代码。

此实现位于 go.tools 存储库中的 playground.js 文件中,可以从 golang.org/x/tools/godoc/static 包导入。其中一些代码干净整洁,另一些则略显粗糙,因为它是合并了几个不同的客户端代码实现的结果。

playground 函数接受一些 HTML 元素,并将它们变成一个交互式游乐场小部件。如果您想将游乐场放在自己的网站上,应该使用此函数(参见下面的“其他客户端”)。

Transport 接口(未正式定义,因为这是 JavaScript)将用户界面与与 Web 前端通信的方式隔离开来。HTTPTransportTransport 的实现,它使用前面描述的基于 HTTP 的协议。SocketTransport 是另一个实现,它使用 WebSocket(参见下面的“离线游玩”)。

为了遵守 同源策略,各个 Web 服务器(例如 godoc)会将对 /compile 的请求代理到 https://golang.ac.cn/compile 上的游乐场服务。常见的 golang.org/x/tools/playground 包执行此代理操作。

离线游玩

Go TourPresent 工具 都可以在离线状态下运行。这对网络连接有限的人或在无法(并且不应该)依赖工作互联网连接的会议上的演示者来说非常棒。

要在离线状态下运行,这些工具将在本地计算机上运行其自己的游乐场后端版本。后端使用普通的 Go 工具链,没有上述任何修改,并使用 WebSocket 与客户端通信。

WebSocket 后端实现可以在 golang.org/x/tools/playground/socket 包中找到。Inside Present 演讲详细讨论了此代码。

其他客户端

游乐场服务不仅被官方 Go 项目使用(Go by Example 是另一个实例),我们很乐意您在自己的网站上使用它。我们只要求您先与我们联系,在您的请求中使用唯一的用户代理(以便我们识别您),并且您的服务对 Go 社区有益。

结论

从 godoc 到 tour 再到本博文,游乐场已成为我们 Go 文档故事中不可或缺的一部分。随着最近添加的模拟文件系统和网络栈,我们很高兴将我们的学习资料扩展到涵盖这些领域。

但最终,游乐场只是冰山一角。随着 Go 1.3 计划中的 Native Client 支持,我们期待看到社区能够用它做什么。

本文是 Go Advent Calendar 的第 12 部分,这是一个在整个 12 月期间每天发布博文系列。

下一篇文章:Go on App Engine:工具、测试和并发
上一篇文章:封面故事
博客索引