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サービスは3つの部分から構成されています。

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

バックエンド

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

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

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

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

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

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

フェイクタイム

playgroundプログラムは、使用できるCPU時間とメモリの量が制限されていますが、使用できる実時間も制限されています。これは、実行中の各プログラムがバックエンドとクライアント間のステートフルなインフラストラクチャでリソースを消費するためです。各playgroundプログラムの実行時間を制限することで、サービスがより予測可能になり、サービス妨害攻撃から防御できます。

しかし、時間を使用するコードを実行すると、これらの制限は窮屈になります。Go 並行処理パターンの講演では、time.Sleeptime.Afterのようなタイミング関数を使用する例で並行処理を示しています。playgroundの初期バージョンで実行した場合、これらのプログラムのスリープは効果がなく、動作は奇妙(時には間違っている)でした。

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

ゴルーチンがtime.Sleep(または同様の関数)を呼び出すと、スケジューラーは保留中のタイマーのヒープにタイマーを追加し、ゴルーチンをスリープ状態にします。一方、特別なタイマーゴルーチンがそのヒープを管理します。タイマーゴルーチンが起動すると、次の保留中のタイマーが発動する準備ができたときに起動するようにスケジューラーに指示し、スリープします。起動すると、どのタイマーの期限が切れたかを確認し、適切なゴルーチンを起動し、スリープに戻ります。

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

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

スケジューラーへのこれらの変更は、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
        }
    }
}

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

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

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

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

実装は、fs_nacl.goファイルとfd_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 Advent Calendarの第12部です。

次の記事:App EngineでのGo:ツール、テスト、および並行性
前の記事:表紙の記事
ブログインデックス