Blog-like notes

Gestion des erreurs d’allocation mémoire en C

Une version de cette note a été publiée sur LinuxFR.org le 26 octobre 2016.

Chaque fois que je commence un nouveau projet en C, je me pose toujours la même question : que faire en cas d’échec d’allocation de mémoire ?

C’est une question qu’on ne se pose pas dans la plupart des autres langages plus récents, où l’allocation de mémoire est généralement une opération cachée loin de la vue du programmeur. Mais en C, la question se pose chaque fois que l’on doit appeler malloc ou toute autre fonction allouant de la mémoire : que faire si malloc renvoie NULL ?

Gérer gracieusement l’erreur

La première possibilité est de tenter de « gérer » l’erreur. On teste le pointeur renvoyé par la fonction d’allocation, et on adopte un comportement approprié en cas d’erreur (par exemple, arrêter le traitement en cours et revenir à la boucle principale du programme — ce que recommandent les GNU Coding Standards pour les programmes interactifs).

Cette approche, idéale sur le papier et conforme aux « bonnes pratiques » (toujours tester la valeur de retour d’une fonction !), n’est malheureusement pas sans problèmes.

D’abord, elle complexifie considérablement le code, la moindre allocation de mémoire donnant lieu à un embranchement et à la nécessité de faire remonter l’erreur au code appelant jusqu’à un niveau capable de prendre la bonne décision (mais on pourrait en dire autant de la gestion des erreurs en général en C, ce n’est pas particulièrement propre aux erreurs d’allocation mémoire).

Dans certains cas, il est possible de se rendre la tâche un peu plus facile en repensant le code. Par exemple, on peut tenter d’allouer d’un coup toute la mémoire nécessaire avant d’effectuer une opération, et annuler proprement ladite opération si l’allocation échoue. Si l’allocation réussit, on n’a plus à se soucier du risque de manque de mémoire pendant toute la durée de l’opération. Cela implique toutefois de pouvoir calculer préalablement la quantité de mémoire nécessaire, ce qui n’est pas toujours possible. Par ailleurs, organiser le code autour de l’allocation de mémoire n’est pas forcément pertinent et risque de nuire à sa lisibilité.

Plus grave encore, le chemin pris en cas d’erreur n’est pratiquement jamais testé, le manque de mémoire étant une condition extrême que le développeur est très peu susceptible de rencontrer en pratique (encore moins sous GNU/Linux, où le comportement par défaut du système est de ne pas refuser la plupart des allocations quelque soit la mémoire réellement disponible, préférant compter sur l’OOM killer pour libérer de la mémoire en cas de besoin) et difficile à provoquer délibérément (à moins sans doute d’utiliser son propre allocateur, instrumentalisé pour simuler des erreurs d’allocation à la demande — beaucoup d’efforts pour tester une situation exceptionnelle). On risque donc de se retrouver avec une gestion du manque de mémoire que l’on pense « gracieuse »…. et qui s’avère en réalité bancale parce que truffée de bugs jamais mis en évidence.

Un argument parfois avancé pour justifier les efforts nécessaires à une gestion gracieuse des erreurs d’allocation est que le programme doit éviter de faire perdre à l’utilisateur son travail en cours. D’après moi, cet argument ne tient pas : si l’on tient à préserver à tout prix, à tout moment, le travail de l’utilisateur, attendre qu’une erreur survienne et tenter à ce moment-là de sauver les meubles est une mauvaise stratégie. Enregistrer régulièrement le travail en cours (pas à la demande de l’utilisateur, mais en arrière-plan) est une solution beaucoup plus robuste, qui permet de faire face non seulement à un soudain manque de mémoire mais aussi à toutes sortes de situation pas forcément prévisibles ou évitables (une coupure de l’alimentation par exemple).

Avorter en cas d’erreur

À l’opposé de la gestion gracieuse, il y a l’approche consistant à terminer immédiatement le programme à la moindre erreur d’allocation. L’idée étant que dans une situation où le système est à court de mémoire, le programme n’a de toute façon pas beaucoup de marge de manœuvre, et qu’il vaut mieux tout arrêter, laisser l’utilisateur ou l’administrateur remédier au problème (en quittant Firefox, peut-être ?), et relancer le programme après.

C’est ce que recommandent les GNU Coding Standards pour les programmes non-interactifs :

If malloc fails in a noninteractive program, make that a fatal error.

Une façon particulièrement simple d’implémenter cette approche est… de ne rien faire, c’est-à-dire de ne pas vérifier le pointeur renvoyé par les fonctions d’allocation. On laissera simplement le programme segfaulter tout seul lors du déréférencement d’un pointeur nul. J’appellerai ça la méthode « YOLO ».

Jens Gustedt suggère une petite variante qui consiste à appeler malloc ainsi :

memset(malloc(size), 0, 1);

où l’on écrit immédiatement 0 au début du bloc alloué de manière à provoquer tout de suite l’erreur de segmentation si jamais l’allocation a échoué.

Il faut néanmoins noter qu’en réalité rien ne garantit que déréférencer un pointeur nul conduira à une erreur de segmentation, le comportement exact en ce cas étant dépendant de l’implémentation (processeur, OS, et/ou compilateur). Cette méthode est donc préférablement à éviter.

Une autre façon classique d’implémenter l’avortement sur erreur est la méthode du « wrapper qui tue ». On enveloppe les fonctions d’allocations dans des wrappers qui se chargent de quitter proprement le programme en cas d’erreur (via exit(3), si on veut que les éventuelles fonctions de nettoyage mises en place par atexit(3) soient appelées, ou plus violemment via abort(3)). Les wrappers eux-mêmes peuvent ensuite être appelés en mode « YOLO », sans vérifier le pointeur retourné — le simple fait que le wrapper retourne indique déjà que l’allocation s’est bien passée.

Voici le wrapper typique que j’utilise généralement dans mes programmes :

void *
xmalloc(size_t s)
{
    void *p;

    if ( ! (p = malloc(s)) && s )
        err(EXIT_FAILURE, "Cannot allocate %lu bytes", s);

    return p;
}

Ici, en cas d’erreur le wrapper affiche la quantité qu’il a tenté d’allouer (ça peut être utile à l’utilisateur pour évaluer la gravité de la situation), puis termine. (La fonction err(3) n’est pas standard, mais elle est assez répandue et peut trivialement être ré-écrite sur une plateforme qui ne la fournit pas.)

On peut aussi imaginer des wrappers plus complexes, comme ceux utilisés par Git, qui en cas d’erreur tentent de libérer un peu de mémoire avant de retenter une allocation, après quoi seulement ils terminent le programme.

À signaler aussi, l’intéressante « approche hybride » proposée par pasBill pasGates sur LinuxFR.org, qui consiste à retenter en boucle les allocations de taille modeste (en respectant un délai entre chaque tentative), en misant sur le fait que le manque de mémoire peut n’être qu’une situation temporaire.

Le cas des bibliothèques

Selon les bibliothèques que vous utilisez dans votre programme, une décision a peut-être déjà été prise pour vous : en effet, si certaines bibliothèques remontent les erreurs d’allocation au code appelant et vous laissent ainsi la possibilité de décider comment vous voulez réagir, d’autres ont déjà fait le choix d’avorter en cas d’erreur. Si ne serait-ce qu’une seule des bibliothèques dont dépend votre programme a adopté cette approche, il ne sert plus à grand’chose de choisir la gestion gracieuse dans le reste de votre programme.

C’est ainsi, par exemple, que j’ai renoncé à gérer gracieusement les erreurs d’allocation dans Gfsecret lorsque j’ai commencé à utiliser GIO pour accéder aux périphériques de stockage USB, parce que cette bibliothèque (comme a priori toutes les bibliothèques gravitant autour de GLib) termine abruptement le programme en cas d’erreur d’allocation.