Goプレイグラウンドの内部

Inside the Go Playground by Andrew Gerrand

はじめに

注: 本記事は現在のGoプレイグラウンドの実装については説明していません。

2010年9月、私たちはGoプレイグラウンドを導入しました。これは任意のGoコードをコンパイル・実行し、プログラムの出力を返すWebサービスです。

あなたがGoプログラマであれば、Goプレイグラウンドを直接使ったことがある、Goツアーを受けたことがある、あるいはGoのドキュメント内にある実行可能なサンプルコードを実行したことがあるなど、すでにプレイグラウンドを使ったことがあるでしょう。

また、go.dev/talksにあるスライドや、このブログの記事(例えば最近のStringsに関する記事)にある「Run」ボタンをクリックすることで使ったこともあるかもしれません。

本記事では、プレイグラウンドがどのように実装され、これらのサービスと統合されているかを見ていきます。この実装は特殊なオペレーティングシステム環境とランタイムに関わるものであり、ここでの説明はGoを使ったシステムプログラミングにある程度慣れていることを前提としています。

概要

overview

プレイグラウンドサービスは3つの部分から構成されています。

  • Googleのサーバー上で動作するバックエンド。RPCリクエストを受け取り、gcツールチェインを使ってユーザープログラムをコンパイルし、ユーザープログラムを実行して、プログラムの出力(またはコンパイルエラー)をRPCレスポンスとして返します。
  • Google App Engine上で動作するフロントエンド。クライアントからHTTPリクエストを受け取り、それに対応するRPCリクエストをバックエンドに送ります。また、いくらかのキャッシュ処理も行います。
  • ユーザーインターフェースを実装し、フロントエンドにHTTPリクエストを送るJavaScriptクライアント。

バックエンド

バックエンドのプログラム自体は単純なので、ここではその実装については触れません。興味深いのは、時刻やネットワーク、ファイルシステムといった中核的な機能を提供しながら、任意のユーザーコードを安全な環境でどのように実行するかという点です。

ユーザープログラムをGoogleのインフラストラクチャから隔離するために、バックエンドはNative Client(「NaCl」)と呼ばれる技術の下でプログラムを実行します。これはWebブラウザ内でx86プログラムを安全に実行するためにGoogleが開発した技術です。バックエンドはNaCl向けの実行ファイルを生成する特別なバージョンのgcツールチェインを使用しています。

(この特別なツールチェインはGo 1.3にマージされました。詳しくは設計文書を参照してください。)

NaClはプログラムが消費できるCPUとRAMの量を制限し、プログラムがネットワークやファイルシステムにアクセスすることを防ぎます。しかしこれは問題を引き起こします。Goの並行性とネットワークのサポートはGoの主要な強みの一つであり、ファイルシステムへのアクセスは多くのプログラムにとって不可欠です。並行性を効果的に示すには時刻が必要ですし、ネットワークとファイルシステムを示すには、当然ながらネットワークとファイルシステムそのものが必要になります。

これらはすべて現在ではサポートされていますが、2010年に公開されたプレイグラウンドの最初のバージョンには、これらのどれもありませんでした。当時の「現在時刻」は2009年11月10日に固定されており、time.Sleep は何も効果を持たず、os パッケージや net パッケージのほとんどの関数はスタブ化されて EINVALID エラーを返すだけになっていました。

1年前、私たちはプレイグラウンドに偽の時刻を実装し、スリープを行うプログラムが正しく振る舞うようにしました。さらに最近のプレイグラウンドの更新では、偽のネットワークスタックと偽のファイルシステムが導入され、プレイグラウンドのツールチェインは通常のGoツールチェインに近いものになりました。これらの機能については以降の節で説明します。

時刻の偽装

プレイグラウンドのプログラムは使用できるCPU時間とメモリ量が制限されていますが、実時間の使用量についても制限されています。これは、実行中の各プログラムがバックエンド上のリソースと、バックエンドとクライアントの間にあるステートフルなインフラストラクチャのリソースを消費するからです。各プレイグラウンドプログラムの実行時間を制限することで、私たちのサービスの挙動をより予測可能にし、サービス拒否攻撃から身を守っています。

