Go が他の多くの言語での非同期プログラミングよりも優れている理由

  • 317
    いいね
  • 0
    コメント

はじめに

非同期プログラミングと呼んでいるのは、ノンブロッキングIOと select, poll, epoll, kqueue のようなIO多重化を利用したネットワークアプリケーションを書くことです。 node.js で websocket 使ったチャットを書くとかそういうのです。

「他の多くの言語」とは、 Python (asyncio), node.js, C# などを想定しています。 Erlang や GHC なんかは Go に近いかも知れません。 async / await がない言語では、「コールバック地獄」や「deferred地獄」のような問題もありますがこの記事では扱っていません。 async / await のメリットを解説した他の記事を参照してください。

あとこの記事は主にランタイムに関する部分を扱っているので、「それは言語じゃなくて処理系の問題だ!」等の頓珍漢な揚げ足取りツッコミはご容赦ください。

非同期プログラミングの難しさ

同期プログラミングと非同期プログラミングの組み合わせ

Linux ではソケットやパイプはノンブロッキングIOが使えますが、ファイルには使えません。また、多くのシステムコール、glibcやライブラリが提供する関数もブロックするものが多いです。

イベントループから呼ばれる関数でブロックしてしまうと、イベントループが回る頻度が落ち、アプリケーション全体の応答性が悪化します。なのでブロックする処理は別のワーカースレッドで動かして、その処理が終わるのをイベントループで待つのが正攻法になります。

難しいのは、ブロックする関数の中には、普段はブロックしないけど特定の条件でのみブロックするというものもあることです。例えば標準出力やファイルにログを書く場合、ノンブロッキングIOを使ってなくてもバッファがいっぱいになるまではブロックしません。ついついブロックする関数を使ってしまっても、環境や条件によって問題が起こったり起こらなかったりします。本番環境で問題が起こっても開発環境で再現できないかもしれません。

node.js の場合最初から非同期プログラミングが前提になっているので常にノンブロッキングIOを使うように統一することは可能かも知れません。しかしそれでも、ある程度以上CPUを使いすぐにイベントループに戻らない処理を書いてしまうとそれがブロックしたのと同じになり、応答性を悪化させます。

また、イベントループとワーカースレッドの間のやり取りにもオーバーヘッドがかかります。ブロックする処理を見つけたらそこだけ細かい粒度でワーカースレッドに投げるよりも、トランザクション単位などの大きな処理を同期プログラミングで書いてワーカースレッドで動かすほうがパフォーマンスが出しやすいこともあります。しかしそうするとアプリケーションに同期プログラミングの部分と非同期プログラミングの部分が存在することになったり、依存するライブラリにも同期用と非同期用ができたりして、メンテナンス性が悪化することになります。

マルチコアの活用

node.js や Python は、C言語でかかれた処理を別スレッドで並列に動かすことができるものの、 ES や Python で書かれた処理は並列には動きません。なのでそういった言語はプロセスを分けることになり、ロードバランスやプロセス管理の手間が増える事になります。またプロセス内のキャッシュなどの効率も落ちます。

マルチスレッドに制限がない言語でも、1つのイベントループは1つのスレッド上で動きます。複数スレッドでそれぞれイベントループを動かすかアプリケーション全体で1つのイベントループだけを使うかというところから非同期IOとマルチスレッドの組み合わせ方を適切に設計しなければ、マルチコアを有効に使えないかもしれません。

Go の場合

非同期プログラミングをしない

Goの場合、アプリケーションは普通の同期プログラミングをするだけで、非同期プログラミングのメリットを享受することができます。

プログラムは goroutine という軽量スレッドの中で動いていて、 Go のランタイムはOSスレッドに対して goroutine を割り当てています。 goroutine の中のコードが TCP の通信などを行うときにノンブロッキングIOで待ち状態になると、ランタイムはそのスレッド上で別の goroutine を動かします。イベントループはランタイムに統合されていて、待っていたIOが処理可能になると、それを待っていた goroutine の状態も実行可能になり、またどこかのスレッドで実行されます。

