lgo - Go (golang) をインタラクティブに実行するための Jupyter Notebook環境

この記事はGo4 Advent Calendar 2017の19日目の記事です。最近、趣味でGo (golang) をJupyter Notebook上でインタラクティブに実行するプロジェクトを作っていて、ある程度きちんと動くようになったので紹介します。

yunabe/lgo - Go (golang) REPL and Jupyter notebook kernel (GitHub)
(気に入ったらぜひStarもして下さい:star:)

ブラウザ上でGoを実行している様子:
lgo.gif

コンソールからも使えます:

>>> jupyter console --kernel lgo
In [1]: a, b := 3, 4

In [2]: func sum(x, y int) int {
      :     return x + y
      :     }

In [3]: import "fmt"

In [4]: fmt.Sprintf("sum(%d, %d) = %d", a, b, sum(a, b))
sum(3, 4) = 7

とりあえず試してみる

Binder
binder (mybinder.org) の設定を用意しておきました。上のlaunch binderボタンからlgoを試すことができます。使い捨てのコンテナ上でJupyter Notebookが起動し利用可能になります。コードは実行せずノートの例を眺めるだけであれば、こちらからnbviewerで例を開いてみてください。

はじめに

僕は元々ちょっとしたツールや趣味のプログラミングには常にPythonを使うPythonのファンでした。しかし最近は将来のメンテナビリティなどを考えるとそういう用途にも型のあるGoの方がいいなと思うことが多くなり、趣味や小規模プロジェクトで言語を選べるときはGoをメインで使うようになりました。

一方で、近年データサイエンスや機械学習などの分野の盛り上がりと共に、Pythonに再び追い風が吹いているように思います。特にJupyter Notebookを使ってトライアンドエラーを繰り返しながらインタラクティブに開発して行くスタイルはデータ処理の分野では欠かせないものとなっています。PythonからGoに乗り換えた身として、こういう分野でもPythonの代わりにGoが使えるようになったらいいなと思い趣味でGoのJupyter Notebook環境を作りました。

Jupyter Notebook?

Pythonをブラウザからインタラクティブに実行するための環境です。コードや実行結果が「ノート」として保存されるので非常に便利です。特にPythonを使ったデータ処理や機械学習の分野では人気のあるツールだと思います。Jupyter Notebook自体についてもっと知りたい人はGoogleでJupyter Notebookを検索して下さい。Jupyter Notebookは「カーネル」を追加することでPython以外の言語もサポートすることが可能です。さまざまな言語のカーネルが存在しています。今回僕が作成したのもGo (golang) 用の新しいカーネルです。

lgo

GitHubはこちら
yunabe/lgo - Go (golang) REPL and Jupyter notebook kernel (GitHub)
(気に入ったらぜひStarもして下さい:star:)

特徴

  • Go(golang)をPythonのようにインタラクティブに記述・実行できます。
  • Juyputer Notebook からの利用
  • Goの言語機能を全てサポート
  • コード補完とドキュメントの表示
  • HTMLや画像の出力にも対応
  • Linuxのみをサポート。Mac, Windows上から使いたい場合はDockerを使って下さい。

lgo.gif

インストールせずに試す

