flang язык, в котором спецификация исполняется

Модель конкурентности flang — контракт

Все семь шагов сделаны: поверхность разбирается, проверки работают, планировщик эталона исполняет прогоны, надзор не только объявляется, но и действует, программа с процессами печатается в Elixir и идёт на настоящей BEAM — процессом BEAM на процесс flang и супервизором OTP на надзор, — прогон перебирает сетку семян, в рантайме C есть свой планировщик, дающий на том же семени побайтово тот же журнал доставок, что и эталон, и модель измерена. Что именно сделано и чем оно отличается от написанного ниже — в разделах «Что сделано в шаге 1», «в шаге 3», «в шаге 4», «в шаге 5», «в шаге 6» и «в шаге 7» перед границей честности.

Что при этом НЕ сделано и чего в списке работ не было: рабочий (параллельный) режим на потоках ОС — сделан проверочный, однопоточный, и цена второго измерена на трёх машинах и названа в шаге 6; печать конкурентности в остальные ПЯТЬ целей — Go, Rust, Python, Java и C# процессов не печатают и с 12 августа 2026 честно об этом отказывают (FLANG_CONC_UNSUPPORTED), а не выбрасывают процесс молча. породить из этого списка вышел шагом Б1 карты conc/RESILIENCE.md: процессы заводятся на ходу, и заводятся они в модели и в цели C — у целей JavaScript и Elixir порождения нет, и их планировщики отвечают на него названной ошибкой «неизвестное действие». JavaScript из этого списка вышел: у цели есть свой планировщик, напечатанный внутрь модуля (flang/src/emit/js/flang_conc.js), и на одном семени он даёт побайтово тот же журнал доставок, что эталон (flang/test/emit-js-conc.test.mjs).

Из этого же списка вышла — наполовину — распределённость. У цели JavaScript есть УЗЕЛ: два процесса операционной системы несут по части объявленных процессов одной программы, обмениваются сообщениями по имени и надзирают друг за другом через границу тем же надзором, что внутри. Разрыв связи не описан, а испытан разрывом. Что именно сделано, чего нет и какой ценой — в flang/conc/DISTRIBUTED.md; там же три решения, на которых такие системы ломаются (имя, отказ, доставка), названные прямо.

И одно, чего в списке не было и чего никто не спросил, пока не измерил: процесс в напечатанном C не мог жить долго. Арена рантайма только росла — 510,6 байта на пробег, ни один не возвращался (шаг 6, «Долгая жизнь процесса»). Для прогона конечной длины это правильно и дёшево; для сервера, а тем более для операционной системы, это был запрет. Снято шагом А2: у процесса своя куча, наклон рабочего режима равен нулю, время жизни памятью не ограничено. Ценой стало копирование сообщения при отправке — пункт «сообщения не копируются» из границы честности вычеркнут. Путь отсюда до отказоустойчивости, с ценой и проверкой на каждый шаг, — flang/conc/RESILIENCE.md.

Заказано как «альтернатива BEAM: пока что модель конкурентности, потом сделаем и vm». Этот документ — про модель. Он объясняет, чем она может отличаться от BEAM не в лучшую сторону вообще, а в конкретных, называемых местах, и чем платит за это.

Что уже есть и работает на нас

Три свойства языка складываются в модель конкурентности почти без добавлений.

Значения неизменяемы. Не по соглашению, а по устройству: добавить возвращает новый список, поля записей не присваиваются. Значит два вычисления, глядящие на одно значение, не могут помешать друг другу — гонок по данным нет не потому, что их ловит анализ, а потому, что их нечем устроить.

Счётчик витков уже написан. fl_tick в рантайме C считает вход в функцию, оборот цикла хвостового самовызова и отскок батута. Он заведён для другого — не дать напечатанной программе крутиться вечно, — но это ровно тот механизм, которым BEAM вытесняет процессы: счёт редукций. То же есть в интерпретаторе (maxSteps) и в бэкендах Go, Rust и Python.

Завершение доказывается. Функция с признаком тотальная завершается на любом входе, и это проверено структурным убыванием, а не измерено. BEAM такого знания не имеет в принципе и потому обязан вытеснять всех; здесь про часть кода известно, что вытеснять его не придётся.

Разрыв, который надо закрыть: у языка нет ни одного понятия, называющего одновременность. Ни процесса, ни сообщения, ни времени.

Что такое процесс, если замыканий нет

В BEAM процесс порождают от функции: spawn(fun () -> ... end). В flang так нельзя, и это не изменилось с появлением функций-значений (flang/cat/HOF.md): значением стала функция, а не замыкание. spawn в BEAM порождает от ЗАМЫКАНИЯ, то есть от кода вместе с захваченным окружением, а замыканий в flang нет и не будет — они вернули бы ровно ту неразрешимость «кто кого зовёт», ради ухода от которой сделана дефункционализация.

Решение: процесс — это объявление, а не значение. У него есть имя, тип состояния, начальное состояние и имя функции-обработчика.

процесс «Счётчик»
  состояние «Счёт»
  начинает с «пустой счёт»
  принимает «Команда счёта»
  обрабатывает «шаг счёта»

Обработчик — обычная функция языка, чистая:

тотальная функция «шаг счёта»
  принимает текущее: «Счёт», сообщение: «Команда счёта»
  возвращает «Отклик счёта»
  разбор сообщение
    случай вариант «прибавить» с «сколько» как сколько
      пусть новое равно (запись «Счёт» с «всего» равным (текущее.«всего» плюс сколько))
      запись «Отклик счёта» с «состояние» равным новое и «действия» равным []
    случай вариант «доложить» с «повод» как повод
      пусть письмо равно (вариант «отправить» с «кому» равным "Журнал" и «что» равным (вариант «записать» с «текст» равным повод))
      запись «Отклик счёта» с «состояние» равным текущее и «действия» равным [письмо]

Отклик — запись из нового состояния и списка действий. Действия описываются, а не выполняются: это та же дисциплина, что у ввода-вывода в контракте теорката, и по той же причине — чтобы сохранить чистоту, а вместе с ней анализ завершаемости, примеры и дифференциальную сверку. Дисциплина оказалась не сходством, а тождеством: план объявляется теми же тремя строками, что процесс, и по той же причине — форма у них одна.

Набор действий:

ДействиеСмысл
отправить кому чтоположить сообщение в почтовый ящик адресата
породить вид, имя, первое сообщениезавести экземпляр объявленного вида под названным именем
остановить почемузавершить себя
отложитьвернуть текущее сообщение в ящик за уже пришедшие
через сколько отправить кому чтотаймер
продолжить с чемдоработать позже, не удерживая планировщик

Из этого набора в шаге 1 сделано всё, кроме породить, а продолжить сделан без поля. Почему — в разделе «Что сделано в шаге 1». породить приехал позже, шагом Б1 карты conc/RESILIENCE.md, и приехал не в той форме, в какой записан выше был раньше: имя не возвращается, а НАЗЫВАЕТСЯ родителем, и вместе с именем подаётся первое сообщение. Оба решения разобраны там же и в шапке flang/src/conc.mjs.

Семантика: обработчик атомарен для своего процесса

Обработчик выполняется целиком, без вытеснения — в пределах своего процесса. Пока он работает, состояние этого процесса не меняет никто. Другие процессы при этом работают: они и должны работать.

Утверждение это пережило появление вытеснения по кванту (RESILIENCE.md, шаг В2), и пережило не по недосмотру. Вытеснение снимает пробег и начинает его ЗАНОВО, а не продолжает с середины: обработчик чист, состояние возвращает значением, и снятый пробег не оставляет после себя ничего. Наблюдаемо это по-прежнему «выполняется целиком»: половины изменения не бывает, потому что изменения не бывает вовсе до возврата.

ЗДЕСЬ БЫЛА ОШИБКА, И ОНА СТОИЛА БЫ ВСЕГО. В первой редакции этого документа атомарность была объявлена глобальной: «пока обработчик работает, никакое сообщение никакого процесса не обрабатывается». Это давало одинаковое поведение на всех восьми целях и воспроизводимость даром — но означало ОДИН поток на всю программу. Модель конкурентности, которая не может занять второе ядро, не альтернатива BEAM, а её противоположность, и никакие рассуждения про «зато предсказуемо» этого не искупают.

Правильная граница проходит по процессу. Основание — не удобство, а устройство языка: состояние принадлежит одному процессу и никому больше, значения неизменяемы, разделяемой памяти нет вовсе. Значит два обработчика в двух процессах не могут помешать друг другу физически, и разрешать им идти одновременно ничего не стоит. Ровно так устроен BEAM, и ровно поэтому он занимает все ядра.

Наблюдаемая семантика — любое чередование атомарных пробегов. Программа не вправе полагаться на конкретное; если полагается, она неверна, и это должно обнаруживаться проверкой, а не в бою.

Отсюда два режима исполнения, и они не противоречат друг другу:

Как идётЗачем
проверочныйодним потоком, чередование по семенивоспроизводимость: гонка на семени 4172 предъявляется, а не описывается словами
рабочийпараллельно, сколько даёт средато, ради чего конкурентность нужна

Второй обязан оставаться в пределах чередований, которые может выдать первый. Это и есть смысл слов «наблюдательно неотличим»: не «похоже», а «ни одного исхода, которого проверочный режим не мог бы показать».

Цена, которую платим: длинный обработчик держит свой процесс и своё ядро

Это отличие от BEAM не в нашу пользу, и прятать его нельзя. BEAM вытесняет процесс посреди вычисления, досчитав редукции; здесь обработчик, начав, доработает до конца.

Что при этом НЕ происходит (после исправления семантики выше): остальные процессы не ждут. Занят один процесс и один поток планировщика.

Что происходит: этот процесс не отвечает на другие сообщения, и его ящик растёт; а если долгих обработчиков столько же, сколько потоков, встаёт и всё остальное. На BEAM то же самое кончилось бы деградацией задержки, здесь — остановкой. Разница настоящая.

Три ответа, и все три явные.

Запас витков для нетотальных обработчиков. Обработчик, для которого завершение не доказано, обязан назвать запас:

обрабатывает «разобрать пакет» с запасом 100000 витков

Исчерпание запаса — не зависание и не молчаливый обрыв, а определённый исход: сообщение отвергается кодом FLANG_BUDGET_EXHAUSTED, процесс падает, надзор решает, что дальше. Считает это fl_tick, уже написанный.

Явное продолжение вместо неявного вытеснения. Действие продолжить даёт обработчику вернуть «я не доделал, вот с чего продолжить» — и планировщик поставит его в очередь наравне с другими. Продолжение — значение (состояние), а не замыкание, поэтому обходится без экспоненциалов и печатается в C прямо.

    случай «свернуть» с «остаток» и «накоплено»
      то если («Длина» от остаток) больше 1000
        то запись «Отклик» с «действия» равным
             [(вариант «продолжить» (запись «Свёртка» с «остаток» равным («Хвост» от остаток) …))]
        иначе …

Разница с BEAM честная: там разбиение на кванты бесплатно для автора и происходит само; здесь оно видно в коде и требует решения. Взамен видно и то, где программа отдаёт управление, — а это ровно то место, где в системе с вытеснением возникают неожиданности.

Доказанная тотальность как право не назначать запас. Тотальному обработчику не нужно писать с запасом N витков: завершение доказано, и назначать ему срок незачем. BEAM такой возможности не имеет — про свой код она этого не знает никогда.

Здесь стояло «планировщик не тратит ничего на счёт витков», и это было неправдой в двух местах сразу, поэтому оба названы прямо.

Первое: планировщик витки СЧИТАЕТ и тотальному тоже. Пределов у него формально не объявлено в программе, но их ДВА — миллион витков (conc.mjs, TOTAL_HANDLER_STEPS) и десять тысяч кадров глубины (TOTAL_HANDLER_DEPTH), — и тотальная фиб от 34 упирается в первый. Пока эти числа были спрятаны в модулях, отказ из них не перечислялся ничем, и множество отказов процесса было замкнуто не до конца (RESILIENCE.md, дыра в шаге Г1). Второй предел прятался дольше первого и был найден прогоном уже после того, как Г1 объявили закрытым.

Второе, и оно важнее. Тотальность — это не ограниченность. Доказано «завершится», не доказано «завершится скоро»: функция Аккермана тотальна. Право не назначать запас снимает вопрос о зависании и не снимает вопроса о задержке.

Свойство, которое отвечает на второй вопрос, — оценка сверху на число витков как функция размера входа, — теперь в языке ЕСТЬ (flang/src/bounded.mjs, шаг В3 карты). Оценка считается многочленом с числовыми коэффициентами, а не словом «линейна»: у обработчика из примера выше она постоянная, у обработчика, идущего по списку из сообщения, — вида 11·н + 25. Дальше она сводится с пределом планировщика, и вывод из сведения — часть проверки, а не примечание:

Покрыт при этом ЧАСТНЫЙ случай, и граница названа в bounded.mjs: структурная саморекурсия по данным, свёртки и циклы по спискам, вызовы функций с оценкой. Оставлены рекурсия по числу (фиб, Аккерман, НОД с объявленной мерой), ветвящаяся и взаимная рекурсия, применение функции-значения, цикл по значению, размер которого не выведен из размера входа, и рекурсия с растущим аргументом. Оценка сверху в общем случае неразрешима, и файл, который делал бы вид, что решил её, был бы враньём.

Сколько стоило бы настоящее вытеснение, если бы мы его написали. Измерено до того, как принималось решение, а не после (bench.mjs, ключ --вытеснение; ssh dev, 256 ядер, gcc 15.2.0, средняя нагрузка 0,48 / 0,19 / 0,12):

ЧтоСколько
переключение swapcontext, туда-обратно278 нс, из них 220 (79 %) — маска сигналов
завести контекст (getcontext+makecontext)249 нс на процесс
стек сопрограммы, которая только уступает40 Б тронуто (5079 Б по RSS на 4096 штуках)
стек обработчика≈290 Б на виток глубины рекурсии (287 и 290 в двух прогонах; свободный член гуляет от 7,6 до 10,7 КиБ — в него входит окружение процесса)
виток fl_tick2,412 нс; с отдельной проверкой кванта 2,365 нс при шуме прибора 0,006 нс

Читается это так. Считать витки и сверять их с квантом — бесплатно: цена отдельной ветви прибором не измеряется вовсе, а если внести квант в тот же предел (max_steps = min(запас, квант)), горячий путь остаётся тем же кодом байт в байт. Дорого другое — ПЕРЕКЛЮЧАТЬСЯ: одно переключение стоит как 116 витков, и четыре пятых этой цены — системный вызов внутри swapcontext. И дорога память: обработчик, идущий по списку из тысячи элементов, требует около 290 КиБ машинного стека, а тысяча таких процессов — 285 МиБ прежде всякой полезной памяти.

Поэтому вытеснение здесь сделано иначе, и это возможно только в языке с чистым обработчиком: пробег, исчерпавший квант, снимается и начинается заново с вдвое большим квантом. Пока обработчик не вернулся, не изменено ничего, значит сохранять его стек незачем. Цена — переделанная работа, и она ограничена: брошено меньше двух истинных цен пробега, всего сделано меньше трёх. Даёт это чередование (короткий обработчик получает пробеги, пока долгий ещё считает), а потолка задержки не даёт: последняя попытка идёт от начала до конца. Потолок даёт ограниченность, а не вытеснение.

Отказ, надзор, перезапуск

Надзор объявляется данными, а не кодом:

надзор «Приём заказов»
  процесс «Счётчик» стратегия «перезапустить»
  процесс «Журнал» стратегия «остановить»
  процесс «Отгрузка» стратегия «передать выше»
  порог отказов 3 за 5000 миллисекунд иначе «передать выше»

Перезапуск здесь точен в том смысле, в каком в системах с изменяемым состоянием он не бывает: состояние процесса — значение, начальное состояние — то же самое значение, что и при первом запуске. Нечего «дочищать»: не бывает наполовину применённой мутации, потому что мутаций нет вовсе.

