2026 г.

Как современные языки управляют памятью

Проект ЦИТадель

Цикл о языках начинается не с синтаксиса, а с решений, определяющих устройство программы. Первое из них — управление памятью. Полезный вопрос здесь звучит так: кто определяет время жизни значения и каким способом предотвращается обращение к уже освобождённой памяти. Ответственность может лежать на программисте, на структуре областей видимости, на среде исполнения или на статических правилах языка. Конкретные механизмы — ручное освобождение, RAII, трассирующая сборка мусора, подсчёт ссылок и владение — дают разные гарантии и имеют разные издержки.

Раздел 1 формулирует задачу. В разделах 2–5 рассматриваются ручное управление и RAII, трассирующая сборка мусора, подсчёт ссылок и владение. Раздел 6 посвящён представлению данных, раздел 7 сводит свойства подходов в таблицу. Завершает статью практикум на Python, JavaScript, Go и Rust.

1. Задача: почему это трудно в принципе

При ручном управлении особенно опасны три класса ошибок. Слишком раннее освобождение приводит к использованию недействительной памяти; повторное освобождение может повредить состояние распределителя; отсутствие освобождения создаёт утечку и со временем способно исчерпать память. Первые два класса в языках вроде C и C++ связаны с неопределённым поведением, порчей данных и уязвимостями.

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

2. Ручное управление и RAII

В C базовый контракт остаётся ручным: программист должен сопоставить выделение и освобождение на всех путях исполнения и договориться о владении указателями. Статические анализаторы и санитайзеры находят часть нарушений, но сам язык не выражает время жизни объекта как проверяемую гарантию.

RAII в C++ связывает время жизни ресурса со временем жизни объекта. При выходе из области видимости, в том числе при раскрутке стека из-за исключения, вызываются деструкторы уже созданных автоматических объектов. Умный указатель unique_ptr выражает единоличное владение и явную передачу, а shared_ptr использует подсчёт сильных ссылок. Это заметно локализует управление ресурсами, но в языке остаются сырые и невладеющие указатели, ручное выделение и низкоуровневые операции. Поэтому итоговая безопасность зависит и от выбранных типов, и от дисциплины программы.

3. Трассирующая сборка мусора

Трассирующий сборщик использует приближение «потенциально нужное — достижимо от корней». К корням относятся ссылки в стеках, регистрах, глобальных переменных и внутренних структурах среды исполнения. Сборщик находит достижимые объекты, а недостижимую память возвращает распределителю. В управляемой куче программа не освобождает объект самостоятельно, поэтому преждевременное и повторное освобождение обычных объектов исключаются механизмом среды.

Стоимость зависит от алгоритма и формы живых данных. Для многих трассирующих сборщиков основная работа маркировки растёт вместе с живым графом и числом указателей; отдельные фазы могут также зависеть от размера всей кучи. Поколенческие сборщики используют наблюдение, что многие объекты живут недолго: молодую область проверяют чаще, старую — реже. Это делает массовые короткоживущие выделения относительно дешёвыми, но не бесплатными.

Сборщик выбирает компромисс между памятью, процессорным временем, пропускной способностью и задержкой. Остановка приложения упрощает обход, а конкурентная работа уменьшает длинные паузы ценой барьеров записи и фоновой нагрузки. Разные сборщики и режимы одной виртуальной машины выбирают разные точки этого компромисса; свойства нельзя переносить с одного алгоритма на все языки со сборкой мусора. Модель затрат современного сборщика Go подробно описана в официальном руководстве.

Достижимое не всегда нужно по смыслу. Неограниченный кэш, забытая подписка или коллекция, из которой не удаляют записи, удерживают объекты достижимыми. Такой рост ищут снимками кучи и профилировщиком. Примеры есть в статье «Как устроен браузер», а влияние пауз и нагрузки сборщика на задержку сервиса связано с материалом «Наблюдаемость».

Освобождение внешних ресурсов нельзя откладывать до сборки памяти. Время запуска финализатора обычно не гарантировано, тогда как файлы, соединения и дескрипторы ограничены. Для них используются отдельные детерминированные конструкции: defer, try-with-resources, using и контекстные менеджеры.

4. Подсчёт ссылок

