Pourquoi l'allocation standard de mémoire tombe à court en code de haute performance

Chaque programmeur C s'appuie sur et pour la gestion dynamique de la mémoire. Ces fonctions sont d'usage général, conçues pour fonctionner sur une grande variété de modèles d'allocation, de tailles d'objets et de durée de vie. Sous le capot, ils gèrent un tas, tiennent des listes libres, fusionnent des blocs libres adjacents et gèrent l'alignement. Cette flexibilité coûte cher : chaque attribution et deallocation peut nécessiter des verrous (pour la sécurité des fils), des appels système et la traversée de structures de données de comptabilité.

Au-delà de la vitesse brute, la fragmentation est un tueur silencieux. Au fil du temps, peut disperser de petites allocations dans le tas, laissant des lacunes qui ne peuvent pas être réutilisées efficacement. Cela entraîne une utilisation accrue de la mémoire, des allocations futures plus lentes et des cycles CPU gaspillés. Les allocataires de la mémoire personnalisée offrent une alternative déterministe et peu overhead en préalternant les grandes régions et en servant des blocs de taille fixe à partir d'une liste libre simple.

Ce guide vous accompagne dans la conception et la mise en œuvre d'un solide bassin de mémoire fixe en C. Vous allez apprendre à structurer le bassin, à gérer les cas de bord comme l'épuisement et l'alignement, et à étendre le modèle aux scénarios multipool.

Principes fondamentaux de conception d'un bassin de mémoire

