お辛了!

你是不是也曾經以為:「Ruby 因為是解譯語言,所以會一行一行邊翻譯邊執行吧?」(我以前也是這麼想的🫣)

……其實,這在現代 Ruby 中是不正確的 😱

我重新從頭研究了一次執行模型之後,終於把 rails s 背後到底發生了什麼串成一條線了,所以整理起來分享給大家。

🗺 全體地圖:程式碼跑起來前的 4 個步驟

Ruby 程式碼在被執行之前,會經過 4 個步驟。

原始碼(只是一串字串)
  ↓ ① Tokenize …… 拆成有意義的單字
  ↓ ② Parse    …… 依照語法變成樹狀結構(AST)
  ↓ ③ Compile  …… 壓平成一串指令(位元組碼 / ISeq)
  ↓ ④ 執行     …… YARV(虛擬機)執行指令

重點是,不是「一行一行」執行,而是先把整個檔案翻譯完再執行。證據如下。

puts "hello"
def foo(  # ← 語法錯誤

如果真的是一行一行執行,應該會先顯示 hello 再報錯;但實際上是在任何東西都還沒執行之前就直接出現 SyntaxError。這證明 Ruby 會在執行前先把整個檔案解析完 💡

接下來我們一個步驟一個步驟看。這些都可以直接在你本機的 irb 裡確認 👍

✂️ ① Tokenize:把字串拆成「單字」

對電腦來說,原始碼一開始只是一串字元。把它切成最小的有意義單位(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 個 token
  • 分類:整數 / 識別子 / 運算子 / 關鍵字……
  • 記錄位置:錯誤訊息裡的「第 3 行」就是從這裡來的

🌳 ② Parse:把單字列變成「樹」

Tokenize 完成後,程式碼仍然只是一列單字,還不知道「哪個和哪個是一組」。

例如 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 = 變成機器碼」?(我以前也是這麼以為的😤)

其實compile 這個詞的意思是「把某種表示形式轉換成另一種表示形式」,至於轉成什麼,會因語言而不同。

例如:

  • Go → 機器碼(CPU 可直接執行)
  • javac → JVM 位元組碼
  • Ruby → YARV 位元組碼(ISeq)
  • TypeScript → JavaScript(甚至是別的語言)

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,被壓平成了「條件判斷 → 若為假則跳轉」的、帶位址的一維指令列

  • AST:適合用來理解巢狀結構的形式(樹)
  • ISeq:適合快速執行的形式(橫向排列的指令列)

指令會在記憶體中以陣列的形式連續排列,PC(程式計數器)只要從左到右讀過去就好。因為是順著記憶體讀取,所以比較容易命中 CPU cache,比起走訪樹結構快非常多。

另外,token 列和 AST 在下一個步驟傳出去之後,就會被丟掉。最後真正留下來的是 ISeq。

🖥 ④ 執行:YARV 重放指令列

YARV(Yet Another Ruby VM,唸作「YARV」) 是用來執行 ISeq 的虛擬機。

這裡你可能會想:「為什麼還要多一個 VM?直接讓 CPU 執行不就好了?」

答案是,CPU 只會執行機器碼,而 ISeq 不是機器碼。而且 Ruby 之所以不能事先全部轉成機器碼,正是因為它的動態特性……

# Ruby 是一種執行中還能長出方法的語言!
define_method(:hello) { "hi!" }   # 執行時定義方法
User.find_by_name("taro")         # ActiveRecord 的動態方法

對於執行中程式碼會改變的語言,原理上就無法事先把全部內容都變成機器碼。 所以 Ruby 採取的做法是:

  • 不轉成機器碼,而是把 ISeq 當成資料保存起來
  • 由 C 語言撰寫的、也就是已經編譯成機器碼的 YARV,逐條讀取 ISeq,代替執行對應的處理

這就是「解譯(interpret)」的意思。用料理來比喻,食譜卡(ISeq)不會被轉成肌肉動作(機器碼),而是由廚師(YARV)邊讀邊做 🍳

🚂 Rails 那又是怎麼回事?:啟動時 vs 請求時

接下來把它接到實務上。當你執行 bundle exec rails s -e production 時,實際上會發生什麼?

啟動時:翻譯會 100% 做完

在正式環境中,啟動時會把 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 的策略就是:用名稱做延遲解析,再靠快取把速度補回來。

🚀 附贈:YJIT 進入機器碼世界

Ruby 3.1 之後加入的 YJIT(唸作「YJIT」)就是用來解決這套架構最後一個弱點:「VM 解譯比機器碼慢」。

  • 所有方法一開始都先走 YARV 路線(解譯)
  • 呼叫次數超過門檻的熱點方法,會在執行時轉成真正的機器碼
  • 之後這個方法就繞過 YARV,直接由 CPU 執行;如果前提失效,就再回到 YARV

把執行引擎的歷史排起來,就能看出進化方向。

時代|引擎方式
~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 來緩解。

感謝你讀到這裡!


原文出處:https://qiita.com/akachiryo/items/0dea3706713f90e7ef7d


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝10   💬2   ❤️5
271
🥈
我愛JS
📝2   💬6   ❤️3
112
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登