Множество отказов процесса — закрытое и вычислимое

Отказом считается ровно четыре вида, и каждый предъявлен прогоном (flang/test/failures.test.mjs), а не переписан отсюда:

  1. исчерпание запаса витков (FLANG_BUDGET_EXHAUSTED) — только у обработчика без признака тотальная: у него есть названный запас, и запас может кончиться;
  2. предел пробега у планировщика (FLANG_RECURSION_LIMIT) — только у обработчика с признаком тотальная, чья оценка витков непостоянна, не выведена вовсе или не меньше предела. Пределов у пробега два, и сводится меньший: миллион витков (TOTAL_HANDLER_STEPS) и десять тысяч кадров глубины (TOTAL_HANDLER_DEPTH);
  3. частичная встроенная форма (FLANG_BUILTIN_ARGS) — форма, определённая не на всех значениях своего типа. Их восемь, и список закрыт: элемент, символ, подстрока, разделить, к числу, голова, хвост, код символа. Сверяется он не с этим абзацем, а с builtins.mjs по исходнику;
  4. **явное остановить** с причиной, отличной от «норма» (FLANG_STOPPED).

Четыре — счёт на день, когда этот абзац был написан. Видов сейчас семь, и все поздние приехали не рассуждением, а прогоном:

  1. ящик адресата полон (FLANG_MAILBOX_FULL, шаг А3) — отказывает ОТПРАВИТЕЛЬ, у которого некуда положить сообщение. Достижим тогда и только тогда, когда в достижимом коде есть отправить или через процессу с ОБЪЯВЛЕННЫМ ящиком;
  2. числовая мера не убыла (FLANG_MEASURE) — у обработчика, чьё доказательство завершения держится на числе: на постоянном шаге (н минус 1) или на объявленной мере (убывает …). Доказательство верно для чисел, а числа языка IEEE-754, поэтому понижение ставит на такой вызов сторожа, и не убывшая мера — отказ, а не вечный цикл. Достижим ровно у тех функций, которым сторожа поставили: список берётся у анализа завершаемости (guards и descents), а не по слову убывает в тексте — мера, объявленная там, где доказала структура, сторожа не получает и отказа не даёт;
  3. связь с узлом потеряна (FLANG_LINK_DOWN) — у ПРЕДСТАВИТЕЛЯ процесса, размещённого на другом узле (flang/conc/DISTRIBUTED.md). Единственный вид, достижимость которого решает не текст программы, а РАЗМЕЩЕНИЕ: кто на каком узле живёт — решение эксплуатации, и в исходнике этого нет ни словом. Поэтому размещение приезжает проверке доводом — flang check --размещение узлы.json, и тем же файлом, каким его получает узел при подъёме. Достижим тогда и только тогда, когда в размещении есть ещё хотя бы один НЕСУЩИЙ узел, кроме собственного узла процесса; программе без размещения этот вид не приписывается вовсе, потому что на одном узле представителей не бывает.

Шестой вид держался незамеченным на двух совпадениях сразу, и оба названы, чтобы не считать это удачей. Сторож множества (flang/test/failures.test.mjs) гонял голый parse, то есть программу БЕЗ отметок меры, — а FLANG_MEASURE бывает только у помеченной, значит сторож был слеп по построению. И числовой меры не было ни в одном примере каталога — теперь есть, measure.flang. А непокрытость шестого вида не бросалась в глаза потому, что оценка витков (bounded.mjs) рекурсию по числу не умеет: у стережённого мерой обработчика достижим ещё и предел витков, и надзор требуется всё равно. Это совпадение, а не свойство, и оно исчезнет в тот день, когда оценка научится числовой рекурсии.

Двух вещей, которые здесь стояли раньше, в множестве нет, и обе убраны по прогону, а не по рассуждению. Деления на ноль не бывает вовсе: числа — IEEE-754, и 1 делить на 0 даёт бесконечность, а не отказ. Неполный разбор и деление структуры не того варианта — ошибка компиляции (FLANG_MATCH_NOT_EXHAUSTIVE), значит до прогона такой отказ не доживает.

Раз множество замкнуто и вычислимо, надзор перестаёт быть только механизмом времени выполнения и становится ещё и анализом: непокрытый достижимый отказ — ошибка проверки (FLANG_UNCOVERED_FAILURE, flang/src/failures.mjs). Покрытым считается процесс, названный хоть в одном надзор … процесс «X» стратегия «…»; процесс с пустым множеством отказов надзора не требует вовсе. Достижимость считается по транзитивному замыканию вызовов и оценивается сверху: «может упасть» значит «в достижимом коде есть место, где отказ этого вида возможен», а не «есть вход, на котором он случится». У OTP такого не может быть: там множество отказов открыто, и сводить надзор не с чем.

При каких условиях замкнуто — и три места, где оно замкнутым не было

Слово «замкнуто» стояло выше без единой оговорки, и прогон показал, что без них это неправда: нашлись три места, где отказ был, а в множестве его не было. Все три закрыты, и каждое закрыто программой-уликой в flang/test/failures.test.mjs, а не абзацем здесь.

И четвёртое место, самое крупное: граница узла. FLANG_LINK_DOWN в анализ не входил вовсе, и потому обещание выше кончалось ровно там, где оно нужнее всего. Улика: программа из двух безотказных по тексту процессов (оба тотальны, оценка витков постоянная, частичных форм нет) проходила flang check с нулём диагностик; размещение разводило их по двум узлам; разрыв ронял представителя FLANG_LINK_DOWN при пустом списке решений надзора. Причина названа тут же и она не «забыли»: множество считается по достижимому КОДУ, а связь в коде не упоминается — её называет размещение, то есть данные узла. Закрыто седьмым видом и вторым входом у анализа: checkTypes(программа, { размещение }). Мимо этой проверки нельзя ни собрать (flang check --размещение), ни ПОДНЯТЬ узел — flang/conc/bin/node.mjs отдаёт проверке своё размещение и на непокрытой программе не поднимается.

И два условия, при которых утверждение верно. Они — часть утверждения, а не сноска к нему:

  1. программа прошла проверки. Множество считается по проверенному AST: неполный разбор, чужой тип аргумента встроенной формы, отклик не той формы — всё это снято проверкой типов, и потому в множестве их нет;
  2. вычислитель исправен. Планировщик кладёт в отказы код, который бросил вычислитель, а запасным берёт FLANG_INTERNAL — для того, что брошено не диагностикой вовсе. Ни он, ни «этого не должно случиться» коды видами отказа не считаются: они означают дефект реализации. Сторож на это настоящий, а не словесный — flang/test/failures.test.mjs гоняет все примеры с процессами по сетке семян и требует, чтобы каждый выданный код лежал в КОДЫ_ОТКАЗА.

Третьего условия нет, и в этом смысл: «замкнуто при исправном компиляторе и исправном рантайме» — ровно то, что обещает любое доказательство о языке, и ровно то, чего нет у Erlang, где множество открыто и при исправном рантайме.

Где замкнутость проверена: у эталона и у трёх целей печати

Сторож замкнутости живёт у эталона, и до 15 августа 2026 этим всё и кончалось: у целей печати ЗАМКНУТОСТЬ НЕ ПРОВЕРЯЛ НИКТО. Причин было две, и обе — недостача охвата, а не расхождение.

Первая: списки программ в emit-c-conc.test.mjs, emit-js-conc.test.mjs и emit-elixir-conc.test.mjs стояли строками, написанными руками, а measure.flang заведён позже — и ни в одну из трёх не попал. Вторая, тише: программы разбирались голым parse, тогда как отметку меры кладёт передний край на каждую команду, включая emit (bin/flang.mjs, markMeasure), а сторожа по ней ставит понижение (defunc.mjs, guardDescent) ДО первого напечатанного байта. Измерено: emitC(parse(measure.flang)), emitJs(…) и emitElixir(…) содержат слово FLANG_MEASURE НОЛЬ раз, помеченные — три (по одному на постусловие сторожа).

Закрыто у всех трёх целей: списки берутся из каталога flang/conc/examples, программы разбираются помеченными, и главная сверка каждой цели требует, чтобы FLANG_MEASURE ПОЯВИЛСЯ в её журнале. Последнее — не украшение: сверка двусторонняя, и снятая отметка теряется обеими сторонами разом (обе досчитывают делитель 1.7763568394002505e-15 и совпадают знак в знак), поэтому совпадение само по себе существования шестого вида у цели не доказывает.

Расхождений не нашлось ни у одной из трёх: C сошёлся побайтово на 3200 совпадениях журнала, JavaScript — на тех же 3200, а с BEAM FLANG_MEASURE пришёл из настоящего дерева супервизоров OTP. Видов отказа сверено у цели: C и JavaScript — четыре (FLANG_BUDGET_EXHAUSTED, FLANG_MAILBOX_FULL, FLANG_MEASURE, FLANG_STOPPED), Elixir — три (пятого у него быть не может: ящик BEAM неограничен, и печать ограниченного ящика честно отказывает).

У остальных пяти целей сверки конкурентности нет и быть не может: Go, Rust, Java, C# и Python процессов не печатают и отказывают FLANG_CONC_UNSUPPORTED, а то, что КАЖДАЯ цель каталога либо печатает процессы, либо отказывает, стережёт flang/test/emit-conc-refuse.test.mjs. Но шестой вид у них всё равно бывает — вне конкурентности: сторож меры печатается всеми восемью целями (измерено на Евклиде: три упоминания FLANG_MEASURE у каждой).

Что этот сторож у цели СРАБАТЫВАЕТ кодом и текстом эталона, проверено с 15 августа 2026 у ВОСЬМИ целей из восьми: у C (emit-c.test.mjs) и у семи корпусных — emit-go, emit-rust, emit-java, emit-csharp, emit-python, emit-elixir, emit-js. У каждой стоит тест «сторож меры: отказ у собранного … дословно тот же, что у интерпретатора»: сетка из десяти входов, и отказ приходит С МАШИНЫ — на 2⁵⁴+4 постоянный шаг ничего не меняет, и цель отвечает FLANG_MEASURE. Непроверенных целей не осталось: восьмая, JavaScript, закрыта последней.

Закрыта и вторая половина того же. Корпусные сверки шести целей разбирали корпус ГОЛЫМ parse, тогда как передний край кладёт отметку меры на каждую команду, включая emit. Сторожа меры несут 43 программы из 94 (две в stdlib, сорок одна в examples/leetcode), и печать от голого разбора не показывала сверке ни одного байта сторожей: FLANG_MEASURE ноль раз против 185. Байтов мимо сверки было 456 605 у Go, 453 652 у Rust, 419 957 у C#, 363 512 у Elixir, 351 107 у Java, 343 999 у Python и 278 565 у JavaScript. Теперь корпус грузится помеченным, и уравнивание оказалось бесплатным там, где меры нет: у 51 программы без числовой меры печать совпала с прежней ПОБАЙТОВО у всех семи целей.

У JavaScript дыра была шире прочих: корпусной сверки у этой цели не было ВОВСЕ — emit-js.test.mjs не читал ни stdlib/, ни examples/leetcode/, и весь корпус языка проходил мимо самой ходовой цели печати. Сверялись 13 моделей FTS (24 функции, 13 310 входов) и десяток программ, собранных из AST руками. Стало: 94 программы, 488 функций, 8058 сверенных входов и 1231 сверенный пример, расхождений с интерпретатором ноль, весь прогон файла — 32 теста за 7 с.

Сверка по совпадению этого не поймала бы никогда: она двусторонняя, а снятая отметка теряется обеими сторонами разом — интерпретатор зовёт то же понижение. Поэтому у каждой из семи стоит требование ПОЯВЛЕНИЯ: FLANG_MEASURE обязан быть в печати корпуса, и его отсутствие красит тест, даже когда обе стороны согласны. Изъятие на JavaScript показало это дословно: с голым parse главная сверка осталась ЗЕЛЁНОЙ (8057 входов вместо 8058, одна точка ушла в отказ по пределу шагов), а покраснел один-единственный тест — тот, что требует появления, с текстом «сторожа меры несут 0 программ корпуса из 94, а несли 43».

Сторожей меры ДВА, и до 16 августа проверен был тот, у которого два места

Всё, что написано выше, — про ПЕРВЫЙ из них. Понижение (src/defunc.mjs) ставит два разных сторожа, а не один с настройками:

Мест в корпусе у них 2 и 98 из ста (node flang/scripts/proof-ledger.mjs: 2 функции «постоянным шагом» и 64 «объявленной мерой» в 44 файлах). До 16 августа проверены у целей были ДВА МЕСТА ИЗ СТА.

С 16 августа сторож объявленной меры сверен у ВСЕХ ВОСЬМИ целей — семь корпусных плюс JavaScript. У каждой два теста: «сторож объявленной меры ПОЯВЛЯЕТСЯ … всеми тремя условиями» (у голого разбора Евклида FLANG_MEASURE ноль раз, у помеченного три, и каждое условие названо своим текстом) и «отказ у собранного … дословно тот же, что у интерпретатора» — сетка из 17 входов, и все три условия приходят С МАШИНЫ: «перестала быть целой» на НОД(1071.5, 462), «ушла ниже нуля» на НОД(−10, 3), «не убыла» на НОД(1, ∞).

Требование появления здесь весомее, чем у первого сторожа, и это измерено изъятием. Снятое третье постусловие (целость) даёт: интерпретатор и напечатанный JavaScript СОГЛАСНЫ на всех 17 точках сетки, НОД(1071.5, 462) у обоих равен 0.5, отказов у эталона 6 вместо 11, упоминаний FLANG_MEASURE в печати 2 вместо 3. Двусторонняя сверка на этом зелёная, а обещание тотальная ложно: цепочка 0.618, 0.382, 0.236 … не кончается никогда.

Второй сторож рантайма — частичная форма, и мест у него 278

FLANG_BUILTIN_ARGS — не только вид отказа, но и самый многочисленный сторож в напечатанном коде. Обходом дерева по закрытому списку ЧАСТИЧНЫЕ (src/failures.mjs) в корпусе насчитано 278 мест в 67 файлах: элемент 114, хвост 53, разделить 35, голова 34, подстрока 29, к числу 7, код символа 6, символ 0. Это против 100 мест сторожа меры.

С 16 августа он сверен у всех восьми целей, и список форм ему источник: тест «сторож частичной формы: все восемь форм отказывают у … кодом и текстом эталона» берёт формы из ЧАСТИЧНЫЕ, требует отказа у эталона на выбранном входе и отказа С МАШИНЫ тем же кодом и текстом — 8 форм × 8 целей. До этого формы покрывались сеткой, написанной руками внутри общего теста строковых форм, а прямое утверждение «с машины пришёл FLANG_BUILTIN_ARGS» стояло только у Java, C# и Elixir и только про символ, у которой мест в корпусе ноль.

У JavaScript сверх этого стоит отрицательный контроль, возможный только у него: рантайм там печатается по потребности, и программа без частичных форм обязана не нести ни одного их сторожа. Остальные семь печатают рантайм целиком.

Что свод считает с 16 августа, и чего он всё ещё не говорит

Здесь стояло, что proof-ledger.mjs считает сторожами рантайма ТОЛЬКО сторожа меры. Это перестало быть правдой: свод печатает обе породы порознь и главное число считает вычитанием функций, а не мест.

что проверяется во время работы (обещание «тотальная» — «завершится или честно откажет»):
  сторож меры:      100 мест у 66 функций
  частичная форма:  197 мест у 124 функций (у тотальных 195 у 122)
  всего:            297 мест
тотальных, у которых во время работы не проверяется НИЧЕГО: 2046 из 2199
  из них не проверяется ничего и у тех, кого они зовут: 1826

Второе число здесь не украшение: «во время работы» читается про работу целиком, а работа функции — это и работа тех, кого она зовёт. Тотальная функция без единого места в своём теле, зовущая «Голова списка», проверку встретит. Считается обратной достижимостью от мест проверки по графу вызовов (call и fnref), оценкой сверху — той же, что у множества отказов процесса.

