💎

長期インターン先で、RubyKaigiの予習勉強会に参加してみた

2025/03/09に公開

はじめに

昨年から長期インターンをやっている会社で、RubyKaigiの予習勉強会が開催され、参加してきました。Rubyの言語処理系について完全に初心者で、理解できていない点が多いですが、ご容赦ください。

そもそもRubyKaigiって何?

日本で開催される最大のRubyカンファレンスです。今年は4/16から4/18の3日間、愛媛県での開催が予定されています。
https://rubykaigi.org/2025/
長期インターン先では、毎年RubyKaigiに向けて勉強会を開催し、当日にたくさんの傷を負わないよう準備してから臨んでいます。自分は当日参加できないのですが、カンファレンスに向けてみんなで準備する会社はあまりなさそうな気がするため、勉強会には参加してみることにしました。

勉強会はどんな雰囲気?

50人くらい座れる大きなセミナー室で、20人ほど参加しました。勉強会に参加しているメンバーは、自分含めて2,3人くらいを除き、当日参加する人ばかりでした。朝11時から始まり、途中休憩を挟みながら、19時近くまで行われました。延べ6人が1時間くらいずつスピーカーとなり、様々なお話をして下さります。

勉強会の内容

勉強会で扱われたのは、Rubyの言語仕様というよりはRubyの言語処理系が主です。

インターンの開発では言語仕様としてのRubyに目を向けることが多く、言語処理系としてのRubyを学ぶことは少ないため、今回の勉強会は非常に良い経験となりました。

勉強会では6個の枠があり、それぞれ下記の内容でした。

1. Rubyの言語処理系についての全体像
2. Rubyパーサー
3. TRICK2025(Rubyによる作品コンテスト)とQuine
4. RubyのWebAssemblyとは
5. Rubyの並行・並列処理
6. RubyのJITコンパイラ

各セッションの概要を紹介します。

※このブログでは、自分がある程度理解できた太文字の内容だけ触れようと思います

1.Rubyの言語処理系についての全体像

1つ目のセッションは、Rubyの言語処理系の詳細には踏み込まず、全体像を捉えるような内容でした。

ruby -e "puts 7 + 8"

「上記をターミナルで実行するとなぜ15が返ってくるのか?」という問いかけを起点に、「プログラムを実行する」とはどういうことか紹介していました。

プログラムを実行するには処理系が必要です。言語仕様と処理系は別物で、異なる環境の処理系が同じである保証はないため、同じソースコードであっても挙動が異なる可能性はあります。

Rubyの処理系のなかで最も有名なものがCRubyです。Ruby開発者のまつもとゆきひろ氏(Matz)にちなんで、MRI(Matz' Ruby Implementation)とも呼ばれます。CRubyで取り込まれた機能がRubyの言語仕様として採用されています。

インタプリタ言語では、基本的に下記のような流れを取ります。

           字句解析     構文解析        実行
[ソースコード] --> [トークン列] --> [構文木(AST)] --> [実行結果]

ただし上記の流れはRuby1.8までで、Ruby1.9以降は高速化を目的としてVMが追加されました。このVMはYARV(Yet Another Ruby VM)と呼ばれます。VMは、計算資源のエミュレートです。

           字句解析     構文解析       コンパイル      実行
[ソースコード] --> [トークン列] --> [構文木(AST)] --> [バイトコード] --> [VMが実行]

Ripperという字句解析や構文解析を行うためのRubyライブラリを用いて、"puts 7 + 8"のコードに対するトークン列や抽象構文木(AST)を取得してみました。

まずはトークン列から。

【コード】

require 'ripper'
require 'pp'

code = 'puts 7 + 8'
tokens = Ripper.lex(code)

pp tokens

【実行結果】

[[[1, 0], :on_ident, "puts", CMDARG],
 [[1, 4], :on_sp, " ", CMDARG],
 [[1, 5], :on_int, "7", END],
 [[1, 6], :on_op, "+", BEG],
 [[1, 7], :on_int, "8", END],
 [[1, 8], :on_nl, "\n", BEG]]

上記は、[[行番号, 列番号], トークン種別, トークンの値, 状態]の形式で表現されています。puts, (スペース), 7, +, 8, (改行)のトークンに分解できていますね。

今度は、分解したトークンから抽象構文木(AST)を作ってみます。

【コード】

require 'ripper'
require 'pp'

code = 'puts 7 + 8'
parsed_sexp = Ripper.sexp(code)

pp parsed_sexp

【実行結果】

[:program,
 [[:command,
   [:@ident, "puts", [1, 0]],
   [:args_add_block, [[:binary, [:@int, "7", [1, 5]], :+, [:@int, "8", [1, 9]]]], false]]]]

ASTについては、以下のスライドがわかりやすいです。
https://speakerdeck.com/euglena1215/ruby-ast-is-better-to-get-used-to-than-to-learn

またVMのメリットとして、①Portabilityが高い、②最適化を行いやすい、という2点があります。

①Portabilityが高い

環境ごとに合わせた処理系を作るのではなく、中間言語で記述された処理系を作ります。各OS/CPUアーキテクチャに対応した処理系を作るより、共通のバイトコードを解釈するVMを作る方が簡単です。YARVのバイトコードはISeqと呼ばれます。

YARVはVMの中でもスタックマシンと呼ばれるものに該当します。ASTをバイトコードにコンパイルし、コンパイルされた命令列を順番に上から実行していきます。

またYARVは、内部スタックと制御フレームスタックの2つのスタックを使用します。

  • 内部スタック:
     Rubyのバイトコード命令のオペランドを一時的に保持。算術演算やメソッド呼び出しの引数・戻り値の管理などに使用される。
  • 制御フレームスタック:
     メソッド呼び出しやブロック実行の際に、プログラムの制御情報(戻りアドレスやスコープ情報)を保持。メソッドが呼び出されるたびに、新しい制御フレームがプッシュされ、戻る際にポップされる。

②最適化を行いやすい

VMのバイトコードは直線的に並んでおり、最適化において重要です。理由の1つが、命令列がメモリ上で直線的に配置されるため、L1/L2キャッシュに当てやすくなり、高速化につながるからです。(他にも理由があるようです。)

さすがに端折りすぎたので、もう少し詳しく説明します。

CPU内部のレジスタへのアクセスと比較して、メインメモリのアクセスの方が数十倍から数百倍遅いです。そのため、なるべくメインメモリへのアクセスは少なくする必要があります。メモリからデータを取得する際に、データを単体で取得するのではなく、次に参照される可能性が高い隣接するデータも含めて持ってきます。この、「隣接データをまとめて取得する単位」をキャッシュラインと呼びます。

ちなみにキャッシュラインのサイズはCPUごとに異なります。自分のMacBook Air(Apple M2)で調べてみたところ、キャッシュラインは128バイトでした。

$ sysctl hw.cachelinesize
> hw.cachelinesize: 128

現代的なCPUでは、L1、L2、L3の複数段からなるキャッシュ階層を設けており、メインメモリにアクセスして取得したデータは全階層のキャッシュに保存されます。

つまり、命令列がメモリ上で直線的に配置されていると、一度のメモリアクセスだけでキャッシュラインの範囲に収まる命令列についてはまとめて取得でき、キャッシュに当てやすくなるから高速化につながるということでした。

2.Rubyパーサー

2つ目は、Ruby Parserの話でした。

parserが行うのは、スクリプトから抽象構文木(AST)を作成することです。parserがASTを作成するには、スクリプトの文字列を解釈できるように。意味のある単位に分割する必要があります。これを字句解析と呼びます。先ほども出てきました。字句解析を行うのがlexer、トークン列を元に構文解析を行ってASTを作成するのがparserです。

lexerはどうやってトークンに分解するのでしょうか。一番単純な実装は、正規表現でマッチさせる方法です。字句解析を行うために開発されたツールがLexであり、Lexを使用すると、字句解析のルールを定義するだけで、対応するC言語のコードを自動生成できます。

例えば、以下のようにルールを定義します。下記のコードでは、正規表現パターン(左側)とそれにマッチした際に実行されるアクション(右側)を定義しています。

[0-9]+     { printf("NUMBER(%s)\n", yytext); }
\+         { printf("PLUS\n"); }
-          { printf("MINUS\n"); }
\*         { printf("MULTIPLY\n"); }
\/         { printf("DIVIDE\n"); }
\(         { printf("LPAREN\n"); }
\)         { printf("RPAREN\n"); }
[ \t\n]+   { /* 空白や改行は無視 */ }
.          { printf("UNKNOWN(%s)\n", yytext); }

このルールに則って、1 + 2を実行してみると、下記のような出力が得られます。

NUMBER(1)
PLUS
NUMBER(2)

3 * 4 / 6を実行すると、下記のような出力が得られます。

NUMBER(3)
MULTIPLY
NUMBER(4)
DIVIDE
NUMBER(6)

Lexは字句解析の機能を提供していますが、構文解析を行うには不十分です。理由としては、2つの理由が挙げられます。

①正規表現だけでは、ネストや再帰的な構造について解析することが困難です。

(()(())) -> Parentheses is valid
(((((()) -> Parentheses is invalid

②文脈に応じた状態遷移や階層的な解析が難しいです。例えば、以下の式です。

(3 + 5) * (2 - 8)

括弧内を先に計算し、その後掛け算を行う必要があります。演算子の優先順位や括弧の対応関係を正しく解析するという処理は、正規表現に基づくパターンマッチングを行うLexには難しいです。

そのために、yaccといった構文解析器が必要です。yaccでは文法規則を定義し、与えられた入力が文法規則から生成できるものかを確認することができます。

文法規則とは

とりあえず、例を見てみます。四則演算の文法規則の例です。

R1. E -> E + E
R2. E -> E - E
R3. E -> E * E
R4. E -> E / E
R5. E -> tINTEGER

この文法規則に従って繰り返し「導出」すれば、様々な数式を作れます。

E -> E * E                           (R3)      
  -> E * E - E                       (R2)
  -> tINTEGER * tINTEGER - tINTEGER  (R5)
  -> 1 * 3 - 2

逆に捉えると、文法規則に従っている数式であれば、繰り返し「還元」することで開始記号が得られるはずです。

1 * 3 - 2 -> tINTEGER * tINTEGER - tINTEGER
          -> E * E - E                       (R5)
          -> E * E                           (R2)
          -> E                               (R3)

正しい数式でなければ、開始記号を得ることができません。

1 *+ 3 - 2 -> tINTEGER *+ tINTEGER - tINTEGER
           -> E *+ E - E                       (R5)
           -> E *+ E                           (R2)

もう還元できないのに開始記号のみになっておらず、Syntax Errorとなります。この判定を一瞬で行えるのがyaccです。この後、yaccの詳細な挙動について説明がありましたが、あまり理解できていないです。parser stackとvalue stackの2つのスタックを使って、処理を行っているとのことです。

本来であれば、

             lexerが         parserが
            トークン化         ASTを作成
[ソースコード] ------> [トークン列] ------> [構文木(AST)]

このような流れで処理が行われるのが分かりやすいし理想的ではありますが、実際には下記のようにlexerとparserの密結合状態が発生しています。

             lexerが         parserが
            トークン化         ASTを作成
[ソースコード] ------> [トークン列] ------> [構文木(AST)]
             <------
             parserが
            状態を伝える

lexerがソースコードをトークン化する際、parserから状態を伝えてもらいながら行っているようです。
なぜlexerだけでトークン化ができないのかは、次の例を見てもらうとわかると思います。

a || b             # ||は、tORとしてトークン化
array.each { || hoge}  # ||は、空のブロック引数。それぞれでトークン化

同じ"||"であっても、文脈によってどのようにトークン化するかは変わってしまいます。

このように、Rubyの文法規則には、文脈に応じてparserが適応する文法規則が異なるケースがあります。単純にlexerが正規表現でマッチさせるだけでは不十分ということですね。

lexerとparserが密結合で、本来parserが担うべきコンテキスト管理をlexerでも行う必要があるのが無駄です。またBison(parser generatorの一種)でサポートしている構文がprimitiveであるために、全ての規則を書く必要があり、lexerとparserの同じ処理を共通化するのが困難です。CRubyでは、Ruby3.3以降はLramaというperser generatorがBisonに置き換えられました。Bisonに依存しなくなり、メンテナンス性が向上しました。

parser generatorによるparserの話ばかりでしたが、Ruby 3.3以降は、Prismという新しい手書きparserが導入されました。Prismは、エラートレラントであるのが従来のparserに比べた時の強みです。エラートレラントとは、parserで何か問題が発生した場合でも可能な限り意味のある結果を返却することを指します。ただPrismはデフォルトパーサーではなく、実験的な位置づけです。(--parser=prismオプションを指定する必要があります)

デフォルトのparserとPrismを比較して、構文エラーが発生した時のメッセージがどう違うのかは、下記の記事でわかりやすく説明されています。
https://zenn.dev/nasa/articles/ruby_prism_begin

3.TRICK2025(Rubyによる作品コンテスト)とQuine

TRICKは、Transcendental Ruby Imbroglio Contest for rubyKaigiの略です。日本語に訳すと、「超絶技巧Ruby意味不明コンテスト in RubyKaigi」だそうです。
https://github.com/tric/trick2025
ルールは、

  • 応募作品は完全なRubyプログラムであること。
  • プログラムのサイズは4096バイト以下であること。スペース以外の文字数は2048文字以下であること。圧縮された応募作品の合計サイズは10MB以下であること。
  • 複数のエントリーを提出することができ、チームは何人のメンバーで構成されていても構いません。
  • 応募作品全体はMITライセンスで提出すること。
  • 審査員に驚き、興奮、笑いをもたらす作品であること。

これだけです。

前回行われたのは2022年とのことで、3年ぶりの開催です。残念ながら、募集は既に締め切ってしまったようです。

様々なプログラムが投稿されるようで、その1つにQuine(クワイン)というものがあります。
Quineは、自身のソースコードと完全に同じ文字列を出力するプログラムです。自己生成プログラミングとも呼びます。

早速、例を見てみます。
https://github.com/mame/trance-book/blob/master/3-2/quine2.rb
これを実行してみると、元のソースコードと同じ文字列が出力されます。コードの中身は全く理解できていないです。すごいですよね。どれだけ苦労したら作れるのか、自分には検討がつきません。

上の例でも十分なのですが、紹介されたQuineの中で衝撃を受けたものがあったので、共有させてください。何かに見えませんか?
https://github.com/knoxknox/qlobe/blob/master/src/qlobe.rb

地球ですね。なんと、これを実行すると45度回転した地球が出力されます。

これだけで終わりません。この出力をまた実行すると、なんとさらに45度回転します。どんどん繰り返し実行していくと、ruby qlobe.rb | ruby | ruby | ruby | rubyあたりで、日本が正面に見えてきました。

下記サイトで気軽に試せるので、良かったらやってみて下さい。
https://mame.github.io/trance-book/demo.html

ちなみにこの回で登壇した社員の方は、「2025」という文字の形で出力されるQuineを作っていました。凄すぎです。ぜひ紹介したいところですが、まだ公開していないので止めておきます。

5.Rubyの並行・並列処理

5つ目は、並行・並列処理に関する話です。前半はRubyに特化しない一般的な話、後半はRubyに特化した話でした。

前半:一般的な並行・並列処理

並行(Concurrent)と並列(Parallel)は似ていますが、タスクの実行処理の観点で異なります。

  • 並行:1つのCPUコアが交互にタスクを切り替えながら処理すること
  • 並列:複数のCPUコアが同時にそれぞれ別のタスクを処理する

ちなみに、CPU命令レベルでも並行・並列処理が問題になることがあるようです。x86アーキテクチャにおいては、値の比較命令(cmp)とレジスタのコピー命令(mov)は、それぞれ独立した命令であり、アトミックではありません。そのため、レースコンディションが発生することがあります。

※レースコンディションとは、共有データに同時にアクセスし、その実行順序によってプログラムの結果が予測不能になる状況です。

その他、プロセス・スレッド、排他制御の話などが続きました。

後半:Rubyにおける並行・並列処理

次のRubyコードは、スレッド1000個を作成し、各スレッドでcountをインクリメントするものです。

count = 0
threads = []

1000.times do
  threads << Thread.new do
    count +=1
  end
end

threads.each(&:join)
puts count

これは排他制御をしておらず、共有変数に対して同時に書き込みが行われて1000より小さな値が返ってきそうです。しかし、実際には何度実行しても1000が返ってきます。

RubyにはGVL(Giant VM Lock)があり、通常GVLが動作中のネイティブスレッド数を1つに制限するためです。GVLとは、Ruby VM全体で動作中のネイティブスレッドを1つに制限するためのロックです。ただし、IO関連のシステムコールを行う場合のみ、GVLを開放します。

GVLのため、RubyでスレッドプログラミングをしてもCPUコアを活かせません。GVLを解放する抜け道として、rb_thread_call_without_gvlというメソッドが用意されているようです。

GVLの説明をした際に、ネイティブスレッドが出てきました。

ネイティブスレッドとは、OSが直接管理するスレッドのことです。
またプログラミング言語のランタイム環境が独自に管理するスレッドのことを言語スレッドと呼びます。OSのスレッドとは独立しており、ランタイムがスケジューリングを行います。

ネイティブスレッドと言語スレッドの対応関係は、1:N、1:1、M:Nなど複数存在します。現在のRuby(1.9~)のスレッドはネイティブスレッドと1:1対応となっており、1つスレッドを作成する度にネイティブスレッドが1つ作られます。ちなみに、1:1モデルを導入する時点でGVLが導入されました。

Ractorと呼ばれる、Ruby3.0で導入された並列・並行処理のための新機能についての話もありました。
Ractorを導入した笹田さんという方の記事が参考になると思うので、興味ある方は下記リンクから。
https://techlife.cookpad.com/entry/2020/12/26/131858

おわりに

かなり長くなってしまいました。普段の開発でRubyを使っておきながら、その言語処理系に目を向けることが少なかったので、とても学びになりました。内容がとても難しかったので、理解できていないことがほとんどですが、自分なりに書き出してみました。
自分はRubyKaigiに現地参加できないので、今年のセッションはアーカイブを見ようと思います。

Discussion