The Go Blog

Go Playground の内側

Andrew Gerrand
2013年12月12日

はじめに

注: この記事は、Go Playground の現在のバージョンについて説明していません。

2010年9月に、Go コードをコンパイルおよび実行し、そのプログラム出力を返す Web サービスである Go Playground を導入しました

Go プログラマーであれば、Go Playground を直接使用したり、Go Tour を受講したり、Go ドキュメントの実行可能な例を実行したりすることで、すでに Playground を使用したことがあるでしょう。

go.dev/talks のスライドデッキや、このブログの投稿 (たとえば、Strings に関する最近の記事など) にある「実行」ボタンのいずれかをクリックして使用したこともあるかもしれません。

この記事では、Playground がこれらのサービスにどのように実装され、統合されているかを見ていきます。実装には、OS 環境とランタイムのバリアントが含まれており、ここでの説明では、Go を使用したシステムプログラミングにある程度の知識があることを前提としています。

概要

Playground サービスには3つの部分があります

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

バックエンド

バックエンドプログラム自体は自明なので、ここではその実装については説明しません。興味深い部分は、時間、ネットワーク、ファイルシステムなどのコア機能を提供しながら、安全な環境で任意のユーザーコードを安全に実行する方法です。

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

(この特別なツールチェーンは Go 1.3 に統合されました。詳細については、設計ドキュメントをお読みください。)

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

これらのすべては今日サポートされていますが、2010年にリリースされた最初のバージョンの Playground にはそれらがありませんでした。現在の時刻は2009年11月10日に固定されており、time.Sleep は効果がなく、os および net パッケージのほとんどの関数は EINVALID エラーを返すようにスタブされていました。

1年前、私たちは Playground で偽の時間を実装しました。これにより、スリープするプログラムが正しく動作するようになりました。最近の Playground の更新では、偽のネットワークスタックと偽のファイルシステムが導入され、Playground のツールチェーンが通常の Go ツールチェーンに似たものになりました。これらの機能は以下のセクションで説明します。

時間の偽装

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

しかし、これらの制限は、時間を使用するコードを実行する際に息苦しくなります。Go Concurrency Patterns の講演では、time.Sleeptime.After のようなタイミング関数を使用する例で並行処理をデモンストレーションしています。Playground の初期バージョンで実行すると、これらのプログラムのスリープは効果がなく、その動作は奇妙 (時には間違った) でした。

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

ゴルーチンが time.Sleep (または類似の関数) を呼び出すと、スケジューラは保留中のタイマーのヒープにタイマーを追加し、ゴルーチンをスリープさせます。一方、特別なタイマーゴルーチンがそのヒープを管理します。タイマーゴルーチンが開始すると、次の保留中のタイマーが起動する準備ができたときに自分を起こすようにスケジューラに伝え、その後スリープします。目覚めると、どのタイマーが期限切れになったかをチェックし、適切なゴルーチンを起こして、再びスリープ状態に戻ります。

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

ランタイムの Playground バージョンは、独自の内部クロックを維持します。変更されたスケジューラがデッドロックを検出すると、保留中のタイマーがあるかどうかを確認します。もしあれば、内部クロックを最も早いタイマーのトリガー時刻に進め、タイマーゴルーチンを起動します。実行は継続され、プログラムは時間が経過したと信じますが、実際にはスリープはほぼ瞬時でした。

スケジューラへのこれらの変更は、proc.ctime.goc で見つけることができます。

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

次のプログラムは、毎秒現在の時刻を出力し、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
        }
    }
}

これはどのように機能するのでしょうか? バックエンド、フロントエンド、クライアント間の連携です。

標準出力および標準エラーへの書き込みのタイミングをキャプチャし、クライアントに提供します。その後、クライアントは正しいタイミングで書き込みを「再生」できるため、プログラムがローカルで実行されているかのように出力が表示されます。

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 を参照)。これまでのところ、標準ライブラリのテストを実行するために必要なデータファイルを提供するためにのみ unzip 機能を使用していますが、ドキュメントの例、ブログ投稿、Go Tour で使用できる一連のファイルを Playground プログラムに提供する予定です。

実装は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 のインスタンスであり、システムコールを使用して実際のファイルと対話します (これらが Playground プログラムが外部と対話する唯一の方法です)。
  • ネットワークソケットには独自の実行機能があり、次のセクションで説明します。

ネットワークの偽装

ファイルシステムと同様に、Playground のネットワークスタックも syscall パッケージによって実装されたインプロセスな偽物です。これにより、Playground プロジェクトはループバックインターフェース (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 です。

フロントエンド

Playground のフロントエンドは、もう一つのシンプルなプログラムです (100行未満)。クライアントから HTTP リクエストを受け取り、バックエンドに RPC リクエストを行い、一部のキャッシングを実行します。

フロントエンドは https://go.dokyumento.jp/compile で HTTP ハンドラを提供します。ハンドラは、body フィールド (実行する Go プログラム) とオプションの version フィールド (ほとんどのクライアントでは "2" である必要があります) を持つ POST リクエストを想定しています。

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

クライアント

Playground を使用する様々なサイトは、ユーザーインターフェース (コードと出力ボックス、実行ボタンなど) の設定と、Playground フロントエンドとの通信のために、共通の JavaScript コードを共有しています。

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

playground 関数は、いくつかの HTML 要素を受け取り、それらをインタラクティブな Playground ウィジェットに変換します。Playground を自分のサイトに組み込みたい場合は、この関数を使用する必要があります (以下の「その他のクライアント」を参照)。

Transport インターフェース (JavaScriptなので正式には定義されていません) は、ユーザーインターフェースとウェブフロントエンドとの通信手段を抽象化しています。HTTPTransport は、前述の HTTP ベースのプロトコルで通信する Transport の実装です。SocketTransport は、WebSocket (以下の「オフラインで遊ぶ」を参照) で通信する別の実装です。

同一オリジンポリシーに準拠するため、様々なウェブサーバー (たとえば godoc) は、/compile へのリクエストを https://go.dokyumento.jp/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 はその一例です)、様々な場所で利用されています。あなたのサイトで自由に利用できることを嬉しく思います。ただし、まず私たちに連絡し、リクエストに一意のユーザーエージェント (あなたを識別できるように) を使用し、あなたのサービスが Go コミュニティに利益をもたらすものであることをお願いしています。

まとめ

godoc からツアー、そしてこのブログに至るまで、Playground は Go のドキュメントにとって不可欠な部分となりました。偽のファイルシステムとネットワークスタックが最近追加されたことで、これらの領域をカバーする学習資料を拡張できることに興奮しています。

しかし、最終的に、Playground は氷山の一角に過ぎません。Go 1.3 で Native Client のサポートが予定されているため、コミュニティがそれを使って何ができるかを見るのが楽しみです。

この記事は、12月を通して毎日投稿されるブログ記事シリーズである Go アドベントカレンダーの第12回です。

次の記事: App Engine 上の Go: ツール、テスト、並行処理
前の記事: The cover story
ブログインデックス