A Tour of Go in Haskellを作ったのと、GoとHaskellの比較

(この記事は Haskell (その2) Advent Calendar 2017 - Qiita の3日目の記事です)

A Tour of Go in Haskell というサイトを作りました。 英語版(開発中) もあります。

サイトのソースは GitHub で管理しています。

概要

今流行りの Go 言語は並行並列処理が簡単に書けることを1つの売りにしているようです。 Haskell も Go と同じく軽量スレッドやチャネルを利用することができ、並行並列が得意な言語の1つです。 そこで、A Tour of Go という Go の有名なチュートリアル並行性 の章を Haskell で書いてみることで、 Haskell と Go を並行並列処理の記述という観点で比べてみよう、というのが A Tour of Go in Haskell になります。

Go ユーザや Haskell に慣れ親しんでいない人が見ても雰囲気がつかめるコードを目標にしたので、あまり難しい書き方はしないようにしています。 たとえば、Haskeller にはよく知られているユーティリティ関数を使わなかったり、ポイントフリーにできるところをポイントフリーにしていなかったり、関数はあまり純粋にせず IO にしていたりします。 また、Go との比較を重視したので、もっぱら async や stm を使い、EvalモナドやParモナドは説明していません。 Haskell に詳しい方はそのあたりご了承下さい。 なお、コードのレファレンス性のために import はかなり明示的に書いています。 その場のコーディング規約にもよると思いますが本来はもっとワイルドカードimport ができます。

サイトの作りとしては完全に静的サイトになっていて、残念ながら現状は本家のようにコードを実行する機能はありません。 一応作る気はあり、静的サイトホスティングとして Firebase Hosting を使っているのもいずれ GCE かなにかでコード実行サーバを立てたいというのがあるためです。 そういえば最近は他にも静的サイトホスティングの選択肢はあるようですが、Firebase Hosting は独自ドメインも使えるし SSL 証明書もついてくるし HTTP/2 で通信されるし、 結構便利な感じでした。

あと、Haskell は言語拡張を多用することに毀誉褒貶があったりしますが、A Tour of Go in Haskell は言語拡張を1つも使わずに書けました。 並行並列が Haskell (GHC) に早い段階から入っていた(枯れた)機能なことが分かります。

比較

いくつかの観点から、比較と言えるほど厳密ではないですが、感触の違いを述べます。 今回はパフォーマンスについては見ていません。

軽量スレッド

軽量スレッドというのはOSのスレッドとは違って言語のランタイムが管理するスレッド(でいいはず)。 軽量というだけあって軽くてたくさん(数千個とか)作ることができます。 どれくらい軽いかというと Haskell の場合はスレッド1つにつき18ワード+1K (pdf) しかメモリを消費しない(slackで教えていただきました m(_ _)m)。 よく並行処理がすごいと言われる Erlang の軽量プロセス(軽量スレッドとは違うのか?)が309ワード(リンク) なことを考えれば、Haskell はなかなかすごいと言えそうです。 Go の軽量スレッドのサイズは分かりませんでしたが、おそらく HaskellErlang くらいでしょう。

さて軽量スレッドについては Go と Haskell で使い勝手はほとんど変わらないと思います。 Go では go とやると軽量スレッドになるところを、Haskell では async とやると軽量スレッドになるというだけです。 (forkIO でもいいが async の方がよく使われていると思います)。

違いは go が専用の構文なのに対して async は普通の関数というところです。

あとたぶん goroutine というのは軽量スレッドという言葉を言い換えたものだと思うのですが、 別の名前をつけることで言語固有のすごい機能として見せるのはうまい手だなと思いました。

チャネル

チャネルはスレッド間で値をやり取りするのに使う First-In-First-Out キューという理解。

Go はかなりチャネルに特化して構文や機能を用意していて、チャネルに関しては Haskell より使いやすいと言えそうです。 チャネルに値を書き込む/取り出すための演算子のような構文があったり、for式でチャネルの要素をイテレーションできるなど、 ストレスフリーにチャネルを取り扱えるように考えられています。

Haskell でも真似できる部分は結構あり、例えば getChanContents 関数を使えば Go の range 句のようなことができます(Range and Close - A Tour of Go in Haskell)。 加えて Haskell演算子が定義できるので工夫すれば Go のような書き味を実現できるかもしれません。 ただ、それはおそらくやりすぎなのでやらない方が良いでしょう。

A Tour of Go in Haskell では取り扱わなかったこととして、Haskell のより高速なチャネルの話題があります。 Hackage には Haskell 標準のチャネルより高性能な実装を目指したパッケージがいくつかあります。 もし標準チャネルの性能に不満があるなら検討してみるとよいかもしれません。

Mutex, STM