Мест частичных форм было 280 (а по исходникам 278: два места добавляет отметка меры у «Полоса слоя», examples/leetcode/733-flood-fill.flang, чья мера читает элемент ряд в сетка; мера считается на каждом витке, значит это места проверки — свод считает то, что ИСПОЛНЯЕТСЯ).

Разделение мест на два случая измерено. Тотальная функция зовёт голова: либо (а) анализ доказал непустоту и проверки в рантайме нет, либо (б) не доказал и проверка есть. Здесь стояло, что случая (а) в корпусе НОЛЬ, потому что анализа непустоты в языке нет вовсе. Обе половины перестали быть правдой: анализ есть (src/types.mjs, длинаНиз), и случай (а) — это 83 места из 280 по корпусу и 161 из 406 по связанным программам.

Измерено не чтением кода, а счётом, и счёт остался тем же: места частичных форм в связанных программах всех 162 файлов ПОСЛЕ дефункционализации против вызовов стерегущих помощников в телах напечатанных функций. Равенство теперь трёхчленное — 406 = 161 доказанное + 245 стерегомых, — и требуется оно по каждому файлу и каждой форме (flang/test/proof.test.mjs, «место частичной формы становится проверкой в печати ТОЛЬКО там, где не доказано»). Тот же тест требует, чтобы у каждого вызванного стерегущего помощника в напечатанном рантайме стоял FLANG_BUILTIN_ARGS, а у каждого доказанного его НЕ БЫЛО.

Снимаются четыре формы из восьми, и отбор не произвольный: у голова, хвост, код символа и разделить условие отказа одно и то же — «длина ноль». элемент, символ и подстрока требуют ДВУСТОРОННЕЙ границы номера — это другой анализ, и он не сделан; к числу частична неустранимо.

Прежнее заглавное число — «2096 тотальных из 2196 во время работы не проверяют ничего» — было неверно дважды: 2096 это 2196 минус 100, то есть вычитание МЕСТ из ФУНКЦИЙ (функций со сторожем меры 66), и молчало оно о частичных формах. Верное число 2046: 66 функций со сторожем меры плюс 122 с частичной формой минус 35 общих — 153 функции с проверкой (тотальных 2199).

Остаток непустоты разложен числами, и он двух пород. Из 128 мест четырёх снимаемых форм не сняты 45, и они распадаются так:

почему не снятоместчем закрывалось бы
аргумент — ПАРАМЕТР функции30тип в подписи: «непустая строка», «непустой список»
цепочка хвост (хвост …) длиннее известной длины9нижняя граница у входа, то есть тот же тип
имя из образца или цикла3ничего простого: хвост от хвоста короче на два
результат вызова, ветви если, выражение-разделитель3тип у результата функции

Тридцать мест из сорока пяти — это ОДНА развилка, и она требует решения владельца, потому что новый тип в подписи есть поверхность совместимости. Образцы прямо из корпуса: «Заменить всё» (core/json.flang) принимает что: строка и делит по ней — доказать непустоту внутри нельзя, её обязан обещать вызывающий; «Дальше по ряду» (rosetta/levenshtein-distance.flang) принимает пред: список числа и берёт голову — то же самое. Сегодня уточнение живёт ВНУТРИ одной функции, ровно как отрезок [1, 10] у чисел: вывести его можно, написать в объявлении — нет.

Второй остаток, вчетверо больший, снимается не непустотой вовсе. элемент (116 мест), подстрока (29) и символ (0) отказывают не на пустом, а на номере вне границ, и им нужна ДВУСТОРОННЯЯ граница — «номер не меньше единицы и не больше длины». Замер на корпусе: местным рассуждением (сравнение номера с длина в том же если) закрывается около десятой части, всё остальное держится на отношении между двумя именами, пришедшими из разных вызовов (лево < право ≤ длина элементы в двоичном поиске и двух указателях). Это другой анализ, и меньше его не сделать. к числу (7 мест) неустранима навсегда.

Остаток, который здесь ОСТАЁТСЯ. Счёт живёт в своде, а не в ведомости модуля (src/proof.mjs): flang check --proof про частичные формы по-прежнему молчит и говорит про тотальную функцию с голова «сторожа нет». Причина названа и измерима: ведомость модуля печатается ещё и на самом flang (flang/self/proof.flang) и сверяется с эталоном ПОБАЙТОВО, а обхода дерева по списку частичных форм у близнеца нет ни одной функции. Близнец и без того отстаёт: из 21 теста flang/test/self-proof.test.mjs красных 7, и все семь — про то, что он не печатает раздела утверждений (это чинит work/dev-claims). Новое поле в строке ведомости увеличило бы отставание, не закрыв ни одного красного.

Заодно измерена цена, которой раньше никто не называл: корпусные сверки Go, Rust, Python и Elixir на полном корпусе НЕ ДОСЧИТЫВАЮТСЯ. Встают они все на одной программе — examples/leetcode/022-generate-parentheses.flang — и на одной точке сетки: испорченный аргумент н = 42, а это Каталан(42) ≈ 6·10²². Предел шагов там стоит и работает; крупнее оказался сам шаг. Измерено двоичным поиском по наименьшему бюджету: «Правильные скобки» от 7 требуют 268 289 шагов у эталона и 2055 у напечатанного кода — у ВСЕХ ВОСЬМИ целей знак в знак, — то есть один и тот же maxSteps покупает напечатанному коду в 130,6 раза больше применений функций. Само по себе это одностороннее и безопасное расхождение, но цена одного применения тогда не была ограничена: добавить копировал весь список за один шаг у семи движков из девяти, работа выходила квадратичной, и 130,6 превращалось в свой квадрат. ГЛАГОЛЫ ЗДЕСЬ В ПРОШЕДШЕМ ВРЕМЕНИ НАРОЧНО: к сборке work/svodka2 продление за постоянное время сделано у C, Go, Rust, Java, C#, Python и JS, у Elixir его заменяет очередь Окасаки, а у вычислителя оно тоже есть (work/interpret-append, work/limits-*). Числа ниже сняты ДО этой починки и описывают, чего стоила копия. Отсюда на одной и той же точке при одном и том же бюджете в 1 600 000 шагов: 450 мс у эталона, 663 мс у цели C — и 423 436 мс у Go, то есть в 639 раз больше (полная таблица по восьми целям — flang/SPEC.md, «Стоимость встроенных форм»). Прогон сверки с прежним бюджетом 5 000 000 не досчитывался за 25 минут и снимался по сроку.

Это закрыто. Две такие точки названы поимённо в flang/test/corpus-grid.mjs (УБЕГАЮЩИЕ), и напечатанный код спрашивается на них бюджетом, который исчерпывает сам; требование при этом ужесточено — было «если он тоже остановился, то тем же кодом», стало «обязан остановиться». Список стережёт flang/test/corpus-runaway.test.mjs в обе стороны. Все восемь целей сверяются на всех 94 программах: 8797 входов плюс две убегающие точки.

У восьмой цели, JavaScript, ту же точку ДОЖДАЛИСЬ до конца — и это меняет формулировку с «встаёт» на «стоит дорого». Напечатанный JS дал на ней ровно тот же отказ, что и эталон: FLANG_RECURSION_LIMIT, «функция «Строить скобки» исчерпала лимит шагов (5000000) на глубине вызовов 43», знак в знак. Только интерпретатор пришёл к нему за 1378 мс, а печать — за 1 290 026 мс, то есть за 21 минуту 30 секунд. Дорог не предел, а ход к нему: шаг печати ДОРОЖАЕТ по ходу, потому что накопитель копируется на каждом шаге, — 10 000 шагов идут 7 мс, 100 000 — 73 мс, 200 000 — 729 мс, 400 000 — 4257 мс, удвоение шагов даёт ушестерение времени. Значит у остальных четырёх целей «не досчитывается» — тоже про цену, а не про зависание, и чинится оно ценой шага (ветка work/corpus-runnable), а не пределом.

Сверка JavaScript при этом досчитывается: исключены не программа, а ДВЕ ТОЧКИ (обе функции программы принимают н), названные поимённо и с зубами — точка обязана найтись в сетке, а интерпретатор на ней обязан отказать пределом шагов, иначе тест краснеет. Из 56 точек этой программы в прогон идут 48, и JavaScript сверяется на всех 94 программах корпуса, а не на 93.

У JavaScript названа и своя, вторая цена, которой нет у остальных семи: чужой ВАРИАНТ на границе напечатанного модуля непредставим, если модуль не печатает ни одного конструктора варианта. Вариант там — экземпляр собственного класса модуля, а класс наружу не выходит; интерпретатор получает вариант, модуль — то, что зовёт записью, и тексты расходятся дословно: «разбор не покрывает значение Нет такого» против «разбор не покрывает значение запись {variant, fields}». Расходятся не движки, а два разных входа: у остальных семи целей значения приезжают через JSON, где имя варианта строка и может быть любым, а здесь вызов идёт напрямую. Таких точек 739 из 8799, все — чужой Нет такого у программ без сумм типов; счёт им ведётся в отчёте теста и ограничен сверху, растущая дыра краснеет. Там, где сумма объявлена (или её завело понижение высшего порядка), чужой вариант строится настоящим конструктором модуля и точка сверяется как у всех. Закрыть остаток мог бы экспорт конструктора варианта из напечатанного модуля; сегодня его нет — и по той же причине вариант, полученный от одного напечатанного модуля, второй такой же модуль считает записью.

Здесь контракт недоговаривает в двух местах, и оба закрыты в шаге 3: какая причина нормальная (ответ — «норма») и кому именно передать выше, если надзор плоский (ответ — надзору над надзором, для чего понадобилась строка надзор «X» стратегия «…»). Подробности — в разделе «Что сделано в шаге 3».

Чего надзор не делает: не ловит нехватку памяти в напечатанном C и не переживает падение процесса операционной системы. Изоляция здесь логическая, а не аппаратная. В BEAM она тоже логическая, но там между процессом и памятью стоит виртуальная машина; в напечатанном C не стоит ничего. Это записано в границе честности ниже.

Почтовый ящик: отложить, а не выбирать

BEAM даёт выборочный приём: receive перебирает ящик и берёт первое подходящее. Это удобно и это же — известный источник патологий: ящик, просматриваемый целиком на каждом сообщении, превращает работу в квадратичную, и понять это по коду нельзя.

Здесь обработчик всегда получает голову очереди. Если сообщение сейчас не к месту, он возвращает действие отложить — и оно уходит за уже пришедшие. Стоимость видна: отложенное сообщение будет предъявлено снова, и если обработчик откладывает всё подряд, это видно в коде и измеримо счётчиком.

Компромисс осознанный: часть протоколов, которые в Erlang пишутся выборочным приёмом в три строки, здесь потребуют состояния «чего я жду». Взамен цена протокола перестаёт быть невидимой.

Воспроизводимость: чередование по семени

Планировщик принимает семя. Одна и та же программа с одним и тем же семенем даёт одно и то же чередование — до конца, включая порядок доставки и срабатывание таймеров.

Это не удобство отладки, а условие того, чтобы конкурентный код проверялся тем же аппаратом, что и остальной язык: примерами в исходнике и сеткой входов. Пример конкурентной программы — это семя плюс ожидаемый итог; сетка — это перебор семян. Гонку, найденную на семени 4172, можно предъявить и повторить, а не описать словами «иногда падает».

Виртуальное время идёт вместе с семенем: таймер на 5000 миллисекунд в проверке не ждёт пять секунд, а срабатывает тогда, когда планировщику больше нечего делать. Настоящие часы подставляет хозяин при настоящем запуске.

Свою BEAM писать не надо — надо в неё печатать

Заказано «такой же крутой параллелизм, как в Elixir», и параллелизм этот действительно даёт BEAM: вытеснение по счёту редукций, куча и сборщик у каждого процесса по отдельности (поэтому нет глобальной паузы), процесс в несколько сотен слов, планировщик на каждое ядро, распределённость и горячая загрузка.

Написать это заново — многолетняя работа команды, и результат в лучшем случае догонит BEAM на её же поле. Но у языка уже есть цель печати Elixir. Значит короткий путь к настоящей BEAM не «сделаем свою», а «напечатаем в эту»:

процесс flang → процесс BEAM (GenServer) надзор flang → супервизор OTP отправить → send почтовый ящик → почтовый ящик процесса

Всё, что перечислено абзацем выше, достаётся при этом даром и в настоящем, а не приблизительном виде. Это не отказ от собственной машины: «потом сделаем и vm» остаётся в силе, но своя машина — способ дать конкурентность целям, у которых нет своей, а не условие, без которого ничего не работает.

ЦельНа чём идётЧто даёт
Elixir ✅процессы BEAM и супервизоры OTPвытеснение, распределённость, горячая загрузка, миллионы процессов
C ✅ (наполовину)планировщик в рантайме, один поток, чередование по семенивоспроизводимость и побайтовая сверка с эталоном; параллелизма нет
Goгорутина и канал на процесснастоящий параллелизм, планировщик среды
Java, C#пул потоков и очерединастоящий параллелизм
Rustпул потоков в рантайменастоящий параллелизм
Pythonпотоки; параллелизм ограничен GILчестно записать ограничение
JavaScriptцикл событий, один потокконкурентность без параллелизма — записать честно

Галочек в таблице две, и вторая — половинчатая нарочно. В шаге 4 напечатан Elixir; в шаге 6 у C появился планировщик, но ПРОВЕРОЧНЫЙ: один поток и чередование по семени. Обещанного этой же таблицей пула потоков в нём нет, и цена, которую он стоил бы, измерена — раздать пробег другому потоку в четыре–четырнадцать раз дороже, чем его выполнить (шаг 6). Строки без галочки — намерение, а не сделанное.

Цели без галочки ОТКАЗЫВАЮТ, а не печатают половину

(Замер ниже сделан, когда таких целей было шесть. Их пять: JavaScript получил свой планировщик и печатает процессы. Замер датирован, а не переписан.)

Здесь стояло «остальные шесть целей печатают обработчики как обычные функции, но процессов из них не делают», и написано это было как оговорка. Оговорка описывала худший из возможных исходов: печать кончалась кодом 0, целевой компилятор принимал результат, программа запускалась и не делала ничего из написанного. Замер на supervision.flang — имя процесса «Работник» не встречалось в напечатанном ни разу у всех шести, а имя «Учётчик» встречалось РОВНО ОДИН раз и только строковым литералом адреса в теле обработчика (Value: rt.Text("Учётчик")). Ни процесса, ни состояния, ни ящика, ни надзора: processes|supervisors в c.mjs встречается 15 раз, в elixir.mjs 10, в остальных шести — ноль.

Теперь печать программы с процессами в цель без планировщика — отказ FLANG_CONC_UNSUPPORTED (flang/src/conc.mjs, требуетПланировщика). Отказ стоит в самих бэкендах, а не в bin/flang.mjs: бэкенды — библиотека, их зовут напрямую из Node, и отказ в команде закрыл бы только командную строку. Программа без процессов печатается во все восемь целей по-прежнему — отказ про процессы, а не про цель, и сторож (flang/test/emit-conc-refuse.test.mjs) проверяет обе половины по каталогу src/emit, а не по списку.

Две цели из восьми параллелизма не дают вовсе, и это не повод притворяться: JavaScript однопоточен, у CPython есть GIL. Обещать «одинаково везде» здесь нельзя, можно обещать «одинаковый набор возможных исходов» — а он и правда одинаков, потому что определён чередованием, а не числом ядер.

Порядок работ поэтому такой: сначала поверхность и планировщик эталона (на нём проверяется всё), затем Elixir — как первая настоящая цель, а не как оптимизация, — и лишь потом планировщик в рантайме C для остальных.

Отдельно про вытеснение. Родной процесс BEAM вытесняется посреди обработчика, поток в пуле — нет. Разница наблюдаема только по задержке, но не по исходу: обработчик обязан завершиться (тотальность доказана либо запас витков назван), а состояние принадлежит одному процессу. Ни одно чередование, возможное на BEAM, не выходит за пределы чередований атомарных пробегов — потому что чужого состояния обработчику всё равно не видно.

