go言語によるhtmlcat実装 htmlcatgo の紹介

go言語の勉強に、motemenさんが作ったhtmlcat(標準入力をブラウザで tail -f できる htmlcat というのを書いた - NaN days - subtech)をgoで実装してみました。この記事ではhtmlcatgoの紹介と実装の見どころについて解説します。

htmlcatgoの紹介

htmlcatgo は、標準入力をブラウザ上でtail -fできるソフトウェアです。元祖htmlcatの使い方とほぼ一緒です。

$ tail -f /var/log/messages | htmlcatgo

のように実行すると、

2013/12/16 08:15:02 htmlcatgo: http://localhost:45273

のようにURLが表示されます。これをブラウザで表示すると、画面にtail -fの結果がリアルタイムで流れてきます。元祖htmlcatにある --execオプションにも対応しています。

インストールはgoコマンドを使うと簡単です。

$ go get github.com/hakobe/htmlcatgo
$ go install github.com/hakobe/htmlcatgo

goで作ったバイナリは単体で動作するので、一度ビルドしたバイナリを、OS/アーキテクチャが同じな別のマシンにコピーして、そのまま使えます。気楽にインストールできるので便利ですね。

元祖htmlcatにあるANSIカラーをブラウザで表示する機能には対応してません。残念。

実装の見どころ: セマフォによるgoroutine間の処理の同期

ソースコードは https://github.com/hakobe/htmlcatgo/blob/master/htmlcat.go に置きました。このコードでは、goroutine間の処理の同期をチャンネルを使ったセマフォで実現してみました。

htmlcatgoでは、標準入力から得られたデータをhttpのサーバに接続しているclientのそれぞれに送る必要があります。以下の図のような構造です。stdin readerは標準入力を読み込みoutチャンネルに出力します。broadcasterは受け取った入力を各http clientのoutチャンネルに出力します。

f:id:hakobe932:20131216223000p:plain

上手のbroadcasterの部分は、例えば以下の様なコードになります。

type Client struct {
    out  chan string // このチャンネルに送られたデータがhttp clientに出力される
}

// stdin readerにも参照があり、標準入力から得られた入力が送られてくる
out := make(chan string)
// http client にも参照があり、対応するClient構造体への参照が送られてくる
addClient := make(chan *Client)

go func() {
    clients := [](*Client){}
    for {
        select {
        case line := <-out: // 標準入力から新しい入力がきた
            // 全クライアントに出力
            for _, client := range clients {
                client.out <- line
            }
        case c := <-addClient: // 新しい http client が接続してきた
            clients = append(clients, c)
        }
    }
}()

outはstdin readerが読みこんだデータが送られてくるチャンネル、Clientはhttp clientに出力するためのチャンネルを持つ構造体、addClientはhttp clientが作られたときにClient構造体が送られてくるチャンネルです。

addClientチャンネルからのデータを受け取ると、新しいclientを追加するためにclientsが変更されます。outチャンネルからデータをうけとるとそのデータを全クライアントのclient.outに送ります。

ここまでは問題がありません。htmlcatgoでは、標準入力からの入力が終了したことを検知して、もう不要なgoroutine( = 上図のbroadcaster)を終了させることにしました。初期の段階では以下の様なコードになりました。

type Client struct {
    out  chan string
    quit chan bool
}

out  := make(chan string)
addClient := make(chan *Client)

quit := make(chan string) // stdin readerにも参照があり、標準入力が終了するとtrueが送られてくる

go func() {
    clients := [](*Client){}
    for {
        select {
        case line := <-out: 
            for _, client := range clients {
                client.out <- line
            }
        case <-quit: // 標準入力が終了した
            // 全クライアントに終了を通知
            for _, client := range clients {
                client.quit <- line
            }
            return // このgoroutineを終了
        case c := <-addClient:
            clients = append(clients, c)
        }
    }
}()