Binder
前述したようにこの launch binderボタンからlgoをブラウザ上で試すことができます。使い捨てのコンテナ上でJupyter Notebookが起動し利用可能になります。コードは実行せずノートの例を眺めるだけであれば、こちらからnbviewerで例を開いてみてください。
より実践的な例としてライフゲーム (Conway's Game of Life)で遊べるノートも用意してあります。

インストール

lgoをローカルの環境にインストール&実行する方法は2つあります。

すでにDockerに慣れている人が手っ取り早くlgoを試してみるにはDockerイメージを使うのがおすすめです。手元のGoの環境と統合して使いたい場合にはソースからインストールして下さい。

使い方

Jupyter Notebook

  • jupyter notebookで通常通りにJupyter Notebookを起動します。ノートを作る際にGo (lgo)を選択して下さい。jupyter notebookをDocker内で起動するときは--ip=0.0.0.0フラグも渡して下さい
  • あとはPythonで使うのと同じようにJupyter Notebook上でGoが実行できます。
  • 変数名や型名にカーソルを合わせてShift-Tabを押すとドキュメントが表示されます。
  • Tabでメソッド名、関数名の補完が行われます。
  • この例のように、_ctx.Displayのメソッドを使えばテキスト以外のデータを表示することもできます。

コマンドラインから使う

jupyter notebookはコマンドラインからもREPLとして利用できます。インストール後jupyter console --kernel lgoで起動して下さい。

In [1]: a, b := 3, 4

In [2]: func sum(x, y int) int {
      :     return x + y
      :     }

In [3]: import "fmt"

In [4]: fmt.Sprintf("sum(%d, %d) = %d", a, b, sum(a, b))
sum(3, 4) = 7

より詳しい使い方などはREADME.mdを読んでもらえばよいと思うので、以下ではREADME.mdに書いていない内部の仕組みなどについて少し書いてみます。

どうやって動いているの?

大雑把にいうと

  • コード変換でlgoのコードを有効なGoプログラムに変換する
  • そのコードを共有ライブラリとしてコンパイル
  • 共有ライブラリを動的ロード(dlopen)する

というようになっています。1つ目は愚直なソースコード→ソースコード変換です。後者の2つは少しややこしいですが、go1.8からサポートされているplugin機構の内部実装と近いことを内部で行なっています。

1. コード変換

lgoはpackage文は不要ですし、main関数を定義しなくても式を書くことができます。

// これは有効なlgoのコードです
a, b := 3, 4
c := a + b
fmt.Printf("c = %d\n", c)

当然ですが、このままではGoとしては有効なファイルではありません。あとのフェーズでGoのツールチェインを使ってコンパイルを行うためにこれをGoとして有効なソースコードに変換します。詳細は省略しますが、package文を追加したり式を関数の中に移動したりします。

package lgoexec

var (
 a, b, c int
)

func lgomain() {
    a, b = 3, 4
    c = a + b
    fmt.Printf("c = %d\n", c)
}

のような感じのGoのソースコードへの書き換えを行います。この辺の処理はconverterパッケージが担当しています

2. 共有ライブラリとしてコンパイル

GoはLinuxでのみ共有ライブラリをサポートしています(-buildmode=shared, -linkshared)。これも利用してユーザーが入力したコードを共有ライブラリとしてビルドします。似たようなbuildmodeとして、-buildmode=pluginがありますが、こちらは依存しているライブラリをすべて一つの.soファイルにまとめてくれるためビルドが遅く生成物も無駄に大きくなってしまうので利用していません。

3. 共有ライブラリをロード

共有ライブラリができたら、あとはdlopenすれば動きますよね!ということでdlopenして共有ライブラリをロードして(若干の後処理をした後)entrypointの関数を呼び出します。この辺りの処理はcmd/runnerパッケージが担当しています。

難しかった点

最初にコンセプトを思いついてからユーザーの入力を共有ライブラリにコンパイルして動的に実行する仕組みを作るところ自体は意外と簡単にできました。しかし実際にJupyter Notebookから使用してGoのコードを実行してみると、そもそもGoがインタラクティブな実行のためてデザインされていないゆえの問題がいくつもあることが分かり、それに合わせてシステムを作り込むのはかなり面倒な作業でした。

Jupyter Notebook Kernel

Jupyter Notebook はバックエンドをここに書かれているプロトコルにもとづいてやり取りを行います。ただ細かい挙動はあまりきちんと定義されていなくて、Jupyter Notebook のクライアント側のコードを読まないと分からないことが多くて苦労しました。あとプロトコルにZMQが使われていますが僕はZMQに触れるのも初めてだったのでなかなか辛かったです。

実行をキャンセルする仕組み

ご存知のようにGoのデフォルトの挙動ではSIGINT(Ctrl-C)を受け取るとプロセスがその場で終了します。Pythonのように例外/panicが発生するのではないので、deferで登録された関数も実行されません。

この挙動はインタラクティブにコードを実行する場合には困ります。そのためシグナルハンドラをカスタマイズして、Ctrl-Cに対してはユーザに入力されたコードを実行中のgoroutineだけ中断してやる必要があります。ここで残念なお知らせです。Goには特定のgoroutineを他のgoroutineから中断する仕組みは存在しません。そのため一度コードの実行を始めてしまったらgoroutine側で自発的に終了を検知して処理を中断しない限りgoroutineは走り続けます。仕方がないのでlgoではユーザの入力をGoに変換する際に、処理と処理の間に実行がキャンセルされていないかをチェックするコードを挿入しています。

a := f()
g(a)

のような入力は

a := f()
ExitIfCtxDone()
g(a)

のようなGoのコードに変換されて実行されています。

さらに厄介なのがチャネルのブロックです。Goでは例えば

ch := make(chan struct{})
<-ch

と書くと誰も書き込まないchから読み込みを行おうとしているのでgoroutineは永遠に停止してしまいます。停止してしまったgoroutineを再開したりキャンセルする手段はありません。これもコードをインタラクティブに実行する場合には非常に困るので、コード変換を行う際にselect文と組み合わせて実行の停止を検出できるようにしています。上のコードは以下の様なGoのコードに書き換えられてから実行されています。

func recvChan(c chan struct{}) (x struct{}) {
    select {
    case x = <-c:
        return
    case <-GetExecContext().Done():
        panic(Exit)
    }
}

ch := make(chan struct{})
recvChan(ch)

goroutineが死ぬとプロセスが死ぬ

これもみなさんご存知のように、Goではあるgoroutineがpanicからrecoverされずに死ぬとプロセス全体が終了します。通常のプログラムではその仕様で問題ないでしょうが、インタラクティブにコードを書いて実行している場合にはこれは困ります。例えば

go func() {
    var p *int
    *p = 0
}()

とか書かれたものを何も考えずにGoのコードとして実行するとnil pointer dereferenceでgoroutineが死んでしまって巻き添えでプロセスも終了してしまいます。これでは困るのでlgoではユーザの入力をGoのソースコードに変換する際に、あらゆるgo文の先頭にpanicからのリカバリー用のコードを自動挿入しています。上のコードは

state := InitGoroutine()
go func() {
    defer FinalizeGoroutine(state)
    var p *int
    *p = 0
}()

のように変換されあらゆるpanicFinalizeGoroutineの中でキャプチャーされています。

使ってみてどうなの?

結構ちゃんと動くようになったと思います。本来インタラクティブに実行できないはずのGoがJupyter Notebook上で動いているのを見るのは楽しいです。一方これがあったら機械学習やデータ処理の分野でPythonを使わないで良くなるかというと、2017年12月現在、Pythonに存在するデータ処理系のライブラリ群(numpy, sklearn, matplotlib, Tensor Flowなど)に相当するものがGoでは貧弱あるいは存在しないので、現実的にはGoをデータ処理に使うのには依然として大きなハードルがあります。

あとトライアンドエラーで使い捨てのコードを書く用途にはGoのプログラムがverboseになる仕様はだるいです。Pythonのようにゆるく書いて動く方がそういう視点では楽ですね。特にエラー処理が面倒です。

f, err := os.Open("/path/to.txt")
if err != nil {
  log.Println(err)
  return
}
data, err := ioutil.ReadAll(f)
if err != nil {
  log.Println(err)
  return
}
// ...

とか書いてるとうんざりしてきます。何しろPythonだったらこれが

data = open('/path.to.txt').read()

になるわけですから。プロダクション用のコードであればエラーハンドリングの処理がこのぐらいverboseであっても構わないと思いますが、トライアンドエラーを繰り返しているような実験的なコードでこれを常に書かないといけないのはやはりとても辛いです。

data := ioutil.ReadAll(os.Open("/path/to.txt"))

のようにエラーを無視して書いて、エラー発生時には自動的にpanicが発生するような機能をlgoに足してもいいのかなと思っています。

GitのQuick start を読んで

$ git clone https://github.com/yunabe/lgo.git
$ cd lgo/docker/jupyter
$ docker-compose up -d

で私のMacに無事導入できました!