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

    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. Возможно эта версия уже доступна в ваших пакетных менеджерах.


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


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


    Комментарии 15

      0
      Для информации — данная статья является переводом моей собственной статьи, опубликованной в 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
        0
        Обновление — вышел Nim 1.4 с ARC и ORC, которые я описал в статье.
        nim-lang.org/blog/2020/10/16/version-140-released.html
          0
          Интересно, если ARC — это Automatic Reference Counting, то как расшифровывается ORC? :)
            +1
            У него нет никакой расшифровки, ORC просто подразумевает под собой работу с циклами (O обозначает замкнутый цикл)
            –1

            ORC выглядит как костыль. Героически боролись со сборщиками мусора со всеми их недостатками и… сделали ещё один сборщик мусора со всеми их недостатками. Чего они не смотрят в сторону Borrowing&Owning?

              0
              А концепция владения решает проблему замкнутых циклов? Мне казалось, что это её слабое место как раз. Разве можно реализовать в рамках концепции владения, без привлечения подсчёта ссылок какой-нибудь двусвязный список?
                –1

                Она их предотвращает. Никто не может владеть самим собой.

                  0
                  Если я правильно Вас понял, то структуры, подобные двусвязным связным спискам и прочим циклическим графам реализовать в рамках концепции владения не получится. Но как быть если такая структура нужна?
                    0

                    Первый элемент владеет вторым, второй — третьим и тд.

                      +1

                      xomachine скорее всего имеет ввиду Кольцевой связный список, и я не вижу, как его можно реализовать хотя бы без RC чисто с владением.


                      Ещё почитайте https://news.ycombinator.com/item?id=16443688, коротко говоря — в Rust довольно сложно делать циклические структуры по сравнению с большинством других языков.


                      Ещё вот — https://rcoh.me/posts/rust-linked-list-basically-impossible/

                        –1

                        Последняя ссылка делается невладеющей и всё, какие проблемы?


                        У Раста свои тараканы. Насколько я понимаю, там нет поддержки наследования владения. Это когда компилятор понимает, что если ты владеешь объектом, который владеет другим объектом, то ты владеешь и вторым тоже. Хотя, кажется RefCell::borrow_mut — это вроде как раз способ ему это объяснить.

                          +1
                          У Раста свои тараканы. Насколько я понимаю, там нет поддержки наследования владения. Это когда компилятор понимает, что если ты владеешь объектом, который владеет другим объектом, то ты владеешь и вторым тоже. Хотя, кажется RefCell::borrow_mut — это вроде как раз способ ему это объяснить.

                          Это не "наследование владения", а скорее транзитивность владения. RefCell::borrow_mut — это вообще про другое. Пожалуйста, не пишите о том, в чём настолько слабо разбираетесь.

                            –1

                            Терминологические споры меня не интересуют.
                            Какая буква в слове "кажется" вам не понятна?
                            Не указывайте мне что делать и я не скажу, куда вам пойти.

                0
                Как по мне, для Borrowing & Owning нужно изменять весь язык, что далеко не является лучшим решением :) С ARC/ORC изменений в код вообще вносить не нужно (кроме редких случаев, например порядка финализаторов, которые использовались с refc)
                  +1
                  Обсуждалось, но было отложено на потом как ломающее изменение — на Ним 2.0

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое