お疲れ様です!
「Rubyってインタプリタ言語だから、1行ずつ翻訳しながら実行してるんでしょ?」と思っていませんか?(私は思っていました🫣)
……実は現代のRubyでは不正確なんです 😱
実行モデルを一から調べ直したところ、rails s の裏側で何が起きているのかまで一本の線で繋がったので、まとめて共有します。
🗺 全体マップ:コードが動くまでの4工程
Rubyのコードが実行されるまでには、4つの工程があります。
ソースコード(ただの文字列)
↓ ① トークナイズ …… 意味のある単語に分ける
↓ ② パース …… 文法に従って木構造(AST)にする
↓ ③ コンパイル …… 平坦な命令列(バイトコード / ISeq)に潰す
↓ ④ 実行 …… YARV(仮想マシン)が命令を実行する
ポイントは、「1行ずつ」ではなくファイル全体を先に翻訳してから実行すること。証拠がこちらです。
puts "hello"
def foo( # ← 構文エラー
本当に1行ずつ実行するなら hello が表示されてからエラーになるはずですが、実際は何も実行される前に SyntaxError になります。実行前にファイル全体をパースしている証拠です 💡
それでは1工程ずつ見ていきます。全部、手元のirbで確認できます 👍
✂️ ① トークナイズ:文字列を「単語」に分ける
コンピュータから見たソースコードは、最初はただの文字の羅列です。これを**意味のある最小単位(トークン)**に区切るのがトークナイズ。日本語の文を単語に分ける「分かち書き」と同じ作業です。
require 'ripper'
Ripper.lex("x = 10 + foo").each { |_, type, str, _| puts "#{type} #{str.inspect}" }
on_ident "x" # 識別子
on_op "=" # 演算子
on_int "10" # 整数(「1」「0」ではなく1個のトークン!)
on_op "+"
on_ident "foo"
やっていることは3つだけ。
-
区切る:
10は2文字で1トークン - 分類する:整数 / 識別子 / 演算子 / キーワード…
- 位置を記録する:エラーメッセージの「line 3」はここ由来
🌳 ② パース:単語の列を「木」にする
トークナイズが終わった時点では、コードはまだ単語の一列です。「どれとどれがまとまりか」が決まっていません。
例えば 1 + 2 * 3 は、並びだけ見れば (1+2)*3 とも 1+(2*3) とも読めますよね。この曖昧さを文法規則で解決し、**木構造(AST:抽象構文木)**に組み上げるのがパースです。
pp RubyVM::AbstractSyntaxTree.parse("1 + 2 * 3")
(OPCALL
(INTEGER 1) :+
(OPCALL (INTEGER 2) :* (INTEGER 3))) # ← 「*が先」が木の形で確定!
木になった時点で解釈が形として固定され、曖昧さが消えます。「if に対応する end がない」などの SyntaxError が出るのもこの工程です。
📼 ③ コンパイル:木を「テープ」に潰す
「コンパイル=機械語にすること」と思っていませんか?(私は思っていました😤)
実はコンパイルとは「ある表現から別の表現への変換」全般を指す言葉で、変換先は言語によって違います。
| コンパイラ | 変換先 |
|---|---|
| Go | 機械語(CPUが直接実行できる) |
| javac | JVMバイトコード |
| Ruby | YARVバイトコード(ISeq) |
| TypeScript | JavaScript(まさかの別言語) |
Rubyのコンパイル結果は ISeq(Instruction Sequence、読みは「アイセック」) というオブジェクトです。実物を見てみましょう。
code = <<~RUBY
def greet(name)
if name.empty?
"hello"
else
"hello, " + name
end
end
RUBY
puts RubyVM::InstructionSequence.compile(code).disasm
== disasm: #<ISeq:greet@...>
local table: [ name@0<Arg> ]
0000 getlocal_WC_0 name@0 # 0番スロットの値をスタックに積む
0002 opt_empty_p # empty? を呼ぶ
0004 branchunless 9 # 偽なら9番地へジャンプ ← ifの成れの果て!
0006 putstring "hello"
0008 leave # return
0009 putstring "hello, " # else側はここから
0011 getlocal_WC_0 name@0
0013 opt_plus # + を呼ぶ
0015 leave
注目ポイントは、木だった if が 「条件→偽ならジャンプ」という番地付きの一直線の命令列に潰されていることです。
- AST:入れ子構造を考えるのに向いた形(木)
- ISeq:高速に実行するのに向いた形(横並びのテープ)
命令はメモリ上に配列としてベタッと連続で並んでいて、PC(プログラムカウンタ)が左から右へ読み進めるだけ。メモリを順に読むのでCPUキャッシュに乗りやすく、木を辿るより圧倒的に速いんです。
ちなみにトークン列とASTは、次の工程に渡したら捨てられます。最後まで残るのはISeqだけです。
🖥 ④ 実行:YARVがテープを再生する
YARV(Yet Another Ruby VM、読みは「ヤルブ」) は、ISeqを実行する仮想マシンです。
ここで疑問が湧きませんか?「なんでVMなんて挟むの?CPUに直接実行させればよくない?」と。
答えは、CPUは機械語しか実行できず、ISeqは機械語ではないからです。そして機械語に事前変換できない理由がRubyらしさそのもので……
# Rubyは実行中にメソッドが生えてくる言語!
define_method(:hello) { "hi!" } # 実行時にメソッド定義
User.find_by_name("taro") # ActiveRecordの動的メソッド
実行中にコードが変わる言語では「事前に全部機械語化」が原理的にできないんです。そこで、
- 機械語化はしない。ISeqはデータとして持っておく
- C言語で書かれた(=ビルド済み機械語の)YARVが、ISeqを1命令ずつ読んで対応する処理を代行する
という構成を取ります。これが「解釈(インタプリト)」です。料理で例えると、レシピカード(ISeq)は最後まで筋肉の動き(機械語)に変換されず、料理人(YARV)が読みながら体を動かしている、という関係です 🍳
🚂 Railsだとどうなる?:起動時 vs リクエスト時
ここからが実務との接続です。bundle exec rails s -e production すると何が起きるのか。
起動時:翻訳は100%終わらせる
本番環境では起動時に app/ 以下を全ファイル eager load します。ファイルごとに①〜④がワンセットで走ります。
user.rb を require
①②③ トークナイズ〜コンパイル → ISeq 完成
④ トップレベルを実行 ← ここが重要!
見落としがちなのが④です。Rubyではクラス定義自体が実行されるコードなんです。
class User < ApplicationRecord # 実行されて Class オブジェクトが生成される
validates :name, presence: true # ただのメソッド呼び出し。この場で実行される
def display_name # 実行されて「メソッド表」に登録される
"#{name} さん"
end
end
def の正体は 「メソッド表への書き込み文」 です。メソッド表とは、各クラスが持つ「メソッド名 → ISeq」の対応表のこと。
User クラスのメソッド表
┌───────────────┬──────────────────┐
│ :display_name │ → ISeq(コンパイル済み本体)│
│ :active? │ → ISeq │
└───────────────┴──────────────────┘
つまり起動完了時点で、メモリ上には**「定数表(クラス名→実体)」「メソッド表(メソッド名→ISeq)」「ルート表」が構築済み**。翻訳作業はゼロ残しです。
リクエスト時:「地図」を引いて「手順書」を実行するだけ
GET /users/1 が来たときに残っている仕事は、たったこれだけです。
① ルート表を引く → 「UsersController#show だ」
② メソッド表を引く → 「show の ISeq はこれだ」
③ YARV が ISeq を実行 → 途中で User.find に当たったら、
また表を引いて find の ISeq へ潜る……の繰り返し
「表(地図)を引く → ISeq(手順書)を実行 → 途中でまた地図を引く」の交互の繰り返しがリクエスト処理の正体です。ファイル読み込みもコンパイルも一切走りません。
そして重要なのが、命令列に書いてあるのはアドレスではなく「名前」(:User や :find というシンボル)だということ。名前で毎回表を引くからこそ、実行中のメソッド定義や再定義(モンキーパッチ)が効く——これが「動的言語」の実体です。
「毎回表引きって遅くない?」と思ったあなたは鋭い 👀 実際にはインラインキャッシュという仕組みがあり、初回の表引き結果を命令の脇に書き込んで、2回目以降はスキップします(再定義されたらキャッシュ破棄)。「名前で遅延解決し、速度はキャッシュで取り返す」がRubyの戦略です。
🚀 おまけ:YJITで機械語の世界へ
Ruby 3.1 から入った YJIT(読みは「ワイジット」) は、この構成の最後の弱点「VMの解釈は機械語より遅い」に答えるものです。
- 全メソッドはまずYARVルート(解釈)で動く
- 呼び出し回数がしきい値を超えたホットなメソッドだけ、実行時に本物の機械語へ変換
- 以後そのメソッドはYARVを迂回してCPUが直接実行(前提が崩れたらYARVに戻る)
実行エンジンの歴史を並べると、進化の方向が見えてきます。
| 時代 | エンジン | 方式 |
|---|---|---|
| 〜1.8 | MRI | ASTを直接たどって解釈(遅い) |
| 1.9〜 | YARV | バイトコード + VM |
| 3.1〜 | YARV + YJIT | ホットスポットだけ機械語化 |
一貫して 「翻訳を前倒しして、実行時の仕事を減らす」 方向に進化しているんですね。
📝 まとめ
最後に全体をおさらいします。
| フェーズ | 何が起きているか |
|---|---|
| 起動時(ファイルごと) | ①トークナイズ → ②パース(AST)→ ③コンパイル(ISeq)→ ④トップレベル実行で定数表・メソッド表を構築 |
| リクエスト時 | 表引き(地図)→ ISeq(手順書)を YARV が実行、の繰り返しだけ。翻訳はゼロ |
| ホットスポット | YJIT が機械語化して YARV を迂回 |
そして「名前を毎回実行時に解決する」性質こそが動的言語の本質で、validates のようなDSLやActiveRecordの動的メソッドはすべてこの上に成り立っています。
最後に、Rubyの実行モデルを一言でまとめるとこうなります 💪
「1行ずつ翻訳」ではなく、実行時に全体をパースしてバイトコードにコンパイルし、VM(YARV)が実行する。メソッド解決が毎回実行時に行われるのが動的言語の本質で、その遅さはインラインキャッシュやYJITで緩和されている。
ここまで読んでいただきありがとうございました!