Обзор Nim 2.4.0: типы-суммы, параллельная сборка и алгоритм управления памятью, который не боится потоков

Обзор Nim 2.4.0: типы-суммы, параллельная сборка и алгоритм управления памятью, который не боится потоков

Полтора месяца назад я разбирал Nim 2.2.12 — патч-релиз, в котором не было ни одной новой конструкции языка, зато была закрыта дыра в асинхронном HTTP-сервере. В том тексте я упомянул, что в ветке devel собирается будущая 2.4. И вот 28 октября 2026 года эта версия доехала до релиза.

Предыдущая стабильная ветка, 2.2, открылась 2 октября 2024 года. То есть между сменами веток прошло два года и почти месяц. Даже команда в анонсе называет этот цикл «слишком долгим». За это время в репозиторий ушло 1126 коммитов от 115 авторов (по подсчёту через API GitHub; тройка самых продуктивных — ringabout, Araq и metagn, на них числится почти две трети всех коммитов). В тот же день вышел и седьмой патч старой ветки, 2.2.14: тем, кто сидит на 2.2 и не собирается переезжать, стоит знать, что ветку бросать не планируют.

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

  • оптимизация коротких строк и новый низкоуровневый строковый API;
  • параллельная инкрементальная сборка, которая наконец-то дошла до поставки;
  • типы-суммы без отдельного перечисления и с обязательным сопоставлением с образцом;
  • легализованные циклические импорты модулей;
  • YRC — новый алгоритм управления памятью, потокобезопасный и собирающий циклы;
  • экспериментальный модификатор owned, который убирает типы из-под надзора сборщика циклов.

Ниже рассмотрим каждый пункт. Все примеры кода я собрал и прогнал на установленном 2.4.0.

Оптимизация коротких строк и новый API

Начнём с того, что такое оптимизация коротких строк, она же SSO (small-string optimization). Обычная строка в куче устроена как структура из трёх полей: указатель на буфер, длина и ёмкость. Каждая строка, даже из трёх символов, — это отдельное выделение памяти. Оптимизация коротких строк убирает это выделение: пока строка помещается прямо внутрь самой структуры, буфер хранится там же, рядом с длиной, и куча не трогается вовсе. Меньше вызовов аллокатора и лишних разыменований, лучше локальность данных.

Включается оптимизация ключом --strings:sso. Но у неё есть цена, и она не в производительности, а в том, что код для работы со строками придётся корректировать.

Дело в том, где теперь живут байты строки. Привычный приём addr s[i] — «дай указатель на i-й символ» — возвращает адрес внутри буфера. Пока буфер в куче, адрес стабилен: строку можно копировать, перемещать, передавать по значению, а указатель продолжает смотреть туда же. С SSO короткая строка хранит байты внутри себя, и любое перемещение строки перемещает и байты: сохранённый указатель превращается в мину.

Поэтому вместе с --strings:sso пришёл новый низкоуровневый API, который заменяет голое взятие адреса операциями с явным намерением. Чтение отделено от записи:

ЗадачаОперацияОговорка
Указатель только для чтенияreadRawData(s, start = 0)валиден, пока строка жива и не изменяется
Указатель, переживающий копии и переездыreadRawDataStable(s, start = 0)принимает var string; сначала выталкивает короткую строку в кучу
Массовая запись в строкуbeginStore(s, newLen, start = 0) … endStore(s)выставляет длину, обеспечивает монопольное владение, отдаёт указатель на запись

Практическая разница видна на типовых приёмах. Массовое дописывание в конец строки раньше выглядело так:

# было
result.setLen newLen
copyMem(addr result[oldLen], buf, buflen)

Теперь:

# стало
copyMem(beginStore(result, newLen, oldLen), buf, buflen)
endStore(result)

beginStore сам выставляет новую длину и разбирается с копированием при записи (copy-on-write: если буфер разделён с другой строкой, перед записью он станет уникальным), поэтому отдельный вызов prepareMutation больше не нужен. Чтение байтов меняется симметрично:

# было
copyMem(dest, addr(s.data[s.pos]), n)

# стало
copyMem(dest, readRawData(s.data, s.pos), n)

