お辛了!
你是不是也曾經以為:「Ruby 因為是解譯語言,所以會一行一行邊翻譯邊執行吧?」(我以前也是這麼想的🫣)
……其實,這在現代 Ruby 中是不正確的 😱
我重新從頭研究了一次執行模型之後,終於把 rails s 背後到底發生了什麼串成一條線了,所以整理起來分享給大家。
Ruby 程式碼在被執行之前,會經過 4 個步驟。
原始碼(只是一串字串)
↓ ① Tokenize …… 拆成有意義的單字
↓ ② Parse …… 依照語法變成樹狀結構(AST)
↓ ③ Compile …… 壓平成一串指令(位元組碼 / ISeq)
↓ ④ 執行 …… YARV(虛擬機)執行指令
重點是,不是「一行一行」執行,而是先把整個檔案翻譯完再執行。證據如下。
puts "hello"
def foo( # ← 語法錯誤
如果真的是一行一行執行,應該會先顯示 hello 再報錯;但實際上是在任何東西都還沒執行之前就直接出現 SyntaxError。這證明 Ruby 會在執行前先把整個檔案解析完 💡
接下來我們一個步驟一個步驟看。這些都可以直接在你本機的 irb 裡確認 👍
對電腦來說,原始碼一開始只是一串字元。把它切成最小的有意義單位(token),就叫做 tokenize。這跟把日文句子分成單字的「分詞」是一樣的工作。
require 'ripper'
Ripper.lex("x = 10 + foo").each { |_, type, str, _| puts "#{type} #{str.inspect}" }
on_ident "x" # 識別子
on_op "=" # 運算子
on_int "10" # 整數(不是「1」「0」兩個 token,而是 1 個 token!)
on_op "+"
on_ident "foo"
做的事情只有三個:
10 雖然是 2 個字元,但只算 1 個 tokenTokenize 完成後,程式碼仍然只是一列單字,還不知道「哪個和哪個是一組」。
例如 1 + 2 * 3,光看排列方式,既可以讀成 (1+2)*3,也可以讀成 1+(2*3)。Parse 就是用語法規則解決這種歧義,並把它組成樹狀結構(AST:抽象語法樹)。
pp RubyVM::AbstractSyntaxTree.parse("1 + 2 * 3")
(OPCALL
(INTEGER 1) :+
(OPCALL (INTEGER 2) :* (INTEGER 3))) # ← 「* 先算」以樹的形狀被確定!
一旦變成樹,解讀方式就以結構固定下來了,歧義也就消失了。像是「if 沒有對應的 end」這類 SyntaxError,也是這個步驟發生的。
你是不是也以為「compile = 變成機器碼」?(我以前也是這麼以為的😤)
其實compile 這個詞的意思是「把某種表示形式轉換成另一種表示形式」,至於轉成什麼,會因語言而不同。
例如:
javac → JVM 位元組碼Ruby 的編譯結果會變成名為 ISeq(Instruction Sequence,唸作「I-seq」) 的物件。來看實際內容。
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 # 如果是 false,就跳到 9 號位址 ← if 的最終樣貌!
0006 putstring "hello"
0008 leave # return
0009 putstring "hello, " # else 分支從這裡開始
0011 getlocal_WC_0 name@0
0013 opt_plus # 呼叫 +
0015 leave
注意重點在於,原本是樹的 if,被壓平成了「條件判斷 → 若為假則跳轉」的、帶位址的一維指令列。
指令會在記憶體中以陣列的形式連續排列,PC(程式計數器)只要從左到右讀過去就好。因為是順著記憶體讀取,所以比較容易命中 CPU cache,比起走訪樹結構快非常多。
另外,token 列和 AST 在下一個步驟傳出去之後,就會被丟掉。最後真正留下來的是 ISeq。
YARV(Yet Another Ruby VM,唸作「YARV」) 是用來執行 ISeq 的虛擬機。
這裡你可能會想:「為什麼還要多一個 VM?直接讓 CPU 執行不就好了?」
答案是,CPU 只會執行機器碼,而 ISeq 不是機器碼。而且 Ruby 之所以不能事先全部轉成機器碼,正是因為它的動態特性……
# Ruby 是一種執行中還能長出方法的語言!
define_method(:hello) { "hi!" } # 執行時定義方法
User.find_by_name("taro") # ActiveRecord 的動態方法
對於執行中程式碼會改變的語言,原理上就無法事先把全部內容都變成機器碼。 所以 Ruby 採取的做法是:
這就是「解譯(interpret)」的意思。用料理來比喻,食譜卡(ISeq)不會被轉成肌肉動作(機器碼),而是由廚師(YARV)邊讀邊做 🍳
接下來把它接到實務上。當你執行 bundle exec rails s -e production 時,實際上會發生什麼?
在正式環境中,啟動時會把 app/ 底下的所有檔案 eager load。每個檔案都會完整跑過 ①〜④ 這一套。
require user.rb
①②③ tokenize〜compile → 完成 ISeq
④ 執行最上層程式碼 ← 這很重要!
很多人會忽略第 ④ 步。Ruby 裡,class 定義本身就是會被執行的程式碼。
class User < ApplicationRecord # 執行,並建立 Class 物件
validates :name, presence: true # 只是方法呼叫,會在此處被執行
def display_name # 執行後註冊到「方法表」
"#{name} さん"
end
end
def 的本質其實是 「寫入方法表的語句」。方法表就是每個 class 擁有的「方法名稱 → ISeq」對照表。
User 類別的方法表
┌───────────────┬──────────────────┐
│ :display_name │ → ISeq(已編譯的本體)│
│ :active? │ → ISeq │
└───────────────┴──────────────────┘
也就是說,到了啟動完成時,記憶體裡已經建立好了 「常數表(類別名稱 → 實體)」、「方法表(方法名稱 → ISeq)」、「根表」。翻譯工作已經完全做完了。
當 GET /users/1 進來時,剩下的工作只有這些。
① 查路由表 → 「是 UsersController#show」
② 查方法表 → 「show 的 ISeq 在這裡」
③ YARV 執行 ISeq → 執行途中如果碰到 User.find,
就再去查表,跳到 find 的 ISeq……
「查表(地圖) → 執行 ISeq(手冊) → 中途再查表」這個交替循環,就是請求處理的本體。 不會再讀檔,也不會再編譯。
而且很重要的一點是,指令列上寫的不是位址,而是名稱(例如 :User、:find 這種 symbol)。正因為每次都用名稱去查表,執行中的方法定義或重新定義(monkey patch)才會生效——這就是動態語言的本質。
如果你想:「每次都查表,不會很慢嗎?」那你很敏銳 👀 實際上有一種叫做 inline cache 的機制,會把第一次查表的結果寫在指令旁邊,之後第二次以後就直接跳過;如果方法被重新定義了,快取就會失效。Ruby 的策略就是:用名稱做延遲解析,再靠快取把速度補回來。
Ruby 3.1 之後加入的 YJIT(唸作「YJIT」)就是用來解決這套架構最後一個弱點:「VM 解譯比機器碼慢」。
把執行引擎的歷史排起來,就能看出進化方向。
時代|引擎方式
~1.8|直接走訪 MRI AST 來解譯(慢)
1.9〜|YARV 位元組碼 + VM
3.1〜|YARV + YJIT,只有熱點才轉成機器碼
整體來看,一直都是往 「把翻譯往前移,減少執行時的工作量」 這個方向演進。
最後來回顧整體流程。
階段|發生什麼事
啟動時(每個檔案)|① tokenize → ② parse(AST)→ ③ compile(ISeq)→ ④ 執行最上層程式碼,建立常數表與方法表
請求時|只會重複「查表(地圖)→ YARV 執行 ISeq(手冊)」;完全不再翻譯
Hotspot|YJIT 會把它轉成機器碼,繞過 YARV
而「每次在執行時才用名稱解決」這個特性,正是動態語言的核心;像 validates 這種 DSL、ActiveRecord 的動態方法,都是建立在這層機制之上。
最後,用一句話來總結 Ruby 的執行模型,就是這樣 💪
不是「一行一行翻譯」,而是在執行前先解析整體、編譯成位元組碼,再由 VM(YARV)執行。方法解析會在執行時進行,這正是動態語言的本質,而這種慢速則透過 inline cache 與 YJIT 來緩解。
感謝你讀到這裡!