Éliminer les fuites de mémoire dans une application propriétaire (x86)
Je suis quelqu’un qui a des champs d’intérêt plutôt diversifiés. Entre autres, j’héberge un serveur du jeu Neverwinter Nights. Vu sa taille importante, j’ai remarqué que la mémoire utilisée par le processus augmentait de plus ou moins 100 mégaoctets à l’heure. Il ne s’agit pas d’un véritable problème pour les serveurs d’une taille plus habituelle et les développeurs n’ont donc jamais eu à se pencher sur ce problème. Comme je suis de nature curieuse, je me suis mis en quête d’une solution.
Contrairement à ce que vous pourriez penser au premier abord, nul besoin d’avoir accès au code lorsqu’on tente de régler un problème de mémoire. Il suffit simplement d’avoir les bons outils et quelques astuces.
Je peux maintenant me targuer d’avoir réglé la plupart de ces problèmes. Mon serveur peut désormais fonctionner pendant des mois sans pertes de mémoire et j’ai même pu régler certains bogues provoqués par ces mêmes pertes. Cependant, une question demeure : pourquoi les développeurs n’ont-ils pas simplement fait un test de fuites de mémoire avant le lancement de Neverwinter Nights?
Les outils
J’ai d’abord essayé des outils déjà existants, par exemple Valgrind, mais les résultats étaient peu fiables et les informations obtenues n’étaient pas suffisamment filtrées. Le tout entrait en conflit avec mes plug-ins sur mesure et m’empêchait de les soumettre à ces mêmes tests. Je soupçonnais un lien de causalité entre les fuites de mémoire liées à ces plug-ins et les ralentissements perçus dans le jeu. Comprenez-moi bien, il s’agit d’excellents outils et Valgrind m’a permis d’économiser un temps précieux, notamment en repérant des corruptions de mémoire. Dans le présent cas, cette solution relève plutôt de l’exagération. À la place, j’ai créé mon propre outil : une simple bibliothèque qui enregistre et me permet de gérer l’allocation de la mémoire.
Pour éliminer les fuites de mémoire que vous aurez ainsi repérées, il suffira d’avoir recours à un débogueur dans le style de GDB, lequel supporte la lecture, l’écriture et les interruptions conditionnelles.
Créer le plug-in pour repérer les fuites de mémoire
Fondamentalement, mon plug-in se contente de jouer le rôle d’hameçon ( « hook ») pour les fonctions malloc, calloc, realloc et les fonctions de libération de mémoire, pour établir un registre des blocs de mémoires alloués.
L’hameçon est établi en créant une méthode avec le même prototype et en chargeant le plug-in avec LD-PRELOAD, ce qui me garantit que ma fonction outrepassera la fonction originale. Par exemple :
void* (*malloc_org)(size_t) = NULL;
void* malloc(size_t size)
{
if (malloc_org == NULL)
{
malloc_org = (void* (*)(size_t))dlsym(RTLD_NEXT, "malloc");
}
if (!hook_malloc) return malloc_org(size);
hook_malloc = false;
void* result = malloc_org(size);
record_allocation(size, result);
hook_malloc = true;
return result;
}
Le fanion hook_malloc vise à éviter que la bibliothèque ne prenne en compte la mémoire qui lui est allouée, ce qui provoquerait une boucle infinie.
La fonction recors_allocation vise à stocker l’information qui concerne les allocations. Je les stocke dans deux cartes distinctes :
// Keep rack the total number of allocation and size done by a specific stack trace.
std::map alloc_info_by_caller;
// Keep track of the stack trace of each allocation
std::unordered_map alloc_info_by_allocptr;
Grâce à ces deux cartes, je peux suivre les allocations et les afficher facilement, en fonction de la trace de leur pile.
La prochaine étape consiste à trouver une façon d’afficher et de gérer l’information disponible dans ces deux cartes. D’abord, il me faut une fonction qui analyse les actions de l’utilisateur. Dans mon dernier article, j’explique comment on peut trouver et créer un hameçon pour l’outil de clavardage dans une application client : http://www.spiria.com/en/blog/logiciel-de-bureau/creating-plugin-closed-source-x86-application
Dans ce cas, le serveur est une application de la console, alors je n’avais plus qu’à créer un hameçon pour l’appel sscanf qui analyse les commandes de la console :
//Hook of the the sscanf call when processing a command line input
int OnProcessKeyboardInput_SScanf(const char* src, const char* format, char* result)
{
bool prev_hook_malloc = hook_malloc;
hook_malloc = false;
// Display the contents of alloc_info_by_caller
if (strncmp(src, "print", 5)==0)
{
for (auto iter=alloc_info_by_caller.begin(); iter!=alloc_info_by_caller.end(); iter++)
{
printf("%u/%u bytes allocated at:\n", iter->second.total_size, iter->second.alloc_count);
iter->first.print();
printf("\n\n");
}
}
// Clear the the maps
else if (strncmp(src, "clear", 11)==0)
{
alloc_info_by_caller.clear();
alloc_info_by_allocptr.clear();
printf("alloc cleared!\n");
}
// Display the size of the maps
else if (strncmp(src, "count", 15)==0)
{
printf("alloc by caller:%u total:%u\n", alloc_info_by_caller.size(), alloc_info_by_allocptr.size());
}
// Enable recoding the allocations
else if (strcmp(src, "hook")==0)
{
check_malloc = true;
printf("free/malloc hook enabled\n");
}
// Disable recording the allocations
else if (strcmp(src, "unhook")==0)
{
check_malloc = false;
printf("free/malloc hook disabled\n");
}
hook_malloc = prev_hook_malloc;
return sscanf(src, format, result);;
}
Repérer une fuite de mémoire grâce à notre plug-in sur mesure
Je ferai le test le plus simple : nous assurer que le serveur n’a pas de fuites lorsqu’il n’est pas actif.
1. Démarrer le serveur et le plug-in.
2. Lorsque le serveur est correctement chargé et donne l’impression qu’il ne fait « rien », je commence la surveillance des allocations. On devrait obtenir « 0 » comme résultat.
3. J’attends environ cinq minutes et j’imprime le résultat, lequel ne devrait pas avoir bougé. Ce n’est malheureusement pas le cas.
4. J’attends encore 5 minutes et j’imprime de nouveau les résultats. Le nombre d’allocations a doublé, ce qui est très étrange. J’imprime la liste des allocations qui n’ont pas été libérées.
5. Je répète l’étape 5, j’ai donc deux listes d’allocations. Voici une capture d’écran :
6. Je compare les résultats obtenus aux étapes nos 4 et 5, et je peux voir que l'allocation faite avec un suivi de pile spécifique, semble avoir une fuite.
Régler la fuite mémoire
J’avais bon espoir de pouvoir régler la problématique par cette simple commande :
void* last_server_allocation = NULL;
void* alloc_server_hook(long size)
{
if (last_server_allocation) free(last_server_allocation);
last_server_allocation = malloc(size);
return last_server_allocation;
}
Malheureusement, la solution n’était pas si simple. Dans tous les cas, la mémoire allouée était stockée dans un objet pour un usage futur. Il m’a donc fallu retracer ce pour quoi cette mémoire était allouée.
Dans le cas présent, je n’ai eu qu’une seule trace de la pile, mais vous devriez normalement en obtenir une longue liste. La prochaine étape consiste à repérer les fuites les plus importantes :
Trouver l’endroit où le pointeur est stocké
L’endroit où le pointeur est stocké à l’origine peut facilement être identifié en jetant un coup d’œil au code assembleur :
0x08272070 <+32>: call 0x830cbc4 //call new
0x08272075 <+37>: mov %eax,%ebx //allocated memory moved in %ebx
0x08272077 <+39>: lea -0xc(%ebp),%eax
0x0827207a <+42>: push %eax
0x0827207b <+43>: push %esi
0x0827207c <+44>: pushl 0xc(%ebp)
0x0827207f <+47>: push %ebx
0x08272080 <+48>: call 0x827428c
0x08272085 <+53>: mov %ebx,0xc(%esi) //allocated memory moved in 0xc(%esi)
Il ne faut cependant pas écarter la possibilité que le pointeur soit partagé et stocké à d’autres endroits par la suite. Pour en avoir le cœur net, j’ai tenté de repérer l’ensemble des usages potentiels. Pour ce faire, j’ai ajouté une condition d’interruption de lecture qui vise l’endroit où il est initialement stocké, ainsi que toutes les copies qui pourraient être faites subséquemment :
//Add a breakpoint where the memory is allocated
(gdb) break *0x08272085
Breakpoint 6 at 0x8272085
(gdb) cont
Continuing.
//The memory is allocated
Breakpoint 6, 0x08272085 in CConnectionLib::ServerConnectToGameSpy(unsigned int) ()
//Add a read watch point at the address that will contain the pointer
(gdb) print /x $esi+0xc
$4 = 0xe90f3cc
(gdb) rwatch *0xe90f3cc
Hardware read watchpoint 7: *0xe90f3cc
(gdb) cont
Continuing.
//First place where the allocated memory is accessed, I updated the watch point so that it doesn't break here anymore.
Hardware read watchpoint 7: *0xe90f3cc
Value = 391371808
0x0827213b in CConnectionLib::HandleServerGameSpyMessage(unsigned long, unsigned char *, unsigned long) ()
(gdb) delete 7
(gdb) rwatch *0xe90f3cc if ($eip != 0x0827213b)
Hardware read watchpoint 8: *0xe90f3cc
(gdb) cont
Continuing.
//Second place where the allocated memory is accessed, I updated the watch point so that it doesn't break here anymore.
Hardware read watchpoint 8: *0xe90f3cc
Value = 391371808
0x0827211c in CConnectionLib::UpdateGameSpyServer(void) ()
(gdb) delete 8
(gdb) rwatch *0xe90f3cc if ($eip != 0x0827213b && $eip != 0x0827211c)
Hardware read watchpoint 9: *0xe90f3cc
(gdb) cont
Continuing.
//Back to where the memory was allocated
Breakpoint 6, 0x08272085 in CConnectionLib::ServerConnectToGameSpy(unsigned int) ()
//This is to confirm that the memory will be allocated again for the same object
(gdb) print /x $esi+0xc
$5 = 0xe90f3cc
(gdb)
Dans ce cas, le pointeur est uniquement accessible via les adresses 0x0827213b et 0x0827211c et ne sera pas copié ailleurs. La seule manière de s’en assurer est de vérifier l’assemblage de fonctions à ces adresses.
Savoir quand la référence est perdue
Il s’agit sans doute de l’endroit où la mémoire allouée est censée être libérée. Pour le savoir, j’ajoute simplement une condition d’interruption d’écriture à chacune des adresses qui contiennent un pointeur de la mémoire allouée.
(gdb) print /x $esi+0xc
$4 = 0xe90f3cc
(gdb) watch *0xe90f3cc
Hardware watchpoint 10: *0xe90f3cc
(gdb) cont
Continuing.
Hardware watchpoint 10: *0xe90f3cc
Old value = 391371808
New value = 409354776
0x08272088 in CConnectionLib::ServerConnectToGameSpy(unsigned int) ()
(gdb) cont
Continuing.
Hardware watchpoint 10: *0xe90f3cc
Old value = 409354776
New value = 412182400
0x08272088 in CConnectionLib::ServerConnectToGameSpy(unsigned int) ()
(gdb)
Continuing.
Dans ce cas, le seul endroit où la référence changera suit l’endroit où elle est allouée.
Libérer la mémoire qui contient la référence ne déclenchera pas la condition d’arrêt par défaut, mais on peut remédier facilement à cette situation à l’aide de ce plug-in sur mesure :
memset(ptr, 0, mem_alloc_info.size);
Savoir lorsqu’elle pourrait être libérée
La dernière chose qui pourrait affecter le lieu et le moment où la mémoire sera libérée est le cas où elle serait déjà libérée sous certaines conditions. Pour repérer ces cas, je pourrais programmer un point de vérification d’écriture sur la mémoire allouée (lorsqu’elle sera libérée, le plug-in réinitialisera la mémoire), mais la solution la plus simple consiste à ajouter une commande au plug-in pour que le moment de libération de la mémoire soit enregistré.
inline void unregister_allocation(void* ptr)
{
auto allocptr_iter = alloc_info_by_allocptr.find(ptr);
if (allocptr_iter != alloc_info_by_allocptr.end())
{
MEM_ALLOC_INFO& mem_alloc_info = allocptr_iter->second;
//check if the allocation is being watched
for (int i=0; i
[...]
if (strncmp(src, "watch", 5)==0)
{
long ptr;
if (sscanf(src, "watch 0x%lx", &ptr)==1)
{
watch_ptr = (void*)ptr;
watch_result.clear();
printf("watching *0x%lx\n", ptr);
}
}
else if (strcmp(src, "clear_watch")==0)
{
watch_ptr = NULL;
watch_result.clear();
printf("watch cleared!\n");
}
else if (strcmp(src, "print_watch")==0)
{
for (auto iter=watch_result.begin(); iter!=watch_result.end(); iter++)
{
iter->print(stdout);
fprintf(stdout, "\n\n");
}
}
Le résultat est:
hook
free/malloc hook enabled
watch 0x08272075
watching *0x8272075
print_watch
./nwserver() [0x830cdd6]
./nwserver() [0x82720a3]
./nwserver() [0x80ae4b4]
./nwserver() [0x80a0c0c]
./nwserver() [0x804bbe7]
Maintenant, sachant que la mémoire allouée par l’appel à l’adresse 0x08272070 et peut être libérée par l’appel à l’adresse 0x0827209e et la référence est redémarrée chaque fois qu’une nouvelle instance est allouée. La solution est la suivante :
void* last_server_allocation = NULL;
void* alloc_server_hook(long size)
{
if (last_server_allocation) free(last_server_allocation);
last_server_allocation = malloc(size);
return last_server_allocation;
}
void free_server_hook(void* ptr)
{
free(ptr);
if (last_server_allocation == ptr)
{
last_server_allocation = NULL;
}
}
[...]
hook_call(0x08272070, (long)alloc_server_hook);
hook_call(0x0827209e, (long)free_server_hook);
Conclusion
Cette expérience m’a permis de réaliser que la recherche et le colmatage de fuites mémoires ne sont pas des questions d’essai-erreur. En ayant les outils suggérés ou même en créant votre propre solution, vous pouvez compiler les informations nécessaires pour créer un logiciel sans la moindre fuite, même si vous n’êtes pas à l’aise avec le code
Dans mon cas, il me reste à faire fonctionner le serveur en temps réel avec mon plug-in pour détecter où fuient les quelques mégaoctets de mémoire perdus chaque jour. Il est même possible de charger ce plug-in poids plume dans un environnement de production sans qu’il n’ait de véritable effet sur l’expérience de l’utilisateur.