Детерминированность

Для встроенных (embedded) сценариев и станков с ЧПУ, для которых задуман язык в перспективе, повторяемость результата запуска в запуск — не абстрактное пожелание, а требование: одна и та же программа с одними и теми же входными данными обязана дать БИТ-В-БИТ одинаковый результат каждый раз. Ниже — то, что уже проверено и честно гарантировано, и то, что сознательно оставлено на будущее.

✅ Арифметика дробное (f32/f64) — строгая IEEE754, без FMA и без fast-math

Кодогенератор не выставляет никаких fast-math флагов на fadd/fsub/fmul/fdiv (build_float_add и соседние в src/codegen.rs используют значения по умолчанию у inkwell/LLVM — то есть строгую, не ослабленную IEEE754 семантику: никакого допущения на перегруппировку (reassoc), на слияние умножения со сложением в одну FMA-инструкцию (contract) или другие вольности). Объектный код собирается с inkwell::OptimizationLevel::None (src/codegen.rs, create_target_machine) — единственный существующий в компиляторе режим, никакого отдельного "оптимизированного" пути сборки, который мог бы случайно дать другой результат, не существует вовсе.

Проверено вживую и на уровне сгенерированного машинного кода, не только "на словах":

  • Один и тот же .код-файл с a * b + c (классический FMA-чувствительный

паттерн) и с накоплением суммы в цикле на миллион итераций — запущен 5 раз подряд, результат бит-в-бит одинаков каждый раз.

  • Дизассемблирование скомпилированного бинарника (objdump -d)

подтверждает: a * b + c даёт РАЗДЕЛЬНЫЕ vmulsd и vaddsd — компилятор НЕ слил их в одну vfmadd-инструкцию (которая дала бы другое промежуточное округление). Инструкций fma/vfmadd в скомпилированной программе нет вовсе.

  • дробное по умолчанию — f64 (двойная точность), дробное32/

дробное64 — явные варианты; на x86-64 используются SSE2-регистры (vmulsd/vaddsd), а не 80-битная x87-арифметика с её известной проблемой двойного округления — отдельный, самостоятельный источник недетерминированности в некоторых старых компиляторах, здесь структурно отсутствующий.

Честно: это гарантия ОДНОЙ программы на ОДНОЙ и той же машине/ архитектуре между запусками — не гарантия побитового совпадения результата МЕЖДУ разными процессорными архитектурами или разными ОС (это отдельный, гораздо более широкий вопрос воспроизводимости между платформами, не рассматривался в этом заходе).

✅ Порядок элементов Словаря — см. раздел Словарь\<Ключ, Значение\> выше

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

Многопоточность (параллельно)

✅ **Исправлен настоящий баг компилятора, найденный именно при исследовании этой темы**: параллельно безусловно (даже БЕЗ флага -g) вызывал get_current_debug_location()/set_current_debug_location() на LLVM-билдере — без включённой отладочной информации это "прилипало" заглушечной (пустой) debug-локацией ко ВСЕМ последующим инструкциям программы, что при определённых условиях (два отдельных блока параллельно, каждый с конкатенацией текста) приводило к настоящему SIGSEGV самого компилятора на этапе эмиссии машинного кода (SelectionDAGISel). Исправлено: debug-локация теперь сохраняется и восстанавливается только тогда, когда отладочная информация действительно включена. Подробности и то, как было найдено и проверено (включая дизассемблирование и backtrace через gdb) — в ИСТОРИЯ_РАЗРАБОТКИ.md.

⚠️ **Порядок побочных эффектов между потоками НЕ детерминирован — и не может быть иначе без отдельного, гораздо более крупного планировщика**: параллельно запускает настоящий поток ОС (pthread_create), и относительный порядок, в котором выполняются/печатают/завершаются НЕСКОЛЬКО одновременно запущенных потоков, зависит от планировщика операционной системы — он не контролируется языком и меняется от запуска к запуску. Проверено вживую: программа с двумя параллельно блоками, каждый печатает 5 строк — при повторном запуске одного и того же скомпилированного бинарника порядок чередования строк между двумя потоками менялся от прогона к прогону (иногда один поток полностью обгонял другой, иногда строки чередовались). Это не баг — это честное, фундаментальное свойство использования настоящих потоков ОС без собственного детерминированного планировщика (отдельная, значительно более крупная задача, сознательно вынесенная за рамки этого захода).

✅ **При этом канал канал из T (один отправитель → один получатель, ровно то использование, что и документировано выше) передаёт ЗНАЧЕНИЕ детерминированно** — проверено вживую: поток считает сумму 100 000 чисел в цикле и отправляет её в канал, главный поток её получает — результат бит-в-бит одинаков при 5 повторных запусках. Это ожидаемо: внутри отправить/получить — честная пара mutex+condition variable, гонки по значению здесь нет, недетерминирован только ОТНОСИТЕЛЬНЫЙ порядок побочных эффектов между независимыми потоками (пример выше), а не содержимое того, что передаётся через канал.

**Как получить детерминированное поведение уже сегодня, имеющимися средствами**: если нужен предсказуемый порядок между несколькими потоками — используйте дождаться_поток(номер)/дождаться_все_потоки() для явной синхронизации стадий (поток B не начинает читать то, что посчитал поток A, пока A не дождались), и канал из T для передачи результата без гонки по данным. Не давайте нескольким потокам писать в один и тот же Список/Словарь/Метрики/Трейсер/Профилировщик одновременно — они не потокобезопасны (см. соответствующие разделы) — заводите отдельный экземпляр на поток, как и рекомендовано в каждом из них.

✅ параллельно детерминировано { ... } — опциональный детерминированный планировщик ("эстафета")