Присваивание по индексу, s[i] = c, продолжает работать как раньше: компилятор переводит его во внутреннюю процедуру, которая сама разбирается с копированием при записи.

Я проверил новый API на маленькой программе: дописываем «WORLD» в конец строки через beginStore, потом читаем эти же байты обратно через readRawData:

proc main() =
  var src = "WORLD"
  var s = "hello "
  let oldLen = s.len
  let newLen = oldLen + src.len
  # beginStore выставляет длину и отдаёт указатель для записи
  copyMem(beginStore(s, newLen, oldLen), addr src[0], src.len)
  endStore(s)
  echo s
  # читаем байты из строки, не трогая addr
  var dest: array[5, char]
  copyMem(addr dest[0], readRawData(s, 6), 5)
  echo dest
main()

Сборка по умолчанию и сборка с --strings:sso дают одинаковый результат:

hello WORLD
['W', 'O', 'R', 'L', 'D']

Это и есть главное свойство нового API: у него одинаковые сигнатуры на всех строковых бэкендах. Код, переписанный на readRawData и beginStore, работает и с SSO, и без, и под любым менеджером памяти: refc, ARC, ORC. То есть портировать можно постепенно, а ключ включить в конце.

Команда предлагает чек-лист миграции:

  • найти в коде все addr s[, unsafeAddr s[ и закешированные указатели внутрь строк;
  • чтение заменить на readRawData (а если указатель должен пережить переезд строки — на readRawDataStable);
  • пары setLen + сырая запись — на beginStore/endStore;
  • выкинуть ставшие лишними prepareMutation;
  • собрать с ключом и без и убедиться, что поведение совпадает.

Если не использовать оптимизацию, обновлять код не потребуется: старые приёмы продолжают компилироваться. Но команда называет портирование само по себе «стоящим рефакторингом на будущее»: такой же строковый API будет у Nimony, экспериментального компилятора, на базе которого готовится Nim 3. Кто перейдёт сейчас, тому потом переезжать будет проще.

Сборка: инкрементальная и параллельная

Вторая новинка, включаемая ключом --ic:on, — IC, incremental compilation, инкрементальная компиляция. Это кеширование результата компиляции по модулям: тронули один файл, пересобирается он и то, что от него зависит, а не весь проект. В Nim эта штука жила в состоянии вечного эксперимента много лет, и в анонсе это признают тремя словами: «IC наконец-то включили в комплект поставки!»

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

Пример такого портирования существует и описан в анонсе: библиотека nimsimd, SIMD-обёртки для Nim. Там макрос {.simd.} помечал процедуры и складывал их в глобальную таблицу simdProcs в одном модуле, а макрос {.hasSimd.} читал эту таблицу в другом. В режиме nim ic глобальная переменная через границу модулей не проходит, и hasSimd переставал видеть помеченные процедуры. Исправление — перевести таблицу на macrocache.CacheTable. Pull request влит 24 сентября 2026 года, и после него графическая библиотека PolyWorld, зависящая от nimsimd, собирается с --ic:on.

Приятный бонус: от формы графа импортов зависит, ускорится ли даже холодная сборка, потому что параллелизм задействуется и на ней. Команда мерила на самом компиляторе: его полная сборка на машине с 16 физическими ядрами занимает 31 секунду с --ic:off и 25 секунд с --ic:on. Это замер холодной сборки, без кеша. У пересборки после мелкой правки выигрыш будет крупнее.

На классическом «здравствуй, мир» с импортом std/strutils ключ --ic:on отработал без сюрпризов: собралось, запустилось, напечатало.

Типы-суммы: без перечисления, с обязательным разбором

Теперь о том, что заметнее всего в коде: о типах-суммах. Это алгебраические типы данных: значение здесь представляет собой один вариант из заранее перечисленных, и у каждого варианта свои поля. В Nim они всегда существовали в виде «размеченных объединений» в духе Ады: отдельное перечисление для метки плюс объект с case по этой метке:

type
  NodeKind = enum  # отдельное перечисление для метки
    IntLit, FloatLit, StringLit, AddOp, SubOp, IfStmt
  Node = ref object
    case kind: NodeKind
    of IntLit: intVal: int
    of FloatLit: floatVal: float
    of StringLit: strVal: string
    of AddOp, SubOp:
      leftOp, rightOp: Node
    of IfStmt:
      condition, thenPart, elsePart: Node

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

type
  Node = ref object
    case
    of IntLit: intVal: int
    of FloatLit: floatVal: float
    of StringLit: strVal: string
    of AddOp, SubOp:
      leftOp, rightOp: Node
    of IfStmt:
      condition, thenPart, elsePart: Node

Под капотом это всё тот же case-объект со скрытым дискриминатором, так что раскладка в памяти и производительность не изменились. Имя ветки становится конструктором, поля передаются именованными аргументами:

let n = AddOp(leftOp: IntLit(intVal: 40), rightOp: IntLit(intVal: 2))

Работают и обобщённые суммы: конкретный экземпляр выводится из значений полей или из ожидаемого типа:

type
  Opt[T] = object
    case
    of None: discard
    of Some: val: T

let a = Some(val: "abc")      # Opt[string]: вывелся из значения
let b: Opt[int] = None()      # ожидаемый тип выбирает экземпляр

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

proc eval(n: Node): int =
  case n
  of IntLit(v): v
  of AddOp(a, b): eval(a) + eval(b)
  of SubOp(a, b): eval(a) - eval(b)
  else: raiseAssert "not an arithmetic expression"

Здесь v, a и b — новые имена, привязанные к полям на время ветки. Символ _ пропускает ненужное поле, запись {AddOp, SubOp}(a, b) накрывает несколько веток с одинаковыми полями разом, а case обязан либо перебрать все ветки, либо иметь else — неполный разбор не скомпилируется.

Этот пример я собрал и запустил как есть: eval от дерева «40 плюс 2» честно напечатал 42. А вот попытка достать поле напрямую, мимо разбора, теперь останавливается компилятором:

let n = IntLit(intVal: 5)
echo n.intVal
Error: field 'intVal' can only be accessed in a pattern matching `case` branch

Nim-старожилы помнят, как было раньше: обращение к полю не той ветки давало FieldDefect в рантайме, в лучшем случае. С отключёнными проверками оно молча читало чужие байты. Теперь целый класс таких ошибок переехал из рантайма в компиляцию. Если поле нужно достать вне case, например, в отладочной печати, — есть лазейка {.cast(uncheckedAccess).}, которая возвращает старое поведение явно и локально:

let n = IntLit(intVal: 5)
{.cast(uncheckedAccess).}:
  echo n.intVal   # скомпилируется и напечатает 5

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

type
  Shape = object
    case
    of Circle: radius: float
    of Rect: w, h: float

var shapes = @[Circle(radius: 1.0), Rect(w: 1.0, h: 2.0)]
for i in 0 ..< shapes.len:
  case shapes[i]
  of Circle(r): r = r * 2       # меняет shapes[i].radius
  of Rect(w, _): w = 3.0
echo shapes

Программа печатает @[Circle(radius: 2.0), Rect(w: 3.0, h: 2.0)]: присваивание ушло прямо в элементы массива. А если разбирается значение, а не место в памяти, поля достаются копиями.

Классические case-объекты с явным перечислением продолжают работать и не объявлены устаревшими: мигрировать никого не заставляют. Разработчики поясняют, что в будущем сопоставление с образцом позволит компилятору убирать проверку дискриминатора там, где ветка ему известна заранее.

Циклические импорты: легализованы по предъявлении

Модули в Nim всегда могли импортировать друг друга, но с оговоркой, которая портила жизнь: внутри цикла модуль видел только те объявления из соседнего модуля, что стоят в тексте до строки import. Отсюда привычное жонглирование порядком объявлений, а в тяжёлых случаях — вынос всех типов в отдельный types.nim, который импортировали обе стороны.

В 2.4 цикл можно объявить честно, прагмой {.cyclic.} на импорте:

# module a
import b {.cyclic.}

type
  A* = object
    b*: B          # тип из b, хотя b импортирует a

proc isEven*(n: int): bool = n == 0 or isOdd(n - 1)
# module b
import a {.cyclic.}

type
  B* = object
    x*: int
    a*: ref A

proc isOdd*(n: int): bool = n != 0 and isEven(n - 1)

Модули, связанные такими импортами, компилятор проверяет пофазно, как одну группу: сначала импорты всех модулей группы, потом все секции типов, потом сигнатуры процедур (тела — позже), потом остальное. Благодаря этому типы могут ссылаться друг на друга через границу модулей, а процедуры — вызывать друг друга, и порядок объявлений внутри файлов больше не играет роли. Классическая взаимная рекурсия «чётное-нечётное» из примера выше собралась и напечатала true для десяти и false для семи.

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

Циклы без прагмы работают по-старому, но объявлены устаревшими. Найти их в своём проекте помогает предупреждение, которое я проверил на той же паре модулей, убрав аннотации:

$ nim c --warning:ImplicitCyclicImport:on a.nim
Warning: import cycle without '.cyclic'; this is deprecated, annotate
the imports on both sides of the cycle, like 'import a {.cyclic.}':
a.nim imports b.nim
b.nim imports a.nim [ImplicitCyclicImport]

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

YRC: управление памятью, которое умеет в потоки

Осталось самое глубокое изменение. Прежде чем перейти к нему, давайте вспомним устройство управления памятью в Nim. Мы разбирали его в посте про 2.2.12. Компилятор сам расставляет освобождение памяти по коду. Это подсчёт ссылок без ручного труда и без пауз stop-the-world. Но подсчёт ссылок сходит с ума из-за циклов, когда объекты ссылаются друг на друга по кругу. В этом случае счётчики никогда не доходят до нуля. Поэтому поверх работает отдельный маленький сборщик циклов. Такая связка называется ORC: O — кольцо, RC — подсчёт ссылок.

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

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

Ключ --mm:ПотокобезопасенСобирает циклы
arcнетнет
atomicArcданет
orcнетда
yrcдада

Вот как это работает. Для ацикличных данных YRC использует атомарный подсчёт ссылок, как atomicArc. Для циклических добавляется барьер записи, небольшая проверка, которая срабатывает только при присваивании указателей и сообщает сборщику об изменении графа ссылок. Фазы «остановить мир» нет, произвольных точек приостановки нет. Отдельного потока-сборщика тоже нет: любой поток, которому понадобилась память, сам прогоняет сборку циклов. Это улучшает локальность и удобно для встраиваемых систем, где каждый поток на счету.

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

Я прогнал и дымовой тест: пара объектов, ссылающихся друг на друга, собранная с --mm:yrc, пережила явный GC_fullCollect() и продолжила работать.

owned: типы, которым не нужен сборщик циклов

Последнее нововведение — экспериментальное, и команда предупреждает об этом вот такими словами: «обещаем, что мы снова сломаем ваш код, если вы будете этим пользоваться». Другими словами, используйте это на свой страх и риск, потому что какие-то фрагменты вашего кода точно перестанут работать так, как вы от них ожидаете. Включается эта штука ключом --experimental:ownedRefs или прагмой {.experimental: "ownedRefs".} в модуле.

Смысл фичи такой. Модификатор owned помечает единственного владельца объекта. Тип owned ref T и owned-замыкания гарантируют: у объекта один владелец, и компилятор при сборке следит за этим:

{.experimental: "ownedRefs".}
type
  Node = ref object
    next: owned Node
    data: int

Гарантия обеспечивается тремя правилами:

  • владельца нельзя копировать, только перемещать, то есть передавать владение дальше;
  • стать владельцем может только новое значение: конструкция объекта, new(T) или лямбда;
  • обычных ссылок на тот же объект может быть сколько угодно, они лишь продлевают ему жизнь после ухода владельца.

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

Зачем это нужно, видно из предыдущего раздела. Если нарисовать стрелку от каждого владельца к его объекту, получится лес: у каждого объекта только одна входящая стрелка, и по таким стрелкам к тому же объекту не вернуться — цикл невозможен. Значит, тип, все ссылки которого owned или .cursor, заведомо не может оказаться частью цикла, и сборщику циклов, что в ORC, что в YRC, незачем за ним следить. Это способ вывести горячие структуры данных из-под надзора сборщика, не отказываясь от автоматического управления памятью в остальной программе.

Если это описание показалось похожим на механизм владения из Rust, вам не показалось. Сходство не случайное. Но родство тут на уровне идей, внутри всё устроено иначе. Давайте обсудим эту разницу.

Совпадают они в главном. В Rust из правила «одно значение — один владелец» само собой выходит отсутствие циклов: пока вы не взяли Rc — умный указатель с подсчётом ссылок, который даёт значению несколько владельцев, — или unsafe, два объекта просто не смогут сослаться друг на друга. В Nim работает то же рассуждение: раз у каждого объекта один владелец, структура становится лесом, и циклу в ней взяться неоткуда. Но там, где Rust делает этот вывод обязательным для всей программы, Nim делает его опциональным, оставляя на ваше усмотрени: хотите такое доказательство для конкретного типа — пометьте ссылки owned, и компилятор его проверит. Перемещение вместо копирования и инициализация только свежими значениями — приёмы те же самые.

Но вот роли в системе отличаются. В Rust владение — это вся модель управления памятью: оно решает, когда вызвать деструктор, и попутно запрещает гонки, висячие указатели и запись через чужую ссылку. За этим следит borrow-чекер, компонент компилятора, проверяющий правила заимствования ссылок и их времена жизни. В Nim owned памятью не заведует вообще никто: освобождение по-прежнему считает ARC, ORC или YRC, а обычные ссылки на тот же объект законно продлевают ему жизнь после ухода владельца. Единственное, что доказывает аннотация, — «эта структура не в циклах», и единственный выигрыш в том, что сборщик циклов может её не проверять. В Rust владение — замена сборщику, в Nim — помощник сборщика.

Проверка при этом намеренно слабая. Никаких времён жизни и дисциплины заимствования. Компилятор следит за двумя вещами: чтобы владельца не копировали и чтобы владельцем становилось только свежее значение. На seq и string модификатор отвергается: они и так уникальные. Мануал честно фиксирует и дыру: нового владельца можно назначить через обычную ссылку, минуя проверку, но последствие ограничивается утечкой памяти — до порчи данных это не доходит. И ещё практическая сторона, которой в Rust нет: всё это добровольно и отключаемо.

Забавно, что «Rust в Nim» на самом деле размазан по двум соседним главам экспериментального руководства. Рядом с owned лежит Isolated[T] с прагмой .sendable и функцией isolate — изоляция целого графа объектов для безопасной передачи между потоками, то есть аналог Send. Мануал прямо называет источник вдохновения: «inspired by Pony’s recover construct» — Pony, язык с акторами и контролем ссылочных возможностей. Карта влияний получается такой: теорема про лес — общая с Rust, изоляция графов — из Pony, а то, что всё это добровольно и ложится поверх подсчёта ссылок, — уже собственная находка Nim.

Стоит ли обновляться

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

Перед переездом стоит сделать четыре вещи:

  • прогнать сборку с --warning:ImplicitCyclicImport:on и разметить найденные циклы прагмой {.cyclic.} с обеих сторон;
  • если планируете --ic:on, проверить свои макросы на глобальные переменные {.compileTime.} и перевести их на macrocache;
  • если хотите SSO, пройтись по чек-листу строкового API: addr s[, unsafeAddr s[, закешированные указатели;
  • не включать --experimental:ownedRefs в коде, который жалко: его ещё будут ломать.

Обновление через choosenim:

$ choosenim update self
$ choosenim update stable

Итого

Nim 2.4.0 — это релиз, которого язык ждал два года. Матрица управления памятью замкнулась: впервые есть режим, одновременно потокобезопасный и собирающий циклы, а экспериментальный owned намечает способ вообще выводить типы из-под сборщика. Инкрементальная сборка вышла из многолетнего эксперимента в поставку, хотя и с оговоркой про макросы с глобальным состоянием. Типы-суммы стали ответом на жалобы, которые звучали годами, и заодно перевели целый класс ошибок из рантайма в компиляцию. Даже строки получили новый API — тоже ради оптимизации, но ещё и для совместимости с будущим Nim 3.

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

Полный список изменений — в объявлении о релизе и в сравнении веток на GitHub. Про циклические импорты есть раздел руководства, про owned — глава экспериментального руководства, а пример портирования макросов под --ic:on — в pull request к nimsimd.

Предыдущий
Наверх