チャネルは便利ですが、スレッド間で変数を共有する方法としてそれだけで完結するというわけではありません。 たとえば int の変数を複数のスレッドで共有してコンフリクトを起こさないようにそれぞれ読み書きをするような場合です。

Go はそのような場合のために古典的な Mutex を用意しているようです。 しかし Mutex を直接的に使うことは変数の書き換えの前後でロック処理およびアンロック処理が明示的に必要で、 シンタクティックノイズなだけでなくデッドロックを生みやすくなってしまいます。 (これは私の憶測ですが、Goの設計はユーザにチャネル以外の共有変数を禁止しようとしているように見えます)。

Haskell ではそのような場合や、複数の共有変数が絡むようなより複雑な場合のために STM(ソフトウェア・トランザクショナル・メモリ)が用意されています。 STM では一連の不可分な共有変数の操作を「トランザクション」という単位でまとめることができます。 トランザクションの実行中に、利用したい共有変数の1つが他のスレッドによってすでに使われていることが分かったら、トランザクションは実行前の状態までロールバックし、変数がフリーになるのを監視します。 いけそうになったらトランザクションはリトライされ、ロールバックせず完遂できるまで繰り返します。

STM を使うと複雑な並行処理もうまく記述できることがあります。 まあ STM にも弱点はあり、ふつうのコードとの組み合わせに注意が必要なこと、ロールバック・リトライの回数やタイミングを人間が完全に予測することはほとんど不可能なことなどがあります。 もしトランザクション中にミサイルを発射する処理が入っていた場合、トランザクションが間違いでロールバックされることになってもミサイルの発射はロールバックできません。 そこまでいかなくても、トランザクション中に何かのインクリメント処理が入り込んでいて実行環境依存でリトライ回数が激増して int の最大値を超える、といったことは十分考えられます。 「ソフトウェア」とわざわざ付けているだけあって元は電子回路上のアルゴリズムなので、任意の処理が差し込めるようにはできていないのです。 Haskell の場合、STM は STM モナドとして IO モナドと分離することでこの問題を完全に回避しています。 もしトランザクション中に副作用のある処理を入れてしまったとしても、実行時ではなくコンパイル時にエラーになります。 IOに型が付くという Haskell の特徴はこういったところでもメリットを生んでいます。

Haskell の STM について詳しく知るには O'Reilly Japan - Haskellによる並列・並行プログラミング が最も良いと思います。 よく言われる「STMは遅いのか?」という話題も取り扱われています。

さて、Mutex のページは Mutex を使う A Tour of Go と STM を使う in Haskell で顕著な違いがある部分です。 ぜひ2つのコードを見比べてほしいと思います。

(補足: GoにもSTMライブラリはあるようですが更新が止まっているようです GitHub - lukechampine/stm: Software Transactional Memory in Go)。

やり残したこと

  • 本家のようにコード実行機能をつけたい。
    • hint-server を使えば、Haskell コードを渡して動的ロードするサーバーが作れそう。
    • あるいは直接 ghc コマンドを叩く。
    • この部分は静的サイトにできないのでサーバ代がかかる……。(たぶんGCE Managed Instance Groupを使う←Twitterで教えていただきました m(_ _)m)
  • 英語版の完成。
  • CSSの改善。

おわりに

Go の軽量スレッドやチャネルの構文はとてもよく設計されていて、特にチャネルに的を絞って専用の構文をたくさん用意する設計選択にはすごいセンスを感じました。 ただ、チャネル以外の共有変数のサポートが薄いというのは、並行並列処理に関してちょっと強すぎる仮定な気もしました。 しかしもしかすると、Go の想定する適応領域(システムプログラミング?)ではスレッドとチャネル以外の並行並列ツールはほとんど必要ないのかもしれません。 そうだとすると Go の設計選択のセンスはやはりすごいということになる。

O'Reilly Japan - Haskellによる並列・並行プログラミング から少し長いですが引用します。

スレッドとロックは、並列画像処理から並行ウェブサーバまで、必要なものをすべて記述できるだけの表現力を有しています。 このような単一の汎用的なAPIが存在するのはたしかに利点ではあります。 しかしながら、並列・並行ソフトウェアの作成を容易にしたいのであれば、 異なる問題に対しては異なるツールを用いるという考えが、やはり重要になってきます。 一本のナイフではうまく切れないのです。

私個人としては1つのツールに寄り掛かるのは恐いので状況に応じたツール選択ができるようになっておきたいという思いがあります。

Haskellによる並列・並行プログラミング」は A Tour of Go in Haskell で利用した async や stm だけでなく、純粋な関数での並列性、メッセージパッシング、そもそも並列と並行の違いとは、といった広範な話題を扱っていて、 Haskell に限らず並列・並行をやる人にとてもおすすめの本です。 A Tour of Go in Haskell を作るにあたっても大いに参考にしました。