Nim переходит к более эффективным моделям управления памятью: ARC и ORC. Давайте узнаем, как именно они изменят работу с памятью в нём.
Введение
Всем привет! В этой статье я постараюсь рассказать, что такое ARC и ORC и как они повлияют на производительность или другие части Nim'а. Я не буду глубоко погружаться в аспекты программной части, а постараюсь дать более или менее высокоуровневое объяснение.
Давайте начнём издалека: Nim всегда был языком со сборщиком мусора (GC). Конечно же GC можно выключить, но тогда при работе с большей частью стандартной библиотеки (а она немаленькая) память будет утекать.
Стандартным GC в Nim уже долгое время является refc
(отложенный подсчёт ссылок с mark & sweep фазой для сборки циклов), хотя доступны и другие варианты, как markAndSweep
, boehm
, go
, regions
.
За последние несколько лет у разработчиков Nim'а появилось несколько разных идей, связанных с деструкторами, собственными ссылками (owned ref) и так далее:
Из симбиоза этих идей получилось то, что в Nim называется ARC
Что такое ARC?
По своей сути ARC это модель управления памятью, основанная на автоматическом подсчёте ссылок (Automatic Reference Counting) с деструкторами и семантикой перемещений (move semantics). Можно заметить то, что ARC в Nim называется так же, как ARC в Swift, но есть одно больше различие — в Nim ARC не использует атомарный подсчёт ссылок.
Подсчёт ссылок остаётся одним из самых популярных алгоритмов для освобождения неиспользуемых ресурсов программы. Счётчик ссылок для любой управляемой (контролируемой runtime) ссылки показывает количество раз, которые ссылка используется в других частях программы. Когда этот счётчик становится нулевым, все данные по этой ссылке освобождаются.
Основным отличием ARC от остальных GC в Nim является то, что ARC полностью детерминированный — компилятор автоматически вставляет деструкторы в программу для удаления переменных (строк, последовательностей, ссылок, и т.д), которые больше не нужны. В этом смысле ARC похож на C++ с его деструкторами (RAII)
Для того, чтобы продемонстрировать этот процесс, мы используем интроспекцию ARC expandArc
, которая доступна начиная с Nim 1.4.
Возьмём простой пример кода на Nim:
proc main =
let mystr = stdin.readLine()
case mystr
of "привет":
echo "Здравствуйте!"
of "пока":
echo "Удачи!"
quit()
else:
discard
main()
И используем эту интроспекцию командой nim c --gc:arc --expandArc:main example.nim
.
--expandArc: main
var mystr
try:
mystr = readLine(stdin)
case mystr
of "привет":
echo ["Здравствуйте!"]
of "пока":
echo ["Удачи!"]
quit(0)
else:
discard
finally:
`=destroy`(mystr)
-- end of expandArc ------------------------
Результат этой интроспекции довольно интересен — Nim завернул тело процедуры main
в try: finally
выражение (код в finally
выполняется всегда, даже если внутри блока try
было вызвано исключение) и вставил вызов =destroy
для строки mystr
, чтобы она уничтожилась после окончания её жизненного цикла.
Благодаря этому мы можем увидеть одну из главных возможностей ARC: управление памятью на основе областей видимости (scope-based MM). Область видимости — это любой отдельный регион кода в программе. Такое управление памятью означает, что компилятор автоматически вставит вызовы деструкторов везде, где это необходимо, после выхода из области видимости. Многие части Nim'а вводят новые области видимости: процедуры, функции, конвертеры, методы, конструкции с block
, циклы for
и while
и так далее.
У ARC к тому же имеются так называемые hooks — специальные процедуры, которые можно привязывать к типам для того, чтобы перезаписать стандартное поведение компилятора при деструкции/перемещении/копировании переменной. Они являются очень полезными при создании нестандартных семантик для своих типов, работы с низкоуровневыми операциями, включающими сырые указателями, или для FFI.
По сравнению с refc
ARC обладает немалым количеством преимуществ (включая упомянутые выше):
Управление памятью на основе областей видимости (деструкторы вставляются после областей видимости) — уменьшает потребление памяти программой и улучшает производительность.
Семантики перемещений — возможность компилятора статически анализировать программу и переводить копии в перемещения там, где это возможно.
Общая куча — в отличие от
refc
, у которого куча отдельная для каждого потока (thread-local heap), в ARC потоки имеют доступ к одной и той же памяти. Благодаря этому не нужно копировать переменные между потоками — вместо этого их можно перемещать. Так же стоит обратить внимание на RFC об изоляции и отправке данных между потоками, которое строится на основе ARC.
Упрощение работы с FFI — к примеру, с
refc
необходимо явно инициализировать его в каждом "чужом" (т.е. не созданным в самой программе) потоке, что не нужно для ARC. Это так же означает, что ARC является намного лучшим выбором для создания общих библиотек, которые будут использоваться из других языков (.dll, .so, нативные модули для Python'а и так далее)
Подходит для программирования в системах реального времени — hard realtime
Избавление от копий (copy elision), в Nim так же называется как вывод курсоров (cursor inference) — позволяет компилятору заменять копии простыми курсорами (алиасами) в большом количестве случаев
В целом, ARC для программ на Nim является отличным шагом вперёд, делающим их быстрее, уменьшающим потребление памяти, и давая им предсказуемое поведение.
Для того, чтобы включить ARC для вашей программы, всё, что нужно сделать, это скомпилировать её с ключом --gc:arc
, или добавить его в конфигурационный файл вашего проекта (.nims
или .cfg
).
Проблема с циклами
Но подождите! Разве мы что-то не забыли? ARC проводит подсчёт ссылок, и, как известно, подсчёт ссылок не может освобождать циклы. Цикл — это отношение нескольких объектов, когда они все зависят друг от друга, и эта зависимость замкнута. Возьмём простой пример цикла: 3 объекта (A, B, C), у каждого их которых есть ссылка на другой объект, создают такую зависимость:
Для того, чтобы найти и собрать данный цикл, нам необходим сборщик циклов — специальная часть рантайма, которая находит и удаляет циклы, более не нужные для выполнения программы.
В Nim'е сборка циклов совершалась mark & sweep фазой refc
GC, но лучше использовать ARC как основу для создания чего-то лучшего. Это приводит нас к:
ORC — сборщик циклов для Nim
ORC является совершенно новым сборщиком циклов, основанным на ARC. Его можно считать полноценным GC, так как он включает в себя фазу локального отслеживания (local tracing phase) в отличие от большинства других отслеживающих GC, которые проводят глобальное отслеживание (global tracing).
Для работы с async в Nim необходимо использовать ORC, потому что асинхронность в Nim'е образует циклы, которые необходимо собрать.
ORC сохраняет большую часть преимуществ ARC кроме детерминированности (частично) — по умолчанию у ORC есть адаптивный лимит для сборки циклов, и hard realtime (тоже частично) — по той же самой причине.
Для включения ORC вам нужно компилировать вашу программу с --gc:orc
, но планируется, что в будущем ORC станет стандартным GC в Nim'е
Я заинтересовался! Как мне можно их протестировать?
Ничего сложного — вам всего лишь нужно поставить последнюю версию — Nim 1.4. Возможно эта версия уже доступна в ваших пакетных менеджерах.
Это всё! Спасибо за чтение данной статьи — я надеюсь, что она вам понравилась!
Источники / дополнительная информация:
Yardanico Автор
Для информации — данная статья является переводом моей собственной статьи, опубликованной в nim-lang.org/blog/2020/10/15/introduction-to-arc-orc-in-nim.html, при этом я немного дополнил некоторые моменты.
Об оригинале на английском проводились обсуждения на:
Reddit: reddit.com/r/programming/comments/jbkerv/introduction_to_arcorc_in_nim
Hacker News: news.ycombinator.com/item?id=24786649