32
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsで学ぶ Rubyの実行モデル入門 〜「1行ずつ翻訳」は誤解だった!〜

32
Posted at

お疲れ様です!

「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で緩和されている。

ここまで読んでいただきありがとうございました!

32
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?