Детерминированность
Для встроенных (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 из 5 запусков) гонка видимости памяти приводила к зависанию.
- Даже после (2) быстрый поток мог полностью отработать и покинуть
эстафету ДО того, как родитель успевал зарегистрировать следующего участника партии — не зависание, а молчаливая потеря самого чередования (один поток печатал всё подряд, затем другой). Исправлено добавлением барьера регистрации всей партии внутри входа в эстафету.
Все три исправления объединены общим принципом: родитель и новосозданные потоки выполняются по-настоящему параллельно друг с другом, и ЛЮБОЕ состояние, которое читает больше одного потока, должно и писаться, и читаться под одним и тем же мьютексом — "обычно работает на x86" не равно "формально гарантировано", а редкие гонки по определению не находятся одним прогоном.