Une piscine de mémoire (également appelée une réserve de dalles ou un pool d'objets) fonctionne sur une idée simple : attribuer un grand bloc contigu de mémoire, le diviser en -slots de taille fixe, et gérer les fentes libres en utilisant une liste liée à une seule. Lorsqu'un consommateur demande de la mémoire, la piscine retourne la première fente de la liste libre. Lorsqu'une fente est libérée, elle est repoussée à la tête de la liste libre.

Pools de taille fixe ou de taille variable

La variante la plus courante est la piscine de taille fixe, où chaque emplacement est de la même taille. Ceci correspond à l'objet que sert la piscine, par exemple, un bassin de nœuds . Les piscines de taille variable (également appelées -allocateurs -aréna) peuvent attribuer des morceaux de différentes tailles, mais elles présentent une complexité : elles doivent gérer une liste libre de tailles de blocs variables, gérer le fractionnement et le coalcing, et éviter toute fragmentation.

Considérations d'alignement

Si votre piscine stocke des objets qui contiennent des types comme , ou des vecteurs SIMD, le bassin doit garantir que chaque emplacement commence à une adresse alignée sur la plus grande exigence d'alignement du type stocké. La norme C exige de renvoyer la mémoire convenablement alignée pour tout type standard, c'est-à-dire au moins . Un bassin personnalisé devrait faire de même. Nous nous assurerons que chaque emplacement est aligné sur en rembourrant la taille de la fente jusqu'au multiple le plus proche de cet alignement. En pratique, utiliser une puissance de deux emplacements ou simplement arrondir fonctionne bien.

Sécurité des fils

Pour les applications à simple filetage, aucune synchronisation n'est nécessaire. Cependant, de nombreux systèmes de production nécessitent un accès simultané. L'ajout de la sécurité des fils à un pool est simple : protégez la liste libre avec un mutex, ou utilisez une liste liée sans verrou avec comparaison atomique et avec une bande. Nous présenterons la version simple filetage de base, mais nous discuterons des points d'extension pour les environnements multifils.

Construire une piscine de mémoire de taille fixe : étape par étape

Nous implémentons un pool qui stocke des objets de taille arbitraire. Le pool lui-même est une structure tenant un pointeur à la mémoire pré-alloquée, une tête de liste libre, la taille de fente (arrondie pour l'alignement) et le nombre total de fentes. La liste libre est une liste liée intégrée dans chaque emplacement libre: chaque emplacement libre stocke un pointeur à la prochaine fente libre. Cela évite toute métadonnée externe en plus.

Structures de données

#include <stddef.h>
#include <stdlib.h>

// Embedded free list node
typedef struct FreeNode {
 struct FreeNode* next;
} FreeNode;

// Pool descriptor
typedef struct MemoryPool {
 size_t slot_size; // Size of each slot (after alignment rounding)
 size_t slot_count; // Number of slots in the pool
 void* pool_start; // Start of the pre‑allocated memory block
 FreeNode* free_list; // Head of the free list
} MemoryPool;

est utilisé uniquement en interne ; les objets attribués occupent la même mémoire. Lorsqu'une fente est libre, ses premiers octets contiennent un pointeur suivant. Lorsqu'elle est attribuée, l'utilisateur du pool écrit leurs données sur ce pointeur. C'est pourquoi la taille de fente doit être au moins – autrement, nous ne pouvons pas stocker les pointeurs de liste libre.

Initialisation

L'initialisation attribue un seul grand bloc de mémoire et relie chaque fente à la liste libre. Nous arrondissons la taille de fente demandée au multiple d'alignement le plus proche (que nous choisissons comme ). Cela assure que chaque fente, et donc chaque pointeur retourné, est correctement aligné.

#include <stdint.h> // for max_align_t

int pool_init(MemoryPool* mp, size_t object_size, size_t object_count) {
 // Round up object_size to the alignment of max_align_t
 size_t alignment = _Alignof(max_align_t);
 size_t aligned_size = (object_size + alignment - 1) & ~(alignment - 1);

 // Ensure slot is large enough to hold a FreeNode pointer
 if (aligned_size < sizeof(FreeNode))
 aligned_size = sizeof(FreeNode);

 mp->slot_size = aligned_size;
 mp->slot_count = object_count;

 // Allocate the contiguous pool memory
 size_t total_size = aligned_size * object_count;
 mp->pool_start = malloc(total_size);
 if (mp->pool_start == NULL)
 return -1; // allocation failure

 // Build the free list
 mp->free_list = (FreeNode*)mp->pool_start;
 FreeNode* current = mp->free_list;
 for (size_t i = 1; i < object_count; i++) {
 current->next = (FreeNode*)((char*)mp->pool_start + i * aligned_size);
 current = current->next;
 }
 current->next = NULL;
 return 0;
}

Nous utilisons qui donne la garantie d'alignement la plus stricte requise par . Pour la plupart des plateformes, il s'agit de 8 ou 16 octets. Le tour d'arrondi bitwise fonctionne pour la puissance de deux alignements. Cela garantit que chaque pointeur retourné est sûr à utiliser avec n'importe quel type standard.

Montant alloué

L'allocation pops le chef de la liste libre et le renvoie. Si la liste libre est vide, le pool est épuisé et nous retournons .

void* pool_alloc(MemoryPool* mp) {
 if (mp->free_list == NULL) {
 return NULL; // pool exhausted
 }
 FreeNode* block = mp->free_list;
 mp->free_list = block->next;
 return (void*)block;
}

Ceci est O(1) et exécute dans une poignée d'instructions. Pas de verrouillages, pas d'appels système.

Libérer une fente

Le libérateur repousse la fente sur la liste libre. L'appelant doit s'assurer que le pointeur appartient à ce pool (on discutera de validation plus tard).

void pool_free(MemoryPool* mp, void* ptr) {
 if (ptr == NULL) return; // standard behavior like free(NULL)
 FreeNode* node = (FreeNode*)ptr;
 node->next = mp->free_list;
 mp->free_list = node;
}

O(1). Pas de fusion, pas de fusion. La fente libérée devient immédiatement disponible pour réutilisation.

Destruction des bassins

Lorsque le pool n'est plus nécessaire, libérer l'allocation sous-jacente.

void pool_destroy(MemoryPool* mp) {
 free(mp->pool_start);
 mp->pool_start = NULL;
 mp->free_list = NULL;
 mp->slot_count = 0;
 mp->slot_size = 0;
}

Appelez toujours avant que la structure de la piscine ne soit hors de portée pour éviter les fuites de mémoire.

Exemple d'utilisation

Voici un exemple complet qui crée un pool de 1024 entiers fentes, en alloue un, écrit une valeur, la lit et la libère.

#include <stdio.h>
#include <assert.h>

int main(void) {
 MemoryPool pool;
 if (pool_init(&pool, sizeof(int), 1024) != 0) {
 fprintf(stderr, "Pool initialization failed\n");
 return 1;
 }

 int* p = (int*)pool_alloc(&pool);
 if (p == NULL) {
 fprintf(stderr, "Pool exhausted\n");
 return 1;
 }

 *p = 42;
 printf("Value: %d\n", *p);

 pool_free(&pool, p);
 pool_destroy(&pool);
 return 0;
}

Dans une application réelle, vous attribueriez un pool pour chaque type d'objet que vous devez gérer. Par exemple, un serveur réseau peut avoir un et un .

Considérations et prorogations avancées

Suivi de l'allocation pour le débogage

Pour le débogage, vous pouvez ajouter un bitfield ou une liste séparée de blocs alloués. Cela vous permet de détecter les doubles-free ou les fuites. En production, le surcoût du suivi est généralement évité – la nature déterministe des piscines rend les bogues plus faciles à trouver par empoisonnement à la mémoire.

Poisonnement de la mémoire

Lorsqu'une fente est libérée, vous pouvez écraser son contenu avec un motif connu (p. ex. ) pour détecter l'utilisation-après-libre. De même, lors de l'attribution, vous pouvez remplir la fente avec un motif pour attraper des lectures non initiales. L'empoisonnement ajoute un petit coût constant mais peut économiser des heures de débogage.

Statistiques sur les stocks d'exportation

Pour l'accordage des performances, exposer les compteurs comme les allocations totales, les libres totaux et le nombre libre courant. Une façon simple est de maintenir un champ dans la structure de la piscine, de décrémenter sur alloc et d'augmenter sur libre. Cela aide également à détecter l'épuisement sans balayage.

// Add to MemoryPool: size_t free_count;
// In pool_alloc: if (mp->free_list) { mp->free_count--; ... }
// In pool_free: mp->free_count++; ...

Piscines de filetage et de sécurité

Pour un accès simultané, enveloppez les fonctions alloc et free avec un mutex :

#include <pthread.h>

typedef struct ThreadSafePool {
 MemoryPool pool;
 pthread_mutex_t lock;
} ThreadSafePool;

void* ts_pool_alloc(ThreadSafePool* tsp) {
 pthread_mutex_lock(&tsp->lock);
 void* ptr = pool_alloc(&tsp->pool);
 pthread_mutex_unlock(&tsp->lock);
 return ptr;
}

void ts_pool_free(ThreadSafePool* tsp, void* ptr) {
 pthread_mutex_lock(&tsp->lock);
 pool_free(&tsp->pool, ptr);
 pthread_mutex_unlock(&tsp->lock);
}

Pour une argumentation plus faible, considérez une liste libre sans verrou en utilisant . Cependant, cela nécessite de gérer le problème ABA – un défi classique décrit dans de nombreux manuels de proximité.

La croissance dynamique du bassin

Si vous avez besoin d'un bassin qui peut s'étendre, vous pouvez maintenir un éventail de morceaux de piscine. Lorsqu'un morceau est épuisé, attribuer un nouveau morceau (de la même taille) et ajouter ses fentes à la liste libre. L'allocateur reste O(1) presque toujours, mais vous devez gérer plusieurs morceaux pendant la destruction.

Points de référence en matière de rendement (conceptuel)

Dans un microbenchmark typique sur un processeur moderne x86‐64, un cycle alloc/libre de pool prend 15 à 30 nanosecondes, tandis que / pour un objet de 32 octets peut prendre 80 à 200 nanosecondes en raison du verrouillage et des métadonnées en sus. Dans les applications réelles, l'amélioration est souvent de 2 à 5× pour les charges de travail lourdes d'allocation.

Pièges courants et comment les éviter

  • Mixation des tailles de piscine: Ne jamais libérer un pointeur qui appartient à un bassin différent (ou à ) avec . Le résultat est un comportement non défini. Considérez stocker un identifiant de piscine dans chaque emplacement pour une sécurité supplémentaire dans les constructions de débogue.
  • Inadéquation de l'alignement:[ Si vous entreposez des types avec des exigences d'alignement inhabituelles (p. ex. ]), assurez-vous que votre alignement de fente est suffisant. La méthode couvre tous les types standard mais peut ne pas couvrir les types SIMD.
  • Pour ne pas appeler : Le sous-jacent n'est jamais libéré si vous sautez la destruction. Utilisez des enveloppes RAII ou un modèle de nettoyage clair.
  • Si vous avez besoin d'objets de différentes tailles, créez des piscines séparées. Essayer d'adapter des tailles variables dans un pool de taille fixe gaspille la mémoire ou provoque la troncation.

Contexte mondial réel et lecture supplémentaire

Les piscines de mémoire personnalisées ne sont pas une nouvelle idée. Elles apparaissent dans pratiquement tous les systèmes à haute performance :

  • Le noyau Linux utilise slab allocators pour les caches d'objets (voir l'interface .
  • Des moteurs de jeu comme Unreal Engine et Godot fournissent des allocataires de piscine intégrés pour les acteurs et les particules.
  • Les bibliothèques de réseautage (p. ex. DPDK utilisent des piscines de mémoire pour les tampons de paquets pour garantir une allocation zéro sur le chemin rapide.
  • La bibliothèque Apache APR comprend une API de pool utilisée par Apache HTTP Server.

Pour une étude plus approfondie, lisez la bibliothèque GNU Cs malloc implementation[ pour comprendre ce que vous évitez, et examinez la documentation de la dalle de noyau pour l'inspiration du design.

Conclusion

L'implémentation en C pur est petite, moins de 50 lignes de code bien conçu, mais elle élimine la fragmentation, les caches manquants et les frais généraux des allocateurs à usage général. En comprenant les compromis (taille fixe par rapport à la taille variable, sécurité du filet, alignement), vous pouvez adapter le pool à votre charge de travail spécifique et réaliser des opérations de mémoire déterministes et quasi-constantes.