Что сделано в шаге 1 — и чем оно отличается от написанного выше

Сделано: поверхность (flang/src/lexer.mjs, flang/src/parser.mjs), проверки (flang/src/types.mjs), планировщик эталона и словарь действий (flang/src/conc.mjs), примеры конкурентных программ (flang/conc/examples/*.flang), тесты (flang/test/conc.test.mjs).

Не сделано и в этом шаге не планировалось: печать в Elixir и C, супервизоры в рантайме, распределённость.

Поверхность разошлась с контрактом в четырёх местах, и все четыре — из-за имён

Ключевое слово запрещает одноимённую переменную во всех файлах репозитория. Это уже стоило переделки: слово «символы» сломало два файла, и разложение строки пришлось переписать на форму из двух частей разложить … на символы. Поэтому каждое слово из контракта проверялось grep -rn по .flang и .fts, и четыре не прошли.

В контрактеВ языкеПочему
начальное «пустой счёт»начинает с «пустой счёт»«начальное» — переменная в flang/core/lexer.flang, четыре места (пусть начальное равно запись …). начинает с — уже занятое слово FTS с тем же смыслом
порог 3 отказа за 5000 миллисекундпорог отказов 3 за 5000 миллисекунд«порог» — параметр в flang/stdlib/optional.flang. Фраза из двух слов именем быть не может, одиночное слово остаётся именем
за «Счётчик» стратегия «…»процесс «Счётчик» стратегия «…»«за» — слишком короткий и слишком частый предлог, чтобы занимать его ради одной строки. процесс в надзоре уже есть
витков, отказа, миллисекундслова-поясненияне резервируются вовсе: парсер их пропускает. Занимать четыре существительных ради читаемости одной строки не надо

Начальное состояние — это функция без параметров нужного типа, а не литерал: перезапуск обязан давать ровно то же значение, что и первый запуск, и функция без параметров — самая короткая запись этого, которая при этом проверяется по типу.

Действия: что есть, чего нет и почему

Сумма «Действие» вводится языком, а не пользователем: парсер приписывает её программе, как только в файле есть хоть один процесс. Иначе две программы называли бы отправку по-разному, и планировщик перестал бы быть общим.

ДействиеЕстьЧто вышло
отправить кому чтодаадресат — имя объявленного процесса литералом; тип груза сверяется с принимает адресата
через сколько отправить кому чтодатаймер по виртуальному времени
остановить почемудапроцесс завершает себя
отложитьдасообщение уходит в конец ящика
продолжитьда, но без полясм. ниже
породить вид, имя, чтода (шаг Б1)вид — имя объявленного процесса литералом; тип первого сообщения сверяется с принимает вида

«породить» сделан шагом Б1, и сделан не так, как был записан. В контракте он «создаёт процесс и возвращает его имя», и ровно на этом слове стоял: вернуть что-либо описанное действие не может по построению — описывает его обработчик, а исполняет планировщик, и пути от планировщика обратно в чистую функцию нет.

Развязка не в том, чтобы научиться возвращать, а в том, чтобы не возвращать: имя порождённому даёт родитель. Адрес и так строка, значит нового вида значений не появляется — ни таблицы pid'ов, ни поколений, ни вопроса «что значит pid мёртвого»; родитель может писать порождённому в том же списке действий, потому что имя придумал сам. Цена названа: уникальность имени — дело программы, занятое имя — отказ порождающего FLANG_NAME_TAKEN (так же устроен register/2 в BEAM), а негодное — FLANG_BAD_NAME: адресом годится не всякая строка, ни пустая, ни с нулевым байтом внутри.

Второе: породить несёт первое сообщение, а не начальное состояние. Обработчик зовётся на сообщение, поэтому процесс, рождённый с пустым ящиком, не побежал бы никогда; начальное состояние берётся у вида, и берётся то же самое вычисленное значение, что при первом запуске.

Открытый вопрос «Именование процессов» этим закрыт наполовину, и вторая половина названа прямо: имя порождённого — обычная строка, но АДРЕСАТ отправить по-прежнему обязан быть литералом, поэтому писать порождённому можно только тем, что дано ему при рождении. Работа уезжает первым сообщением, ответ приходит процессу с объявленным именем.

«продолжить» сделан без поля «с чем». Отклик уже несёт новое состояние. Второе поле означало бы два источника правды об одном и том же, и при расхождении планировщику пришлось бы выбирать — то есть решать произволом. Поэтому продолжение — это состояние из отклика, а действие говорит только «я не доделал»: сообщение возвращается в голову ящика (в отличие от отложить, которое кладёт в конец), и планировщик получает управление.

Адресат обязан быть литералом. Проверка «необъявленный процесс в отправить — ошибка» требует, чтобы имя было известно до запуска. Вычисленное имя проверить нечем, поэтому оно отвергается. Это ограничение шага 1; снимет его решение открытого вопроса про именование процессов.

Тип груза в словаре — джокер. Полиморфизма в языке нет, «сообщение любого типа» иначе не выражается. Потеря меньше, чем кажется: проверка типов сверяет груз с принимает адресата по имени процесса, то есть ровно там, где адресат известен.

Имена действий строчные — как в контракте. Цена: случай «отправить» в разбор прочитается как связывание любое, а не как вариант (парсер отличает вариант от связывания по прописной букве). Разбирать действия надо явной формой случай вариант «отправить», которой пользуется stdlib. Обработчики действия только строят, поэтому в шаге 1 это не мешает, но написать об этом надо.

Надзор разбирается и проверяется, но не действует

надзор даёт узел AST; проверка требует, чтобы под надзором стояли объявленные процессы и чтобы стратегия была одной из трёх. Планировщик эталона стратегий не применяет: упавший процесс остаётся остановленным. Перезапуска, порога и эскалации в шаге 1 нет — это шаг 3 порядка работ.

Это положение шага 3 отменено: надзор действует, см. ниже. Абзац оставлен как запись о том, что было сделано в шаге 1, а не как описание нынешнего поведения.

Планировщик: что именно определено семенем

Единственный источник недетерминизма — выбор процесса из очереди готовых. Всё остальное определено жёстко: порядок ящика, порядок действий отклика, порядок срабатывания таймеров при равном сроке, порядок перечисления процессов. Отсюда обещание «одно семя — один журнал доставок, побайтово», и оно проверяется повторным прогоном после повторного разбора исходника.

Виртуальное время идёт двумя способами: плюс единица на каждый пробег обработчика и скачок к ближайшему таймеру, когда готовых нет. Первого в контракте не было, и без него таймер не сработал бы никогда в программе, которая всё время занята — например, откладывает сообщения по кругу.

Определённые исходы вместо зависаний, все три:

Что случилосьЧто видно
нечего делать, таймеров нетисход: "покой" — этим же обнаруживается взаимная блокировка
нетотальный обработчик исчерпал запасзапись журнала «запас исчерпан», код FLANG_BUDGET_EXHAUSTED, процесс остановлен, состояние осталось прежним
работа не кончается (например, вечное откладывание)исход: "предел пробегов" после maxTurns пробегов

Прогон — это пример

прогон «два прибавления и доклад»
  семя 4172
  дано «Счётчик» принимает (вариант «прибавить» с «сколько» равным 2)
  ожидается «Счётчик» равен (запись «Счёт» с «всего» равным 5)

Слова дано, ожидается и равен — те же, что у обычного пример. Прогоны идут в тот же вывод flang test, что и примеры функций: конкурентность не должна становиться местом, где проверки заканчиваются.

Чего у прогона нет НА МОМЕНТ ШАГА 1: перебора семян и сверки журнала с напечатанным кодом — печати тогда не было вовсе. Первое сделано в шаге 5 (семя от N до M), второе — в шаге 4.

Новые коды диагностик

КодКогда
FLANG_PROCESSобъявление процесса, надзора или прогона не той формы
FLANG_UNKNOWN_PROCESSадресат отправить, поднадзорный или получатель в прогоне не объявлен
FLANG_HANDLER_NOT_TOTALнетотальный обработчик без запаса витков
FLANG_BUDGET_EXHAUSTEDзапас витков исчерпан во время прогона
FLANG_STOPPEDпроцесс остановил себя по причине, отличной от «норма» (шаг 3)
FLANG_UNCOVERED_FAILUREу процесса есть достижимый отказ, а надзора над ним нет (шаг Г1)
FLANG_INITIAL_FAILUREначальное состояние процесса может отказать; надзор такое не покрывает
FLANG_CONC_UNSUPPORTEDпечать в цель, у которой планировщика нет: процессы напечатать нечем, а напечатать программу без них значило бы собрать не то, что написано

Что сделано в шаге 3 — надзор в действии

Сделано: стратегии применяются по-настоящему (flang/src/conc.mjs), порог отказов считается по виртуальному времени, «передать выше» доходит до надзора ступенью выше или до остановки всей программы, прогон умеет проверять исход надзора, примеры — flang/conc/examples/supervision.flang и flang/conc/examples/escalate.flang, проверки — flang/test/conc.test.mjs.

Не сделано и в этом шаге не планировалось: печать конкурентности в целевые языки (шаг 4), супервизоры в рантайме C, распределённость.

Отказ: виды перечислены выше, и нормальную причину пришлось назвать словом

Отказом считается ровно то, что названо контрактом выше: исчерпание запаса витков, ошибка выполнения и явное остановить с причиной, отличной от нормальной. Но контракт не назвал, какая причина нормальная, а без этого имени «упал» и «закончил работу» — одно и то же событие: оба выглядят как остановить с какой-то строкой.

Нормальная причина — строка «норма», одна на всю модель. Взято у BEAM (exit(:normal) не отказ, любая другая причина — отказ): соглашение чужое, но проверенное, а заводить своё там, где чужое работает тридцать лет, незачем. Если каждая программа назовёт нормальную остановку по-своему, надзор перестанет быть общим — ровно та же причина, по которой сумма «Действие» вводится языком, а не пользователем.

Про «ошибку выполнения» надо сказать отдельно: в программе, прошедшей проверки, их почти не бывает. Деление на ноль даёт значение IEEE-754, а не ошибку; разбор суммы исчерпывающий, потому что этого требует проверка типов. Реально достижимы выход за границу строки или списка (символ 99 в "12") и пустой разделитель в разделить. Это хорошая новость про язык и неудобная — про проверку: третий вид отказа проверяется одним тестом на символ, а не примером в исходнике.

Перезапуск точен, и это видно в коде

Начальное состояние вычисляется один раз, при заводе процесса, и на перезапуске подставляется то же самое значение. Не «пересчитанное так же» — то же самое: функция без параметров чиста, поэтому разницы нет, но так обещание контракта становится буквальным, а не рассуждением. Дочищать нечего, потому что чистить нечего: состояние неизменяемо, наполовину применённой мутации не бывает.

Перезапуск трогает только состояние. Ящик остаётся, и это осознанное расхождение с BEAM, где ящик умирает вместе с процессом. Причина в устройстве: здесь сообщение — значение в общей арене, а не копия в чужой куче, и терять уже принятые сообщения из-за того, что обработчик споткнулся об одно из них, значило бы терять данные без технической нужды. Сообщение, на котором процесс упал, при этом не возвращается: оно снято с ящика до пробега, и вечного цикла «упал — подняли — упал на том же» не выходит. Очередь из трёх ядовитых сообщений упрётся в порог отказов — то есть будет замечена, а не проглочена.

«Выше» потребовало строки, которой в контракте не было

Контракт называет стратегию передать выше, но нигде не говорит, кому. Надзор в контракте плоский: он перечисляет процессы. Передавать было бы некому, и стратегия оказалась бы синонимом «остановить».

Поэтому поверхность выросла на одну строку — надзор «X» стратегия «…» внутри надзора:

надзор «Завод»
  надзор «Смена» стратегия «перезапустить»
  процесс «Касса» стратегия «передать выше»

Новых слов это не заняло вовсе: надзор и стратегия уже ключевые. Устройство получилось то же, что в OTP: супервизор надзирает за супервизорами.

Отсюда два решения, которые контракт не предопределял:

Процесс, не стоящий ни под каким надзором, остаётся остановленным — как в шаге 1.

Порог: N+1 в скользящем окне, и окно измеряется пробегами

порог отказов N за M миллисекунд иначе «стратегия» работает так: пока отказов в окне не больше N, применяется стратегия, названная за процессом; отказ номер N+1 в том же окне уходит на запасную. Окно скользящее — отказы старше время − M в счёт не идут, иначе «три отказа за час» и «три отказа за всё время работы» были бы одним утверждением.

Чтобы окно двигалось, пришлось изменить правило времени из шага 1: пробег, кончившийся отказом, теперь тоже стоит единицу виртуального времени. Раньше он не стоил ничего, и у процесса, который только и делает, что падает, окно стояло на месте: «три отказа за 5000 миллисекунд» означало бы «три отказа за всю жизнь».

Здесь же самое неприятное расхождение шага 3 с поверхностью. Слово «миллисекунд» в проверочном режиме неправда. Единица виртуального времени — это один пробег обработчика (плюс скачок к сроку ближайшего таймера, когда делать нечего), а не миллисекунда, и порог 2 за 5000 в проверке означает «2 отказа за 5000 единиц», где единица набегает пробегом. В рабочем режиме единицей станут настоящие часы, и одна и та же программа поведёт себя иначе. Убирать слово из поверхности не стали — в рабочем режиме оно верно, — но обещание «проверочный режим не показывает исходов, которых не бывает в рабочем» на порог отказов не распространяется, и это записано здесь, а не подразумевается.

Счётчик отказов после срабатывания не сбрасывается: все следующие отказы в том же окне тоже идут по запасной стратегии. Это решение по умолчанию, а не вывод из контракта; выбрано потому, что обратное («сработал — забыли») позволило бы процессу падать вдвое чаще порога и ни разу его не превысить.

Прогон научился спрашивать про надзор

Одного состояния не хватило. «Состояние равно начальному» верно и для процесса, которому вообще ничего не приходило, — по нему перезапуск не отличить от бездействия. Поэтому у ожидается появилась вторая форма:

прогон «упал и вернулся ровно к началу»
  семя 1
  дано «Работник» принимает (вариант «подавиться» с «почему» равным "вход")
  ожидается «Работник» стратегия «перезапустить» 1 раз
  ожидается «Работник» равен (запись «Счёт» с «всего» равным 0)

Новых слов снова ноль: стратегия уже ключевое, «раз» — слово-пояснение. Проверка типов требует, чтобы стратегия была одной из трёх, а процесс стоял под надзором: ожидание решения о процессе, за которым никто не следит, не сбудется никогда, и сказать об этом лучше до прогона.

Чего прогон не умеет: назвать исход всей программы. Формы ожидается исход «отказ дошёл доверху» нет, потому что все ожидания прогона — про процесс, а исход про процесс не скажешь. В escalate.flang остановка доверху проверяется косвенно — по единственному решению «передать выше» и по тому, что состояния остались теми, какими были в момент отказа; прямо она проверяется тестом flang/test/conc.test.mjs, а не примером в исходнике. Это дырка в том самом обещании «конкурентность проверяется тем же аппаратом, что и остальной язык», и закрывать её надо отдельным решением о форме, а не поспешным словом.

Новые проверки типов

За объявлением встало поведение, и от этого проверок стало больше — ровно на те вопросы, на которые планировщик обязан знать ответ до запуска:

Что отвергаетсяПочему
процесс под двумя надзорамиоб одном отказе было бы два решения, и выбирать нечем
надзор под двумя надзорами«передать выше» вело бы сразу в две стороны
круг в надзоре«передать выше» ходило бы по кольцу, не кончаясь исходом
надзор за необъявленным надзоромпередавать выше было бы некому
в прогоне: стратегия для процесса без надзораожидание не сбудется никогда

Чего в шаге 3 нет

Что сделано в шаге 4 — печать процессов в Elixir

Сделано: процесс flang печатается в GenServer, надзор — в дерево супервизоров OTP, действия исполняются после возврата обработчика, прогон конкурентной программы идёт на настоящей BEAM (flang/src/emit/elixir.mjs, flang/src/emit/elixir/flang_conc.ex, flang/src/emit/elixir/flang_cli.ex), и напечатанное сверяется с эталоном по НАБОРУ исходов на сетке семян (flang/test/emit-elixir-conc.test.mjs).

Не сделано и в этом шаге не планировалось: печать конкурентности в остальные семь целей, планировщик в рантайме C, распределённость, породить.

Порядок работ внизу этого документа при этом поправлен: печать в Elixir была шестым пунктом, а стала четвёртым. Основание — не удобство, а прямое противоречие внутри самого контракта: раздел «Свою BEAM писать не надо» говорит «сначала поверхность и планировщик эталона, затем Elixir — как первая настоящая цель, а не как оптимизация, — и лишь потом планировщик в рантайме C для остальных», а список работ ставил C впереди. Список был неправ.

Отображение: что во что

flangElixir/OTP
процессGenServer, зарегистрированный под именем процесса
состояние процессасостояние GenServer
обработчикчистая функция flang, вызываемая из handle_info/handle_cast
почтовый ящикпочтовый ящик процесса BEAM
отправитьsend
отложитьsend самому себе — в хвост, то есть за уже пришедшие
продолжить{:noreply, …, {:continue, …}}
через … отправитьProcess.send_after
остановить «норма»{:stop, :normal, …}
остановить иначе{:stop, {:flang_stopped, …}, …} — отказ
надзордерево супервизоров OTP (см. ниже)
перезапуститьребёнок :transient
остановитьребёнок :temporary
передать вышеребёнок под супервизором с интенсивностью 0
порог отказовmax_restarts/max_seconds супервизора

Процессы, надзоры и прогоны печатаются данными — функцией conc_plan/0 в модуле программы, — а дерево из них строит рантайм. Иначе одно объявление надзора размазалось бы по трём напечатанным модулям, и правка одной строки в исходнике перестала бы читаться как правка одной строки на выходе.

Три супервизора OTP на один надзор flang

Это главное, что пришлось придумать, и придумывать пришлось потому, что OTP и flang делят одно и то же решение по-разному. Стратегия в flang названа за каждым ребёнком; у OTP по ребёнку настраивается только вид перезапуска (:permanent, :transient, :temporary), а «уйти наверх» — свойство супервизора: он уходит, лишь исчерпав свою интенсивность перезапусков. Выразить «передать выше» настройкой ребёнка нечем.

Отсюда устройство, которое иначе выглядело бы нагромождением:

S_верх (интенсивность 0 — любой перезапуск здесь означает «выше») ├── S_выше (интенсивность 0) ← дети «передать выше» ├── S_снова (интенсивность = порог) ← дети «перезапустить» └── дети «остановить», :temporary, напрямую

Ребёнок «перезапустить» падает — его поднимает S_снова, до S_верх это не доходит. Ребёнок «передать выше» падает — S_выше обязан его перезапустить, а перезапускать ему нельзя, и он умирает; вслед за ним умирает S_верх, и отказ оказывается ровно там, где ему положено: у надзора ступенью выше. Ребёнок «остановить» — :temporary: его смерть перезапуском не считается и никого не будит. Корень программы — тоже супервизор с интенсивностью 0: смерть надзора верхней ступени останавливает всё, и это исход «отказ дошёл доверху».

Две мелочи, которые стоили бы всего, если бы их не заметить:

«Продолжить» совпало с BEAM точно, и это редкость

Контракт требует от продолжить: сообщение возвращается в голову ящика, а планировщик получает управление. На BEAM в голову собственного ящика положить нечего — ящик читается с головы и пишется в хвост. Но handle_continue выполняется перед любым сообщением из ящика и уже после возврата из обработчика: процесс отдал управление, а очередь его не обогнала. Это ровно требуемое, без единой подпорки.

Ящик пришлось спасать руками

Контракт решает иначе, чем BEAM: перезапуск трогает только состояние, ящик остаётся. На BEAM ящик умирает вместе с процессом, поэтому обещание печатается явно: процесс ловит выходы, в terminate/2 вычерпывает остаток ящика в хранилище, следующее воплощение забирает его в init/1 и досылает себе. Порядок сохраняется; сообщение, на котором процесс упал, не возвращается.

Без этого сверка на supervision.flang разошлась бы сразу: прогон «третий отказ в окне» кладёт в ящик три ядовитых сообщения, и на BEAM после первого отказа двух оставшихся не стало бы вовсе — вместо трёх отказов вышел бы один.

Дырка, которая осталась. Между смертью процесса и регистрацией нового воплощения имя не зарегистрировано, и сообщение, отправленное ровно в это окно, пропадает. У эталона такого окна нет: там процесс — запись в таблице, и она никуда не девается. Окно измеряется микросекундами и в примерах не проявилось, но оно есть, и закрыть его нечем, пока имя процесса — имя BEAM.

Запас витков остался, и вот почему

Задача была решить, как отобразить запас витков на машину, которая вытесняет сама. Решение: оставить как есть, и вот основание.

Вытеснение спасает соседей, а не сам процесс. Незавершающийся обработчик на BEAM не остановится никогда: он будет крутиться, занимая ядро, а его ящик будет расти, пока узел не съест всю память. Никакого исхода из этого не следует, а контракт требует именно исхода: «исчерпание запаса — не зависание и не молчаливый обрыв, а определённый исход». Поэтому запас остаётся, и считает его тот же счётчик, что и в остальных целях (Flang.Rt.step).

Что при этом досталось даром: счётчик живёт в словаре процесса, а словарь у процесса BEAM свой. Два обработчика не мешают друг другу не по договорённости, а по устройству — в остальных целях за это пришлось бы платить.

Тотальный обработчик не платит ничего: Flang.Rt.enter/step печатаются только в функции, способные к рекурсии, и тотальный обработчик без рекурсии не содержит их вовсе. Доказанная тотальность здесь — право не называть запас, а не ускорение планировщика; ускорять нечего, потому что считать и так нечего.

Единица запаса у BEAM своя, как и у всех целей: «виток» интерпретатора — итерация его машины, «виток» Elixir — вход в функцию и хвостовой вызов. Их всегда меньше, значит эталон упирается в предел первым, и расхождение одностороннее. Сравнимо поэтому не число витков, а исход: исчерпал или нет.

Сверка: набор исходов, а не исход

У остальных бэкендов сверка простая: на одном входе то же значение и та же ошибка, что у интерпретатора. Здесь так нельзя — семантика определена любым чередованием, семени у BEAM нет, и требовать от неё исхода с семени 4172 значило бы требовать того, чего модель не обещает.

Устроено так:

  1. Эталон гоняется на тысяче семян на каждый прогон; из итогов собирается множество: состояния всех процессов, кто жив, исход программы, список отказов.
  2. Напечатанное собирается настоящим elixirc --warnings-as-errors и идёт на настоящей BEAM по двадцать пять раз на прогон.
  3. Каждый исход с BEAM обязан лежать в множестве эталона — не «походить», а лежать: сравниваются канонические записи значений.

Одно место потребовало отдельного решения: **дано кладётся в ящики при приостановленных процессах**. У эталона «дано «Счётчик» принимает …» значит «сообщение уже в ящике, когда планировщик делает первый выбор»; если разносить их по одному живым процессам, первый обработчик может увидеть ящик, в котором второго сообщения ещё нет, — состояние, которого у эталона не бывает вовсе, и в программе с отложить оно наблюдаемо. Поэтому на время раздачи процессы приостанавливаются (:erlang.suspend_process): доставку это не останавливает, а исполнение останавливает. Пользуется этим только прогон; в настоящей работе никаких «всех сразу» не бывает и быть не может.

У такой сверки два слабых места, и оба закрыты отдельными проверками. Множество могло не насытиться — тогда исход выпадал бы из него не из-за ошибки печати, а из-за нехватки семян; проверяется тем, что набор с половины сетки совпадает с набором со всей. Множество могло оказаться слишком широким — тогда в него попадало бы что угодно; проверяется тем, что достаточно изменить в исходе одно число, и он из набора выпадает, и тем, что наборы двух прогонов одной программы с разными входными сообщениями не пересекаются.

Третья проверка на зубы напрашивалась и не подошла, и это стоит записать: «добавить программе лишнее входное сообщение — исход обязан выпасть из набора». На supervision.flang она не сбывается, и правильно делает: четвёртое ядовитое сообщение приходит процессу, которого после третьего отказа уже остановил надзор. Возмущение ненаблюдаемо по существу, а не по слабости сверки, — и проверка, которая требовала бы обратного, была бы неверна сама.

Сверх исходов сверяется журнал доставок — то, чего требует пункт 2 раздела «Что проверяется и чем». Побайтового совпадения тут быть не может, но множество журналов эталона на сетке семян насыщается тоже: у race.flang в нём 84 чередования — это ровно все, какие допускает причинность (шесть порядков работников на четырнадцать способов вложить в них сборщика), и попасть туда случайно нельзя. Журнал с BEAM обязан лежать в этом множестве.

Числа последнего прогона: 6 программ, 11 прогонов, 275 запусков на BEAM, 250 сверенных журналов, 178 различных чередований у эталона. Всё сошлось.

Цена названа отдельно, потому что она непривычная: почти всё время этой проверки уходит не на счёт, а на ОЖИДАНИЕ. Один запуск — поднять дерево, разнести сообщения, дождаться покоя, погасить дерево; на свободной машине это десятки миллисекунд, на занятой — секунды, потому что задержка планирования на BEAM растёт вместе с очередью машины. Поэтому повторов двадцать пять, а не сорок, и поэтому у разговора с BEAM есть предел времени: зависший прогон обязан кончиться ошибкой, а не молчаливо повесить весь набор проверок.

Про вытеснение: утверждение контракта подтвердилось, но не так, как ждалось

В контракте записано: «Ни одно чередование, возможное на BEAM, не выходит за пределы чередований атомарных пробегов — потому что чужого состояния обработчику всё равно не видно». Контрпримера не нашлось: сотни запусков, ни одного исхода вне набора эталона. Довод, почему его и не должно быть, тоже держится — состояние принадлежит одному процессу, прочитать его снаружи можно только сообщением, а сообщение обрабатывается между пробегами, а не внутри.

Но измерение показало то, чего в контракте не было. Редукционный квант BEAM много больше пробега обработчика: процесс успевает вычерпать весь свой ящик за один заход планировщика, и чередования от этого выпадают очень неравномерно. На race.flang сорок запусков подряд дают одно и то же чередование; на трёхстах их оказывается четыре — 297 раз одно и по разу три других. Все четыре лежат в наборе эталона из 84 чередований, то есть BEAM действительно перемежает пробеги и не выходит за модель. Но распределение такое, что сверка доказывает включение, а не покрытие: чередование, которого BEAM не делает, она проверить не может — и не должна.

Это записано здесь, а не подразумевается: «BEAM ходит по всем чередованиям» — неправда. Она ходит по тем, что выпадают её планировщику, и на маленьких обработчиках почти всегда по одному и тому же. Ровно поэтому проверочный режим с семенем не заменяется прогоном на настоящей машине: перебор семян показывает то, чего машина не покажет и за тысячу запусков.

Где напечатанное расходится с эталоном

ЧтоЭталонElixir/OTP
порог отказовсчитает ВСЕ отказы, дошедшие до надзораmax_restarts считает только перезапуски детей «перезапустить»; в надзоре со смешанными стратегиями счёт разойдётся
окно порогапробеги обработчикасекунды настоящих часов, и меньше секунды окна не бывает
иначе «остановить»останавливает упавший процессумирает S_снова, а с ним все дети «перезапустить» этого надзора
таймерсрабатывает, когда планировщику нечего делатьмиллисекунды настоящих часов: у mailbox.flang эталон делает 11 пробегов, BEAM — около двух тысяч, а исход при этом один и тот же
начальное состоянието же самое значение, вычисленное один разпересчитывается на каждом заводе; функция чиста, поэтому значение то же, но буквальность обещания стала ненаблюдаемой
«покой»точное знание: очередь готовых пуставывод из наблюдения: несколько замеров подряд никто не занят, ящики пусты, таймеры не ждут
«жив»поле записи: снял и прочиталдва разных мига: процесс, решивший остановиться, ещё живёт, пока gen_server зовёт terminate/2, а под нагрузкой этот промежуток растягивается до миллисекунд. Пришлось помечать «останавливается» самому, а у супервизоров спрашивать which_children — вызов ждёт конца перезапуска. Без этих двух заплат остановленный процесс попадал в живые раз на несколько десятков прогонов

Отдельно: отчёты OTP на время прогона выключаются. Отказ процесса здесь — событие модели, а не поломка, и отчёт об аварии на каждый такой отказ забил бы вывод проверки тем, чего проверка и добивается. Выключает их только run/3, то есть прогон; настоящая работа начинается с start/1, и там не выключается ничего.

И ещё одно, чего у эталона нет вовсе: наблюдение стоит денег. Состояния процессов и журнал доставок пишутся в общую таблицу ETS — две записи на пробег плюс общий счётчик, который слегка сериализует старты пробегов. Без этого итог прогона, кончившегося отказом доверху, был бы ненаблюдаем вовсе: состояние процесса на BEAM умирает вместе с процессом. Ценой этого измерение слегка меняет то, что измеряет, — и назвать это надо здесь, а не в примечании.

Чего в шаге 4 нет

Что сделано в шаге 5 — перебор семян в исходнике

Сделано: у прогона появилась сетка семян семя от N до M, у ожидания — третья форма любое из [ … ], множество итогов проверяется в обе стороны, расхождение называет семя (flang/src/parser.mjs, flang/src/types.mjs, flang/src/conc.mjs), сетки стоят в примерах (counter.flang, race.flang, supervision.flang), проверки — в flang/test/conc.test.mjs.

Этим закрыт пункт 3 раздела «Что проверяется и чем», о котором до сих пор стояло «пункта 3 по-прежнему нет как сетки семян в исходнике».

Не сделано и в этом шаге не планировалось: планировщик в рантайме C, печать конкурентности в остальные семь целей, породить.

Зачем это вообще, если сверка с BEAM уже перебирает семена

Перебирает — но в JS-тесте (flang/test/emit-elixir-conc.test.mjs), то есть в инструменте разработчика языка, а не на поверхности языка. Автор конкурентной программы про этот перебор не знает и воспользоваться им не может: в исходнике он пишет семя 4172 и получает утверждение об ОДНОМ чередовании. Утверждение «состояние не рассыпается ни при каком порядке» на одном семени не проверяется никак — а именно оно и есть то, что модель обещает, и то, что ломается в бою.

Довод, что «настоящая машина проверит это лучше», измерением опровергнут ещё в шаге 4 и записан там же: редукционный квант BEAM много больше пробега обработчика, поэтому на race.flang триста запусков дают четыре чередования из восьмидесяти четырёх законных, и 297 раз из 300 — одно и то же. Живая машина ходит по тем чередованиям, что выпадают её планировщику; перебор семян ходит по всем, какие модель допускает. Это разные проверки, и вторая не заменяется первой.

Поверхность: ноль новых ключевых слов, и это не украшение

прогон «порядок в сборщике — одно из шести чередований, и других нет»
  семя от 1 до 1000
  дано «Левый» принимает (вариант «тик» с «метка» равным "1")
  дано «Левый» принимает (вариант «тик» с «метка» равным "2")
  дано «Правый» принимает (вариант «тик» с «метка» равным "1")
  дано «Правый» принимает (вариант «тик» с «метка» равным "2")
  ожидается «Левый» равен (запись «Счёт» с «всего» равным 2)
  ожидается «Правый» равен (запись «Счёт» с «всего» равным 2)
  ожидается «Сборщик» любое из [
    (запись «Метки» с «строки» равным ["Л1", "Л2", "П1", "П2"]),
    (запись «Метки» с «строки» равным ["Л1", "П1", "Л2", "П2"]),
    (запись «Метки» с «строки» равным ["Л1", "П1", "П2", "Л2"]),
    (запись «Метки» с «строки» равным ["П1", "Л1", "Л2", "П2"]),
    (запись «Метки» с «строки» равным ["П1", "Л1", "П2", "Л2"]),
    (запись «Метки» с «строки» равным ["П1", "П2", "Л1", "Л2"])]

Это flang/conc/examples/race.flang дословно, а не сокращение: шесть значений выписаны потому, что их ровно шесть, и седьмое туда не влезет.

Ни одного нового слова таблица не получила, и это условие, а не достижение: таблица лежит в ДВУХ местах (src/lexer.mjs и self/lexer.flang), сверяется тестом, а каждое новое слово запрещает одноимённую переменную во всех файлах репозитория. За сутки на этом обожглись девять раз; «символы» когда-то сломали два файла и заставили переделать поверхность.

ЧастьИз чего собрана
семя от N до Mсемя и от (то же of, каким пишется «Длина» от строка)
до, «семян»слова-пояснения: парсер пропускает их так же, как «витков» и «миллисекунд»
любое из [ … ]любое (тот же джокер) и из (тот же предлог); множество — обычный список литералов

Отдельно про семя: сетка называется тем же словом, что и одно семя, а не новым «семена». Одно понятие — один оборот; семя 4172 осталось ровно тем, чем было, и разница между «одно чередование» и «сетка» видна предлогом.

Отдельно про любое из: множество итогов пишется списком, потому что список в языке уже есть, а спутать его с состоянием нельзя — тип состояния процесса всегда объявленное имя (состояние «Счёт»), списком он не бывает.

Множество проверяется в ОБЕ стороны, и это главное решение шага

Очевидное чтение «каждый итог лежит в названном множестве» — проверка односторонняя, и она тем слабее, чем шире множество. Дописав в список десять лишних значений, автор ослабляет проверку и ничего об этом не узнаёт: она по-прежнему зелёная. Ровно этой болезнью болела бы и сверка с BEAM, если бы у неё не было отдельных проверок «на зубы» (шаг 4).

Поэтому проверяются оба включения:

Тогда список в исходнике — это ИМЕННО множество достижимых итогов, а не верхняя оценка на него. У race.flang это шесть порядков — все, какие допускает причинность (метки каждого работника идут по порядку, значит итог — способ вложить пару в пару), и седьмого там быть не может, а пятого не хватило бы.

Цена названа честно: половина «встретилось» зависит от того, насытилась ли сетка. Не насытилась — прогон краснеет с «значение не встретилось ни на одном семени», и автор либо расширяет сетку, либо убирает значение. Ошибаться в эту сторону безопасно: непроверенное утверждение краснеет, а не зеленеет молча.

Расхождение называет семя — иначе сетка была бы хуже одного семени

Прогон на тысяче семян, сообщающий «где-то сломалось», хуже прогона на одном: на одном хотя бы понятно, где смотреть. Поэтому в отчёте о неудаче стоит наСемени — первое семя, на котором ожидание не сбылось, — и по нему чередование предъявляется поодиночке: достаточно написать семя 4172 вместо сетки, и прогон повторит ровно тот же журнал доставок, побайтово.

Это проверяется тестом, а не обещается: тест берёт семя из отчёта сетки, подставляет его в одиночный прогон и требует того же расхождения.

Что отвергается разбором и проверкой типов

ЧтоПочему
любое из при одном семени — и семя от 5 до 5 тоже одно семявторая половина проверки («каждое значение встретилось») не может сбыться ни при каком множестве длиннее единицы — ожидание заведомо ложно
семя от 9 до 4пустая сетка сбывается, не проверив ни одного чередования: утверждение обо всех элементах пустого множества истинно
пустое любое из []то же самое с другой стороны
повтор значения в множествечитатель видит N итогов, а их меньше; планировщику всё равно, читателю — нет
значение не того типасверяется КАЖДЫЙ элемент множества, а не первый: чинить по одной ошибке за прогон — плохая сделка

Первая строка таблицы стояла здесь раньше, чем сбывалась, и это надо назвать: проверка спрашивала, есть ли в записи предлог от, а не сколько семян в отрезке. семя от 5 до 5 — сетка по записи и одно чередование по делу, поэтому любое из на ней проходило проверку молча и краснело уже прогоном, перечисляя «не встретилось» для всех значений, кроме одного доставшегося. Отвергается это теперь до прогона, как и сказано строкой выше; отрезок из двух семян и тот же от N до N с равен (законная запись одного чередования) проходят по-прежнему.

Чего в шаге 5 нет

Что сделано в шаге 6 — планировщик в рантайме C

Сделано: планировщик конкурентности живёт настоящими файлами рантайма (flang/src/emit/c/flang_conc.h, flang_conc.c), программа с процессами печатает план данными (flang/src/emit/c.mjs), у прогонщика появился второй запрос — {"run":"…","seed":"N","journal":"1"} (flang/src/emit/c/flang_cli.c; journal необязателен, по умолчанию единица — см. «Журнал необязателен» ниже), и напечатанное сверяется с эталоном ПОБАЙТОВО по журналу доставок на каждом семени сетки (flang/test/emit-c-conc.test.mjs).

Не сделано и в этом шаге не планировалось: рабочий (параллельный) режим на потоках ОС, печать конкурентности в остальные пять целей (шестой из них, JavaScript, свой планировщик получил — см. «Планировщик в напечатанном JavaScript»), породить, распределённость. Про первое сказано отдельно и с числами — см. ниже.

Что было до этого шага: печать молчала

Первое, что надо было выяснить, — что вообще происходит с программой, объявляющей процессы, при печати в C. Ответ измерен, а не предположен:

$ flang emit flang/conc/examples/counter.flang --target c --out … {"target":"c","files":[flang_runtime.h, flang_runtime.c, schyotchik_i_zhurnal.h, schyotchik_i_zhurnal.c, flang_cli.c, Makefile]} $ grep -c 'процесс|ящик|планировщик' *.c 0

То есть: печаталось, собиралось cc -std=c99 -Wall -Wextra -Werror -pedantic без единого предупреждения, запускалось — и не содержало ни одного процесса. Объявления процесс, надзор и прогон молча выбрасывались; на выходе оставались обработчики как обычные чистые функции и конструкторы вариантов «Действие». Ни ошибки, ни предупреждения, ни строки в отчёте.

Это худший из трёх возможных ответов. «Не печатается вовсе» заметил бы первый же пользователь; «печатается без планировщика, и об этом сказано» было бы честным долгом. А молчание выглядит как успех: команда возвращает ноль, файлов шесть, собирается чисто — и программа, у которой отняли всю конкурентность, ничем не отличается от программы, у которой её и не было.

Дифференциальная сверка вернулась в исходном виде — и это главное

Пункт 2 раздела «Что проверяется и чем» требовал побайтово одинакового журнала доставок при одинаковом семени. В шаге 4 его пришлось переписать: у BEAM семени нет и быть не может, поэтому сверка с ней идёт по НАБОРУ исходов, и это слабее.

Здесь требование выполняется буквально и целиком. Планировщик в C принимает семя, и на одном и том же семени он обязан выдать ту же строку, что эталон:

время | процесс | исход пробега | код отказа | доставленное сообщение

Сообщение в этой строке не украшение: без него совпали бы и два планировщика, которые одинаково выбирают процесс, но по-разному переставляют ящик, — а именно это и делает отложить. Сверх журнала сверяется итог: исход программы, состояния всех процессов, кто жив, отказы, решения надзора, виртуальное время и число пробегов.

Числа последнего прогона (node --test flang/test/emit-c-conc.test.mjs, gcc 13.3.0, машина ниже): 6 программ, 12 прогонов, 200 семян на прогон, 2400 побайтовых совпадений журнала, 90 различных чередований, 3 секунды. Ни одного расхождения.

У побайтового равенства есть ровно одна слабость: оно сбывается и тогда, когда обе стороны ничего не делают. Поэтому проверяется и обратное, тремя проверками: чередований на сетке ДЕВЯНОСТО, а не одно; переставленные местами два соседних пробега из совпадения выпадают; и на race.flang двести семян дают у C не менее шести разных журналов — то есть семя действительно решает, а не игнорируется.

Кооперативный планировщик на одном потоке, и вот почему

Контракт называет два режима: проверочный «одним потоком, чередование по семени» и рабочий «параллельно, сколько даёт среда». Сделан первый.

Довод «побайтовая сверка с потоками невозможна» верен, но его недостаточно: он объясняет, почему проверочный режим обязан быть однопоточным, и ничего не говорит о том, стоит ли рядом строить второй. Поэтому измерено, во что обошёлся бы пул потоков — на той же машине и в тот же момент, что и пробег (flang/conc/bench.mjs, раздел «Почему проверочный режим кооперативный»).

Машин три, и это не для солидности: отношение оказалось разным вчетверо, и одна машина здесь солгала бы.

ЧтоМашина А (8 ядер, окружение)Машина Б (16 ядер)Машина В (256 ядер)
пробег обработчика в напечатанном C1,15 мкс0,58 мкс1,76 мкс
передача работы другому потоку (mutex + condvar, туда и обратно)15,9 мкс2,20 мкс9,43 мкс
завести поток и убрать37,4 мкс7,80 мкс118 мкс
взять незанятый замок6 нс8 нс11 нс
во сколько передача дороже пробега13,8×3,8×5,3×

Машина В добавлена отдельно и с оговоркой: у неё три прогона, и отношение вышло 5,3× / 6,5× / 9,1× при нагрузке 30–137. В таблице стоит минимум, потому что помеха может замер только удлинить. Разброс внутри одной машины (5,3–9,1) сам по себе — довод в пользу того, что единственного числа у этого порога нет.

Вывод, который держится на всех трёх: раздать пробег другому потоку в несколько раз дороже, чем его выполнить — от четырёх раз до четырнадцати, смотря по машине. Пул потоков, раздающий ПРОБЕГИ, начинает окупаться с обработчика в 2–16 микросекунд, то есть в четыре–четырнадцать раз тяжелее измеренного. Это не «потоки плохие»: это названный порог, и он зависит от машины настолько, что называть его одним числом нельзя.

Машина В сказала и то, чего не говорили две первые: завести поток стоит тем дороже, чем больше у машины ядер — 7,8 мкс на шестнадцати, 37 на восьми и 118 на двухстах пятидесяти шести. «По потоку на процесс» на большой машине дороже всего именно там, где процессов хотелось бы больше всего.

Второе число говорит о другом и тоже важно: сделать планировщик потокобезопасным почти бесплатно — незанятый замок стоит 6–11 нс, меньше процента пробега, и на всех трёх машинах одинаково. Дорого не разделяемое состояние, а передача управления. Значит рабочий режим, когда он появится, обязан раздавать потокам не пробеги, а ПРОЦЕССЫ: поток берёт процесс и вычерпывает его ящик целиком, платя за передачу один раз на пачку, а не один раз на сообщение. Записано здесь как найденное измерением, а не как сделанное.

Очередь готовых: у эталона O(объявленных), в C — O(готовых)

Измерение шага 7 записало находку и не стало её чинить: планировщик эталона на каждом пробеге пересобирает очередь готовых полным перебором всех объявленных процессов, поэтому переключение стоит O(числа процессов). Чинить это в эталоне нельзя дёшево — эталон есть то, с чем сверяется напечатанное.

В рантайме C очередь готовых поддерживается списком индексов, отсортированным по порядку объявления: вставка и удаление идут двоичным поиском, выбор по семени — обращением по индексу. Наблюдаемо это не меняет ничего, и в этом весь смысл: содержимое очереди на каждом пробеге в точности то же самое, что даёт перебор эталона, — тот же список в том же порядке, — поэтому семя выбирает тот же процесс, а журнал совпадает побайтово. Быстрее стал способ получить список, а не список.

Замер (кольцо из P процессов, 9995 пробегов на замер, минимум из семи повторов, из времени вычтен тот же прогон с ящиком из одного сообщения — чтобы убрать запуск процесса и вычисление P начальных состояний):

Процессовэталон, АC, Аэталон, БC, Б
23,961,201,600,578
2505,711,123,150,580
5007,901,114,920,577
100013,911,149,120,603
200030,911,1717,630,587

Прямая по пяти точкам:

эталонC
машина А2,17 мкс + 13,7 нс на процесс1,145 мкс + 0,003 нс на процесс
машина Б1,18 мкс + 8,1 нс на процесс0,580 мкс + 0,007 нс на процесс
машина В3,28 мкс + 20,4 нс на процесс1,774 мкс + −0,001 нс на процесс

Наклон упал на три порядка с лишним — то есть в пределах измерения его нет вовсе, и это то самое, что доказывается: очередь готовых перестала зависеть от числа объявленных процессов. На машине В наклон вышел отрицательным на тысячную долю наносекунды: это не «стало быстрее от процессов», это ноль, измеренный прибором с шумом.

Самый чистый замер из всех трёх машин снят на машине В на почти пустой машине (нагрузка 3–5): 1,768 / 1,771 / 1,763 / 1,767 мкс на пробег при 2, 250, 500 и 1000 процессах — четыре числа в пределах полупроцента друг от друга. Пятая точка (2000 процессов) в тот прогон попала под всплеск нагрузки до 17 и дала 2,46; в прямую взят прогон, все пять точек которого сняты при сравнимой нагрузке.

Условия. Машина А: Node v24.18.0, gcc 13.3.0, восемь процессоров доступно (os.cpus() и availableParallelism() сходятся на восьми), средняя нагрузка за минуту 6–9. Машина Б: Node v24.18.1, gcc 13.3.0, шестнадцать процессоров, средняя нагрузка 24–36. Машина В: Node v26.7.0, gcc 15.2.0, clang 21.1.8, Elixir 1.20.3 на Erlang/OTP 29, двести пятьдесят шесть процессоров, 499 ГиБ памяти, средняя нагрузка от 3 до 214 — три прогона в разные моменты. Она заведена в отчёт потому, что предыдущие две мерили планировщик на восьми и шестнадцати ядрах под чужой нагрузкой, а такой замер о планировщике не говорит почти ничего. Время у эталона — процессорное, у C — настенное время чужого процесса за вычетом базового замера: process.cpuUsage() считает только своё и для дочернего процесса дал бы ноль. Поэтому сравнивать надо не столбцы, а наклоны: наклон от нагрузки почти не зависит (помеха поднимает все точки разом), а свободный член — это и есть измерение занятой машины.

Где C расходится с эталоном, и это измерено, а не предположено

Единица запаса витков. Она у каждой цели своя — то же самое записано про Elixir в шаге 4. Виток интерпретатора — итерация его машины; виток C (fl_tick) — вход в функцию, оборот цикла хвостового самовызова и отскок батута. Витков C поэтому всегда МЕНЬШЕ, и эталон упирается в предел первым.

Насколько именно — измерено на budget.flang двоичным поиском по входу при объявленном с запасом 2000 витков:

наибольшая длина, которую обработчик успевает разобрать
эталон140
напечатанный C1998

Разрыв четырнадцатикратный, и в нём лежит вся честная оговорка к сверке: побайтовое совпадение журналов доказано там, где исход не решается запасом. Программа, чей обработчик тратит от 141 до 1998 «длин», у эталона упадёт с FLANG_BUDGET_EXHAUSTED, а в C досчитает — и журналы разойдутся законно. Расхождение одностороннее и безопасное (напечатанный код не объявит исчерпанным то, что эталон досчитал), но оно есть, и на шести примерах оно не проявляется только потому, что там запас исчерпывает НЕЗАВЕРШАЮЩИЙСЯ обработчик: он не завершается ни при каком запасе, и обе стороны это одинаково обнаруживают.

Что НЕ сверяется побайтово. Тексты ошибок отказавшего обработчика: в журнале сверяется код (FLANG_BUDGET_EXHAUSTED, FLANG_STOPPED, FLANG_PROCESS), а текст — нет. Тексты рантайма C и интерпретатора совпадают по отдельному контракту бэкенда (emit-c.test.mjs), и требовать их ещё и здесь значило бы проверять одно двумя тестами.

Как это устроено в напечатанном коде

flangC
процессстрока таблицы fl_conc_process: имя, обработчик, начало, тотальность, запас
состояние процессаfl_value в арене; перезапуск подставляет то же значение, вычисленное один раз
почтовый ящиккольцевой буфер fl_conc_box — снять с головы, положить в хвост, вернуть в голову
отправитьзапись в конец ящика адресата, O(1)
отложитьзапись в конец СВОЕГО ящика
продолжитьзапись в голову своего ящика
через … отправитьтаймер по виртуальному времени
остановитьснятие с очереди готовых; «норма» — не отказ
надзортаблица fl_conc_supervisor плюс два массива связей: над процессом и над надзором
порог отказовскользящее окно из времён отказов, по надзору
прогонтаблица fl_conc_run_spec плюс функция, строящая входные сообщения в арене

Процессы, надзоры и прогоны печатаются данными, а сам планировщик печатается байт в байт для всех программ. Причина та же, по какой так сделано в Elixir, и ещё одна сверху: планировщик, который печатался бы под каждую программу заново, пришлось бы сверять с эталоном не один раз, а на каждой программе по отдельности.

Память: всё живёт в арене вызывающего и умирает вместе с ней. Проверено valgrind'ом на двадцати прогонах supervision.flang — ноль ошибок, ноль потерянных байт, четыре выделения и четыре освобождения на весь процесс.

Долгая жизнь процесса не проверялась ни разу — и её нет

Шаг 7 спросил, сколько стоит пробег, сколько отправка, сколько переключение и сколько процессов помещается. Он не спросил, сколько процесс живёт — и это оказался единственный вопрос, на который ответ отрицательный.

Мерка добавлена (flang/conc/bench.mjs, раздел «Сколько живёт процесс»): два процесса перебрасывают друг другу один «тик», в ящике всё время не больше одного сообщения, число пробегов задаёт запрос. Значит весь рост арены — от пробегов и ни от чего больше; кольцо с прогоном из M сообщений для этого не годилось бы, потому что там растёт и вход.

Приборов два, и общего у них нет ничего. valgrind называет точное число байт, выданных malloc: счётчик, а не измерение, и от нагрузки он не зависит вовсе. Пиковый RSS меряет страницы, отобранные у ядра.

ПробеговБайт выдано mallocВыделенийОсвобождений
500336 00077
1 000577 7601010
2 0001 126 8161616
4 0002 159 3282626
8 0004 158 7524444

Прямая: 88 824 байт + 510,6 байт на пробег. Второй прибор о том же: RSS 6 172 КиБ на десяти тысячах пробегов, 51 228 на ста тысячах, 473 116 на миллионе — 481,8 байт на пробег.

Пять чисел точного прибора совпали на машинах А и В до байта, при разных компиляторах (gcc 13.3.0 и 15.2.0) и разном числе ядер. Это свойство прибора, а не удача: счётчик malloc не зависит ни от нагрузки, ни от машины.

Равенство «выделений» и «освобождений» не опровергало вывода, а объясняло его: арена отдавала блоки системе один раз, в самом конце, вместе со смертью процесса ОС. До этого не возвращалось ничего — в flang_conc.c не было ни одного fl_arena_reset, и перезапуск процесса надзором арену не трогал, потому что трогать её было нечем: она общая, и в ней лежали состояния соседей.

Процесс flang в напечатанном C не может жить долго. Не «медленно» — не может. На тысяче сообщений в секунду он съедает 41,1 ГиБ в сутки; восьми гигабайт ему хватит на четыре с половиной часа.

Абзац выше — про то, как было до шага А2. Прошедшее время в нём не редакторская правка, а результат: наклон снят, и снят до нуля. Числа нового замера — ниже, в «Своя куча у процесса»; вывод «жить долго не может» больше не верен, и оставлен он здесь целиком потому, что без него не видно, что именно было сломано.

Часть утечки — наблюдение, а не работа: журнал доставок писал запись на каждый пробег, и рабочему режиму он не нужен вовсе. Остальное — состояния, сообщения и списки действий.

Журнал необязателен (шаг А1)

Запрос прогонщика принимает journal. По умолчанию единица: прогон ведёт журнал целиком, иначе побайтовую сверку с эталоном не на чем было бы делать. "journal":"0" — рабочий режим: журнала нет ни в памяти, ни в ответе, и поля «журнал» в ответе тоже нет. Не пустой список: пустой список означал бы «пробегов не было», и сверка, сравнив пустоту с пустотой, промолчала бы вместо того, чтобы покраснеть.

Измерено теми же двумя приборами на той же программе:

с журналомбез журнала
valgrind, байт на пробег510,6338,8
пиковый RSS, байт на пробег482,7336,3
на 1000 сообщений в секунду41,1 ГиБ в сутки27,3 ГиБ в сутки

Журнал стоил треть роста. Два прибора расходятся в оценке того, сколько именно (171,8 против 146,4 байта на пробег), и сходятся в том, сколько осталось: 338,8 и 336,3 — разница 0,7 %. Второе и есть результат замера.

Наблюдаемое от признака не зависит ничем: на всех шести программах и всех их прогонах исход, состояния, живые, отказы, решения надзора, время и число пробегов совпадают с точностью до символа, а 2400 побайтовых совпадений журнала целы (flang/test/emit-c-conc.test.mjs).

Вывод абзаца выше от этого не меняется: течь стало медленнее, а не перестало. Восьми гигабайт хватит на шесть с половиной часов вместо четырёх с половиной — это отсрочка, а не решение, и решение остаётся за арéной на процесс.

Это не значит, что шаг 6 сделан плохо. Проверочный режим — прогон конечной длины, и для него общая арена правильна: она дешевле всего и умирает вместе с прогоном. Это значит, что у слова «проверочный» есть второе измерение, которое до сих пор не было названо: режим не только однопоточный, но и конечный по времени жизни. Что с этим делать и чего это стоит — flang/conc/RESILIENCE.md.

Своя куча у процесса (шаг А2)

Владелец выбрал арену на процесс (А0), и вот что из этого вышло.

Устройство. У процесса своя куча двумя половинами. Обработчик считает в ЧЕРНОВИКЕ — отдельной арене, общей на всех и сбрасываемой после каждого пробега. Когда пробег вернулся, живое — это ровно новое состояние и то, что осталось в ящике: обработчик чист, значит всё прочее, что пробег построил, мусор в ту же наносекунду. Живое переносится в свободную половину, занятая сбрасывается целиком, половины меняются местами. Отправленное уезжает копией в кучу адресата.

Сборщика в этом нет: мусор не обходится, не помечается и не считается — он перестаёт существовать вместе с половиной. Ни барьеров записи, ни паузы.

Тот же довод, которым В2 обосновал снятие пробега по кванту, обосновывает и это: пока обработчик не вернулся, наружу не ушло ничего, поэтому границу между «черновиком» и «наружу» вообще есть где провести. В BEAM так нельзя — там процесс меняет свою кучу по ходу дела.

Измерено теми же двумя приборами на той же программе, на ssh dev (256 ядер, gcc 15.2.0):

было (общая арена)стало (куча на процесс)
valgrind, байт на пробег, рабочий режим338,80,0
пиковый RSS, байт на пробег, рабочий режим336,30,0
valgrind, байт на пробег, с журналом510,6221,2
пиковый RSS, байт на пробег, с журналом481,8177,6
на 1000 сообщений в секунду, рабочий режим27,3 ГиБ в суткинисколько
цена пробега с пустым сообщением488,8 нс341,2 нс
цена одного числа в сообщении0,01 нс6,40 нс

Ноль здесь — не округление и не «в пределах измерения». Счётчик malloc называет на 1000 пробегов и на 8000 пробегов ОДНО И ТО ЖЕ число, 467 136 байт при девяти выделениях и девяти освобождениях, и на это стоит отдельная проверка (flang/test/emit-c-conc.test.mjs, «шаг А2»), которая сравнивает их на РАВЕНСТВО, а не на «меньше чем»: порог пропустил бы медленную течь. Вторая проверка того же утверждения — миллион пробегов под ulimit -v 128 МиБ: до А2 такой прогон кончался FLANG_MEMORY, теперь проходит, и десять миллионов тоже.

У обеих проверок есть зубы, и это не формальность: за сутки в репозитории нашлись две проверки, молчавшие на настоящей поломке. Тот же прибор на той же программе обязан УВИДЕТЬ рост там, где он есть, — с журналом; если и там выйдет равенство, значит ослеп прибор, а не программа перестала течь.

Что подорожало и почему это сказано числом. Отправка теперь копирует, и копия стоит 6,40 нс на число груза против 0,01 нс прежде. Свободный член пробега при этом упал с 488,8 до 341,2 нс: черновик сбрасывается и остаётся горячим в кэше, а прежняя арена росла и покупала у malloc новые куски. Отсюда следствие, которого никто не заказывал: сообщение примерно до двух десятков чисел стало ДЕШЕВЛЕ, чем было, и дорожает только крупное — на 256 числах пробег идёт 1977 нс против 491 нс.

Чего это НЕ снимает. Журнал доставок как рос, так и растёт — 221,2 байта на пробег, — потому что журнал и есть запись обо всех пробегах; в рабочем режиме ("journal":"0") его нет вовсе (шаг А1). Растут и списки отказов и решений надзора: они пропорциональны числу ОТКАЗОВ, а не пробегов, и процесс, падающий без конца, память всё ещё занимает. Это отдельное свойство, и валить его в один счёт с «байтами на пробег» было бы подтасовкой.

Побайтовая сверка с эталоном цела: 2400 совпадений журнала на 90 различных чередованиях, valgrind — ноль потерянных байт, ноль байт в использовании на выходе.

Чего в шаге 6 нет

Планировщик в напечатанном JavaScript

Третья цель из восьми, и выбрана она не по заметности, а по цене: эталон УЖЕ на JavaScript, поэтому здесь не проектирование заново, а перенос 1666 строк flang/src/conc.mjs на представление значений напечатанного модуля. Планировщик живёт настоящим файлом рядом с бэкендом — flang/src/emit/js/flang_conc.js, 611 строк, — и печатается ЦЕЛИКОМ и только той программе, у которой есть процессы; плюс 269 строк склейки в js.mjs — столько же файл теряет, если убрать из него планировщик (план данными, границы пробега, два экспорта). Ровно так же устроены C (flang_conc.c) и Elixir (flang_conc.ex): один планировщик на все программы, а не печать нового на каждую.

Своего счётчика витков у планировщика НЕТ, и это стоит сказать прямо, потому что сначала он был: счётчик в модуле один — тот же, что считает шаги и глубину у обычной программы ($step/$enter, emit/js.mjs), — а планировщик ставит ему предел на время пробега обработчика и читает показание. Два счётчика в одном модуле означали бы два разных числа предела при одном и том же витке: тот же обработчик отказал бы по одному прибору, досчитав по другому, и разъезд был бы молчаливым.

Что сверяется и почему это сильнейшая из трёх сверок

Побайтовая, как у C, а не оракул по множеству, как у Elixir: семя у этого планировщика есть, значит требование пункта 2 контракта выполнимо целиком. На каждом семени сетки сравниваются строки

время | процесс | исход пробега | код отказа | доставленное сообщение

и сверх журнала — итог: исход программы, состояния всех процессов, кто жив, какие были отказы, какие решения принял надзор, виртуальное время, число пробегов. Числа прогона (flang/test/emit-js-conc.test.mjs): 7 программ, 14 прогонов, 200 семян на прогон — 2800 побайтовых совпадений журнала, 146 различных чередований. Зубы у сверки те же, что у C: переставленные два соседних пробега из совпадения выпадают, и на race.flang 200 семян дают не два чередования, а десятки.

Отдельно проверено то, что обещано ВМЕСТО «одинаково везде»: набор возможных исходов у напечатанного и у эталона совпадает в обе стороны на той же сетке (односторонняя проверка сбывалась бы тем легче, чем шире набор эталона). Наборы на прогон: race — 6 исходов, mailbox — 9, backpressure — 17, у остальных 1.

Три честные границы, и все три названы полем, а не абзацем

Чего у напечатанного планировщика нет намеренно: горячей замены и вытеснения по кванту — оба свойства эталона, у напечатанного C их тоже нет, и журнал доставок от их отсутствия не меняется ни на байт. Ограниченный ящик (шаг А3), от которого Elixir честно отказался, здесь работает: планировщик свой, потолок настоящий, и backpressure.flang сверяется побайтово вместе с остальными.

Что сделано в шаге 7 — измерение

Контракт требовал: «сколько процессов помещается, сколько стоит отправка, сколько переключение. До этого числа не называются». Измерено — flang/conc/bench.mjs, запуск node --expose-gc flang/conc/bench.mjs.

Условия измерения названы первыми, потому что без них числа не числа

Машина: Node v24.18.0, Elixir 1.20.2 на Erlang/OTP 29 (erts-17.0.3, JIT). Процессоров процессу доступно восемь (os.cpus(), /proc/cpuinfo и availableParallelism() сходятся на восьми; nproc --all при этом называет сто двадцать — окружение показывает процессу часть машины).

И машина БЫЛА ЗАНЯТА чужой работой: средняя нагрузка за минуту во время замеров держалась между 18 и 76. Чьей именно работой заняты те ядра, которых процессу не видно, сказать нельзя, и это ещё один довод в пользу устройства ниже: спор о том, чья это нагрузка, главных чисел отчёта не касается вовсе.

Отсюда устройство измерения, и оно важнее самих чисел:

  1. Главные числа — те, что от нагрузки не зависят вовсе. Витки интерпретатора (двоичный поиск по запасу, а не таймер), редукции BEAM, байты процесса, целые счётчики пробегов и доставок. Ни одно из них не меняется от того, чем ещё занят компьютер, и все они повторяются от прогона к прогону: витки — точно, редукции — до десятой доли, байты — до байта.
  2. Секунды меряются процессорным временем, а не настенным. process.cpuUsage() стоит, пока процессор отдан соседу.
  3. Из повторов берётся минимум. Помеха может замер только удлинить.
  4. Разности заменены отношениями, а замеры пары чередуются. Помеха умножает оба замера примерно одинаково и из отношения уходит.

Числа времени ниже — верхние оценки: на свободной машине они меньше, но насколько, сказать нельзя, потому что свободной машины не было.

Одно число из счётчиков BEAM в список load-независимых НЕ попало, и врать об этом нельзя: переключения контекста от нагрузки зависят. Что с ними делать, сказано ниже, в разделе про доставку.

Сколько стоит пробег и отправка: витки, и они точны

Виток — единица самого языка, та же, которой пишется с запасом N витков. Считается двоичным поиском: наименьший запас, на котором обработчик ещё не падает с FLANG_BUDGET_EXHAUSTED.

ЧтоВитков
пробег обработчика, меняющего состояние и не шлющего ничего17
тот же пробег с одним отправить26
описание одной отправки9

Девять витков — это построение значения вариант «отправить» с «кому» … и «что» … и укладка его в список действий. Работы планировщика в этих девяти нет: доставка — поиск в таблице процессов и запись в конец ящика, обе O(1).

Отсюда важное следствие, которого до измерения назвать было нельзя: отправка в этой модели стоит примерно половину пробега (9 витков сверх 17), и стоит она в обработчике, а не в планировщике. Это прямое следствие решения «действие описывается, а не выполняется»: описание — это построенное значение, и оно не бесплатно.

Второе измерение, независимое от первого, говорит то же самое: пробег с отправкой дороже пробега без неё на 30–60% по процессорному времени (замеры чередуются, берётся отношение, а не разность — см. ниже про устройство). Витки предсказывают 26/17 − 1 = 53%. Два прибора, у которых нет ничего общего, сошлись — и это единственная причина верить более шумному из них.

Сколько стоит переключение: у эталона — O(числа процессов)

Это главная находка измерения, и она не в пользу эталона.

Планировщик эталона на КАЖДОМ пробеге пересобирает очередь готовых полным перебором всех процессов (порядок.filter(…) в runConcurrent). Значит переключение стоит O(P), а не O(1). Измерение это подтверждает: время пробега растёт по прямой с числом объявленных процессов.

Процессовмкс на пробег (процессорное время, минимум из 7)
22,8–5,1
2504,6–5,2
5007,2–8,0
100013,3–15,2
200026,3–28,3

Прямая по пяти прогонам: около 2 мкс + 12–13 нс на каждый объявленный процесс (наклон в пяти замерах — 11,4; 12,3; 12,6; 12,8; 13,3 нс). Нагрузка во время этих замеров — от 20 до 54; наклон её почти не заметил, а свободный член заметил: он и есть та часть, где меряется медленная машина, а не алгоритм.

Что это значит на практике: эталон — проверочный планировщик, и на программах из десятков процессов он ведёт себя как надо, а на тысячах перестаёт. Чинится это очередью готовых вместо перебора; здесь не чинится, потому что менять планировщик эталона — это менять то, с чем сверяется напечатанное (шаг 4), и такая правка стоит отдельной сверки с BEAM. Записано как найденное, а не как исправленное.

Сколько процессов помещается: у модели стенка не там, где кажется

*Раздел писался до шага Б1, и с ним разошёлся: породить теперь есть, поэтому всё, что сказано ниже про «объявить миллион процессов», описывает ОДИН из двух способов, а не единственный. Второй — прогон: стенд рой (flang/conc/zamer/gen.mjs) заводит миллион процессов из двух объявлений и семисот байт исходника. Числа ниже оставлены как измеренные: цена объявления никуда не делась, она просто перестала быть обязательной.*

**У модели не было породить** (шаг 1, было записано и в границе честности), поэтому набор процессов задавался ОБЪЯВЛЕНИЕМ. Чтобы процессов стало миллион, миллион объявлений надо было написать в исходнике и провести через разбор, проверку типов и печать. Значит и мерить надо обе стенки.

Стенка первая — цена объявления (Node, процессорное время, нагрузка ~47):

ПроцессовИсходникASTРазборТипыПечать
10019 КиБ~280 Б/проц1 мс<1 мс5 мс
1000173 КиБ~280 Б/проц14 мс1 мс7 мс
100001,7 МиБ~280 Б/проц144 мс8 мс50 мс

Растёт линейно, и до десятков тысяч процессов эта стенка не мешает: 1,7 МиБ исходника и 200 мс на всю цепочку. Мешает она человеку, а не машине — 177 байт исходника на процесс писать руками никто не станет.

Стенка вторая — байты на настоящей BEAM. Спрашивается у самого процесса (Process.info(pid, :memory): стек, куча, словарь и служебное), берётся медиана по всем процессам, и число повторяется от прогона к прогону ДО БАЙТА:

ЧтоБайт на процесс
голый GenServer, только считающий сообщения2720
процесс flang в дереве надзора2768
предел процессов узла BEAM1 048 576 (по умолчанию)

Процесс flang стоит на 48 байт дороже голого GenServer — это 1,8%. Столько занимает его состояние: запись из спецификации процесса и значения состояния. Ни дерево надзора, ни ловля выходов, ни счётчик витков в словаре процесса на покое не стоят почти ничего.

Первый вариант этого замера считал разность :erlang.memory(:processes) до и после и давал от 2 до 5 КиБ на процесс, а однажды показал у дерева flang МЕНЬШЕ, чем у голых GenServer'ов. Он мерил не процессы, а моменты сборки мусора. Записано здесь, потому что «измерение, которое иногда даёт отрицательный результат» — это не шум, а негодный прибор, и заменять его надо, а не усреднять.

Отсюда ответ на вопрос контракта, и его надо сказать целиком: в память узла помещается примерно столько же процессов flang, сколько процессов BEAM — миллион процессов по 2,7 КиБ это 2,7 ГиБ, то есть предел узла по умолчанию (1 048 576) и предел по памяти сходятся примерно в одной точке. Объявить миллион процессов было нечем: миллион объявлений — это 177 МиБ исходника. Шаг Б1 это снял: породить заводит процессы прогоном, и миллион работающих процессов в цели C измерен — 11,6 с процессорного и 1,7 ГиБ, то есть 1764 байта на процесс (docs/planirovshchik-zamer.md, дополнение о порождении). На BEAM порождения у нас по-прежнему нет: цель Elixir печатает статический план.

Сколько стоит доставка на настоящей BEAM: редукции и переключения

Редукция — валюта планировщика BEAM: по их счёту он вытесняет процессы. Число редукций на доставку не зависит от загрузки машины вовсе.

ЧтоРедукций на сообщениеПереключений контекста на сообщение
голый GenServer, только считающий сообщения19,30,46–0,70
процесс flang (тот же обработчик, что мерился витками)106,00,52–0,75

Число редукций повторяется от прогона к прогону до десятой доли (19,2–19,3 и 105,9–106,1 в пяти прогонах при нагрузке от 20 до 50). Процесс flang стоит BEAM в 5,5 раза дороже голого GenServer — это и есть цена модели поверх машины, названная числом, а не словами.

Из 106 редукций 13,4 — наблюдение: две записи в общую таблицу ETS (состояние процесса и запись журнала) плюс общий счётчик пробегов. Контракт шага 4 признавал, что «наблюдение стоит денег»; теперь известно, сколько именно — 13% пробега. Наблюдение нужно прогону, а не работе, но выключается оно сегодня только вместе с прогоном.

Остальные ~73 редукции сверх голого GenServer — это работа напечатанного обработчика (17 витков flang превращаются в вызовы функций Elixir), постройка и разбор списка действий и их исполнение.

Переключений контекста на сообщение — почти поровну: flang добавляет к ним около десятой доли, а не разы. Это ожидаемо: переключает их BEAM по своему кванту, а не по нашим пробегам, и это то же самое, что было измерено в шаге 4 с другой стороны — процесс успевает вычерпать весь ящик за один заход планировщика, поэтому чередований выпадает мало.

Про переключения надо сказать отдельно, чтобы не соврать: это единственный счётчик BEAM в этом отчёте, который от нагрузки ЗАВИСИТ. На нагрузке 20–25 он давал 0,46 и 0,52, на нагрузке 35–40 — 0,70 и 0,75: чем занятее машина, тем чаще ядро отбирает у планировщика BEAM его поток. Устойчиво здесь не само число, а разница между двумя строками таблицы — она держится около 10% в любом замере.

Чего измерение не даёт

Граница честности: что это против BEAM

Чего у нас может быть лучше.

Чего у нас не будет, и это не «пока».

Где мы решили иначе, и это видно. Перезапуск не очищает почтовый ящик, а в BEAM ящик умирает вместе с процессом. Основание — устройство: сообщение здесь значение в общей арене, а не копия в чужой куче, и терять принятые сообщения из-за спотыкания обработчика не за что. Следствие тоже надо назвать: перезапущенный процесс может немедленно получить те же сообщения, что довели предыдущее воплощение до отказа, и защищает от этого только порог отказов.

Про слово «альтернатива». BEAM — это виртуальная машина, планировщик, сборщик мусора, распределённая среда и тридцать лет эксплуатации. Здесь описывается модель конкурентности и планировщик к ней. Называть это альтернативой BEAM целиком было бы неправдой; альтернативой модели акторов BEAM — правдой, и то после того, как модель заработает и будет измерена.

Она заработала и измерена (шаги 4 и 7), поэтому условие снимается — но вместе с ценой, которую теперь тоже можно назвать числом, а не словом: доставка сообщения процессу flang стоит BEAM 106 редукций против 19 у голого GenServer. В пять с половиной раз дороже — за неизменяемое состояние, доказанную завершаемость обработчика, точный перезапуск и воспроизводимое чередование. Дорого это или дёшево, решает задача; важно, что цена больше не безымянна.

Что проверяется и чем

Тем же аппаратом, что и остальной язык, — иначе конкурентность станет местом, где проверки заканчиваются.

  1. Примеры в исходнике. Пример конкурентной программы — семя, входные сообщения, ожидаемый итог. Работает как обычный пример.
  2. Дифференциальная сверка. Планировщик эталона и планировщик напечатанного кода обязаны давать одинаковое чередование на одинаковом семени — побайтово одинаковый журнал доставок. Это сильнее сверки значений: расхождение в планировщике проявится задолго до расхождения в итоге.
  3. Перебор семян. Сетка входов дополняется сеткой семян. Утверждение «состояние не рассыпается ни при каком чередовании» проверяется на конечном наборе — и записывается как проверенное, а не доказанное.
  4. Анализ завершаемости. Обработчик без признака тотальная и без запаса витков — ошибка проверки, а не предупреждение.

Из этих четырёх в шаге 1 заработали первый и четвёртый, в шаге 4 — второй (и не в том виде, в каком записан выше), в шаге 5 — третий, в шаге 6 — второй в исходном виде.

Пункт 2 пришлось переписать — и половину переписанного удалось вернуть. «Побайтово одинаковый журнал на одинаковом семени» требует, чтобы у обоих планировщиков было семя. У BEAM его нет и быть не может: чередование выбирает её планировщик. Поэтому сверка с ней идёт по НАБОРУ: эталон на тысяче семян даёт множество исходов и множество журналов, и каждый исход (и каждый журнал) с BEAM обязан в это множество попасть. Слабее побайтового совпадения — но применимо к настоящей машине, а побайтовое совпадение к ней неприменимо вовсе. Подробности и числа — в разделе «Что сделано в шаге 4»; там же записано, что множество проверено на насыщение и на зубы, без чего такая сверка ничего не значила бы.

У планировщика в рантайме C семя ЕСТЬ, потому что его писали мы. Поэтому там пункт 2 работает буквально: одно семя — один журнал, побайтово тот же, что у эталона, на каждом семени сетки. Это единственная цель, где сверка планировщика такая же строгая, как сверка значений у остальных бэкендов, и оговорка к ней ровно одна и измерена: там, где исход решается ЗАПАСОМ ВИТКОВ, единица запаса у целей своя, и журналы разойдутся законно (шаг 6, «Где C расходится с эталоном»).

Пункт 3 закрыт в шаге 5. Сетка семян стала поверхностью языка: семя от N до M перебирает отрезок, ожидается «П» равен … на сетке становится инвариантом («при любом чередовании»), а ожидается «П» любое из [ … ] называет множество достижимых итогов и проверяется в ОБЕ стороны — ни один итог не вышел за множество, и каждое названное значение где-то встретилось. Подробности и то, чего у сетки нет, — в разделе «Что сделано в шаге 5».

Почему этого не заменяет перебор из теста печати, сказано там же, но повторить стоит: тот перебор живёт в JS-тесте, то есть в инструменте разработчика языка. Автор конкурентной программы писал в исходнике семя 4172 и получал утверждение об одном чередовании; утверждение «состояние не рассыпается ни при каком порядке» не проверялось никак.

Чего проверка не даёт: свободы от взаимной блокировки. Два процесса, каждый из которых ждёт сообщения от другого, — законная программа, и модель её не запрещает. Перебор семян такую пару обнаружит (планировщику нечего делать, а процессы не завершились), но это обнаружение на конечном наборе, а не доказательство отсутствия.

Порядок работ

  1. Поверхность: процесс, состояние, начинает с, принимает, обрабатывает, тип отклика, набор действий. Разбор и проверка типов; печать не тронута.
  2. Планировщик эталона на JavaScript: очередь готовых, ящики, виртуальное время, семя. Примеры конкурентных программ работают.
  3. Надзор: стратегии, порог, отказ как определённый исход. Работает; «передать выше» потребовало надзора над надзором, а порог считается пробегами, а не миллисекундами — см. «Что сделано в шаге 3».
  4. Печать в Elixir: процесс в GenServer, надзор в супервизоры OTP, сверка с эталоном по набору исходов на сетке семян. Работает; чем разошлось — см. «Что сделано в шаге 4». Порядок пунктов здесь исправлен: Elixir был шестым, а раздел «Свою BEAM писать не надо» с самого начала называл его первой настоящей целью, идущей раньше рантайма C. Список противоречил тексту, и неправ был список.
  5. Перебор семян в сетке входов: семя от N до M и ожидается «П» любое из [ … ], множество проверяется в обе стороны, расхождение называет семя. Работает; ноль новых ключевых слов — см. «Что сделано в шаге 5». Номер поменялся местами с рантаймом C: перебор семян от него не зависит вовсе, а ждать его значило бы держать пункт 3 «Что проверяется и чем» открытым без всякой на то причины.
  6. Планировщик в рантайме Cflang/src/emit/c/flang_conc.[ch], план данными, прогон запросом прогонщика, и дифференциальная сверка вернулась в ИСХОДНОМ виде: то же семя — побайтово тот же журнал доставок, 2400 совпадений на 90 различных чередованиях. Сделано наполовину, и половина названа: режим проверочный (один поток), рабочего пула потоков нет, и во что он обошёлся бы — измерено на трёх машинах. У слова «проверочный» есть и второе измерение, названное позже самого шага: режим был конечен по времени жизни — арена не возвращала памяти, 510,6 байта на пробег. Это снято шагом А2: у процесса своя куча, наклон рабочего режима равен нулю. Печать конкурентности в остальные пять целей не сделана, а JavaScript из этого списка вышел — см. «Что сделано в шаге 6».
  7. Измерение: flang/conc/bench.mjs, числа и условия — в «Что сделано в шаге 7». Главные числа выбраны так, чтобы не зависеть от загрузки машины (витки, редукции, байты), потому что свободной машины за время работы не случилось ни разу. Сделано раньше шестого пункта намеренно: мерить есть что — эталон и настоящая BEAM работают, — а «до этого числа не называются» запрещало называть числа, а не откладывать измерение до последнего шага. Чего измерение не покрывает, названо там же: рантайма C нет, значит и мерить в нём нечего.

Шаги 1–3 не трогают печать и идут параллельно с теоркатом. Шаг 4 печати коснулся, но от ввода-вывода НЕ зависел, и это выяснилось делом: он сделан, а процессам хватило разговора друг с другом.

Зависимость, записанная здесь раньше, была лишней — но обратная связь появилась и настоящая. Ввод-вывод сделан (не монадой, а машиной продолжений), и «Поручение» там уже является значением. Значит связать его с процессом можно тем же способом, каким связано «Действие»: обработчик вернёт поручение в списке действий, а исполнит его хозяин. Пока это не сделано, напечатанной программе не с чем говорить, кроме как с процессами, — и это ограничение шага 4, а не BEAM.

Открытые вопросы

Вопрос «ящик неограничен или ограничен» отсюда УБРАН: шаг А3 ответил на него обоими вариантами сразу, и это оказалось не компромиссом, а правильным ответом. Ящик неограничен, пока его размер не объявлен, — программы, написанные до А3, не изменились ни в чём. принимает «T» с ящиком N сообщений делает переполнение определённым исходом: отправитель отказывает FLANG_MAILBOX_FULL, и решение принимает надзор. Да, отправка теперь может отказать, и это записано в множество достижимых отказов пятым видом, то есть надзор над отправителем стал обязательным — это и есть та самая смена семантики, о которой вопрос предупреждал, и она оказалась не ценой, а приобретением. Числа, на которых стоит решение: 454,3 байта на застрявшее сообщение до и ноль после (flang/conc/RESILIENCE.md, шаг А3).

Вопрос «общая арена или арена на процесс» отсюда УБРАН: владелец выбрал арену на процесс (А0), она сделана (А2), и «сообщения не копируются» вычеркнуто из границы честности выше. Открытым он был ровно до выбора.