При подсчёте ссылок среда или библиотека хранит число сильных владельцев объекта. Когда последняя сильная ссылка исчезает, объект можно освободить немедленно. Этот механизм используют ARC в Swift, shared_ptr в C++ и основная реализация Python — CPython. Для ациклических графов он обычно позволяет освобождать объекты без отдельного полного обхода.

Циклы. Взаимные сильные ссылки могут удерживать недостижимый цикл. CPython дополняет счётчики трассирующим сборщиком циклов. В Swift программист отмечает невладеющие связи как weak или unowned, выбирая вариант в соответствии с допустимым временем жизни. Такие ссылки выражают направление владения, но правильность этой модели остаётся обязанностью разработчика.

Стоимость обновления счётчиков. Создание и уничтожение сильных ссылок требует дополнительных операций; при совместном доступе из разных потоков реализация должна обеспечить необходимую синхронизацию. Фактическая цена зависит от языка, оптимизаций компилятора и того, насколько часто ссылки переходят между потоками. Поэтому подсчёт ссылок нельзя считать безусловно дешевле трассирующей сборки.

5. Владение и заимствование

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

Владение. У значения есть владелец; передача некопируемого значения обычно перемещает владение. Когда владелец покидает область, значение уничтожается. Типы с семантикой Copy действительно копируются, поэтому формула «присваивание всегда означает переезд» была бы неверной.

Заимствование. Для одной области данных одновременно допускаются либо несколько неизменяемых ссылок, либо одна изменяемая, причём ссылка не может пережить владельца. В сочетании с типажами Send и Sync эти правила предотвращают гонки данных в безопасном Rust. Подробнее связь с конкурентностью рассматривается в следующей статье цикла.

Цена подхода — необходимость выражать структуру владения так, чтобы компилятор мог её проверить. Для графов со сложным совместным владением применяют индексы, Rc/Arc, внутреннюю изменяемость или низкоуровневый unsafe. Последний переносит доказательство корректности на программиста, написавшего ограниченный участок кода, и не делает ошибочную операцию безопасной. Утечки также остаются возможными, например при цикле из Rc; безопасность памяти не означает автоматического ограничения её потребления.

6. Представление данных: значения и ссылки

На стоимость памяти влияет не только способ освобождения, но и представление данных. Плотный массив небольших значений обычно требует меньше служебной памяти и лучше использует кэш процессора, чем граф отдельно выделенных объектов. Для трассирующего сборщика большое число указателей означает дополнительную работу сканирования. Эффект зависит от компоновки, оптимизаций и характера доступа, поэтому его нужно измерять на конкретной программе. Общее практическое правило — избегать ненужных отдельных выделений и промежуточных указателей независимо от выбранного языка.

7. Сравнение стратегий и гарантий

СтратегияКто управляетЧто предотвращаетсяЧто остаётсяЦена
C / ручное управлениеПрограммист; помогают анализаторы и тестыЯзык ничего не предотвращает автоматическиРаннее, повторное и пропущенное освобождениеЯвный контроль и высокая цена проверки
RAII / умные указатели (C++)Структура кода и программистМногие пропущенные освобождения на структурированных путяхСырые указатели, неверное владение, циклы shared_ptrНеобходимость последовательно соблюдать модель
Трассирующая сборкаСреда исполненияРаннее и повторное освобождение управляемых объектовРост достижимого графа; внешние ресурсы требуют отдельного управленияПамять, процессорное время и возможные паузы
Подсчёт ссылокСреда или библиотекаРаннее и повторное освобождение управляемых объектовЦиклы сильных ссылокОбновление счётчиков и синхронизация
Владение (безопасный Rust)Компилятор по правилам безопасного кодаРаннее и повторное освобождение, гонки данныхУтечки; корректность unsafeНеобходимость явно выражать владение; ограничения статического анализа

Различие между утечкой и нарушением безопасности памяти принципиально. Утечка истощает ресурсы, а использование после освобождения или выход за границы может открыть доступ к чужим данным и управлению программой. По данным команды Chrome, опубликованным в 2021 году, более 70% обнаруженных ею серьёзных дефектов безопасности относились к безопасности памяти в C и C++. Это показатель конкретной большой кодовой базы, а не универсальная доля для любого проекта, но он объясняет интерес индустрии к языкам и средствам, исключающим такие ошибки по построению. Источник: Google Security Blog.

