Управление потоком

Предпосылки: Исполнение — фреймы, PC/SP/CFP, стек вызовов, push/pop при вызове метода.

Исполнение | Объекты и классы

В предыдущей заметке VM шагала по инструкциям линейно: PC двигался вперёд, инструкция за инструкцией. Но if, while, break требуют менять направление. Как YARV реализует переходы?

Переходы бывают двух масштабов. Простой — прыжок внутри одного ISeq: if, while, until. Сложный — прыжок через границу scope: break из блока должен покинуть текущий фрейм и вернуть управление в родительский. Проследим оба на конкретной программе:

x = if 2 < 10 then "yes" else "no" end
puts x
 
10.times { |n| break if n == 5 }
puts "done"

Первая строка содержит ветвление — YARV должен выбрать путь. Вторая часть содержит break из блока — YARV должен выбраться через границу фрейма.

Ветвление: if/else

Начнём с первой строки нашей программы. Когда Ruby компилирует if...else, он вычисляет условие и ставит branchunless — прыжок к else-ветке, если условие ложно. Сразу после идёт true-ветка, затем jump перескакивает else-ветку:

вычислить условие
branchunless → else
  true-ветка
  jump → конец
else-ветка
конец

branchunless означает «перейди, если НЕ true». Ruby ставит true-ветку сразу после условия — так при истинном условии VM просто продолжает вперёд без прыжка. Прыгать нужно только при ложном условии. Для unless — зеркально: используется branchif.

Ветвление выбирает один из двух путей. Но что если путь нужно повторять? Циклы устроены похоже. while i < 10 компилируется так: в начале jump на проверку условия (она стоит в конце тела цикла), затем тело, в конце — проверка условия и branchif назад к телу. Цикл крутится, пока условие истинно. until — то же, но с branchunless.

Все три инструкции — branchif, branchunless, jump — работают внутри одного ISeq. Они сдвигают PC на заданное смещение, и VM продолжает цикл «прочитай → выполни» с новой позиции.

Прыжок через scope: break из блока

Переходим ко второй части нашей программы — 10.times { |n| break if n == 5 }. Внутри блока стоит break:

10.times { |n| break if n == 5 }
puts "done"

Блок — отдельный ISeq, отдельный фрейм. Инструкция jump здесь не поможет: она умеет прыгать только внутри одного ISeq. А break должен покинуть и блок, и C-метод Integer#times, и вернуть управление в основную программу — к puts "done".

Для таких кросс-scope переходов Ruby использует два механизма: инструкцию throw и таблицу перехвата (catch table).

Catch table — список записей, который компилятор прикрепляет к ISeq. Каждая запись говорит: «если событие типа X произошло, пока PC находится между позициями A и B — перейди к позиции C». Типов событий шесть: BREAK, RESCUE, ENSURE, RETRY, REDO, NEXT.

Когда блок содержит break, компилятор делает две вещи: в ISeq блока генерирует инструкцию throw 2 (число 2 — код для break), а в ISeq родительского scope добавляет запись в catch table — тип BREAK, диапазон — инструкции вокруг send :times, точка продолжения — сразу после send.

При исполнении throw 2 YARV начинает искать запись типа BREAK. Сначала проверяет catch table текущего ISeq. Если не нашёл — спускается по стеку фреймов к следующему. Нашёл — сбрасывает CFP (указатель на текущий фрейм), PC и SP к точке, указанной в записи. Выполнение продолжается в родительском scope, как будто send :times вернул управление.

Тот же механизм работает для других кросс-scope переходов. return изнутри блока тоже использует throw — VM раскручивает стек до фрейма метода и возвращает из него. rescue перехватывает исключение через запись RESCUE в catch table. ensure — запись ENSURE, которая срабатывает при любом выходе из защищённого блока. next и redo внутри блока — записи NEXT и REDO.

break — обычная, повседневная конструкция. Но внутри Ruby реализует её через механизм, похожий на поднятие исключения: throw бросает «событие», а catch table ловит его. Ruby спрятал сложный механизм за простым ключевым словом.

Это имеет практические последствия. break из блока дороже обычного прыжка: VM раскручивает стек фреймов, проверяя catch table на каждом уровне — по стоимости это ближе к исключению, чем к jump. ensure-блоки срабатывают при break и return из блока, потому что ENSURE — запись в той же catch table: любой throw проходит через неё при раскрутке. А если подходящая запись не найдена — например, break вызван вне блока — VM не знает, куда прыгнуть, и бросает LocalJumpError.

Инструменты

Оба механизма — прыжки внутри ISeq и кросс-scope throw — видны в дизассемблере. Ветвление из первой части нашей программы:

code = 'x = if 2 < 10 then "yes" else "no" end; puts x'
puts RubyVM::InstructionSequence.compile(code).disasm
0000 putobject                              2
0002 putobject                              10
0004 opt_lt                   <calldata!mid:<, argc:1>
0006 branchunless                           12
0008 putstring                              "yes"
0010 jump                                   14
0012 putstring                              "no"
0014 setlocal_WC_0                          x@0
...

opt_lt вычисляет 2 < 10, branchunless 12 прыгает к putstring "no" при ложном условии, jump 14 перескакивает else-ветку при истинном.

Catch table и throw видны при break из блока:

puts RubyVM::InstructionSequence.compile('10.times { |n| break if n == 5 }').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,32)>
== catch table
| catch type: break  st: 0000 ed: 0005 sp: 0000 cont: 0005
|------------------------------------------------------------------------
0000 putobject                              10
0002 send                     <calldata!mid:times, argc:0>, block in <compiled>
0005 leave

== disasm: #<ISeq:block in <compiled>@<compiled>:1 (1,9)-(1,32)>
...
0001 getlocal_WC_0                          n@0
0003 putobject                              5
0005 opt_eq                   <calldata!mid:==, argc:1>
0007 branchunless                           13
0009 putnil
0010 throw                                  2
...

Два ISeq. В блоке: opt_eq проверяет n == 5, branchunless пропускает break при ложном условии, throw 2 выполняет break. В основной программе: catch table с записью break — при throw 2 выполнение продолжается с позиции 0005, сразу после send :times.

Sources

  • Pat Shaughnessy, 2013, Ruby Under a Microscope — глава 4: управление потоком.
  • Исходники Ruby (коммит 0d4538b57d, 2026-01-10): insns.def (branchunless, branchif, jump, throw), iseq.h (catch table types).

Исполнение | Объекты и классы