gc
— это реализация консервативного, локального по потокам сборщика мусора с маркировкой и очисткой. Реализация обеспечивает полнофункциональную замену стандартных вызовов POSIX malloc()
, calloc()
, realloc()
и free()
.
Целью gc
является предоставление концептуально чистой реализации GC с маркировкой и очисткой, не углубляясь в глубины оптимизации, специфичной для архитектуры (см., например, GC Boehm для такой задачи). Он должен быть особенно пригоден для учебных целей и открыт для всех видов оптимизации (PR приветствуются!).
Первоначальной мотивацией для gc
было мое желание написать свой собственный LISP на C полностью с нуля, а это потребовало сборки мусора.
Эта работа была бы невозможна без возможности читать работы других, в первую очередь Boehm GC, tgc Orangeduck (который также следует идеалам компактности и простоты) и The Garbage Collection Handbook.
gc
. $ git clone [email protected]:mkirchner/gc.git
$ cd gc
Для компиляции с помощью компилятора clang
:
$ make test
Чтобы использовать коллекцию компиляторов GNU (GCC):
$ make test CC=gcc
Тесты должны завершиться успешно. Чтобы создать текущий отчет о покрытии:
$ make coverage
...
#include "gc.h"
...
void some_fun () {
...
int * my_array = gc_calloc ( & gc , 1024 , sizeof ( int ));
for ( size_t i = 0 ; i < 1024 ; ++ i ) {
my_array [ i ] = 42 ;
}
...
// look ma, no free!
}
int main ( int argc , char * argv []) {
gc_start ( & gc , & argc );
...
some_fun ();
...
gc_stop ( & gc );
return 0 ;
}
Здесь описывается основной API, дополнительные сведения см. gc.h
, а также низкоуровневый API.
Чтобы инициализировать и запустить сбор мусора, используйте функцию gc_start()
и передайте адрес нижней части стека :
void gc_start ( GarbageCollector * gc , void * bos );
Параметр нижней части стека bos
должен указывать на переменную, выделенную в стеке, и отмечать нижний конец стека, с которого начинается поиск корня (сканирование).
Сбор мусора можно остановить, приостановить и возобновить с помощью
void gc_stop ( GarbageCollector * gc );
void gc_pause ( GarbageCollector * gc );
void gc_resume ( GarbageCollector * gc );
и сбор мусора вручную можно запустить с помощью
size_t gc_run ( GarbageCollector * gc );
gc
поддерживает распределение памяти в стиле malloc()
, calloc()
и realloc()
. Соответствующие сигнатуры функций имитируют функции POSIX (за исключением того, что нам нужно передать сборщик мусора в качестве первого аргумента):
void * gc_malloc ( GarbageCollector * gc , size_t size );
void * gc_calloc ( GarbageCollector * gc , size_t count , size_t size );
void * gc_realloc ( GarbageCollector * gc , void * ptr , size_t size );
Через расширенный интерфейс можно передать указатель на функцию деструктора:
void * dtor ( void * obj ) {
// do some cleanup work
obj -> parent -> deregister ();
obj -> db -> disconnect ()
...
// no need to free obj
}
...
SomeObject * obj = gc_malloc_ext ( gc , sizeof ( SomeObject ), dtor );
...
gc
поддерживает статические выделения, которые собираются мусором только тогда, когда сборщик мусора завершает работу через gc_stop()
. Просто используйте соответствующую вспомогательную функцию:
void * gc_malloc_static ( GarbageCollector * gc , size_t size , void ( * dtor )( void * ));
Статическое распределение ожидает указатель на функцию финализации; просто установите значение NULL
если финализация не требуется.
Обратите внимание, что gc
в настоящее время не гарантирует определенный порядок при сборе статических переменных. Если статические переменные необходимо освободить в определенном порядке, пользователь должен вызвать для них gc_free()
в желаемой последовательности перед вызовом gc_stop()
, см. ниже. .
Также возможно вызвать явное освобождение памяти, используя
void gc_free ( GarbageCollector * gc , void * ptr );
Вызов gc_free()
гарантированно (а) завершает/уничтожает объект, на который указывает ptr
, если это применимо, и (b) освобождает память, на которую указывает ptr
, независимо от текущего планирования сборки мусора, а также будет работать, если GC был приостановлено с помощью gc_pause()
выше.
gc
также предлагает реализацию strdup()
, которая возвращает копию, собранную мусором:
char * gc_strdup ( GarbageCollector * gc , const char * s );
Фундаментальная идея сборки мусора заключается в автоматизации цикла выделения/освобождения памяти. Это достигается путем отслеживания всей выделенной памяти и периодического освобождения памяти, которая все еще выделена, но недоступна.
Многие продвинутые сборщики мусора также реализуют свой собственный подход к выделению памяти (т.е. заменяют malloc()
). Это часто позволяет им распределять память более эффективно или для более быстрого доступа, но за это приходится платить архитектурными реализациями и повышенной сложностью. gc
обходит эти проблемы, возвращаясь к реализации POSIX *alloc()
и разделяя метаданные управления памятью и сборки мусора. Это делает gc
намного проще для понимания, но, конечно же, менее эффективно с точки зрения пространства и времени, чем более оптимизированные подходы.
Основная структура данных внутри gc
представляет собой хэш-карту, которая сопоставляет адрес выделенной памяти с метаданными сборки мусора в этой памяти:
Элементы хэш-карты представляют собой распределения, смоделированные с помощью struct
Allocation
:
typedef struct Allocation {
void * ptr ; // mem pointer
size_t size ; // allocated size in bytes
char tag ; // the tag for mark-and-sweep
void ( * dtor )( void * ); // destructor
struct Allocation * next ; // separate chaining
} Allocation ;
Каждый экземпляр Allocation
содержит указатель на выделенную память, размер выделенной памяти в этом месте, тег для маркировки и очистки (см. ниже), необязательный указатель на функцию деструктора и указатель на следующий экземпляр Allocation
( отдельное связывание см. ниже).
Распределения собираются в AllocationMap
typedef struct AllocationMap {
size_t capacity ;
size_t min_capacity ;
double downsize_factor ;
double upsize_factor ;
double sweep_factor ;
size_t sweep_limit ;
size_t size ;
Allocation * * allocs ;
} AllocationMap ;
это вместе с набором static
функций внутри gc.c
обеспечивает семантику хеш-карты для реализации общедоступного API.
AllocationMap
— это центральная структура данных в структуре GarbageCollector
, которая является частью общедоступного API:
typedef struct GarbageCollector {
struct AllocationMap * allocs ;
bool paused ;
void * bos ;
size_t min_size ;
} GarbageCollector ;
При наличии базовых структур данных любой запрос выделения памяти gc_*alloc()
представляет собой двухэтапную процедуру: во-первых, выделение памяти с помощью системных функций (т. е. стандартных функций malloc()
), а во-вторых, добавление или обновление связанных метаданных в хеш-карта.
Для gc_free()
используйте указатель, чтобы найти метаданные в хэш-карте, определить, требует ли освобождение вызова деструктора, вызвать при необходимости, освободить управляемую память и удалить запись метаданных из хэш-карты.
Эти структуры данных и связанные с ними интерфейсы позволяют управлять метаданными, необходимыми для создания сборщика мусора.
gc
запускает сбор при двух обстоятельствах: (а) когда какой-либо из вызовов системного выделения завершается неудачно (в надежде освободить достаточно памяти для выполнения текущего запроса); и (б) когда количество записей в хеш-карте превышает динамически корректируемую верхнюю отметку.
Если происходит любой из этих случаев, gc
останавливает мир и запускает сборку мусора по всем текущим выделениям. Эта функциональность реализована в функции gc_run()
, которая является частью общедоступного API и делегирует всю работу функциям gc_mark()
и gc_sweep()
, которые являются частью частного API.
Задача gc_mark()
— найти корни и пометить все известные выделения, на которые ссылаются из корня (или выделения, на которые ссылаются из корня, т. е. транзитивно), как «используемые». Как только маркировка завершена, gc_sweep()
перебирает все известные выделения и освобождает все неиспользуемые (т. е. немаркированные) выделения, возвращается в gc_run()
, и мир продолжает работать.
gc
сохранит доступные выделения памяти и соберет все остальное. Распределение считается достижимым, если выполняется любое из следующих условий:
gc_start()
(т. е. bos
— это наименьший адрес стека, рассматриваемый на этапе маркировки).gc_*alloc()
есть указатель, который указывает на выделенное содержимое.GC_TAG_ROOT
.Наивный алгоритм маркировки и очистки работает в два этапа. Во-первых, на этапе маркировки алгоритм находит и помечает все корневые выделения и все выделения, достижимые из корней. Во-вторых, на этапе очистки алгоритм пропускает все известные выделения, собирая все выделения, которые не были помечены и поэтому считаются недостижимыми.
В начале этапа маркировки мы сначала просматриваем все известные распределения и находим явные корни с помощью набора тегов GC_TAG_ROOT
. Каждый из этих корней является отправной точкой для рекурсивной маркировки в глубину.
gc
впоследствии обнаруживает все корни в стеке (начиная с указателя нижней части стека bos
, который передается в gc_start()
) и регистры (путем сброса их в стек до фазы маркировки) и использует их в качестве отправных точек для маркировка тоже.
Учитывая корневое выделение, маркировка состоит из (1) установки поля tag
в объекте Allocation
в значение GC_TAG_MARK
и (2) сканирования выделенной памяти на предмет указателей на известные выделения, рекурсивно повторяя процесс.
Базовая реализация представляет собой простой рекурсивный поиск в глубину, который сканирует все содержимое памяти в поисках потенциальных ссылок:
void gc_mark_alloc ( GarbageCollector * gc , void * ptr )
{
Allocation * alloc = gc_allocation_map_get ( gc -> allocs , ptr );
if ( alloc && !( alloc -> tag & GC_TAG_MARK )) {
alloc -> tag |= GC_TAG_MARK ;
for ( char * p = ( char * ) alloc -> ptr ;
p < ( char * ) alloc -> ptr + alloc -> size ;
++ p ) {
gc_mark_alloc ( gc , * ( void * * ) p );
}
}
}
В gc.c
gc_mark()
запускает процесс маркировки, отмечая известные корни в стеке посредством вызова gc_mark_roots()
. Чтобы отметить корни, мы делаем один полный проход по всем известным распределениям. Затем мы приступаем к сбросу регистров в стек.
Чтобы сделать содержимое регистра ЦП доступным для поиска root, gc
сбрасывает его в стек. Это реализовано несколько переносимым способом с помощью setjmp()
, который сохраняет их в переменной jmp_buf
прямо перед тем, как мы разметим стек:
...
/* Dump registers onto stack and scan the stack */
void ( * volatile _mark_stack )( GarbageCollector * ) = gc_mark_stack ;
jmp_buf ctx ;
memset ( & ctx , 0 , sizeof ( jmp_buf ));
setjmp ( ctx );
_mark_stack ( gc );
...
Обход с использованием указателя volatile
функции _mark_stack
к функции gc_mark_stack()
необходим, чтобы избежать встраивания вызова в gc_mark_stack()
.
После маркировки всей доступной памяти и, следовательно, потенциально все еще используемой, сбор недоступных выделений становится тривиальным. Вот реализация gc_sweep()
:
size_t gc_sweep ( GarbageCollector * gc )
{
size_t total = 0 ;
for ( size_t i = 0 ; i < gc -> allocs -> capacity ; ++ i ) {
Allocation * chunk = gc -> allocs -> allocs [ i ];
Allocation * next = NULL ;
while ( chunk ) {
if ( chunk -> tag & GC_TAG_MARK ) {
/* unmark */
chunk -> tag &= ~ GC_TAG_MARK ;
chunk = chunk -> next ;
} else {
total += chunk -> size ;
if ( chunk -> dtor ) {
chunk -> dtor ( chunk -> ptr );
}
free ( chunk -> ptr );
next = chunk -> next ;
gc_allocation_map_remove ( gc -> allocs , chunk -> ptr , false);
chunk = next ;
}
}
}
gc_allocation_map_resize_to_fit ( gc -> allocs );
return total ;
}
Мы перебираем все распределения в хэш-карте (цикл for
), следуя каждой цепочке (цикл while
с chunk = chunk->next
update) и либо (1) снимаем пометку с фрагмента, если он был отмечен; или (2) вызвать деструктор фрагмента и освободить память, если она не была помечена, сохраняя промежуточный итог объема освобождаемой памяти.
На этом марка и подметание завершаются. Остановленный мир возобновляется, и мы готовы к следующему запуску!