Введение в ARC/ORC в Nim

Nim переходит к более эффективным моделям управления памятью: ARC и ORC. Давайте узнаем, как именно они изменят работу с памятью в нём.

9cf062842a21964001796f28d3ba8c22.png


Введение

Всем привет! В этой статье я постараюсь рассказать, что такое 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), у каждого их которых есть ссылка на другой объект, создают такую зависимость:

vzdksziwms5zjjldtqa18s_qabg.png

Для того, чтобы найти и собрать данный цикл, нам необходим сборщик циклов — специальная часть рантайма, которая находит и удаляет циклы, более не нужные для выполнения программы.

В 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’е


Я заинтересовался! Как мне можно их протестировать?

ARC уже доступен в релизах Nim 1.2.x, но из-за большого количества недоработок лучше дождаться релиза Nim 1.4, в котором ARC и ORC будут доступны для широкого тестирования. Однако, если вам хочется протестировать их уже сейчас, то вы можете попробовать релиз-кандидат версии 1.4.

Это всё! Спасибо за чтение данной статьи — я надеюсь, что она вам понравилась!

Источники / дополнительная информация:


© Habrahabr.ru