しかし、時刻を使うコードを実行する際には、これらの制限が息苦しいものになります。Go Concurrency Patternsというトークでは、time.Sleeptime.After のようなタイミング関数を使ったサンプルで並行性を実演しています。初期のバージョンのプレイグラウンドでこれらのプログラムを実行すると、スリープが何の効果も持たず、その振る舞いは奇妙な(そして時には誤った)ものになっていました。

巧妙なトリックを使うと、実際にはスリープに一切時間がかからないにもかかわらず、Goプログラムに自分がスリープしていると思い込ませられます。このトリックを説明するには、まずスケジューラがスリープ中のゴルーチンをどのように管理しているかを理解する必要があります。

ゴルーチンが time.Sleep(または同様の関数)を呼び出すと、スケジューラは保留中のタイマーのヒープにタイマーを追加し、そのゴルーチンをスリープさせます。一方で、特別なタイマーゴルーチンがそのヒープを管理しています。タイマーゴルーチンが起動すると、次に発火予定のタイマーの準備ができたときに自分を起こすようスケジューラに伝えてからスリープします。目覚めると、どのタイマーが満了したかを確認し、該当するゴルーチンを起こしてから再びスリープします。

このトリックのポイントは、タイマーゴルーチンを起こす条件を変更することです。特定の時間が経過した後に起こすのではなく、デッドロック、つまりすべてのゴルーチンがブロックされている状態を待つようにスケジューラを変更するのです。

プレイグラウンド版のランタイムは独自の内部時計を保持しています。変更されたスケジューラがデッドロックを検知すると、保留中のタイマーがあるかどうかを確認します。もしあれば、内部時計を最も早く発火するタイマーの時刻まで進め、タイマーゴルーチンを起こします。実行が継続され、プログラムは時間が経過したと信じますが、実際にはスリープはほぼ瞬時に終わっています。

このスケジューラへの変更は proc.ctime.goc に見られます。

偽の時刻はバックエンドでのリソース枯渇の問題を解決しますが、プログラムの出力についてはどうでしょうか。スリープするプログラムが、一切の時間をかけずに正しく実行を完了するのを見るのは奇妙なことでしょう。

次のプログラムは1秒ごとに現在時刻を表示し、3秒後に終了します。実際に実行してみてください。

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

そして、(ユーザーのWebブラウザ内で動作する)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を参照)。現時点では、この展開機能は標準ライブラリのテストを実行するために必要なデータファイルを提供するためだけに使われていますが、将来的にはドキュメントのサンプルやブログ記事、Goツアーで使えるファイル一式をプレイグラウンドのプログラムに提供する予定です。

