くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

Rubyのコードはどうやって実行される?

今回はRubyのコードがどのような流れで実行されるのかをまとめます

rubyコマンドを実行してからコンソールに結果が出力されるまでに下記の手順を行います

それぞれの手順を細かく見ていきます

字句解析

字句解析とはソースコードを読み込んで、トークン列へと変換することです

Rubyコードは字句解析でトークン列に変換され、次の構文解析のプロセスを行います

ただし、全トークンを事前に生成するわけではなく、必要な分だけ順次トークン化していく(構文解析の中で字句解析が呼び出される形)ので字句解析は何回も行われます。

def hello(name)
  puts "Hello, #{name}"
end

例えば上記の処理があるとします

  • def → キーワード(tDEF)
  • hello → 識別子(tIDENTIFIER)
  • ( → 左括弧(tLPAREN)
  • name → 識別子(tIDENTIFIER)
  • ) → 右括弧(tRPAREN)
  • 改行 → 終端記号(tNL)
  • puts → 識別子(tIDENTIFIER)
  • "Hello, #{name}" → 文字列リテラル(tSTRING)
  • end → キーワード(tEND

字句解析では、空白やコメントの除去、キーワードと識別子の区別、数値リテラル、文字列リテラルの認識、演算子や記号の認識、文字列補間の処理などを行います(文字列の読み取り、トークンの識別、トークンの分類)

元々は字句解析にはparse.yを使用していましたが、Ruby3.3以降はPrismを使用して字句解析を行っています

構文解析

構文解析とはRubyが分かるように、トークン列をグループ化する作業です

トークン列を文法のルールに従ってグループ化し、コードの構造を木構造(AST)で表現します

下記のようにDefNodeがdefの部分であり、ノードの開始となります。そこから木構造でノードを追加していきます。下記はPrismを使用した際のASTです

DefNode (メソッド定義ノード)
├─ name: "hello"
│   └─ type: Symbol
│   └─ value: :hello
│   └─ location: (1,4)-(1,9)
│
├─ parameters: ParametersNode (パラメータノード)
│   ├─ requireds: Array
│   │   └─ [0] RequiredParameterNode
│   │       ├─ name: "name"
│   │       ├─ type: Symbol
│   │       ├─ value: :name
│   │       └─ location: (1,10)-(1,14)
│   │
│   ├─ optionals: [] (空)
│   ├─ rest: nil
│   ├─ posts: [] (空)
│   ├─ keywords: [] (空)
│   ├─ keyword_rest: nil
│   └─ block: nil
│
├─ body: StatementsNode (文のリストノード)
│   └─ body: Array
│       └─ [0] CallNode (メソッド呼び出しノード)
│           ├─ receiver: nil (レシーバなし、トップレベル)
│           │
│           ├─ call_operator: nil
│           │
│           ├─ name: "puts"
│           │   └─ type: Symbol
│           │   └─ value: :puts
│           │
│           ├─ message_loc: (2,2)-(2,6)
│           │
│           ├─ opening_loc: nil (括弧なし)
│           │
│           ├─ arguments: ArgumentsNode (引数ノード)
│           │   └─ arguments: Array
│           │       └─ [0] InterpolatedStringNode (式展開文字列ノード)
│           │           ├─ opening_loc: (2,7)-(2,8) (")
│           │           │
│           │           ├─ parts: Array
│           │           │   ├─ [0] StringNode (通常文字列部分)
│           │           │   │   ├─ flags: FORCED_UTF8_ENCODING
│           │           │   │   ├─ content: "Hello, "
│           │           │   │   ├─ unescaped: "Hello, "
│           │           │   │   └─ location: (2,8)-(2,15)
│           │           │   │
│           │           │   └─ [1] EmbeddedStatementsNode (式展開部分)
│           │           │       ├─ opening_loc: (2,15)-(2,17) (#{)
│           │           │       │
│           │           │       ├─ statements: StatementsNode
│           │           │       │   └─ body: Array
│           │           │       │       └─ [0] LocalVariableReadNode
│           │           │       │           ├─ name: "name"
│           │           │       │           ├─ type: Symbol
│           │           │       │           ├─ value: :name
│           │           │       │           ├─ depth: 0 (スコープの深さ)
│           │           │       │           └─ location: (2,17)-(2,21)
│           │           │       │
│           │           │       ├─ closing_loc: (2,21)-(2,22) (})
│           │           │       └─ location: (2,15)-(2,22)
│           │           │
│           │           ├─ closing_loc: (2,22)-(2,23) (")
│           │           └─ location: (2,7)-(2,23)
│           │
│           ├─ closing_loc: nil
│           ├─ block: nil (ブロックなし)
│           ├─ flags: IGNORE_VISIBILITY
│           └─ location: (2,2)-(2,23)
│
├─ locals: ["name"] (ローカル変数のリスト)
│
├─ def_keyword_loc: (1,0)-(1,3) (def)
│
├─ operator_loc: nil
│
├─ lparen_loc: (1,9)-(1,10) (()
│
├─ rparen_loc: (1,14)-(1,15) ())
│
├─ equal_loc: nil
│
├─ end_keyword_loc: (3,0)-(3,3) (end)
│
└─ location: (1,0)-(3,3) (全体の位置)

ちなみに、構文解析器は再帰下降パーサーまたはLR(LALR)パーサーの手法を使っています

コンパイル

コンパイルとは、コードをプログラミング言語から別の言語(ターゲット言語)に変えることです

Rubyインタプリタがコードを読み込み、木構造(AST)からYARVバイトコード命令列に変換します

AST構造をYARV命令にコンパイルするために、Ruby木構造の一番上から再帰的にツリーを反復しながら、それぞれのASTノードをYARY命令に変換していきます

ASTノードにはノードタイプが定義されているので、ノードタイプに一致するYARY命令に変換します。例えば、NODE_CALLputself + opt_send_without_blockに変換されます

コンパイルすると下記のようになります

== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(3,3)> (catch: FALSE)
0000 putspecialobject             1                                   (   1)[Li]
0002 putobject                    :hello
0004 putiseq                      hello
0006 opt_send_without_block       <callinfo!mid:core#define_method, argc:2, ARGS_SIMPLE>, <callcache>
0009 leave

== disasm: #<ISeq:hello@<compiled>:1 (1,0)-(3,3)> (catch: FALSE)
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] name@0<Arg>
0000 putself                                                          (   2)[LiCa]
0001 putobject                    "Hello, "
0003 getlocal_WC_0                name@0
0005 dup
0006 checktype                    T_STRING
0008 branchif                     15
0010 dup
0011 opt_send_without_block       <callinfo!mid:to_s, argc:0, FCALL|ARGS_SIMPLE>, <callcache>
0014 tostring
0015 concatstrings                2
0017 opt_send_without_block       <callinfo!mid:puts, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
0020 leave                                                            (   3)[Re]

1つ目の命令列はメソッド定義部分で、2つ目の命令列はメソッド本体部分を表しています

コンパイラはまず、ASTのルートノードを見てDefNode(メソッド定義)を発見します。DefNodeを発見するとメソッド本体(body部分)を別の命令列(ISeq)としてコンパイルして、 トップレベルにはメソッドを定義する命令だけ生成します。

実行

補足程度ですが、コンパイルされたバイトコード命令列を実行するときにYARVバイトコード実行するのですが、JITコンパイラを有効にしている場合にはバイトコードを最適化してくれます

何回も呼ばれている処理はホットスポットとして検出して、YARVバイトコードx86/ARM機械語に変換します。以降は機械語を直接実行することで、大幅に高速化されます

そして、Ruby 3.1以降ではYJITというオプションで有効にできるJITコンパイラがあり、実行時にバイトコード機械語に変換して高速化します

Ruby 3.3以降では、RJITというMJITの後継となる新しいJITコンパイラがあります。MJITは実行時にCコンパイラが必要で、YJITはビルド時にRustコンパイラが必要ですが、RJITはどちらも不要です

参照

github.com

zenn.dev

yui-knk.hatenablog.com

github.com

speakerdeck.com

github.com

techblog.raksul.com