なので、アプリケーションは非同期プログラミングと同期プログラミングのモデルを使い分ける必要がありません。

ブロック対策

システムコールや cgo による外部ライブラリ呼び出しをしている goroutine は、バックグラウンドで動いている監視スレッドにより監視されています。すぐに戻ってこないなら、同じスレッド上に割り当てられていた goroutine は別のスレッド(なければ作る)に移動されるので、ブロックしても他のイベントの処理に与える影響はわずかです。

ロードバランス

どれかの goroutine が長時間実行を続ける場合、その goroutine は停止され他の goroutine に実行権が移ります。また、実行可能な goroutine がなくなったスレッドは他のスレッドの実行待ちの goroutine を奪います。 (steal)

なので、ある程度まとまってCPUを使う処理があったとしても、全体のCPU使用率に十分空きがあれば応答時間に与える影響はごくわずかです。
イベントループとワーカースレッドプールの使い分けなどについても一切考えるこなく、簡単にマルチコアを有効活用できます。

デバッグや最適化

非同期プログラミングのために何も特別なことをしないので、シンプルなコールツリーに対してデバッグや最適化をすることができます。

例えば await をクロージャへのコールバックなどにコンパイラが変形するような処理系の場合は通常の処理中のコールスタックと await 中のコールスタックの扱いが分かれている (後者がデバッガのバックトレースコマンドで直接見えないなど) 場合もあるかもしれませんが、 Go の場合はそういった違いはありません。

なので、ノンブロッキングIOのメリットを受けつつ、メンテナンスコストを上げずに済むのも Go のメリットの一つです。

まとめ

Go より「現代的」で「強力」な言語はいくつもあります。しかしそれが必ずしも生産性に繋がるとは限りません。

言語の生産性に、言語そのものと同じくらいライブラリや情報の質と量が強く影響するのは皆知っていると思いますが、この記事で紹介したようなランタイムの特性もやはり生産性に大きく影響します。

少なくとも僕は、ネットワークを使うアプリケーションでは Go でとても生産的になれると感じています。

補足(追記)

フレームワークのを選べる自由 vs フレームワーク乱立のデメリット

これをランタイムで行わざるを得ないのはGo「言語」の「利点」ではなくむしろ「弱点」。他の現代的で強力な言語ならあえてランタイムを薄くしてライブラリレベルで同じことを好みの抽象度・戦略で実現できますので。
http://b.hatena.ne.jp/entry/329868791/comment/megumin1

まず、上のブロック対策やロードバランスで書いたようなことを Go くらい効率よくライブラリレベルで実現している「他の現代的で強力な言語」って何でしょう?個別ケースではなくて「他の現代的で強力な言語」とまとめないといけないくらい沢山あるなら、とりあえず具体例を 3, 4 個教えてほしいところです。

もう一つ、Go と他の言語というより、 Go, Erlang, node.js, C#, (Python も asyncio が普及すればこちら側) のように標準ライブラリやランタイムで非同期プログラミングがサポートされている言語とそうでない言語でどちらが良いのかという問題もあります。

好みのライブラリやフレームワークを選べると言うのは一見メリットに見えます。でも、例えば AAA と BBB という非同期プログラミングフレームワークがあり、 AAA-redis, AAA-mysql, AAA-http2, BBB-redis, BBB-mysql, BBB-http2, というライブラリがあったとしましょう。しかし AAA-http2 は品質が悪く、 BBB-redis はメンテされてません!といった事がよく起こってしまうのです。

非同期プログラミングが標準になってない言語では、まずマスが同期プログラミングになってしまうので、非同期プログラミングはニッチな分野になります。そのニッチな分野で、フレームワークの数×ライブラリの種類だけのプロジェクトが健康的にメンテされることは期待しにくいです。

理論上は可能、と、現実に生産的な環境が今揃っているかどうかは別問題なのです。