В данной статье будет описано создание кастомного аллокатора на си c регистрацией колбеков, которые будут вызваны при освобождении памяти. Нужен для того, чтобы при создании записать туда деструктор, а в конце просто вызвать free, не погружаясь в детали его работы.
Основная идея
Задумка весьма простая — аллокатор кастомный, так давайте просто выделим немного больше памяти, в её начало положим callback‑информацию и вернём смещённый указатель. Диаграммой выглядит это всё примерно так:
В callback данных просто‑напросто лежит указатель на массив callback‑функций, его длина и ёмкость (capacity).
Функция callback_free извлекает из callback‑данных информацию про функции, по очереди их вызывает и после передаёт весь кусок функции free.
Реализация
Как всегда, начнём с объявлений типов:
typedef void (*callback_fn)(void *addr, void *res);
typedef struct {
void *resource;
callback_fn fn;
} callback_t;
callback может использовать какие‑то дополнительные значения, поэтому их передаём через указатель resource. callback_t
получается неким аналогом замыкания, но в сишном стиле.
Теперь объявляем структуру chunk_t
:
typedef struct {
size_t capacity;
size_t length;
callback_t *callbacks;
alignas(max_align_t) char memory[];
} chunk_t;
Зачем там alignas?
Стандарнтый аллокатор в си возвращает указатель на участок памяти с выравниванием соответствующим max_align_t, то есть таким, что оно годится для любого стандартного типа.
Компилятор же волен упаковать нашу структуру произвольным образом, а это означает, что необходимое выравнивание может потеряться. Чтобы этого не произошло явно указываем, что участок memory выровнян так же как max_align_t
В будущем часто понадобится сдвигать указатель от начала структуры к memory
и в обратную сторону, поэтому добавляем две вспомогательные функции
static inline void* chunk_to_ptr(chunk_t *chunk) {
char *_ptr = (char *) chunk;
// Не использую &chunk->memory, чтобы сохранить единообразность кода
return _ptr + offsetof(chunk_t, memory);
}
static inline void* ptr_to_chunk(void *ptr) {
char *_ptr = (char *) ptr;
return _ptr - offsetof(chunk_t, memory);
}
Теперь определить callback_alloc
и callback_free
не составляет никакого труда:
void* callback_alloc(size_t size) {
chunk_t *chunk = malloc(sizeof(chunk_t) + size);
// Инициализировали массив callback-ов
chunk->capacity = 0;
chunk->length = 0;
chunk->callbacks = nullptr;
return chunk_to_ptr(chunk);
}
void callback_free(void *ptr) {
// Стандартный free умеет принимать nullptr, дублируем его поведение
if(! ptr)
return;
chunk_t *chunk = ptr_to_chunk(ptr);
for(int i = 0; i < chunk->length; i ++) {
// Достаём функцию и доп.данные из массива
callback_fn fn = chunk->callbacks[i].fn;
void *resource = chunk->callbacks[i].resource;
// Вызываем callback
fn(ptr, resource);
}
// Освободили массив callback-ов
free(chunk->callbacks);
// Освободили занятую память
free(ptr_to_chunk(ptr));
}
И основная функциональность — функция add_callback
void add_callback(void *ptr, callback_t callback) {
if(! ptr)
return;
chunk_t *chunk = ptr_to_chunk(ptr);
if(! chunk->callbacks) {
// Инициализируем список, если пустой
// (memcpy не работает с пустыми указателями,
// поэтому обрабатываем отдельно)
chunk->capacity = 10;
chunk->length = 0;
chunk->callbacks = malloc(10 * sizeof(callback_t));
}
if(chunk->length >= chunk->capacity) {
// Переаллокация, если заполнили массив
size_t capacity = chunk->capacity;
callback_t *old_callbacks = chunk->callbacks;
callback_t *new_callbacks = malloc((capacity + 10) * sizeof(callback_t));
memcpy(new_callbacks, old_callbacks, capacity * sizeof(callback_t));
free(old_callbacks);
chunk->callbacks = new_callbacks;
chunk->capacity += 10;
}
// Добавили в конец новый callback
chunk->callbacks[chunk->length ++] = callback;
}
Примеры использования
Двумерный массив
Начнём с банального — двумерный динамический массив, но теперь не нужно в конце писать цикл с free для освобождения одномерных подмассивов.
Для этого создаём функцию-конструктор array2d_ctor
, куда передаём размеры по x
, y
и размер элемента.
void* array2d_ctor(size_t size_y, size_t size_x, size_t elem_size) {
// Создаём как обычно массив
void **array2d = callback_alloc(size_y * sizeof(void *));
for(int i = 0; i < size_y; i ++)
array2d[i] = callback_alloc(size_x * elem_size);
// Прокидываем размер массива в деструктор, чтобы там знать сколько
// итераций нужно делать в цикле
size_t *arr_data = malloc(sizeof(size_t));
*arr_data = size_y;
add_callback(array2d, (callback_t) {
.resource = arr_data,
.fn = array2d_dtor
});
return array2d;
}
Ну и без деструктора ничего не заведётся, поэтому добавляем и его:
void array2d_dtor(void *ptr, void *resource) {
void **array2d = ptr;
// Излекаем размер массива и чистим resource
size_t size_y = *(size_t *)resource;
free(resource);
// Освобождаем память занятую подмассивами
for(int i = 0; i < size_y; i ++)
callback_free(array2d[i]);
}
Создадим двумерный массив с табличкой умножения, выведем её и освободим массив:
int main() {
int **matrix = array2d_ctor(10, 10, sizeof(int));
for(int i = 0; i < 10; i ++)
for(int j = 0; j < 10; j ++)
matrix[i][j] = (i + 1) * (j + 1);
for(int i = 0; i < 10; i ++) {
for(int j = 0; j < 10; j ++)
printf("%3d ", matrix[i][j]);
printf("\n");
}
callback_free(matrix);
return 0;
}
Запускаем с санитайзером, и всё заработало. Таким образом мы немного упростили себе заботу о ресурсах в си.
Отладочная печать
Можно посмотреть какой ресурс когда освобождается, и тоже при помощи callback‑ов. В этот раз функция не использует ничего, кроме самого указателя ptr (замыкание ни на что не замкнуто). Думаю, такие функции не редкость, поэтому имеет смысл упростить их добавление. Так в нашем коде появляется:
add_simple_callback
typedef void (*simple_fn) (void *addr);
void call_fn(void *ptr, void *resource) {
simple_fn fn = resource;
fn(ptr);
}
void add_simple_callback(void *ptr, simple_fn fn) {
add_callback(ptr, (callback_t) {
.resource = fn,
.fn = call_fn
});
}
И теперь с его помощью делаем так:
void dtor_print(void *ptr) {
printf("Destructor on %p called\n", ptr);
}
int main() {
int **matrix = array2d_ctor(10, 10, sizeof(int));
add_simple_callback(matrix, dtor_print);
puts("Constructed matrix");
callback_free(matrix);
return 0;
}
Запускаем и получаем:
$ gcc -std=c2x malloc_c.c
$ ./a.out
Constructed matrix
Destructor on 0x1f4a2c0 called
Код, сборка, итог
Исходный код
#include <stdio.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#ifdef OLD
#include <stdalign.h>
#define nullptr NULL
#endif
typedef void (*simple_fn) (void *addr);
typedef void (*callback_fn)(void *addr, void *res);
typedef struct {
void *resource;
callback_fn fn;
} callback_t;
typedef struct {
size_t capacity;
size_t length;
callback_t *callbacks;
alignas(max_align_t) char memory[];
} chunk_t;
static inline void* chunk_to_ptr(chunk_t *chunk) {
char *_ptr = (char *) chunk;
return _ptr + offsetof(chunk_t, memory);
}
static inline void* ptr_to_chunk(void *ptr) {
char *_ptr = (char *) ptr;
return _ptr - offsetof(chunk_t, memory);
}
void* callback_alloc(size_t size) {
chunk_t *chunk = malloc(sizeof(chunk_t) + size);
chunk->capacity = 0;
chunk->length = 0;
chunk->callbacks = nullptr;
return chunk_to_ptr(chunk);
}
void callback_free(void *ptr) {
if(! ptr)
return;
chunk_t *chunk = ptr_to_chunk(ptr);
for(int i = 0; i < chunk->length; i ++) {
callback_fn fn = chunk->callbacks[i].fn;
void *resource = chunk->callbacks[i].resource;
fn(ptr, resource);
}
free(chunk->callbacks);
free(ptr_to_chunk(ptr));
}
void add_callback(void *ptr, callback_t callback) {
if(! ptr)
return;
chunk_t *chunk = ptr_to_chunk(ptr);
if(! chunk->callbacks) {
chunk->capacity = 10;
chunk->length = 0;
chunk->callbacks = malloc(10 * sizeof(callback_t));
}
if(chunk->length >= chunk->capacity) {
size_t capacity = chunk->capacity;
callback_t *old_callbacks = chunk->callbacks;
callback_t *new_callbacks = malloc((capacity + 10) * sizeof(callback_t));
memcpy(new_callbacks, old_callbacks, capacity * sizeof(callback_t));
free(old_callbacks);
chunk->callbacks = new_callbacks;
chunk->capacity += 10;
}
chunk->callbacks[chunk->length ++] = callback;
}
void call_fn(void *ptr, void *resource) {
simple_fn fn = resource;
fn(ptr);
}
void add_simple_callback(void *ptr, simple_fn fn) {
add_callback(ptr, (callback_t) {
.resource = fn,
.fn = call_fn
});
}
void dtor_print(void *ptr) {
printf("Destructor on %p called\n", ptr);
}
void array2d_dtor(void *ptr, void *resource) {
void **array2d = ptr;
size_t size_y = *(size_t *)resource;
for(int i = 0; i < size_y; i ++)
callback_free(array2d[i]);
free(resource);
}
void* array2d_ctor(size_t size_y, size_t size_x, size_t elem_size) {
void **array2d = callback_alloc(size_y * sizeof(void *));
for(int i = 0; i < size_y; i ++)
array2d[i] = callback_alloc(size_x * elem_size);
size_t *arr_data = malloc(sizeof(size_t));
*arr_data = size_y;
add_callback(array2d, (callback_t) {
.resource = arr_data,
.fn = array2d_dtor
});
return array2d;
}
int main() {
int **matrix = array2d_ctor(10, 10, sizeof(int));
add_simple_callback(matrix, dtor_print);
puts("Constructed matrix");
callback_free(matrix);
return 0;
}
Всё собиралось gcc
самой последней версии (буквально собран из исходников день назад). На версиях младше 13-й или в clang компилировать с флагом -DOLD
.
Весь код под WTFPL, используйте как угодно.
На этом у меня всё.
Комментарии (16)
Apoheliy
00.00.0000 00:00+4Т.к. у Вас добавление колбэков сделано отдельно, то (возможно) лучше его закрыть неким подобием локов (системные, или свои) при работе с многопоточностью. (Или прямо напишите, что эти функции однопоточные).
Если не хочется разбираться с локами, то лучше сделать выделение памяти и добавление колбэка (например, одного) в одном вызове функции.
С обработкой ошибок что-то сделать: если добавляем колбэк и malloc вернёт NULL, то всё крэшнется.
Примечание: если вооружиться опытом C++, то:
достаточно одного колбэка (добавление можно затолкать прямо в вызов alloc);
дополнительные данные (resource) не требуются, т.к. есть доступ к удаляемому объекту и если сильно хочется, то можно добавлять дополнительные данные туда.
orenty7 Автор
00.00.0000 00:00+2Про локи и обработку ошибок справедливо, но не хотелось сильно загромождать код. Как-никак это больше "смотрите как могу", чем готовый продукт)
Можно сделать как в плюсах, но я бы для этого воспользовался иной конструкцией. Механизм callback-ов, как мне кажется, должен позволять навесить их несколько штук, а resource может использоваться для проделывания всяких штук за пределами объекта, например, уведомить какой-то кусок кода, что данного объекта больше не существует или послать что-то в сокет.
В общем, если делать только деструкторы, то разумно сделать так, но я больше думаю о callback-ах как о подписке на событие удаления объекта
Anidal
00.00.0000 00:00А ничего, что
alignas
specifier (since C++11)? Это уже не чистый С, а какой-то кадавр получаетсяorenty7 Автор
00.00.0000 00:00+1Начиная с C11 есть ключевое слово _Alignas и в хедере stdalign.h определён макрос alignas, который в него раскрывается. Но тут я тут пользуюсь самым новым стандартом C23 (он же c2x), в котором alignas сделали ключевым словом (и добавили nullptr), а для старых версий просто добавляете флаг -DOLD, который превращает это в валидный C11 код. Попробуйте скомпилировать, на gcc-10 и clang-11 точно работает
Ritan
00.00.0000 00:00+1Основная польза от деструкторов в том, что их сложно забыть вызвать. Потому и уходим от сырых указателей к умным
Можно обойтись одним колбеком на чанк, как наиболее распространенный случай. А если нужно больше, то уложить их в сам освобождаемый объект и вызвать из первого. Меньше аллокаций - меньше проблем.
masscry
00.00.0000 00:00+3Здравствуйте!
Основная задача деструкторов в C++ - вызывать код при разрушении автоматических переменных. Ваше решение будто-бы никак этот вопрос не решает.
А как похитрее завернуть malloc/free, чтобы еще хитрее выстрелить себе в ногу в случае чего - то, да с этим ваш код справляется.
fk0
00.00.0000 00:00+1В голом C нет неявного потока управления, и это мотивация отсутствия там деструкторов. Жалко что не ввели defer в C23...
VladimirFarshatov
00.00.0000 00:00автоматические - это как правило переменные, размещенные на стеке. Их разрушение происходит без участия программиста по авершению контекста. Тут можно вести речь про автоматичекий указатель, которому выделена память в куче, и типичную ошибку начинающего сиониста - забывчивость освобождения кучи по завершению контекста.
Кмк, данный подход можно дополнить небольшим #define return() который будет вызывать этот callback перед возвратом. Если делать универсальный макрос, то там придется попотеть, "настраивая" его под конкретный вызов с возвращаемым параметром. Но .. тоже решаемо как мне видится. Заодно, такой макрос может решать вопрос отложенного вызова типа defer() языка Go..
Кмк, автору стоит доработать пакетик. :)
fk0
00.00.0000 00:00+1В GCC/Clang есть __attribute__((cleanup(function))) var. Но оно не решает проблему полностью, т.к. дальше захочется подсчёта ссылок. А с этим сразу сложно, т.к. в GCC вложенные функции с (неработающими -- т.к. исполняемый стек) трамплинами, в Clang есть blocks (недолямбды...), и всё это нестандарт. Потом захочется отличать перемещение и копирование. И так будет заново изобретаться C++...
RekGRpth
00.00.0000 00:00+2попробуйте talloc (hierarchical, reference counted memory pool system with destructors)
fk0
00.00.0000 00:00+8Метод, в определённом смысле, широко известный, т.к. массово применяется в микрософтовских API. В частности для строк и где-то ещё.
Если программа взаимодействует с сторонними библиотеками и обменивается с ними объектами, может быть нежданным сюрпризом когда в свой free() приедет чужой указатель, выделенный на стороне (и без такого трюка).
Поэтому, возможно, нет смысла в таком трюке вообще, и нужно просто определить свои стуктуры, где эти деструкторы хранить в явном виде. Хотя да, иногда бывается хочется в неявном...
callback может использовать какие‑то дополнительные значения, поэтому их передаём через указатель resource...
Можно всё вывернуть немного наоборот и получится проще, без опасного "void*", который обычно является источником багов:
typedef struct callback_functor { void (*callback_fn)(struct callback_fuctor *, void *addr); /* This structure may be extended... */ } callback_functor_t;
Идея в том, что при необходимости может быть создана другая структура, первым элементом которой будет callback_functor_t (таким образом реализуется "наследование" на C). Затем функция free может вызвать callback_functor_t->callback_fn() с передачей ему указателя на сам callback_functor_t (хранящийся "перед началом" выделенного блока), а внутри коллбэка тип callback_functor_t можно "привести" к типу расширенной структуры с помощью широко известного макроса container_of. Здесь во всей цепочке отсутствует void* (кроме самого указателя на освобождаемую память) и нет практически возможности совместить что-то несовместимое, что привело бы к ошибке.
И это скорей не аналог замыкания, а аналог std::function (фунцкионального объекта), но в сишном стиле.
То, что у вас коллбэки навешиваются после аллокации -- скорей плохо. Это же просто можно забыть сделать. Будет лучше, если бы функция аллокации сразу принимала указатель на коллбэк и его размер, и копировала бы его по значению (в адрес ниже возвращаемого указателя, разумеется выравненный на max_align_t).
Чтоб размер не принимать явно, функция замещающая malloc может быть сделана макросом (внутри которого будет делаться sizeof(*(callback_argument)). Разумеется здесь возникает проблема, что поскольку предполагается расширение типа callback_functor_t другим типом, то собственно типы получаются разные и функция замещающая malloc не может проверить, что ей не подсунули что-то непотребное (либо тип будет приведен к callback_functor_t, но тогда потеряется тип и размер расширенного функтора). Это можно обойти, если договориться, что все взаимозаменяемые типы (унаследованные и базовый) будут содержать специальный именованный член -- пустую структуру, например, первым элементом структуры. Тогда можно передавать ссылку на этот член и легко преобразовывать указатель между разными типами с помощью опять же макроса container_of. Это опять же лучше, чем void*, т.к. совсем уж что угодно присвоить к чему угодно компилятор не позволит, и проверка типов продолжит работать (хотя для программиста появляется возможность сделать ошибку в определении структур, впрочем там сложности никакой и ошибиться тяжело). В данном случае в макросе можно адресовать специальный именованный член стурктуры, чтоб убедиться что стрктура имеет нужный тип (либо там нет такого члена). Это не совсем утиная типизация, т.к. этот самый спец. член всё же может иметь разные типы (пустой структуры, но разные, т.к. ключевое слово struct каждый раз декларирует новый, не совместимый тип) для каждых несовместимых классов объектов. Не знаю понятно ли объяснил, как-то так:
typedef struct {} callback_func_tag; typedef struct callback_functor { callback_func_tag tag; void (*destructor)(struct callback_fuctor *, void *addr); /* This structure may be extended... */ } callback_functor_t; #define special_malloc(size, deleter) \ _special_malloc(size, &deleter->tag, sizeof(*(deleter))) void* _special_malloc(size_t size, callback_func_tag *tag, size_t deleter_size) { size_t bottom_size = (deleter_size + sizeof(size_t) - 1) & (sieof(size_t) - 1); bottom_size = (bottom_size + _Alignof(max_align_t) - 1) & (_Alignof(max_align_t) - 1); void *ptr = malloc(size + bottom_size); if (!ptr) return NULL; void *result = (char*)ptr + bottom_size; ((size_t*)result)[-1] = bottom_size; memcpy(ptr, tag, deleter_size) return result; } void special_free(void *ptr) { if (!ptr) return; size_t bottom_size = ((size_t*)ptr)[-1]; callback_functor_t *functor = (char*)ptr - bottom_size; functor->destructor(functior, ptr); free(ptr); } struct extended_callbck { callback_func_tag tag; callback_functor_t base; // context follows: char *zzz; int xxx; }; void example_destructor(callback_functor_t *pthis, void *addr) { struct extended_callback *cb = container_of(pthis, callback_func_tag, tag); cb->zzz, cb->xxx...; } char* example1(size_t n) { char *mem = special_malloc(n, &(struct extended_callback){ .base.destructor = example_destructor, .zzz = NULL, .xxx = 2 }); } return mem; } void example2(char *p) { special_free(p); }
Ну разумеется callback_functor_t и всё от него наследуемое -- должно быть перемещаемым (на него нигде в памяти не должно сохраняться указателей).
В принципе то же что у автора примерно, только немного другие механизмы, где нет void*. Реализация container_of макроса может выглядеть вот так:
#define container_of(ptr, type, member) ((type*)((char*)(1 ? (ptr) : &((type*)0)->member) - offsetof(type, member)))
В линуксе по смыслу такая же, но полагается на GNU-расширения. Впрочем, пустые структуры тоже не строгое соответствие ISO-C.
PS: но может проще писать на C++ тогда... Там std::unique_ptr уже сразу из коробки.
orenty7 Автор
00.00.0000 00:00Идея с расширяемой структурой очень годная. Такой подход действительно сильно мощнее/удобнее того, что сделал я, но реализацию, кажется, можно сделать немного проще.
По стандарту можно кастовать указатель на структуру и на её первый элемент. Я думаю, наследники обязаны иметь функциональность базового класса, и тогда
tag
не нужен. Просто во всех наследников положим первым полемcallback_base
типаcallback_functor_t
. Отпадает необходимость и в макросе, так как теперь перегонять в начальный тип можно напрямую
Sergey_zx
00.00.0000 00:00Я в отладке просто переназначаю malloc и free, или new delete на свои вызовы где создаются, таблицы и отслеживается все распределение памяти. А по завершению вызываю функцию которая проверяет-закрывает все распределенное и выводит список проблем.
В релизе же все это отключено.
fk0
Метод, в определённом смысле, широко известный, т.к. массово применяется в микрософтовских API. В частности для строк и где-то ещё.
Если программа взаимодействует с сторонними библиотеками и обменивается с ними объектами, может быть нежданным сюрпризом когда в свой free() приедет чужой указатель, выделенный на стороне (и без такого трюка).
Поэтому, возможно, нет смысла в таком трюке вообще, и нужно просто определить свои стуктуры, где эти деструкторы хранить в явном виде. Хотя да, иногда бывается хочется в неявном...
Можно всё вывернуть немного наоборот и получится проще, без опасного "void*", который обычно является источником багов:
Идея в том, что при необходимости может быть создана другая структура, первым элементом которой будет callback_functor_t (таким образом реализуется "наследование" на C). Затем функция free может вызвать callback_functor_t->callback_fn() с передачей ему указателя на сам callback_functor_t (хранящийся "перед началом" выделенного блока), а внутри коллбэка тип callback_functor_t можно "привести" к типу расширенной структуры с помощью широко известного макроса container_of. Здесь во всей цепочке отсутствует void* (кроме самого указателя на освобождаемую память) и нет практически возможности совместить что-то несовместимое, что привело бы к ошибке.
И это скорей не аналог замыкания, а аналог std::function (фунцкионального объекта), но в сишном стиле.
То, что у вас коллбэки навешиваются после аллокации -- скорей плохо. Это же просто можно забыть сделать. Будет лучше, если бы функция аллокации сразу принимала указатель на коллбэк и его размер, и копировала бы его по значению (в адрес ниже возвращаемого указателя, разумеется выравненный на max_align_t).
Чтоб размер не принимать явно, функция замещающая malloc может быть сделана макросом (внутри которого будет делаться sizeof(*(callback_argument)). Разумеется здесь возникает проблема, что поскольку предполагается расширение типа callback_functor_t другим типом, то собственно типы получаются разные и функция замещающая malloc не может проверить, что ей не подсунули что-то непотребное (либо тип будет приведен к callback_functor_t, но тогда потеряется тип и размер расширенного функтора). Это можно обойти, если договориться, что все взаимозаменяемые типы (унаследованные и базовый) будут содержать специальный именованный член -- пустую структуру, например, первым элементом структуры. Тогда можно передавать ссылку на этот член и легко преобразовывать указатель между разными типами с помощью опять же макроса container_of. Это опять же лучше, чем void*, т.к. совсем уж что угодно присвоить к чему угодно компилятор не позволит, и проверка типов продолжит работать (хотя для программиста появляется возможность сделать ошибку в определении структур, впрочем там сложности никакой и ошибиться тяжело). В данном случае в макросе можно адресовать специальный именованный член стурктуры, чтоб убедиться что стрктура имеет нужный тип (либо там нет такого члена). Это не совсем утиная типизация, т.к. этот самый спец. член всё же может иметь разные типы (пустой структуры, но разные, т.к. ключевое слово struct каждый раз декларирует новый, не совместимый тип) для каждых несовместимых классов объектов. Не знаю понятно ли объяснил, как-то так:
Ну разумеется callback_functor_t и всё от него наследуемое -- должно быть перемещаемым (на него нигде в памяти не должно сохраняться указателей).
В принципе то же что у автора примерно, только немного другие механизмы, где нет void*. Реализация container_of макроса может выглядеть вот так:
В линуксе по смыслу такая же, но полагается на GNU-расширения. Впрочем, пустые структуры тоже не строгое соответствие ISO-C.
PS: но может проще писать на C++ тогда... Там std::unique_ptr уже сразу из коробки.