Начиная с этой версии, у параллельно есть опциональный модификатор — параллельно детерминировано { ... }. Он не меняет поведение ОБЫЧНОГО параллельно (оно по-прежнему честные, неупорядоченные потоки ОС, см. выше) — это отдельный, явно запрашиваемый режим для случаев, когда нужен ПРЕДСКАЗУЕМЫЙ, воспроизводимый порядок чередования между несколькими потоками, без переписывания логики под канал из T/ дождаться_поток.

Идея ("эстафета", она же баtton): все потоки, запущенные с детерминировано в пределах одной "партии" (см. ниже), по очереди, строго по кругу, передают друг другу право выполняться — как эстафетную палочку. Каждый поток выполняет свой код ТОЛЬКО пока палочка у него, и обязан отдать её следующему на ближайшей "точке передачи" (yield-point). Реальной параллельности между детерминировано-потоками поэтому НЕТ — в каждый момент физически работает ровно один из них (это сознательный компромисс honest v1: простой, проверяемый протокол ценой производительности, а не попытка повторить настоящий параллелизм с детерминированным результатом — такая задача несравнимо труднее и не ставилась).

Пример (ровно test_детерм_планировщик.код из tests/):

функция главная()
    параллельно детерминировано
        целое i = 0;
        пока (i меньше 5)
            вывести("А" + целое_в_текст(i));
            i = i + 1;
        конец
    конец
    параллельно детерминировано
        целое i = 0;
        пока (i меньше 5)
            вывести("Б" + целое_в_текст(i));
            i = i + 1;
        конец
    конец
    дождаться_все_потоки();
конец

Вывод этой программы **бит-в-бит одинаков при ЛЮБОМ числе повторных запусков** (проверено — см. ниже): А0 Б0 А1 Б1 А2 Б2 А3 Б3 А4 Б4. Без детерминировано порядок строк между А*/Б* был бы случайным (см. предыдущий раздел).

**Точки передачи (yield-points) — честно ограниченный, узкий список v1**: палочка передаётся следующему участнику ТОЛЬКО в эти моменты:

  • в начале КАЖДОЙ итерации цикла пока (перед телом цикла);
  • сразу после каждого вызова вывести(...);
  • сразу после канал.отправить(...) и канал.получить().

⚠️ для каждого НЕ является точкой передачи в этой версии — цикл для каждого внутри детерминировано-блока выполнится целиком без передачи палочки между итерациями (детерминированный ПОРЯДОК ВНУТРИ одного такого потока от этого не нарушается — просто другие детерминировано-потоки не получат слово, пока этот для каждого не закончится полностью). Если нужна передача хода на каждой итерации коллекции — перепишите цикл через пока с ручным счётчиком.

**"Партия" (батч) — те же правила, что уже были у обычного дождаться_все_потоки()**: все детерминировано-потоки, запущенные между двумя соседними вызовами дождаться_все_потоки() (или от начала функции до первого такого вызова), образуют одну партию и участвуют в одной общей эстафете; дождаться_все_потоки() ждёт (pthread_join) всех и обнуляет счётчики для следующей партии — следующая партия, даже если она в том же главная(), начинает новую, независимую эстафету. Максимум — 256 детерминировано-потоков на партию (тот же лимит, что и у обычных потоков через дождаться_поток).

✅ Главный поток не участвует в эстафете — он просто вызывает дождаться_все_потоки() и ждёт снаружи, как и для обычных потоков.

✅ **Обычный (не детерминировано) параллельно можно свободно запускать в той же партии рядом с детерминировано-потоками** — он не участвует в эстафете, не блокирует её и не блокируется ею (его собственный порядок, как и раньше, недетерминирован).

Как проверялось (живым запуском, не умозрительно — см. подробности, включая оба найденных и исправленных дедлока/гонки, в ИСТОРИЯ_РАЗРАБОТКИ.md):

  • 4 разных сценария (два потока одинаковой длины; три потока РАЗНОЙ

длины, включая досрочный выход из эстафеты; соло-поток (N=1) сам по себе; смешанная партия — соло-батч, затем обычный параллельно рядом с двумя детерминировано-потоками) — **каждый прогонялся минимум 30–300 раз подряд, результат бит-в-бит одинаков во ВСЕХ прогонах, ни одного зависания**.

  • Отдельный, намеренно минимальный тест (2 потока, по 2 оператора

вывести без цикла, обычный параллельно в той же партии) — 300/300 чистых запусков и **8/8 чистых запусков под valgrind --leak-check=full** (0 ошибок, 0 утечек в каждом) после финального исправления.

  • opt -passes=verify — пройден на сгенерированном .ll всех

тестовых сценариев, и с -g, и без.

Честно о найденных и исправленных по дороге багах (три подряд, каждый найден исключительно живым тестированием, не умозрительно — подробный, технический разбор каждого — в ИСТОРИЯ_РАЗРАБОТКИ.md):

  1. Счётчик эстафеты не сбрасывался между партиями — вторая партия

зависала навсегда.

  1. Запись родителя в общий флаг активности потока делалась без мьютекса

— редкая (~1 из 5 запусков) гонка видимости памяти приводила к зависанию.

  1. Даже после (2) быстрый поток мог полностью отработать и покинуть

эстафету ДО того, как родитель успевал зарегистрировать следующего участника партии — не зависание, а молчаливая потеря самого чередования (один поток печатал всё подряд, затем другой). Исправлено добавлением барьера регистрации всей партии внутри входа в эстафету.

Все три исправления объединены общим принципом: родитель и новосозданные потоки выполняются по-настоящему параллельно друг с другом, и ЛЮБОЕ состояние, которое читает больше одного потока, должно и писаться, и читаться под одним и тем же мьютексом — "обычно работает на x86" не равно "формально гарантировано", а редкие гонки по определению не находятся одним прогоном.