8. Практикум: наблюдение за механизмами памяти

Задания показывают механизмы на конкретных реализациях. Поведение CPython не следует автоматически переносить на все реализации Python.

Задание 1. Счётчик в CPython. Создайте обычный пользовательский объект и измерьте число ссылок с помощью sys.getrefcount. Учтите, что сам вызов временно добавляет ещё одну ссылку, а некоторые встроенные объекты могут быть бессмертными. Создайте дополнительные имена, затем удалите их и сравните значения. Добавьте __del__ только как наблюдательный сигнал и проверьте момент его вызова для ациклического объекта.

Задание 2. Цикл в CPython. Временно отключите автоматический сборщик циклов, свяжите два пользовательских объекта сильными ссылками и удалите внешние имена. Затем вызовите gc.collect() вручную и сравните результат. После опыта обязательно снова включите сборщик. Повторите пример с одной ссылкой weakref.ref и объясните, почему цикл сильного владения исчез.

Задание 3. Достижимое не значит нужное (JavaScript). Создайте растущую коллекцию объектов в Map и сравните снимки кучи. Затем выясните, можно ли использовать WeakMap: его ключи должны быть объектами, записи нельзя перечислять, а исчезновение возможно только при отсутствии других сильных ссылок на ключ. Если эти условия не подходят к кэшу, реализуйте явное ограничение размера или удаление записей.

Задание 4. Проверка владения (Rust Playground). По очереди попробуйте использовать String после перемещения, вернуть ссылку на локальное значение и одновременно создать две изменяемые ссылки. Прочитайте диагностику компилятора и исправьте каждую программу, не переходя к unsafe. Затем свяжите результат со статьёй «Конкурентность в современных языках».

Задание 5. Наблюдение за сборщиком. В своей версии CPython выведите gc.get_stats(), gc.get_threshold() и gc.get_count(), предварительно сверившись с документацией именно этой версии: состав поколений и смысл порогов менялись. В Go запустите небольшую выделяющую память программу с GODEBUG=gctrace=1 и разберите поля отчёта, не предполагая заранее конкретной длительности пауз.

Задание 6*. Представление данных. Сравните обработку координат, хранящихся в плотном буфере array('d'), и списка отдельных экземпляров класса Point. Измерьте время, пиковую память и размер структур, сохраняя одинаковое число координат. Объясните ограничения сравнения: интерфейсы различаются, а результат зависит от реализации Python и характера доступа.

Итоги

  • Стратегии управления памятью назначают ответственного и заменяют общий вопрос о будущем использовании проверяемым правилом.
  • Трассирующая сборка исключает ручное освобождение управляемых объектов, но расходует память и процессорное время; достижимый, но ненужный граф остаётся возможной утечкой.
  • Подсчёт ссылок позволяет освобождать ациклические графы при исчезновении последней сильной ссылки, но требует решения для циклов и оплачивает обновление счётчиков.
  • Безопасный Rust проверяет владение и заимствование статически; unsafe и утечки остаются за пределами этой гарантии.
  • Представление данных и число отдельных выделений существенно влияют на память, работу сборщика и кэш процессора; эффект нужно измерять.
  • Нарушения безопасности памяти особенно важны для системного кода, потому что могут становиться эксплуатируемыми уязвимостями.

Литература

  1. R. Jones, A. Hosking, E. Moss, "The Garbage Collection Handbook," 2nd ed., CRC Press, 2023 — энциклопедия сборки мусора.
  2. S. Klabnik, C. Nichols, "The Rust Programming Language" — главы о владении и заимствовании; свободно онлайн. doc.rust-lang.org/book
  3. Go GC Guide (go.dev/doc/gc-guide); модуль gc и устройство поколений в Python (docs.python.org).
  4. Связанные материалы сайта: «Как устроен браузер» (сборщик и утечки в движке JavaScript) и «Наблюдаемость» (задержки и профилирование).
  5. Оглавление раздела «Языки и парадигмы программирования»; другие статьи цикла: «Конкурентность в современных языках», «Как современные языки обращаются с ошибками» и «Системы типов: что доказывает компилятор».
404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией