rubygems を 1.5 倍に高速化した方法(stackprof --d3-flamegraph の使い方)

タイトルは釣りです。明日 ISUCON 10の予選があると小耳に挟んだので、Ruby で参加する人が絶対に抑えておくべき? Ruby 高速化の tips をひとつ。stackprof --d3-flamegraph のご紹介です。

例題

ちょうど今日、gem install aws-sdk にかかる時間を 37 秒から 24 秒ほどに高速化しました。

変更前:

$ time ruby -I lib bin/gem install --no-doc aws-sdk
Successfully installed aws-sdk-3.0.1
1 gem installed
real    0m37.104s
user    0m36.952s
sys     0m0.333s

変更後:

$ time ruby -I lib bin/gem install --no-doc aws-sdk
Successfully installed aws-sdk-3.0.1
1 gem installed
real    0m23.905s
user    0m23.740s
sys     0m0.365s

stackprof --d3-flamegraph ってやつを使ったら 2 時間くらいでできました。

stackprof の使い方

このあたりはみんな知ってると思うのでざっくりと。

対象である gem コマンドに StackProf.run(...) do ... end を挟み込みます。rack だったら use StackProf::Middleware でしょうか。

diff --git a/bin/gem b/bin/gem
index baf607308..6a508e989 100755
--- a/bin/gem
+++ b/bin/gem
@@ -11,8 +11,13 @@

 args = ARGV.clone

+require 'stackprof'
+StackProf.run(mode: :cpu, out: 'stackprof.dump', raw: true) do
+
 begin
   Gem::GemRunner.new.run args
 rescue Gem::SystemExitException => e
-  exit e.exit_code
+  e.exit_code
+end
+
 end

raw: true というのが最重要ポイントです。あと、exit すると stackprof.dump が出力されないようなので、少しだけいじっています。

実行する。

$ time ruby -I lib bin/gem install --no-doc aws-sdk
Successfully installed aws-sdk-3.0.1
1 gem installed

real    0m36.562s
user    0m36.418s
sys     0m0.313s

stackprof.dump ができていると思うので、stackprof コマンドで結果を見ます。

$ stackprof stackprof.dump
==================================
  Mode: cpu(1000)
  Samples: 3543 (0.06% miss rate)
  GC: 340 (9.60%)
==================================
     TOTAL    (pct)     SAMPLES    (pct)     FRAME
       567  (16.0%)         374  (10.6%)     Gem::Version#<=>
       446  (12.6%)         349   (9.9%)     Gem::Requirement.parse
       287   (8.1%)         287   (8.1%)     (sweeping)
       498  (14.1%)         257   (7.3%)     URI::Generic#hash
       227   (6.4%)         227   (6.4%)     Gem::Version#canonical_segments
      1145  (32.3%)         198   (5.6%)     Gem::Resolver::APISpecification#initialize
       630  (17.8%)         184   (5.2%)     Gem::Requirement#initialize
       172   (4.9%)         172   (4.9%)     Gem::Version.new
       241   (6.8%)         136   (3.8%)     URI::Generic#component_ary
       842  (23.8%)         128   (3.6%)     Gem::Dependency#initialize
       131   (3.7%)         106   (3.0%)     Gem::Platform#=~
       714  (20.2%)          84   (2.4%)     Gem::Requirement.create
        81   (2.3%)          76   (2.1%)     URI::Generic#component
       156   (4.4%)          75   (2.1%)     Gem::Version#hash
      3070  (86.6%)          70   (2.0%)     Gem::Resolver#search_for
        52   (1.5%)          52   (1.5%)     Gem::Platform.local
       412  (11.6%)          51   (1.4%)     Gem::Requirement#satisfied_by?
        47   (1.3%)          47   (1.3%)     Gem::Version#_version
        46   (1.3%)          45   (1.3%)     Gem::Requirement#none?
       564  (15.9%)          44   (1.2%)     Gem::Dependency#match?
        44   (1.2%)          44   (1.2%)     Gem::Resolver::Specification#initialize
        42   (1.2%)          42   (1.2%)     (marking)
        42   (1.2%)          42   (1.2%)     Gem::Platform.new
        33   (0.9%)          33   (0.9%)     Gem::Resolver::DependencyRequest#name
        33   (0.9%)          30   (0.8%)     Gem::StubSpecification#data
        30   (0.8%)          30   (0.8%)     Gem::Resolver::ActivationRequest#initialize
        28   (0.8%)          28   (0.8%)     Gem::Version#prerelease?
        26   (0.7%)          26   (0.7%)     Gem::Dependency#requirement
        24   (0.7%)          24   (0.7%)     URI::Generic#userinfo
      1765  (49.8%)          22   (0.6%)     Gem::Resolver::APISet#find_all

なるほど、Gem::Version#<=>ボトルネックなんですね。じゃあこれを高速化しましょう!Gem::Version#<=> のコードを見て……という前に考えるとよいことがあります。

このメソッドはバージョン番号の比較をする演算なので、それ自体がすごく遅いとは考えにくいです。そういうのを速くしようとすると、近視眼的で hacky な最適化になりがちです。どの当たりに時間がかかっているのか、もうすこし大局的に眺めてみましょう。

stackprof --d3-flamegraph の使い方

そこで、flamegraph を使ってみます。次のように実行します。

$ stackprof --d3-flamegraph stackprof.dump > stackprof.html

このとき、

.../lib/stackprof/report.rb:197:in `print_d3_flamegraph': profile does not include raw samples (add `raw: true` to collecting StackProf.run) (RuntimeError)

のようなエラーが出たら、上述の raw: true を付け忘れているので、直して再プロファイルしてください。

この html を開くと、フレーム(炎)というか山のような図が出てきます。

f:id:ku-ma-me:20200911223800p:plain
stackprof --d3-flamegraph の出力(html)

これは、スタックフレームを表現しています。下から 2 番目が起動スクリプトの bin/gem があり、そこから呼び出されているメソッドが上に積み重なっています。

勘違いしやすいですが、このグラフは時系列とは一切関係ありません。左から右に時間が進んでいる図ではありません。実行中のスタックフレームを定期的に観測し、そのスタックフレームを辞書順にソートして並べたものになっています。

見方にコツがあります。例でいうと、下から 6 番目の Gem::GemRunner#run とその上の Gem::CommandManager#run は、横幅がほとんど同じです。これは、Gem::GemRunner#runGem::CommandManager#run を呼び出しており、前者の実行時間は後者の実行時間とほとんど同じ、という意味です。よって、Gem::GemRunner#run のコードを最適化しようと思ったときに考えるべきなのは、「Gem::CommandManager#run の呼び出し回数を減らせないか」ということです。しかしコードを見ると、前者は後者を 1 回呼び出しているだけでした。つまり、GemRunner#run を何年眺めても何の高速化もできそうにないということになります。

実際の --d3-flamegraph の結果を置いておきます。

rubygems-stackprof-d3-flamegraph.netlify.app

高速化できそうなところを探す

ここからは結局、気合(デバッグプリント)です。

このグラフはわりと横幅が同じところが多く、上の方には Gem::Resolver クラスのメソッドがいっぱい並んでいます。aws-sdk は大量の依存を持つので、依存関係の解決に時間がかかっているのは直感にあいますね。一番横幅が長そうな子供を選んでたどっていくと、find_all というメソッドがいくつか並んだあとで、Gem::Resolver::APISpecification#initialize が 26.347% も占めていることがわかります。

f:id:ku-ma-me:20200911224103p:plain
Gem::Resolver::APISpecification#initialize を発見したところ

呼び出し元の Gem::Resolver::APISet#find_all と合わせてみると、Gem::Resolver::APISpecification というのは、rubygems.org のサーバから送られてきたデータをオブジェクト化したもののように見えます。いくつかデバッグ出力を挟んで実行してログを観察すると、中身がまったく同じ Gem::Resolver::APISpecification インスタンスを何回も作ってる気配を感じました。この手のオブジェクトは中身を破壊的に変更することはなさそうなので、生成したオブジェクトを使い回せるのでは?と考えて、オブジェクト生成をメモ化する魔法の 7 行を書いてみます。

  @@cache = {}
  def self.new(set, api_data)
    cache_key = [set, api_data]
    cache = @@cache[cache_key]
    return cache if cache
    @@cache[cache_key] = super
  end

再実行。

$ time ruby -I lib bin/gem install --no-doc aws-sdk
Successfully installed aws-sdk-3.0.1
1 gem installed

real    0m25.387s
user    0m24.436s
sys     0m0.354s

やったね。破壊的操作がないことを確かめるため、各フィールドを freeze してもテストが通ることを確認。まあ大丈夫じゃないかな……ということで PR を送って無事マージされました(もしどこかで破壊してて動かなくなってたらごめんね!)。

github.com

修正後の flamegraph

修正後の flamegraph も置いておきます。

rubygems-stackprof-d3-flamegraph.netlify.app

Gem::Resolver::APISpecification#initializeの代わりにGem::Resolver::APISpecification.newが来るようになりました。割合も 15.249% に減っています。 ここからsuperでたまにGem::Resolver::APISpecification#initializeを呼んでいるはずなのですが、定期的な観測にひっかからないほどレアになったということでしょう(たぶん)。

なお、デバッグプリントからの推測ですが、gem install は、公開されているすべてのバージョンのメタ情報(依存関係情報)をフェッチしてきて、すべてオブジェクト化してから依存関係を解決するようです。これは公開バージョンが増えるほど遅くなりそうなので、まだまだ改善の余地がありそうです。

まとめ

stackprof --d3-flamegraph のご紹介でした。実はこの機能を作ったのは自分なので、自慢です *1 。でも役に立つと思います。

Ruby で ISUCON に参加する人たち、ぜひがんばってください。

*1:--d3-flamegraph の前から flamegraph を生成する方法はあったのですが、いまいち素直に使えなかったので。

カロリーメイトリキッドのQuineを書きました

縁あって、カロリーメイトリキッドのプロモーション用にちょっとした Ruby プログラムを書かせてもらいました。

www.otsuka.co.jp

↑のリンクを開いて、cd .Quine したところにある CML_quine.rb がそれです。 cat CML_quine.rb とすると中身が見えます。ruby CML_quine.rb すると動きます。

f:id:ku-ma-me:20200803184915p:plain
CalorieMate-Liquid-Quine

実行してみましたか?サイト上で気楽に実行できるので、ぜひ試してみてください。

これがどういうプログラムなのか、簡単に解説しておきます *1

ローカルでの遊び方

サイト上で ruby CML_quine.rb をするだけでも楽しめますが、自分のパソコンに保存するとより楽しめます。

まず、cat CML_quine.rb した中身をまるごとコピーしてください。 n=2;で始まる行の頭から、'CalorieMate-Liquid-Quine' で始まる行の終わりまでです。 それをペーストして、CML_quine.rb というファイル名でローカルに保存してください。

そして、Ruby をインストールして、ターミナル(端末)で ruby CML_quine.rb で実行してください。 Windows Terminal で動作確認しています。

Quine

Quine とは、実行すると自分のソースコードを出力するプログラムのことです。 といっても、ソースコードをファイル読み込みして出力するわけではありません。 ちょっとしたパズルのようなコードを書くことで、ファイル読み込みせずに自分を出力できます。 詳しくはWIkipediaの記事や拙著『あなたの知らない超絶技巧プログラミングの世界』をご覧ください。

そして、このプログラムはQuineみたいなもので、実行するとまずソースコードを出力し、それからアニメーションをします。

「みたいなもの」と言ったのは、1回実行しただけではちょっとだけ違う結果になるからです。 サイトのreadme.mdにも書いてありますが、次のように3回実行を繰り返すと元に戻るようになっています。

$ ruby CML_quine.rb  > CML_quine2.rb

$ ruby CML_quine2.rb > CML_quine3.rb

$ ruby CML_quine3.rb > CML_quine4.rb

$ diff -s CML_quine.rb CML_quine4.rb
ファイル CML_quine.rb と CML_quine4.rb は同一です

ターミナル上でのアニメーション

各プログラムの違いは、『フレーバー』です。 CML_quine.rb と CML_quine2.rb と CML_quine3.rb をそれぞれ単体で実行すると、いずれも同じアニメーションをしますが、最終的に出来上がるフレーバーが変わるようになってます。 次のように実行してみてください。

$ ruby CML_quine.rb

$ ruby CML_quine2.rb

$ ruby CML_quine3.rb

このアニメーションは、エスケープシーケンスを使って色付き文字のモードにしたり、カーソルを移動したりすることで行っています。

ロゴを描くためのカーソルの動きのデータは、すべてコード中に埋め込まれています。 そのままだと巨大になってしまうので、バイトペア符号化によって圧縮しています。

流体シミュレーション

最後。缶に黄色い液体が注ぎ込まれるアニメーションですが、これは決まった動きをハードコーディングしているわけではなく、流体シミュレーションを使って実行時に計算しています。 流体力学の原理であるナビエ-ストークス方程式Smoothed Particle Hydrodynamicsと言われる方法で数値的・近似的に解きます。 簡単に言えば、液体を表現する粒子同士がお互いに押し合ったり(圧力)、粘ついたり(粘性)、重力に引っ張られたり(外力)するのをシミュレーションしています。

実はここは、以前C言語で書いたコードの移植です *2 。こちら。

www.youtube.com

ただ、今回はシミュレーションの精度を上げるために粒子の数を増やしたこともあって実行速度が遅くなってしまったので、空間分割法を実装して高速化しました。

おまけ

「プログラムを表示した後、アニメーションで上書きしちゃったら Quine にならないじゃないじゃん!」と思う人がいるかもしれません。 しかし、背景色と文字色が同じになってて読めないだけで、実は文字はちゃんと表示されています。 次の動画のように端末からコピペすれば実行できます。

「出力にエスケープシーケンスを混ぜたらプログラムとして実行できない!」と思う人がいるかもしれません。 しかし、エスケープシーケンスはコメント内に出すようになっているので、実はちゃんと動きます。 次のように実行すれば、エスケープシーケンス入りの CML_quine2.rb がちゃんとプログラムとして動くことがわかります。

$ script -q -c "ruby CML_quine.rb" /dev/null | tee CML_quine2.rb
$ ruby CML_quine2.rb
$ vim CML_quine2.rb

ということで

このコードはカロリーメイトリキッドを 3 種類ぜんぶ飲みながら実装しました。

以上の処理はどこか見えないところに隠されているわけではなく、cat CML_quine.rb で見ることができる 22 行のプログラムの中にすべて詰め込まれています。 プログラミング言語 Ruby の簡潔さ・強力な言語機能・柔軟性を悪用するとこんなこともできるんですよ。他の言語ではなかなかむずかしい。

ということで、腕に覚えのある人はぜひ解読してみてください! 他にもここで解説しなかった工夫がいくつか仕込まれています *3

なお、Quine に興味がある人は、拙著『あなたの知らない超絶技巧プログラミングの世界』に詳しいやり方からたくさんの実例までむちゃくちゃ詳しく書いてあるので、ぜひ読んでみてください。(宣伝)

*1:念のために書いておくと、このブログを書くことは関係者の方に了承を得ております。

*2:余談ですが、元のソースコードを失っていたので、難読化コードを読み解いた。

*3:流体シミュレーションをさらに高速化するためのチート(上げ底)や、缶の形状を実行時に生成する式、isatty を使っているところ、謎のイニシャル :-)などなど

CPU律速なRuby/Pythonコードはデフォルト設定のdocker上で遅くなる

English version

要約

dockerはデフォルトでセキュリティ機構(Spectre脆弱性の対策)を有効にします。この影響で、RubyPythonのようなインタプリタは速度が劣化します。特にCPU律速なプログラムで顕著に遅くなります(実行時間が倍くらいになることがあります)。

現象

Rubyで1億回ループするコードを、直接ホスト上で実行する場合と、docker上で実行する場合で実行時間を比較してみます。

直接ホスト上で実行した場合:

$ ruby -ve 't = Time.now; i=0;while i<100_000_000;i+=1;end; puts "#{ Time.now - t } sec"'
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
1.321703922 sec

docker上で実行した場合:

$ docker run -it --rm ruby:2.7 ruby -ve 't = Time.now; i=0;while i<100_000_000;i+=1;end; puts "#{ Time.now - t } sec"'
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
2.452876383 sec

dockerを使うと実行時間がおよそ倍になっています(1.3秒→2.5秒)。

docker runに--security-opt seccomp=unconfinedというオプションを与えて実行すると、ホストと同等程度の速度になります。

$ docker run --security-opt seccomp=unconfined -it --rm ruby:2.7 ruby -ve 't = Time.now; i=0;while i<100_000_000;i+=1;end; puts "#{ Time.now - t } sec"'
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
1.333669449 sec

Ruby特有ではなく、Pythonでも同じ現象が起きます(i=0 / while i<100000000: i+=1 という2行のコードで7.0秒→11秒)。

なお、カーネルの設定次第では、この現象が再現しないこともあります(後述)。

原因

LinuxカーネルにはSpectre脆弱性の対策がいくつか実装されています。

この対策の中に間接分岐予測を抑制するもの(STIBPと呼ばれる)があり、CPU律速なコードが50%ほど速度低下するということで、Linuxカーネルのデフォルトではオフになっています(参考)。 一方、dockerはデフォルトで、Spectre脆弱性の対策を有効にしてコンテナを実行します。*1

これはほぼすべてのプログラムの速度を低下させます*2が、RubyPythonのようなインタプリタは間接分岐(switch/caseやdirect threadingなど)を多用しているので、特に強く影響を受けます。dockerの外では対策がオフなので高速に実行され、dockerの中ではオンなので速度劣化が起きます。

証拠として、上記のベンチマークプログラムのperf statを見てみると、branch-missesが圧倒的に増えていることが観察できました(522,663回→199,260,442回)。 https://www.phoronix.com/scan.php?page=article&item=linux-420-stibp&num=2 --security-opt seccomp=unconfined あり(Spectre対策なし)

 Performance counter stats for process id '153095':
          1,235.67 msec task-clock                #    0.618 CPUs utilized          
                 8      context-switches          #    0.006 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
                 2      page-faults               #    0.002 K/sec                  
     4,284,307,990      cycles                    #    3.467 GHz                    
    13,903,977,890      instructions              #    3.25  insn per cycle         
     1,700,742,230      branches                  # 1376.378 M/sec                  
           522,663      branch-misses             #    0.03% of all branches        
       2.000223507 seconds time elapsed

--security-opt seccomp=unconfined なし(Spectre対策あり)

 Performance counter stats for process id '152556':
          3,300.42 msec task-clock                #    0.550 CPUs utilized          
                16      context-switches          #    0.005 K/sec                  
                 2      cpu-migrations            #    0.001 K/sec                  
                 2      page-faults               #    0.001 K/sec                  
    11,912,594,779      cycles                    #    3.609 GHz                    
    13,906,818,105      instructions              #    1.17  insn per cycle         
     1,701,237,677      branches                  #  515.460 M/sec                  
       199,260,442      branch-misses             #   11.71% of all branches        
       6.000985834 seconds time elapsed

なおこの問題は、STIBPがconditional(デフォルトはオフだがseccompで有効にできる)のときだけ起きます。STIBPがdisabled(常にオフ)またはforced(常にオン)の環境では、dockerだけ遅いという現象は起きません。disabledの場合ホストもdockerも速い(そして脆弱)、forcedの場合はホストもdockerも遅い、ということになります。*3

ちなみに、STIBP以外にも、投機的ストアバイアスのサイドチャネル攻撃の対策(spec_store_bypass_disable)もかなり速度劣化を起こすようです。笹田耕一さんの調査によると spectre_v2_user=off spec_store_bypass_disable=off というカーネルオプションを用いることで、--security-opt seccomp=unconfinedなしのDocker上でも速度劣化がなくなったということでした。

対策

残念ながら、あまりオススメできる対策はありません。--security-opt seccomp=unconfined をつければ速くなりますが、Spectre攻撃に対して脆弱になるのでやめたほうがいいでしょう(ちなみに--privilegedでも速くなります)。

ただし、これはCPU律速なコードでしか問題にならないと思います。Ruby on Railsを使ったWebアプリは多くの場合IOやGC律速になっているので、Spectre対策を止めても観測できるほどの速度向上はしないでしょう(たぶん)。なので当面気にしないことをおすすめします。

長期的には、CPU側でSpectre対策がなされればこの問題は解決すると思います(ただし10年スパンで考える必要はある)。もしくは、笹田耕一さんがRubyKaigi 2019で発表していた実行方式(context threading)は間接分岐を多用しないので、これが取り込まれれば解決するかもしれません(数年スパンで考える必要はある)。

謝辞

  • 相談ツイートに反応してくださったみなさん(特に@ryotaraiさんのリツイート
  • ruby-jp Slackの #container チャンネルのみなさん(特に件のオプションが効く理由を調査してくれた笹田耕一さん)
  • Rubyコミッタのみなさん

余談

Ruby 3の速度目標ベンチマークになってるoptcarrotがこの影響をとても受けて、ホストだと33 fpsなのがdockerだと14 fpsになるので困ってました。原因と(一応の)対策がわかったので、これでベンチマークが取れる。

蛇足(2020/05/23 12:00)

  • 律速とは、速度を決定する要因、つまりボトルネックのことです :-)

  • dockerが遅いと言えば、ふつうはdockerが仮想化しているファイルシステムやネットワーク関係を疑うところですが、メイン部で一切syscallをしないCPU律速なコードでも遅くなることがある、というのが意外なところでした。

  • Ruby/Pythonコードは」というタイトルにしてしまいましたが、本文に書いた通りだいたいどんなプログラムでも遅くなります(脚注に書いたように、実際Javamemcachedなども遅くなるらしい)。最初に気づいたのがRubyで、Rubyにはちょっと詳しいので限定して書いてしまった。他のプログラムよりRuby/Pythonのようなインタプリタは重症度がひどい可能性はあると思ってますが、特に比較はしていないし、ワークロードに強く依存するので比較は難しそうです。

  • この問題は「誰かが悪い」ということではないです :-) 用途を考えると、dockerがSpectre対策をデフォルトで有効にするのは理解できます(意識してやってるのか、たまたまそうなってるのかは不明ですが)。

*1:笹田注:より正確には、Linuxの起動時の設定が、「通常実行では脆弱性対策を有効にしない(脆弱なまま)だが、seccompなどを利用すると有効にする」となってるときにこの現象が起きるようです。手元の Ubuntu 18.04 がそういう設定でした。最近の Docker は、デフォルトでseccompを有効にするため、何もしないとこれらの対策が入り、速度劣化が観察できる、というようです。Linux Kernel の起動オプションで色々選べるようです。具体的な設定は Spectre Side Channels — The Linux Kernel documentation をご覧下さい。Docker起動時にseccompの設定で制御できるのかはわかりませんでした(出来ても良さそうですが)。

*2:この記事によると、STIBPはJava、Node.js、memcachedPHPなどのベンチマークで速度劣化が確認されたようです。

*3:どういう設定になっているかは /sys/devices/system/cpu/vulnerabilities/spectre_v2 というファイルを見ればわかります。

Rubyのテストのややこしい失敗を直した話

Ruby の CI 維持業というのはこんな感じという事例紹介。

CIを観察する

RubyのCIがときどき次のように失敗していました。

  1) Error:
TestM17N#test_object_inspect_external:
Encoding::CompatibilityError: incompatible character encodings: UTF-8 and UTF-16BE
    /tmp/ruby/v2/src/trunk-test-random/test/ruby/test_m17n.rb:311:in `encode'
    /tmp/ruby/v2/src/trunk-test-random/test/ruby/test_m17n.rb:311:in `inspect'
    /tmp/ruby/v2/src/trunk-test-random/test/ruby/test_m17n.rb:313:in `inspect'
    /tmp/ruby/v2/src/trunk-test-random/test/ruby/test_m17n.rb:313:in `test_object_inspect_external'

http://ci.rvm.jp/logfiles/brlog.trunk-test-random.20200322-221411

なんか例外が発生しています。必ず再現するわけではなく、手元で再現もできないのですが、日に数回程度失敗し続けていて鬱陶しいテストでした。このログだけ見て問題箇所を特定できたらエスパーです。

問題を切り分ける

まず、ボトムアップトップダウンの両面から観察を試みます。

ボトムアップ観察、つまり例外発生箇所から観察します。エラーメッセージの "incompatible character encodings" で検索すると、5箇所ほどrb_raiseしている箇所がありました。しかし、どれで発動しているのかよくわかりませんでした。

トップダウン観察は、失敗しているテストを見ることです。

def test_object_inspect_external
...
  Encoding.default_external = Encoding::UTF_16BE
...
  def o.inspect
    "abc".encode(Encoding.default_external)
  end

  assert_equal '[abc]', [o].inspect
...
end

問題を切り分けるために、inspect が無関係であることを確かめたいのですが、このテストだけ切り出しても問題が再現しないので、確かめることができません。そこで、"abc".encode(Encoding.default_external) だけで例外が出るかどうか、それらのエンコーディングが妙なことになっていないかなどをデバッグ出力させるコードをテストに突っ込んで、しばらくCIを観察することにしました。

デバッグ情報を集める

翌朝CIを見ると、めでたく失敗が起きていましたデバッグ出力を見ると、"abc".encode(Encoding.default_external) 単体で例外が起きることが確認できました。また、それらのエンコーディングも期待通り。意外性はなかった。

ということで問題はほぼ特定できたのですが、しかし、ちょっと考えてもどういう経緯でこの例外が投げられるかはわかりませんでした。そこで、例外発生時の C のスタックトレースを採取することを試みます。次のようなデバッグコードをテストコードにコミットしました。

TracePoint.new(:raise) do |tp|
  Process.kill(:SEGV, $$)
end.enable { "abc".encode(Encoding.default_external) }

TracePoint ってこういう風につかうものなんですよね。

問題を分析する

また翌朝、CIが失敗していて無事Cのスタックトレースが取れました

String#encode が呼ばれてから raise が起きるまでのトレース。

/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(rb_raise+0x94) [0x7fc6c5e4cf74] /tmp/ruby/v2/src/trunk-test-random/error.c:2689
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(enc_compatible_p+0x0) [0x7fc6c5e32d80] /tmp/ruby/v2/src/trunk-test-random/encoding.c:915
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(rb_enc_check) (null):0
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(rb_file_expand_path_internal+0x109) [0x7fc6c5e61cf9] /tmp/ruby/v2/src/trunk-test-random/file.c:3640
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(rb_file_expand_path_internal+0x847) [0x7fc6c5e62437] /tmp/ruby/v2/src/trunk-test-random/file.c:3746
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(rb_find_file_ext+0x3fb) [0x7fc6c5e62f3b] /tmp/ruby/v2/src/trunk-test-random/file.c:6330
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(require_internal+0x2dc) [0x7fc6c5eb044c] /tmp/ruby/v2/src/trunk-test-random/load.c:909
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(rb_require_string+0x23) [0x7fc6c5eb2173] /tmp/ruby/v2/src/trunk-test-random/load.c:1111
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(load_transcoder_entry+0xa8) [0x7fc6c5fe7908] /tmp/ruby/v2/src/trunk-test-random/transcode.c:386
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(load_transcoder_entry+0x14) [0x7fc6c5feb5d0] /tmp/ruby/v2/src/trunk-test-random/transcode.c:372
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(rb_econv_open) /tmp/ruby/v2/src/trunk-test-random/transcode.c:943
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(str_transcode0+0x268) [0x7fc6c5feed28] /tmp/ruby/v2/src/trunk-test-random/transcode.c:2275
/tmp/ruby/v2/build/trunk-test-random/libruby.so.2.8.0(str_encode+0x64) [0x7fc6c5fef3f4] /tmp/ruby/v2/src/trunk-test-random/transcode.c:2763

問題を分析するのに十分な情報が取れました。これをもとに、各C関数の実装を解読していきます。1時間くらいがんばる。結果として、

  • Encoding.default_external = Encoding::UTF_16BE とすることで、ファイルシステムエンコーディングが UTF-16BE とみなされるようになる
  • そのあと String#encode によって Encoding::UTF_16BE の実体がロードされようとする
  • このロードの際、LOAD_PATHの文字列(UTF-8)が相対パスのとき、カレントディレクトリ(UTF-16BE)と結合しようとして件の例外が投げられる

という現象であると仮説が立てられました。

この現象自体がバグかどうかは判断が難しい。ファイルシステムが UTF-16BE ならば LOAD_PATH に UTF-8 文字列を入れるのがおかしいわけで、この例外自体は正当かもしれない。でも現実問題として、いまの Rubyファイルシステムが ASCII-compatible でないエンコーディングの場合をサポートしてない気がするので、ちょっと微妙。

仮説を検証する

この仮説をもとに、再現コードを作ります。デバッグ出力をはさみつつ30分ほどがんばると、次のコードで件の例外が観測できました。

$LOAD_PATH << "no_existing_dir"
Encoding.default_external = Encoding::UTF_16BE
load "dummy.rb" #=> incompatible character encodings: UTF-8 and UTF-16BE

存在するディレクトリへの相対パスを $LOAD_PATH に追加すると内部的に絶対パスに変換されるので、問題は発現しません。存在しないディレクトリを加えるところが再現のポイントでした。

ということで、この問題は、「何かのテストが存在しないディレクトリを $LOAD_PATH に追加している」「その後、件のテストを実行すると例外が投げられる」という現象である可能性が高まりました。

問題のテストを探す

テストの中で $LOAD_PATH をいじっているコードを全部見ていきます。たくさんあったので面倒でしたが、ほとんどの箇所は絶対パスを入れているので、丁寧に見ていけば見つけられました。

case ENV['JSON']
when 'pure'
  $:.unshift 'lib'
  require 'json/pure'
when 'ext'
  $:.unshift 'ext', 'lib'
  require 'json/ext'
else
  $:.unshift 'ext', 'lib'
  require 'json'
end

https://github.com/ruby/ruby/blob/13e9551b97d6bb9fcd09283692f6090f4c418059/test/json/test_helper.rb

まさか JSON のテストが原因とは、最初の問題からは想像できないですね。

ちなみに、これは JSON を gem として開発するときのハックのようです(JSONはpure Ruby実装とC実装の2つを持っていて、環境変数によってテスト対象を制御できるようにしている)。しかし Ruby にバンドルされた状況下では常にC実装を使うので、このハックは無意味です。

手元で再現させる

これが原因であることを確認するためには、json と件のテストだけを実行すればいいです。

$ make test-all TESTS="json ruby/m17n -n test_object_inspect_external"
Run options: 
  --seed=94886
  "--ruby=./miniruby -I../ruby.src/lib -I. -I.ext/common  ../ruby.src/tool/runruby.rb --extout=.ext  -- --disable-gems"
  --excludes-dir=../ruby.src/test/excludes
  --name=!/memory_leak/
  -n
  test_object_inspect_external

# Running tests:

[1/1] TestM17N#test_object_inspect_external/home/mame/work/ruby.src/test/ruby/test_m17n.rb:319: [BUG] Segmentation fault at 0x000003e80000751d
...

無事問題が再現しました。ということで、これが原因ということで間違いなさそうです(ひょっとしたら他にもあるかもしれないけど)。

しれっと再現したと書いていますが、2つポイントがあります。

  • in-place build では再現しません(カレントディレクトリにたまたま ext も lib も存在するので発動条件を満たさない)。out-of-place のときは lib が存在しないので発動します。(問題のCIを管理しているささださんが out-of-place build 派だという知識があると有利。)
  • -n test_object_inspect_external によって、件のテストだけを実行することが必要です。なぜなら、他の M17N のテストを先に実行すると UTF_16BE のオートロードが先に行われてしまい、問題のテストでファイルロードが行われなくなって再現しなくなる。

修正する

ここで相対パスを入れなければ大丈夫なので、雑に絶対パスにしてやりました。これで手元では問題が再現されなくなりました。コミットして CI 観察継続中。

まとめ

Ruby の CI がときどき失敗する現象の調査と修正の事例紹介でした。JSON のテストが M17N のテストを失敗させるとかなかなか難しいですね。今回はテストの修正だけで済みましたが、本体側の修正が必要になる場合ももちろんあります。また、これはRubyの知識で完結している問題でしたが、システムプログラミングの知識が要求されるようなことも多いです。Rubyの品質をたもつのは総合格闘技

山手Quineが高輪ゲートウェイをサポートしました

2009年に、実行するたびに東京→神田→秋葉原→...と進んでいき、一周したら元に戻る『山手Quine』というのを作りました。

mametter.hatenablog.com

あれから11年経ち、駅の方が増えるという理由で、まさかのバージョンアップをしました。

github.com

当時のソースコード(正確には、ソースコードソースコード)は残ってなかったので、だいたい作り直し。高輪ゲートウェイの名称が発表された日にはできてたんですが、公開するのを忘れてました。

なお、「高輪ゲートウェイなんか見たくない」という人は、環境変数 NO_TGW=1 を設定すればスキップできます。

こういうプログラムの作り方は拙著にアホほど詳しく書いてあるので、興味ある人はぜひ読んでみてください。(宣伝)

パイプライン演算子の歴史

(You can read this article in English.)

Ruby の開発版にパイプライン演算子(pipeline operator)が試験的に導入されましたが、いろいろあってプチ炎上になっています(チケット)。

せっかくの機会なので、パイプライン演算子の歴史を調べてみました。付け焼き刃の調査なので、間違ってたら教えてください。

パイプライン演算子とは

こんな感じのものです。

x |> f |> g |> h  # h(g(f(x))) と同じ意味

h(g(f(x))) という関数適用の式は、関数が呼ばれる順序(fgh)と、プログラムの字面上の順序(hgf)が逆でわかりにくいとされます。この問題は、特に、関数が大きくなったときに顕著になります。

wonderful_process_h(
  marvelous_process_g(
    fantastic_process_f(
      astonishing_argument_x
    )
  )
)

語順問題に加え、インデント地獄まで発生しています。人類はとにかく深いインデントをきらいます。

|> を使うと、インデントを深めず、実行順の通りに書くことができます。

astonishing_argument_x
|> fantastic_process_f
|> marvelous_process_g
|> wonderful_process_h

言語ごとに実現方法がだいぶ異なるのですが、ユーザ視点ではだいたいこういうものです。

Isabelle/ML のパイプライン演算子

The Early History of F# によると、パイプライン演算子を最初に導入したのは定理証明支援系の Isabelle/ML だそうです。1994 年の議論が発掘されています。

目的は上に述べた通り、関数名を処理順に並べていきたいというものでした。AAA というものを作り、それに BBB を足し、それに CCC を足し、DDD を足し、という処理を ML で素直に書くと

add DDD (add CCC (add BBB (make AAA)))

みたいな感じになります。これを、

make AAA
|> add BBB
|> add CCC
|> add DDD

と書くために導入されたことがわかります。

ちなみに、導入当初は also という名前だったようです。ML では infix という機能を使って文字だけの関数を中置演算子にできます*1。上の例で言うと実は add を中置演算子にする手もあるのですが(make AAA add BBB add CCC add DDD と書ける)、たくさんある add 系関数をすべて中置演算子にするのは抵抗がある、ということで、also を導入することになったようです。

さらに余談ですが、also から |> に至るまでにいろいろな名前が検討されていておもしろいです(ツイート)。

Isabelle/ML のコミットログを貼っておきます。

F# のパイプライン演算子

F# は Microsoft が開発した .NET Framework 向けの言語の 1 つです。OCaml がベースになっています。

F# がパイプライン演算子を取り入れたことで、パイプライン演算子は広く一般に認知されました。F# の主設計者である Don Syme も、著書の "Expert F#" でパイプライン演算子を「おそらく最も重要な演算子」と言っています。

The Early History of F# によると F# がパイプライン演算子を導入したのは 2003 年らしいです。F# で |> が重宝されるのには、「語順が気持ちいい」という以外に、「型推論で都合がよかった」という特有の事情があるようです。F# の型推論はふつうの HM 型推論ではないらしく*2、プログラムの字面上で先に書かれた式から型を決定していくらしいです。その結果、型注釈が余分に必要になることがあります。

let data = ["one"; "three"]
data |> List.map (fun s -> s.Length)

は、datastring list とあらかじめ分かるので型注釈はいりませんが、|> なしで書くと

List.map (fun (s: string) -> s.Length) data

というように、data が後に出てきてしまうので、無名関数の引数に s: string という型注釈が必要になってしまっています。*3

余談ですが、F# で広まったパイプライン演算子は、2013 年に OCaml に逆輸入されました。F# は OCaml ベースですが、パイプライン演算子については F# が先とのこと。

ML 系におけるパイプライン演算子のポイント

歴史からちょっと外れて、要点を整理しておきます。

ML 系言語における |> は、言語組み込みの演算子ではなく、単なる関数として実現されています。関数型プログラミングフリークが好きそうな非常にクールなハックです。このハックが成立する背景には、次のポイントがあると言えます。

  1. ユーザが中置演算子を定義できること
  2. カリー化が組み込みであること
  3. 「プライマリ」な引数を最後に受け取るという慣習があること

1 はまあ面白機能という感じですが、2 と 3 が特に重要だと思います。

「プライマリ」とは、たとえばリスト処理関数ならリスト、配列処理関数なら配列のように、その関数の「主対象」と言えるような引数のことです。F# ではそういう引数を最後に受け取る仕様が徹底されています("Expert F# より引用↓)。たとえば List.map だと、まず変換関数(クロージャ)を受け取り、それからリストを受け取って、その各要素を変換した新しいリストを返します。

List.map   : ('a -> 'b) -> 'a list -> 'b list
Array.map  : ('a -> 'b) -> 'a[] -> 'b[]
Option.map : ('a -> 'b) -> 'a option -> 'b option
Seq.map    : ('a -> 'b) -> #seq<'a> -> seq<'b>

これらのポイントによって、|> は単に次の定義だけで実現できてしまいます。クール。*4

let (|>) x f = f x

パイプライン演算子とメソッドチェーン

述べた通り、F# の |> は、処理順に関数をつなげて並べるために使われます(x |> f |> g |> h)。

ところで、オブジェクト指向プログラミングによるメソッドチェーンも、処理順に関数をつなげて並べます(x.f().g().h())。

これが偶然の一致なのか、F# がこれをねらって |> を導入したのかはわかりません。しかし、.NET Framework の別言語である C#オブジェクト指向であることもあってか、パイプライン演算子とメソッドチェーンを関連付けて考える人は結構いるようです。

Elixir のパイプライン演算子

歴史にもどります。

Elixir がパイプライン演算子を導入しました。Elixir の作者の José Valim によると F# が由来のようです。見た目は F# と同じように書けます。

x |> f |> g |> h

見た目こそ F# たちと同じですが、実は言語仕様面から見たらかなり別物です。Elixir において |> は、単なる演算子というより、言語組み込みの構文です*5

x |> f(args..) # f(x, args...) と同じ意味

|> の左側の評価結果を、右側の関数呼び出しの先頭の引数にします(最後ではなく先頭)。

ふつうの二項演算子であれば、左右の式は独立した式になります。足し算を例にすると、expr1 + expr2 の 2 つの expr はどちらも単体で成立する式ですよね。F# の expr1 |> expr2 でも、expr2 は単体で成立する関数です。引数を複数取る関数のときは、カリー化を活用して部分適用しておきます。パイプラインで渡したい「プライマリ」の引数は最後なので、これがピタッとハマります。

一方 Elixir の |> の右側は、単体の関数呼び出し式として見ると引数が足らないので、独立していません。よって、Elixir の |>演算子というより構文と言うべきでしょう。

なぜこうなっているかというと、Elixir は先に述べた 3 つのポイントを 1 つも満たしていないためです。中置演算子を自由に増やすことはできず*6、デフォルトのカリー化はなく、主要な引数を最後に受け取る関数群の一貫設計もありません*7。なので、F# のパイプライン演算子の見た目だけを真似たもので、個人的な感覚では完全な別物、という印象です。

とはいえ、実用プログラミング言語の設計においては見た目こそが重要なことであり、言語マニアから見て別物とかは、ユーザ視点ではどうでもいいことです。(皮肉ではなく本当に)

Ruby のパイプライン演算子

最後に、つい先日 Ruby に試験的に導入されたパイプライン演算子の話です。

x |> f |> g |> h # x.f().g().h() と同じ意味
x |> f(args...) # x.f(args...) と同じ意味

|> の左側の式の評価結果をレシーバとして、右の名前のメソッドを呼び出します。

Ruby も Elixir と同じく、前述のポイントを満たしていません。よって、単なる演算子ではなく構文として導入する必要がありました。

Elixir の |> は、左側の式を右側の関数呼び出しの第一引数としました。一方 Ruby|> は、右側のメソッド呼び出しのレシーバとしています。これは、Ruby のメソッドにおける「プライマリ」な引数がレシーバであることを考えると、ある意味で自然な設計です。

しかしこれでは . と大差がありません。何がうれしいかというと、複数行に渡るメソッドチェーンを明示的に書いたり、括弧を省略できたりするというのが挙げられます。

(1..)
.take(10)
.each {|x| p x }

1..
|> take 10
|> each {|x| p x }

下のほうが簡潔でよい、という人がいることを否定することは出来ません。(自分もすごい良いと思ってるのかというと、まあそこまでではないんですが)

一方で問題点もあります。

  • Elixir に慣れた人たちにはわかりにくい
  • 従来のメソッドチェーンとできることが変わらないので意義が乏しい

前者については、自分が知る限りレシーバという概念がある言語にはじめてパイプライン演算子を導入する事例なんですよね*8Ruby は Elixir じゃないし Elixir はオブジェクト指向じゃないんで、「Elixir と違う!」とだけ言ってもしょうがない。また、Ruby では関数的なメソッド(File.joinMath.logなど)はそれほど出てこないので、Elixir に合わせる需要が少ない可能性も考えられます。

後者については、何か有用な使い方が見つかるかも知れないし、見つからなければ matz が消すと思うんで、リリースの 12 月までもうちょっとゆっくり考えてみてもよいのではないかと、個人的には思っています。

しかしこのあたりの問題点の指摘をうけて、現在名称や記号の変更が検討されています。Ruby の「パイプライン演算子」は幻想で終わってしまうのか、生暖かく見守っていきましょう。

まとめ

パイプラインの歴史を駆け足で追ってみました。ほとんど一晩でざっと調べただけなので、なんか間違ってたら教えてください!

追記(2019-06-16) ○○言語の pipeline に言及がない!っていう悲しみの声を聞きます。ここでは Ruby のパイプライン演算子の先祖(と自分が思っているもの)だけを書きました。他の言語にもありますが、まともに調べてないので詳しい言及はせず、知ってる範囲で列挙だけしておきます。

  • Elm と Julia には |> がある。
  • Scala には pipe が最近入った。
  • R には %>% を提供するライブラリ(tidyverse)がある。
  • Racket や Clojure の threading macro も同じような概念。
  • JavaScript では部分適用とあわせて現在検討中。

なお、興味深いことに shell スクリプトのパイプラインは、あまり関係ないかも知れません。というのも、Isabelle/ML の議論では "pipeline" という名前は一回も出てきていません。| という記号も、シェルを意識したという雰囲気は感じられませんでした。F# では "pipe" という名前がついていますが、名前をつけるときにシェルを意識していたかどうかはわかりません。

追記(2019-06-17)

@cedretaber さんの記事がとても参考になります。ざっと書くと

  • Clojure の threading macro
  • BuckleScript/Reason のパイプファースト演算子(Elixir のように先頭に挿入する構文、記号はそれぞれ |.-> らしい)
  • D言語の UFCS(func(a, b, c) という関数呼び出しを、a.func(b, c) と書ける機能)

という感じです。あと Elixir の |> は言語組み込みではなくマクロだそうです(この本文にも注釈を足しておきました)。詳しくはあちらの記事をご参照ください。

qiita.com

*1:できない ML もあるかも。

*2:追記(2019-06-17):話はもうちょっと複雑で、基本は HM 型推論だけど、.NET のライブラリで推論が効かないケースがあるとコメントで教えてもらいました。

*3:この例は The Early History of F# からの引用です。

*4:実行時オーバーヘッドにならないよう、処理系がなにか特別な最適化をしている、という話はどこかで見かけました。

*5:追記(2019/06/17):言語組み込みではなくマクロで実現されているそうです。

*6:あらかじめ用意されたいくつかの中置演算子を自由に再定義することは可能なようです。

*7:逆に、主要な引数を最初に受け取る一貫設計になっているのだと思います。

*8:メジャーな前例があったら教えて!

新雑誌「n月刊ラムダノート」の『「コルーチン」とは何だったのか?』の草稿を公開します

『Ruby でつくる Ruby』などでお世話になっているラムダノートが、新しい雑誌「n月刊ラムダノート」を創刊しました。

www.lambdanote.com

コンピュータ関係の技術情報の記事だけが載るそうです。創刊号は、『TCPの再送制御機構』、『「コルーチン」とは何だったのか?』、『MLOpsの歩き方』、の 3 本です。

  • TCP の再送制御機構』は、パケットを送ってから返事が来るまでの RTT (Round-Trip Time) を計測する方法や、RTT を使った再送のアルゴリズムや、RTT を使わない再送のアルゴリズムなど、TCP の再送に関する仕様・実装の歴史から最新提案までを、日本語話者の中では間違いなく世界一詳しい第一人者である西田佳史さん(@nsd)が広く深く紹介しています。
  • 『「コルーチン」とは何だったのか?』は、ぼくが書きました。伝統的なコルーチンの説明から、JavaScript 界隈から始まった言葉の転用までを駆け足で紹介しています。
  • 『MLOps の歩き方』は、MLOps とは DevOps の ML 版 *1 ということで、機械学習を実際のサービス運用で使っていく上で発生する課題や解決を、この分野のプロの practitioner であり『仕事ではじめる機械学習』の著者の一人でもある有賀康顕さん(@chezou)が最新状況を網羅的に解説しています。

とんでもなくマニアックな雑誌ですね。

さて、ラムダノートと相談して、『「コルーチン」とは何だったのか?』の草稿を公開することにしました。上記の通りこの雑誌は多方向にマニアックで、けっしてこの記事が「n月刊ラムダノート」の方向性を代表するようなものではないのですが、なんとなく雰囲気を感じて頂ければ幸いです。

執筆と草稿公開の経緯

さっさと草稿を見たい人はこの節をスキップ。

執筆のきっかけは、最初は↓の不用意なツイートです。

「独裁者を辞めた途端」云々は、"Ruby is dead" みたいな軽口だったんですが、あまり上品じゃなかったですね。反省してます。

このツイートにはいくつかご意見や情報をいただきましたが、Python が(ぼくの知っていた)コルーチンとは違うものをコルーチンと呼んでいるのは確かなようでした。現状をまとめてみようと思い、最初はブログ記事として書き始めました。

しかし初期の草稿を @bonotake さんに見てもらい、そこからさらに掘り下げて調べていくうちに、自分の勘違い *2 や複雑な経緯がわかってきて、うまくまとめられず放置してました。

そんなとき、ラムダノートの鹿野さん(@golden_lucky)から雑誌を創刊すると聞いて、この記事の出口が決まりました。もう少し正確に言うと、昨年末頃に鹿野さんから「雑誌創刊するから記事ないですか」と言われたときには、コルーチンの記事を完全に忘れていて、「考えとく」で終わりました。今年になって、鹿野さんやうちの奥さん(@hirekoke)とコルーチンや継続について雑談していて、日本語での体系的な解説が欲しいという話になり、「そういえばこんなブログ記事を書きかけてた」と言って見せたら、「えっこれ雑誌に載せられるのでは」ってなりました。雑談は大切。

草稿を公開することにした理由ですが、

  • もともとブログ用に書いていたものなので、できれば一般公開して広く知ってもらいたかった(遠藤の気持ち)
  • 草稿を公開することで「n月刊ラムダノート」の雰囲気を知ってもらったり、ラムダノートでの執筆プロセスが身近であることを知ってもらったりしたい(ラムダノートの気持ち)

ということで、著者と出版社の希望がかさなったためです。ここで公開するこの草稿はわりとファイナルに近いもの(ラムダノートに内容・構成レベルの改善提案をいただいて直した後のもの)です。それでもまだまだ適当な状態ですね……。「★なんかそれっぽい書き出し」とか。図もまったくありません。参考文献は、Slack で適当にリンク貼りつけて伝えました。そのあたりがいい感じになっているのは、だいたいラムダノートによる「編集」です。ということでみなさんも、ちょっと気合を入れたブログ記事を書いたら、公開する前にラムダノートに提案をしてみるといい感じになるかもしれません *3 。ちょうど記事募集をはじめたらしいので、見てみるとよいのではないでしょうか。

ということでここから草稿です↓

「コルーチン」という用語が指すもの(仮題)

★なんかそれっぽい書き出し

「コルーチン」という言葉が指すものが、プログラミング言語の文化圏によってだいぶ変わってしまっているようです。

調べた限りで分かった状況をまとめてみたいと思います。間違ってるところがあったら、ご指摘ください。

1. 伝統的なコルーチン

まず、伝統的なコルーチンである「対称コルーチン」と「非対称コルーチン」を説明します。

1.1. 対称コルーチン

対称コルーチンは、互いに制御を渡し合う手続きのようなものです。疑似コードで示します。

coroutine A() {
  print("A-start");
  B.transfer();
  print("A-end");
}

coroutine B() {
  print("B-start");
  A.transfer();
  print("B-end");
}

A.transfer(); //=> "A-start", "B-start", "A-end"

X.transfer() で、コルーチン X に処理を渡します。このように、対称コルーチンでは飛び先を常に明示します。非常に原始的・明示的で分かりやすい反面、実用上はなかなか扱いにくく、実際にはほとんど使われていません。

対称コルーチンは、コルーチンの発案者(の一人)とされる Conway の 1963 年の論文に登場する、元祖のコルーチンです。メインルーチン・サブルーチンのような親子関係ではなく、対等に協調し合うルーチンとして考案されました。COBOL コンパイラを実装する上で、lexer と parser を別モジュールとして実装するためのテクニックだったようです。 transfer で中断したコルーチンを再開するのは、完全にプログラマの責任になります。再開し忘れると、コードが実行されないことになります。上の例でも print("B-end") が実行されていません。再開しそこねて、後処理(ファイルのクローズとか)が抜けると、痛い目にあいます。対称コルーチンの提案は構造化プログラミング(1969 年)より前なので、現代的なプログラミングと相性がよくないのはしかたないですが、現代で使われていないのはこういう不便さのためだと思われます。

対称コルーチンが実装されている例。

1.2. 非対称コルーチン

対称コルーチンの間に親子関係を持たせることで使いやすくしたものです。次の疑似コードを見てください。

coroutine A {
  print("foo");
  yield "bar";
  print("baz");
}

// 呼び出し側
x = A.resume(); //=> "foo"
print(x);       //=> "bar"
A.resume();     //=> "baz"

A.resume() でコルーチン X を起動し、yield でコルーチンを「中断」し、呼び出し側に制御を一旦もどします。すると、A.resume() の呼び出しがリターンして、yield の引数であった "bar" が変数 x に代入されます。その後、print(x) を実行してから、再度 X.resume() すると、コルーチンの中断箇所から実行を再開します。すると、yield "bar" が終了し、最後の print("baz") が実行されます。コルーチン A は、resume のたびに、yield までを細切れに実行できる関数のように振る舞います。

重要なことなのでもう一度言っておきます。非対称コルーチンで「中断」と言ったら、「呼び出し側に制御を一旦もどす(そのあとで再度 resume されたら中断箇所から再開する)」という意味です。後の説明でも出てくるので覚えておいてください。

元祖のコルーチンと区別を明示するために、元祖の方を対称コルーチン(symmetric coroutine)、こっちを非対称コルーチン(asymmetric coroutine)と言います(参照: http://www.inf.puc-rio.br/~roberto/docs/MCC15-04.pdf)。しかし、前述の通り、元祖のコルーチンはほとんど使われていないので、単にコルーチンと言ったらだいたい非対称コルーチンを指します(少なくとも後述する JavaScript の「イディオム」が登場するまでは)。他に、非対称コルーチンは semi-coroutine や fiber という名前で呼ばれることもあります。

対称コルーチンとの違いは、resume で別のコルーチンを呼び出した場合、必ず制御が戻ってくることです(呼び出されたコルーチンが yield または終了すると戻ってくる)。これにより、必ず実行してほしい後処理が抜ける心配が大きく減ります。ただし、yield で中断したコルーチンが再度 resume されてくるかどうかはプログラマ次第なので、yield の後に必ず実行してほしい後処理を書くのはちょっと注意が必要です。(余談ですが、この問題は本質的に解決不可能で、Ruby で未解決の最古のバグチケット #595 にもなっています)

次は、非対称コルーチンを実装した言語・ライブラリの例です。

  • Simula
  • Lua の SemiCoroutine
  • Ruby の Fiber#resume / Fiber.yield や、Enumerator
  • Python の generator (PEP 342)
  • JavaScript の function* / yield
  • C#イテレータ (yield で IEnumerator を得る。Unity の文化圏ではコルーチンとも呼ぶ)
  • Boost.Coroutine の asymmetric_coroutine
  • ScalaJava での実験的な実装

あとで出てくるのでもう一度いっておきますが、「JavaScript の function*/yield」=「Python の generator」は、非対称コルーチンです。

1.3. コルーチンの利用例

伝統的なコルーチンの代表的なユースケースは、無限長の列を表現することです。非対称コルーチンで説明します。

coroutine natural_numbers() {
  i = 1;
  while(true) {
    yield i;
    i += 1;
  }
}

このコルーチンは、最初に resume すると 1 を yield し、次に resume すると 2 を yield し、というのを無限に繰り返します。このように、resume するごとに次の要素を yield で返す非対称コルーチンは、無限列を表現しています。

このような無限列の表現を、ジェネレータといいます。非対称コルーチンが一部の言語で generator やイテレータと呼ばれているのは、このユースケースに特化しているためです*4

Ruby は Enumerator と Fiber の両方を提供しています。前者はジェネレータとして使いやすい API 、後者は対称・非対称コルーチンとして使いやすい API になっているという違いがありますが、Enumerator は内部的に Fiber で実装されていて、本質的に同等です。なお、Fiber という名前はマイクロソフト .NET の Fiber に由来します(Fiber は「繊維」という意味です。スレッド(糸)よりもメモリ使用量が少なく大量に作れる軽量スレッドという意味で名付けられたようですが、実体としてはコルーチンの再発明です。))。

他にも非対称コルーチンは、ゲームやシミュレーションのオブジェクト(resume するごとにフレームやシミュレーション単位 1 つ分だけ動作をすすめる)を表現するためなどにも使われます。

2. JavaScript における事情

ここから、JavaScript コミュニティでコルーチンと呼ばれるものの話が始まります。これまでの話とはだいぶ違うので、気持ちを切り替えて読んでください。

2.1. JavaScript の言語的特徴

ご存じのとおり、JavaScript は、ブラウザの上で動く言語です。マウスクリックのようなユーザ操作のイベントが起きたときに呼ばれる関数(コールバック)を書くのが元々の想定ユースケースでした。一方で、JavaScript はシングルスレッドの言語です*5。そのため、コールバックの中で時間のかかる処理(sleep や IO 待ちなど)を行うと、その間は他のイベントが処理できなくなってしまいます。

これを解決するために、JavaScript の世界では、「すべての関数は一瞬でリターンする」という API 設計が徹底されています。たとえば n ミリ秒間待ってから何かを行いたい場合、JavaScript 以外の言語では sleep(n) のような「呼び出しから n ミリ秒後にリターンする関数」を使いますが、JavaScript では setTimeout(function() { ... }, n) のように「呼び出しから n ミリ秒後にコールバックする関数を登録する関数」を使います。

// 普通の言語の 1 秒待ち
sleep(1000);
print("foo");

// JavaScript の 1 秒待ち
setTimeout(function() {
  print("foo");
}, 1000);

この言語設計は非常に合理的ですが、結果的に JavaScript のプログラムはコールバックであふれることになりました。コールバックの中にコールバックを書く、ということも日常的に行われます。

// 普通の言語で 1 秒ごとに foo 、bar 、baz という
sleep(1000);
print("foo");
sleep(1000);
print("bar");
sleep(1000);
print("baz");

// JavaScript で 1 秒ごとに foo 、bar 、baz という
setTimeout(function() {
  print("foo");
  setTimeout(function() {
    print("bar");
    setTimeout(function() {
      print("baz");
    }, 1000);
  }, 1000);
}, 1000);

このように、インデントがどんどん深まって行きます。この現象でコードが読み書きしづらくなることは、コールバック地獄と呼ばれています。

2.2. コルーチンを使ったイディオム

実は、このインデントの現象は、非対称コルーチンできれいに解決できます。疑似コードで説明します。

coroutine A {
  yield setTimeout(function() { A.resume(); }, 1000);
  print("foo");

  yield setTimeout(function() { A.resume(); }, 1000);
  print("bar");

  yield setTimeout(function() { A.resume(); }, 1000);
  print("baz");
}

A.resume();

A.resume() を呼ぶと、コルーチン A は setTimeout によってコールバックを設定し、yield で一旦呼び出し側に処理を戻します。それから 1 秒後にコールバックが呼ばれ、そのコールバックは A.resume() を行います。これにより、yield したところから再開して print("foo") が実行されます。そしてまた setTimeout をして yield します。これを繰り返すことで、1 秒ごとに何かやる処理を表現できます。

JavaScript でも、非対称コルーチンである function*/yield を用いて、次のようなコードを書くのが流行しました。

const co = require('co')
co(function* () {
  yield new Promise(function(f) { setTimeout(f, 1000); });
  print("foo");

  yield new Promise(function(f) { setTimeout(f, 1000); });
  print("bar");

  yield new Promise(function(f) { setTimeout(f, 1000); });
  print("baz");
})

co とか new Promise(...) とか登場人物が増えてややこしいのですが、簡単に説明すると

  • new Promise(...) は、コールバック関数の登録を受け付ける "Promise" オブジェクトを返します。
  • co は、渡された非対称コルーチンを resume し、yield で中断したら "Promise" オブジェクトにコールバックを登録し、コールバックが起きたら再度 resume する、を繰り返す補助関数です。

これらによって本質的には上のコルーチンのコードと同等になっています*6

さて、JavaScript コミュニティでは、このイディオムを漠然と「コルーチン」と呼ぶようです。その理由を推測すると、

  • function/yield は実際に非対称コルーチンであるから(ただし、JavaScript では通常 function/yield のことをジェネレータと呼びます)
  • 「ブロック処理があったら関数実行が中断する」という説明がなんとなく「中断できる関数」という非対称コルーチンの説明に似ているから

というのが何となく絡み合って、慣習になったのだと思われます。実際、このあたりを混乱したような説明をしばしば見かけます。しかし、ここで言っている「中断」は、非対称コルーチンで言っていた「中断」とはもはや意味が違うことに注意してください。このイディオムの説明で言う「中断」は、「ブロック処理を待つこと」であって、「呼び出し側に処理を一旦返すこと」ではなくなっています。この言葉の使い方は、同期的なブロック処理 API を避けている JavaScript の事情に特化したものになっています。

(これ以下削除で良さそう)上のイディオムの中で、具体的に何をコルーチンと呼んでいるかは、人によって異なるようです。少なくとも以下が観測できました。

★promise が未定義語になってしまった

2.3. async function の登場

JavaScript の人たちは、コルーチンを使ったイディオムでは満足せず、async/await という専用の文法を導入するに至りました。C# の async/await を参考に取り込んだと思われます。

async function asyncABC() {
  await sleep(1000);
  print("foo");

  await sleep(1000);
  print("bar");

  await sleep(1000);
  print("baz");
}

これはしばしば、前述の「イディオム」への構文糖として説明されます*7

  • async function foo() { ... }function foo() { co(function* () { ... }) } 相当*8
  • await baryield bar 相当

ざっと調べた限り、JavaScript の言語仕様においては、async function で定義された関数は "async function" と呼ぶようで、これのことをコルーチンとは呼んでいないと思います。前述のイディオムとの区別が曖昧になりますしね。

async function と伝統的コルーチンは、全くの別物であることに注意してください。async function は function*/yield の構文糖ではありますが、非対称コルーチンの特徴を失っています。というのも、co が暗黙的に埋め込まれるので、「呼び出し側に処理を一旦戻す」ということができなくなっているのです。await で非対称コルーチンの yield を呼んでも、co がそれを拾って勝手に resume してしまうので、非対称コルーチンでいう「中断」にはなりません。

また、他にも異なる点があります。伝統的なコルーチンでは、コルーチンが transfer/resume/yield したとき、次にどのコルーチンが再開するかはプログラマは完全に把握できるものでした。よって、「次にどのコルーチンが再開するかをプログラマが制御する・制御できる」という性質もコルーチンの特徴のひとつと考えられていました。しかし JavaScript では、イベント処理のために async function を使うことが多かったので、await してからいつどれが再開するかはプログラマにも把握できず、場合によっては複数の async function が再開可能な状態になる、という状況が生まれました。どの async function が再開するかは、言語やライブラリが用意したスケジューラが勝手に決める(プログラマが管理しない・できない)ということになります。

重要なのでもう一度いいますが、async function と伝統的コルーチンは、全くの別物です。

3. 「コルーチン」の新たな意味の誕生

JavaScript の async function は非同期処理の記述として大ヒットし、他言語にも取り入れられるようになりました。

3.1. Python のコルーチン

Python は 3.5 から async/await を導入しました。このとき、JavaScript が async function と呼んでいるものを、Python は「コルーチン」と名付けました。

Python に async/await を提案した PEP 492 によると、「generator から native coroutine を切り出す」と書かれています。これだけ読むと「generator = 非対称コルーチン」を意識していると思えますが(実際、PEP 342 の方は generator = (asymmetric) coroutine を明確に意識しています)、読み進めると、この提案は完全に JavaScript のイディオムを念頭においているようです。PEP 492 には、JavaScript の他に HHVM 、DartScala などを参考にしたと書かれていますが、ざっと調べた限りこれらは誰も async function を「コルーチン」と呼んでいません。JavaScript コミュニティでなんとなく呼ばれている「コルーチン」を、なんとなく持ってきたのだと思います。

ここにきて、async function を指して「コルーチン」と呼ぶ用法が確立したようです。

少し補足すると、Python の async は暗黙的に co 相当の機能を埋め込むわけではなくもう少しややこしいもののようです。async function と future と組み合わせれば、対称コルーチンをエンコードすることも出来るようです。ただ、これをもって「async で定義された関数は対称コルーチンである」とは(普通は)言わないと思います。

3.2. Kotlin のコルーチン

Kotlin では、非対称コルーチン(ジェネレータ)と async function をすべてまとめて「コルーチン」と呼ぶようです。

sequence { ... } と書けば非対称コルーチンが、async { ... } と書けば async function が得られるようです。

Kotlin は規格策定のときにいろいろサーベイをした様子が伺え、伝統的なコルーチンから JS や Python の状況までを踏まえて、このようにしたのではないかと思います。

4. まとめ

コルーチンという言葉はプログラミング言語文化圏によって、いろいろなものを指します。とくにこの 10 年で、その意味はとても拡散してしまったようです。

繰り返しですが、調べながら書いたもので、特に JS には詳しくありません。なんか事実誤認があったらご指摘ください。

謝辞

この文書をまとめるにあたって、調査やレビューを @bonotake さんや笹田耕一さんにご協力いただきました。ありがとうございました。

付録A. Promise と JavaScript

Promise は、並行処理を記述する言語機能です。JavaScript 以外の言語の Promise は、こんな感じに動きます。

// 1 秒かかる計算を Promise にする
x = new Promise(function() { sleep(1000); return 42; });

// Promise の生成は一瞬で終わる(1 秒待たされない)

// get を呼ぶと、sleep(1) が終わるのを待って、計算結果の 42 が得られる
print(x.get()); //=> 42

しかし JavaScript では、sleep(1) は書けないですし、それを同期的に待ち受ける get() 関数も書けません。そこで、例によってコールバックを使います*9

x = new Promise(function(f) { setTimeout(function() { f(42); }, 1000); });

x.then(function(r) { print(r) }); //=> (1 秒後に)42

Promise オブジェクトに対して then メソッドで関数を渡すと、Promise の計算が終わったあとでコールバックしてくれます。

2 つの Promise を連続で待ちたい場合、単純に考えると入れ子にする必要があります。

x = new Promise(function(f) { setTimeout(function() { f(42); }, 1000); });

x.then(function(r) {
  print(r); //=> (1 秒後に)42

  y = new Promise(function(f) { setTimeout(function() { f(43); }, 1000); });

  y.then(function(r) {
    print(r); //=> (さらに 1 秒後に)43
  });
});

これではインデント問題は解決されません。しかし JavaScriptthen は少し工夫されていて、コールバックを含めた全体の Promise を返します。つまり、次のように書けます。

x = new Promise(function(f) { setTimeout(function() { f(42); }, 1000); });

x.then(function(r) {
  print(r); //=> (1 秒後に)42

  y = new Promise(function(f) { setTimeout(function() { f(43); }, 1000); });

  return y;
}).then(function(r) {
  print(r); //=> (さらに 1 秒後に)43
});

これにより、インデント問題は解決されました。しかし、then を大量に書く必要があり、あまり簡潔とは言えません。そのため、本文のコルーチンを使ったイディオムに進化していったようです。

付録B. 継続とコルーチンの関係

たまにコルーチンと混同される言語機能に、「継続」という非常に強力な言語機能があります。継続とは何かを説明するととても長いのですが、関係だけを一言で言えば、継続はコルーチンの上位互換です。継続を使うと、コルーチンを表現できます。つまり、継続を使えば、resume や yield を普通の関数として定義できてしまいます。

しかし、継続=コルーチンではありません。継続にできてコルーチンにできない典型的な例だけを挙げておきます。

for i in 0..2 do
  for j in 0..2 do
    for k in 0..2 do
      puts "(%d, %d, %d)" % [i, j, k]
    end
  end
end

というコードは、(0, 0, 0) から (2, 2, 2) まで辞書順に出力するコードです。これは、要素の数だけインデントが深まるというインデント地獄になっています。

コルーチンでは、このインデント地獄を解決することはできません。しかし継続を使うと、

try do
  i = amb(0..2)
  j = amb(0..2)
  k = amb(0..2)
  puts "(%d, %d, %d)" % [i, j, k]
end

というコードで同じ動作をする関数 amb が書けます。

ポイントは、amb(0..2) という 1 回の関数呼び出しが 3 回リターンする(それぞれ 0 、1 、2 を返す)というところです。これはコルーチンにはできず、継続にはできるところです。詳細は、Scheme/継続の種類と利用例 - Wikibooks や他の参考書を見てください。

*1:Meta Language ではなく Machine Learning のほう。

*2:コルーチンはプログラミング言語理論の中で生まれたと思い込んでましたが、出自はもっと実践的なものだったとか。くわしくは記事の方で。

*3:さすがに「★なんかそれっぽい書き出し」とかはひどいので、なるべくちゃんと書きましょう(自省)。

*4:歴史的には、ジェネレータは 1970 年代に CLU や Icon という言語の中で登場していますが、当時の論文にはコルーチンの語が出てこないので、おそらく独立して再発明されたものと思われます。

*5:シングルスレッドの理由としては、性能の低い計算機でも動かせること、バグの元であるマルチスレッドプログラミングをユーザに課さないこと、元々の想定ユースケースではシングルスレッドで問題なかったこと、などが考えられます。なお、WebWorker のような拡張を使うとマルチスレッドができますが、これは置いておきます。

*6:"Promise" については、付録 A で簡単に説明します。

*7:もちろん、実際の実装が構文糖になっているわけではないと思います。

*8:簡単のため、引数は無視しています。

*9:コールバックによる Promise は Python の Twisted ライブラリの deferred から持ってきたようです。