Управление потоком
Предпосылки: Исполнение — фреймы, 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).disasm0000 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).
← Исполнение | Объекты и классы →