Memory Management Patterns
The Problem with malloc/free Everywhere
Raw malloc and free calls scattered throughout a codebase lead to leaks, double frees, and use-after-free bugs. Structured patterns reduce these risks by making allocation and deallocation predictable.
Arena Allocation
An arena (also called a bump allocator or region allocator) allocates from a large pre-allocated block. Individual allocations are not freed. Instead, the entire arena is freed at once.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
struct Arena {
uint8_t *buffer;
size_t capacity;
size_t offset;
};
struct Arena *arena_create(size_t capacity) {
struct Arena *a = malloc(sizeof(*a));
if (!a) return NULL;
a->buffer = malloc(capacity);
if (!a->buffer) { free(a); return NULL; }
a->capacity = capacity;
a->offset = 0;
return a;
}
void *arena_alloc(struct Arena *a, size_t size) {
/* Align to 8 bytes */
size_t aligned = (size + 7) & ~(size_t)7;
if (a->offset + aligned > a->capacity) {
return NULL; /* out of space */
}
void *ptr = a->buffer + a->offset;
a->offset += aligned;
return ptr;
}
void arena_reset(struct Arena *a) {
a->offset = 0; /* "free" everything at once */
}
void arena_destroy(struct Arena *a) {
if (a) {
free(a->buffer);
free(a);
}
}
int main(void) {
struct Arena *arena = arena_create(4096);
/* Allocate several objects from the arena */
int *nums = arena_alloc(arena, 10 * sizeof(int));
char *name = arena_alloc(arena, 64);
for (int i = 0; i < 10; i++) nums[i] = i * i;
snprintf(name, 64, "Alice");
printf("nums[3] = %d, name = %s\n", nums[3], name);
printf("Arena used: %zu / %zu bytes\n", arena->offset, arena->capacity);
arena_reset(arena); /* free everything at once */
printf("After reset: %zu / %zu bytes\n", arena->offset, arena->capacity);
arena_destroy(arena);
return 0;
}
nums[3] = 9, name = Alice
Arena used: 144 / 4096 bytes
After reset: 0 / 4096 bytes
Arenas are ideal when many objects share a common lifetime: a request handler, a compiler pass, or a game frame. No individual frees means no use-after-free bugs, no double frees, and fast allocation (just a pointer bump).
Object Pools
An object pool pre-allocates a fixed number of objects and recycles them:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define POOL_SIZE 8
struct Connection {
int id;
int active;
char host[64];
};
struct ConnectionPool {
struct Connection slots[POOL_SIZE];
int used[POOL_SIZE];
};
void pool_init(struct ConnectionPool *pool) {
memset(pool, 0, sizeof(*pool));
}
struct Connection *pool_acquire(struct ConnectionPool *pool) {
for (int i = 0; i < POOL_SIZE; i++) {
if (!pool->used[i]) {
pool->used[i] = 1;
pool->slots[i].active = 1;
pool->slots[i].id = i;
return &pool->slots[i];
}
}
return NULL; /* pool exhausted */
}
void pool_release(struct ConnectionPool *pool, struct Connection *conn) {
int index = (int)(conn - pool->slots);
if (index >= 0 && index < POOL_SIZE) {
pool->used[index] = 0;
conn->active = 0;
}
}
int main(void) {
struct ConnectionPool pool;
pool_init(&pool);
struct Connection *c1 = pool_acquire(&pool);
struct Connection *c2 = pool_acquire(&pool);
snprintf(c1->host, sizeof(c1->host), "db-primary.local");
snprintf(c2->host, sizeof(c2->host), "db-replica.local");
printf("c1: id=%d host=%s\n", c1->id, c1->host);
printf("c2: id=%d host=%s\n", c2->id, c2->host);
pool_release(&pool, c1);
struct Connection *c3 = pool_acquire(&pool); /* reuses c1's slot */
printf("c3: id=%d (reused slot)\n", c3->id);
pool_release(&pool, c2);
pool_release(&pool, c3);
return 0;
}
c1: id=0 host=db-primary.local
c2: id=1 host=db-replica.local
c3: id=0 (reused slot)
Object pools avoid repeated malloc/free overhead and prevent heap fragmentation. They are common in game engines, database connection managers, and real-time systems.
RAII in C: Cleanup with goto
C does not have destructors, but the goto cleanup pattern provides similar guarantees:
#include <stdio.h>
#include <stdlib.h>
int process_data(const char *input_path, const char *output_path) {
int result = -1;
FILE *in = NULL;
FILE *out = NULL;
char *buffer = NULL;
in = fopen(input_path, "r");
if (!in) { perror("fopen input"); goto cleanup; }
out = fopen(output_path, "w");
if (!out) { perror("fopen output"); goto cleanup; }
buffer = malloc(4096);
if (!buffer) { perror("malloc"); goto cleanup; }
size_t n;
while ((n = fread(buffer, 1, 4096, in)) > 0) {
if (fwrite(buffer, 1, n, out) != n) {
perror("fwrite");
goto cleanup;
}
}
result = 0; /* success */
cleanup:
free(buffer);
if (out) fclose(out);
if (in) fclose(in);
return result;
}
Every error path jumps to cleanup, which releases resources in reverse order of acquisition. This prevents leaks on any failure and keeps cleanup logic in one place.
Cleanup with attribute((cleanup))
GCC and Clang support automatic cleanup via a function attribute:
#include <stdio.h>
#include <stdlib.h>
static void free_ptr(void *p) {
free(*(void **)p);
}
static void close_file(FILE **fp) {
if (*fp) fclose(*fp);
}
int process(const char *path) {
__attribute__((cleanup(close_file))) FILE *f = fopen(path, "r");
if (!f) return -1;
__attribute__((cleanup(free_ptr))) char *buf = malloc(1024);
if (!buf) return -1;
/* buf and f are automatically cleaned up when they go out of scope */
size_t n = fread(buf, 1, 1024, f);
printf("Read %zu bytes\n", n);
return 0;
}
The cleanup function is called automatically when the variable goes out of scope, similar to C++ RAII. This is a compiler extension, not standard C.
Reference Counting
Reference counting tracks how many owners share a resource. The resource is freed when the count reaches zero.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct SharedString {
char *data;
int refcount;
};
struct SharedString *shared_string_create(const char *str) {
struct SharedString *s = malloc(sizeof(*s));
if (!s) return NULL;
s->data = strdup(str);
if (!s->data) { free(s); return NULL; }
s->refcount = 1;
return s;
}
struct SharedString *shared_string_retain(struct SharedString *s) {
if (s) s->refcount++;
return s;
}
void shared_string_release(struct SharedString *s) {
if (!s) return;
s->refcount--;
if (s->refcount <= 0) {
free(s->data);
free(s);
}
}
int main(void) {
struct SharedString *s1 = shared_string_create("hello");
struct SharedString *s2 = shared_string_retain(s1); /* s1 and s2 share */
printf("s1: \"%s\" (refcount=%d)\n", s1->data, s1->refcount);
shared_string_release(s1); /* refcount drops to 1 */
printf("After releasing s1: refcount=%d\n", s2->refcount);
shared_string_release(s2); /* refcount drops to 0, memory freed */
return 0;
}
s1: "hello" (refcount=2)
After releasing s1: refcount=1
Reference counting is simple but cannot handle cycles (A references B, B references A). It is used in CPython, GLib, COM, and Core Foundation.
The init/destroy Pattern
For stack-allocated structs, use init and destroy instead of create and free:
#include <stdio.h>
#include <stdlib.h>
struct Buffer {
char *data;
size_t size;
};
int buffer_init(struct Buffer *buf, size_t size) {
buf->data = malloc(size);
if (!buf->data) return -1;
buf->size = size;
return 0;
}
void buffer_destroy(struct Buffer *buf) {
free(buf->data);
buf->data = NULL;
buf->size = 0;
}
int main(void) {
struct Buffer buf;
if (buffer_init(&buf, 1024) != 0) {
fprintf(stderr, "Failed to initialize buffer\n");
return 1;
}
snprintf(buf.data, buf.size, "Hello from buffer");
printf("%s\n", buf.data);
buffer_destroy(&buf);
return 0;
}
Hello from buffer
The struct itself lives on the stack (no heap allocation for the struct). Only internal resources are heap-allocated. This avoids an extra malloc/free for the struct and works well when the lifetime matches a scope.
Memory Ownership Documentation
In C, ownership must be documented because the language does not enforce it. Use comments and naming conventions.
/**
* Create a new parser for the given source.
* @param source The source string. The parser does NOT take ownership;
* the caller must keep it alive for the parser's lifetime.
* @return A new parser. The caller owns this and must call parser_destroy().
* Returns NULL on allocation failure.
*/
struct Parser *parser_create(const char *source);
/**
* Get the current token text.
* @return A pointer into the parser's internal buffer.
* Valid only until the next call to parser_next().
* The caller must NOT free this pointer.
*/
const char *parser_current_text(const struct Parser *p);
/**
* Extract all tokens as a newly allocated array.
* @param out_count Set to the number of tokens.
* @return A malloc'd array of malloc'd strings.
* The caller must free each string and then free the array.
*/
char **parser_all_tokens(const struct Parser *p, int *out_count);
Clear ownership documentation prevents entire categories of bugs.
Common Pitfalls
- Arena overflow without checking. Always check the return value of
arena_alloc. Running past the buffer is a buffer overflow. - Forgetting to reset or destroy arenas. An arena that is never reset or destroyed still leaks all its memory.
- Reference count imbalance. Every
retainneeds a matchingrelease. One missingreleaseleaks; one extrareleasecauses use-after-free. - Thread safety. Reference counting requires atomic operations in multi-threaded programs. A simple
refcount++is a data race. - Using cleanup attributes portably.
__attribute__((cleanup))is a GCC/Clang extension. Code that must compile with MSVC cannot use it. - Mixing patterns. Allocating from an arena and then calling
freeon the result is wrong. Each pattern has its own deallocation mechanism.
Key Takeaways
- Arena allocation groups objects with the same lifetime and frees them all at once. Fast, simple, and leak-resistant.
- Object pools pre-allocate a fixed set of objects, eliminating repeated malloc/free and fragmentation.
- The
gotocleanup pattern is C's standard idiom for ensuring resources are freed on every exit path. - Reference counting enables shared ownership but cannot handle cycles and requires atomic operations for thread safety.
- The init/destroy pattern works for stack-allocated structs with internal heap resources.
- Document ownership in comments: who allocates, who frees, and how long pointers remain valid.