一見良さそうに見えますた、このコードには問題があります。もし次のような順番で処理が進んだらどうなるでしょうか。

  • 1. 標準入力からの入力が終了し、stdin readerからquitチャンネルにtrueが送られてくる
  • 2. select文が実行され case <-quit: 節の実行に入る
  • 3. 新しいhttp clientが接続し、http clientのgroutineからaddClientチャンネルにデータが送られてくる
    • 現在quitの処理中なので、addClientに送ったデータが処理されまで http client はブロックして待っている
  • 4. case <-quit: 節で全クライアントのclient.quitチャンネルにtrueが送られる(3のclientは含まれない)
  • 5. case <-quit: 節でreturnし goroutineが終了する

5が終わった段階で、3でaddClientチャンネルに送られたデータを処理するgoroutineはもう終了してしまっています。addClientチャンネルにデータを送信しているhttp clientは、データが処理されず、送信がいつまでも完了しないためブロックしたままになってしまいます。

ここでの問題は、case <-quit: 節でgoroutineの終了処理が始まっているにもかかわらず、http clientがaddClientチャンネルにデータを送信できている点にあります。そこで、セマフォを使って、終了処理とClient追加処理を明示的に同期することにしました。

go言語ではバッファのあるチャンネルをセマフォ代わりに使えます。(Effective Go にも解説があります。)セマフォを使ったコードは以下のようになります。

type Client struct {
    out  chan string
    quit chan bool
}

out  := make(chan string)
addClient := make(chan *Client)
quit := make(chan string)

// セマフォ代わりのチャンネルを用意する
sem := make(chan bool, 1) // といってもサイズが1なのでMutexかも知れない
sem <- true

go func() {
    clients := [](*Client){}
    for {
        select {
        case line := <-out: 
            for _, client := range clients {
                client.out <- line
            }
        case <-quit: // 標準入力が終了した
            <- sem // セマフォを取ることで終了処理が開始したことを示す
            // 全クライアントに終了を通知
            for _, client := range clients {
                client.quit <- line
            }
            close(sem) // もう終了処理が終わってセマフォが取れることがないのでチャンネルをclose
            return // goroutine 終了
        case c := <-addClient: // 直接チャンネルには送信せず Add 関数を利用する
            clients = append(clients, c)
            sem <- true // 呼び出し側でとったセマフォを返す
        }
    }
}()

// @ http client
_, ok := <- sem // セマフォがとれれば終了処理は開始してない (または他のhttp clientがセマフォをとってる)
if ok { // セマフォが取れる、かつcloseしてない( = goroutineが終了してない)ときはチャンネルにclient を送る
    addClient <- client
}

select文の case <-quit: 節ではセマフォをまず取得することで、終了処理が開始したことを示します。http clientでは、addClientチャンネルにデータを送る前にはセマフォを取るようにします。セマフォがとれれば終了処理が開始していないので、addClientチャンネルにデータを送ることができます。

終了処理が実行中にセマフォを取ろうとするとブロックしますが、終了処理が終わった時点でブロックが解除されます。ただし、その段階でセマフォのチャンネル(=sem)はcloseされているので、返却値の二番目(ok変数)がfalseになっているため、addClientチャンネルにデータを送ってはいけないことがわかります。これで、終了処理とClient追加処理の競合がなくなりました。

このように、チャンネルをセマフォをとして使ってgoroutine間の同期を取ることで、標準入力が終了した際に、安全にgoroutineを終了できるようになりました。

goroutineとチャンネルがあれば、同期処理とはおさらばだ、と思っていた時期が僕にもありましたが、どうやらそうはいかないようでした。今回の場合のように、複数のgoroutineが1つのgoroutineをコントロールしたり、goroutine自体が終了したりした場合には同期処理が必要でした。どういった時にチャンネルを使い、どういったときに基本的な同期処理を使えばいいのかなどのバランスはまだ良くわからず、goroutineがチャンネルがどうなっているのか、詳しく調べる必要がありそうでした。(golangにはsyncパッケージがあって基本的な同期処理も使えるようですから、必要に応じて使っても良さそうな雰囲気はします。)

おまけ

実際のhtmlcatgoのgoroutineと関係を図にしてみました。せっかく作ったので貼っておきますので、コードリーディングの際の参考にどうぞ。

f:id:hakobe932:20131217005506p:plain