実装は fs_nacl.gofd_nacl.go にあります(これらは _nacl というサフィックスによって、GOOSnacl に設定されているときのみ 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 に対応し、各 filefileImpl インターフェースを実装する値を提供します。このインターフェースにはいくつかの実装があります。

  • 通常のファイルや /dev/random のようなデバイスは fsysFile によって表現されます。
  • 標準入力、標準出力、標準エラー出力は naclFile のインスタンスであり、実際のファイルとやり取りするためにシステムコールを使用します(これらはプレイグラウンドのプログラムが外部の世界とやり取りできる唯一の手段です)。
  • ネットワークソケットには独自の実装があり、次の節で説明します。

ネットワークの偽装

ファイルシステムと同様に、プレイグラウンドのネットワークスタックも syscall パッケージによって実装されたプロセス内の偽物です。これにより、プレイグラウンドのプロジェクトはループバックインターフェース(127.0.0.1)を使用できます。他のホストへのリクエストは失敗します。

実行可能なサンプルとして、次のプログラムを実行してみてください。このプログラムはTCPポートでリッスンし、着信する接続を待ち受け、その接続からのデータを標準出力にコピーして終了します。別のゴルーチンでは、リッスンしているポートに接続し、その接続に文字列を書き込んで接続を閉じます。

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 にあります。読み始めるのに良い場所は、fileImpl インターフェースのネットワークソケット実装である netFile です。

フロントエンド

プレイグラウンドのフロントエンドも同様に単純なプログラム(100行に満たない)です。クライアントからHTTPリクエストを受け取り、バックエンドにRPCリクエストを送り、いくらかのキャッシュ処理を行います。

フロントエンドは https://golang.org/compile でHTTPハンドラを提供しています。このハンドラは body フィールド(実行するGoプログラム)と、オプションの version フィールド(ほとんどのクライアントでは "2" とすべきです)を持つPOSTリクエストを期待します。

フロントエンドはコンパイルリクエストを受け取ると、まずmemcacheを確認し、そのソースの以前のコンパイル結果がキャッシュされていないかを調べます。見つかった場合は、キャッシュされたレスポンスを返します。このキャッシュによって、Goのホームページにあるような人気のプログラムがバックエンドに過負荷をかけるのを防いでいます。キャッシュされたレスポンスがない場合、フロントエンドはバックエンドにRPCリクエストを送り、レスポンスをmemcacheに保存し、再生用イベントを解析して、(前述の通り)JSONオブジェクトをHTTPレスポンスとしてクライアントに返します。

クライアント

プレイグラウンドを利用するさまざまなサイトは、ユーザーインターフェース(コードボックスや出力ボックス、実行ボタンなど)のセットアップや、プレイグラウンドのフロントエンドとの通信を行う共通のJavaScriptコードを共有しています。

この実装は go.tools リポジトリ内の playground.js というファイルにあり、golang.org/x/tools/godoc/static パッケージからインポートできます。このコードはクライアント側のコードのいくつかの異なる実装を統合した結果なので、綺麗な部分もあれば、少々雑然とした部分もあります。

playground 関数はいくつかのHTML要素を受け取り、それらをインタラクティブなプレイグラウンドウィジェットに変換します。自分のサイトにプレイグラウンドを設置したい場合は、この関数を使うべきです(後述の「他のクライアント」を参照してください)。

Transport インターフェース(JavaScriptなので正式に定義されているわけではありません)は、ユーザーインターフェースをWebフロントエンドと話す手段から抽象化します。HTTPTransport は、前述のHTTPベースのプロトコルを話す Transport の実装です。SocketTransport はWebSocketを話す別の実装です(後述の「オフラインでの実行」を参照してください)。

同一オリジンポリシーに準拠するため、(例えばgodocのような)さまざまなWebサーバーは /compile へのリクエストを https://golang.org/compile にあるプレイグラウンドサービスにプロキシします。この共通のgolang.org/x/tools/playgroundパッケージがこのプロキシ処理を行っています。

オフラインでの実行

GoツアーPresentツールはどちらもオフラインで実行できます。これはインターネット接続が限られている人や、動作するインターネット接続に頼れない、あるいは頼るべきではないカンファレンスの発表者にとって都合の良いものです。

オフラインで実行するために、これらのツールはローカルマシン上で独自バージョンのプレイグラウンドバックエンドを実行します。このバックエンドは前述の変更を一切含まない通常のGoツールチェインを使用し、クライアントとの通信にはWebSocketを使用します。

WebSocketバックエンドの実装は golang.org/x/tools/playground/socket パッケージにあります。Inside Present というトークではこのコードについて詳しく説明しています。

他のクライアント

プレイグラウンドサービスは公式のGoプロジェクトだけでなく(Go by Exampleもその一例です)、他でも利用されています。私たちは皆さんが自分のサイトでプレイグラウンドを使うことを歓迎します。お願いしたいのは、事前に連絡をいただくこと、リクエストの際に(私たちが皆さんを識別できるよう)独自のユーザーエージェントを使用すること、そしてそのサービスがGoコミュニティにとって有益であることです。

結論

godocからツアー、そしてこのブログに至るまで、プレイグラウンドは私たちのGoドキュメントの物語に欠かせない部分になりました。最近追加された偽のファイルシステムとネットワークスタックによって、これらの領域をカバーする学習教材を拡充できることを楽しみにしています。

しかし、結局のところ、プレイグラウンドは氷山の一角に過ぎません。Go 1.3でNative Clientのサポートが予定されているなかで、コミュニティがこれを使って何を成し遂げるのか、私たちは楽しみにしています。

本記事はGo Advent Calendarの12日目の記事であり、12月を通して毎日投稿されるブログ記事シリーズの一つです。

By Andrew Gerrand