Python sous le capot — Chapitre 1 : Fonctionnement de la VM CPython

Avant-propos

Ceci est la traduction de l’article de Victor Skvortsov du 31 août 2020 : Python behind the scenes #1: how the CPython VM works.

Introduction

Vous êtes vous déjà demandé ce que fait python quand vous exécutez un programme ?

$ python script.py

Cet article est le premier d’une série qui cherche à répondre spécifiquement à cette question. Nous allons plonger dans les composants internes de CPython, l’implémentation la plus populaire de Python. Nous chercherons ainsi à comprendre le fonctionnement interne du langage. Si vous êtes familier avec Python et que vous êtes à l’aise avec la lecture de code écrit en C, mais que vous avez peu d’expérience avec le code source de CPython, il y a de fortes chances que vous trouviez cet article intéressant.

Pourquoi étudier CPython ?

Commençons par quelques faits bien connus. CPython est un interpréteur Python écrit en C. C’est l’une des implémentations Python, aux côtés de PyPy, Jython, IronPython et bien d’autres. CPython se distingue par le fait qu’il s’agit de l’implémentation originale, la plus maintenue et la plus populaire.

CPython implémente donc Python, mais qu’est-ce que Python ? On pourrait répondre simplement qu’il s’agit d’un langage de programmation. La réponse devient beaucoup plus nuancée lorsqu’on repose la question correctement : Qu’est-ce qui définit ce qu’est Python ? Python, contrairement à un langage comme le C, n’a pas de spécification formelle. La chose qui s’en rapproche le plus est le Python Language Reference qui commence par ces mots :

Bien qu’essayant d’être aussi précis que possible, j’ai choisi d’utiliser l’anglais pour tout, sauf la syntaxe et l’analyse lexicale, plutôt que des spécifications formelles. Cela devrait rendre le document plus compréhensible pour le lecteur moyen, mais laissera place à des ambiguïtés. Par conséquent, si vous veniez de Mars et que vous tentiez de réimplémenter Python à partir de ce seul document, vous devrez peut-être deviner des choses et en fait, vous finiriez probablement par implémenter un langage tout à fait différent. En revanche, si vous utilisez Python et que vous vous demandez quelles sont les règles précises concernant une zone particulière du langage, vous devriez certainement pouvoir les trouver ici.

Donc Python n’est pas défini uniquement par sa référence. Il serait également faux de dire que Python est défini par son implémentation de référence, CPython, car certains détails d’implémentation ne font pas partie du langage. Un exemple est le garbage collector qui repose sur un comptage de références. Puisqu’il n’y a pas de source unique de vérité, nous pouvons dire que Python est défini en partie par le Python Language Reference et en partie par son implémentation principale, CPython.

Une telle nuance peut sembler superflue, mais je pense qu’il est crucial de clarifier le rôle de CPython. Vous vous demandez peut-être toujours pourquoi nous devrions nous y intéresser. Outre la simple curiosité, j’y vois les raisons suivantes :

Ce qu’il faut pour comprendre le fonctionnement de CPython

CPython a été conçu pour être facile à maintenir. Un nouveau venu doit pouvoir lire le code source et comprendre ce qu’il fait, mais cela peut prendre un certain temps, chose que je souhaite raccourcir en écrivant cette série.

Comment cette série est organisée

J’ai choisi une approche descendante. Dans cette partie, nous explorerons les concepts de base de la machine virtuelle (abrégé VM) CPython. Ensuite, nous verrons comment CPython compile un programme en « quelque chose » que la VM peut exécuter. Après cela, nous nous familiariserons avec le code source et passerons par l’exécution d’un programme, étudiant au passage les principales parties de l’interpréteur. Pour finir, nous prendrons des aspects précis du langage et regarderons comment ils sont implémentés. Ce n’est en aucun cas un plan strict, mais l’idée que je m’en fais.

Note : Dans cet article, je me réfère à CPython 3.9. Certains détails d’implémentation changeront sûrement au fil des évolutions de CPython. J’essaierai de faire un suivi des modifications d’ajouter des notes de mise à jour.

Vision d’ensemble

L’exécution d’un programme Python est composée grossièrement de trois étapes :

Durant l’étape d’initialisation, CPython initialise les structures de données nécessaires à l’exécution de Python. Il prépare également toutes sortes de choses comme les types standards, configure et charge les modules standards, configure le système d’importation de modules, etc. C’est une étape très importante, trop souvent négligée par les explorateurs du code de CPython en raison de sa nature préparatoire.

Ensuite vient l’étape de compilation. CPython est un interpréteur, pas un compilateur dans le sens où il ne génère pas de code machine. La plupart des interpréteurs traduisent le code source en une forme intermédiaire1 avant de l’exécuter, c’est le cas de CPython. Cette phase de traduction fait la même chose qu’un compilateur classique : Il analyse le code source, construit son AST (Abstract Syntax Tree), génère du bytecode depuis cet AST et effectue même quelques optimisations du bytecode.

Avant de passer à l’étape suivante, nous devons comprendre ce qu’on entend par bytecode. Un bytecode est une série d’instructions. Chaque instruction se compose de deux octets : Un octet pour l’opcode et l’autre pour l’argument. Prenons un exemple :

def g(x):
    return x + 3

CPython traduit le corps de la fonction g en la suite d’octets suivant : [124, 0, 100, 1, 23, 0, 83, 0]. Si nous la désassemblons en exécutant le module standard dis, voici ce qu’on obtient :

2           0 LOAD_FAST            0 (x)
            2 LOAD_CONST           1 (3)
            4 BINARY_ADD
            6 RETURN_VALUE

L’opcode LOAD_FAST correspond à l’octet 124 et a l’argument 0. L’opcode LOAD_CONST correspond à l’octet 100 et a l’argument 1. Les instructions BINARY_ADD et RETURN_VALUE sont toujours codées respectivement (23, 0) et (83, 0) puisqu’elles ne nécessitent pas d’argument.

Au cœur de CPython se trouve une machine virtuelle (VM) qui exécute du bytecode. Si on regarde l’exemple précédent, on peut deviner comment cela fonctionne. La VM de CPython fonctionne « en pile »2. Cela signifie qu’elle exécute les instructions en utilisant une pile pour stocker et récupérer les données. L’instruction LOAD_FAST pousse la variable locale (ici, x) sur la pile. LOAD_CONST pousse une constante (ici, 3) sur la pile. BINARY_ADD prends deux objets de la pile, les ajoute et pousse le résultat. Enfin, RETURN_VALUE prends ce qu’il trouve sur la pile et renvoie le résultat à son appelant3.

L’exécution du bytecode se fait dans une boucle d’évaluation géante qui tourne tant qu’il reste a des instructions à exécuter. Elle ne s’arrête que pour renvoyer une valeur ou lorsqu’une erreur se produit.

Ce bref aperçu soulève de nombreuses questions :

Pour répondre ces questions intrigantes, nous devons examiner les concepts centraux de la VM CPython.

Code objects, function objects, frames

Les code objects

Nous avons vu à quoi ressemble le bytecode d’une fonction simple. Mais un programme Python est généralement plus compliqué. Comment la VM exécute-t-elle un module contenant des définitions et des appels de fonctions ?

Prenons un programme :

def f(x):
    return x + 1

print(f(1))

À quoi ressemble son bytecode ? Pour le savoir, analysons ce que fait ce programme. Il définit une fonction f, appelle la fonction f avec 1 comme argument et affiche le résultat de l’appel. Quoi que fasse la fonction f, elle ne fait pas partie du bytecode du module. Nous pouvons nous en assurer en le désassemblant.

1           0 LOAD_CONST               0 (<code object f at 0x10bffd1e0, file "example.py", line 1>)
            2 LOAD_CONST               1 ('f')
            4 MAKE_FUNCTION            0
            6 STORE_NAME               0 (f)

4           8 LOAD_NAME                1 (print)
           10 LOAD_NAME                0 (f)
           12 LOAD_CONST               2 (1)
           14 CALL_FUNCTION            1
           16 CALL_FUNCTION            1
           18 POP_TOP
           20 LOAD_CONST               3 (None)
           22 RETURN_VALUE

Sur la première ligne, nous définissons la fonction f créée à partir de quelque chose appelé « code object » et en lui assignant le nom f. Remarquez l’absence du bytecode de la fonction f, supposé renvoyer l’argument incrémenté.

Les morceaux de code exécutés en une seule unité, tel les module ou les corps de fonctions, sont appelés « blocs de code ». CPython stocke des informations sur ce que fait un code block dans une structure appelée « code object ». Il contient le bytecode et les listes des noms de variables utilisées dans le bloc. Exécuter un module ou appeler une fonction revient à évaluer le code object correspondant.

Les function objects

En revanche, une fonction n’est pas juste un code object. Elle doit contenir des informations supplémentaires, comme le nom, la docstring les arguments par défaut et les valeurs des variables définies dans sa portée. Ces informations, combinées à un code object, sont stockées à l’intérieur d’un « function object ». L’instruction MAKE_FUNCTION est utilisée pour le créer. Dans le code source de CPython, la définition de la structure d’un function object est précédé du commentaire suivant :

Il ne faut pas confondre les function objects avec les code objects :

Les function objects sont créés par l’exécution de la déclaration 'def'. Ils référencent un code object dans leur attribut code, qui est juste un objet syntaxique, c.à.d rien de plus qu’une version compilée de quelques lignes de code. Il y a un code object par « fragment » de code source, mais chaque code object peut être référencé par zéro ou beaucoup de function objects suivant le nombre de fois où l’instruction 'def' a été exécutée jusqu’à présent.

Comment se peut-il que plusieurs function objects référence un unique code object ? Voici un exemple :

def make_add_x(x):
    def add_x(y):
        return x + y
    return add_x

add_4 = make_add_x(4)
add_5 = make_add_x(5)

Le bytecode de la fonction make_add_x contient une instruction MAKE_FUNCTION. Les fonctions add_4 et add_5 sont le résultat de l’appel de cette instruction avec le même code object comme argument. Mais il y a un argument qui change — la valeur de x. Chaque function object contient sa propre valeur de x via le mécanisme de « cellules »4 qui nous permet de créer les closures add_4 et add_5.

Je vous invite à jeter un œil aux définitions des structures des code et function objets avant de passer au prochain concept.

struct PyCodeObject {
    PyObject_HEAD
    int co_argcount;            /* Nombre d’arguments, à l’exception de *args */
    int co_posonlyargcount;     /* Nombre d’arguments positionnels uniquement */
    int co_kwonlyargcount;      /* Nombre d’arguments mot-clés uniquement */
    int co_nlocals;             /* Nombre de variables locales */
    int co_stacksize;           /* Nombre d’entrées nécessaires à la pile d’évaluation */
    int co_flags;               /* CO_..., voir ci-dessous */
    int co_firstlineno;         /* Premier numéro de ligne de la source */
    PyObject *co_code;          /* Opcodes d’instruction */
    PyObject *co_consts;        /* list (constantes utilisées) */
    PyObject *co_names;         /* list de strings (noms utilisés) */
    PyObject *co_varnames;      /* tuple de strings (noms des variables locales) */
    PyObject *co_freevars;      /* tuple de strings (noms des variables libres) */
    PyObject *co_cellvars;      /* tuple de strings (noms des cellules) */

    Py_ssize_t *co_cell2arg;    /* Maps les cellules qui sont des arguments. */
    PyObject *co_filename;      /* unicode (fichier d’où il a été chargé) */
    PyObject *co_name;          /* unicode (nom, pour référence) */
        /* ... autres membres ... */
};
typedef struct {
    PyObject_HEAD
    PyObject *func_code;        /* Un code object, l’attribut __code__ */
    PyObject *func_globals;     /* Un dictionnaire (d’autres structures de mappages ne feront pas l’affaire) */
    PyObject *func_defaults;    /* NULL ou un tuple */
    PyObject *func_kwdefaults;  /* NULL ou un dict */
    PyObject *func_closure;     /* NULL ou un tuple de cell objects */
    PyObject *func_doc;         /* L’attribut __doc__, peut être de n’importe quel type */
    PyObject *func_name;        /* L’attribut __name__, une string */
    PyObject *func_dict;        /* L’attribut __dict__, un dict ou NULL */
    PyObject *func_weakreflist; /* Liste de références faibles */
    PyObject *func_module;      /* L’attribut __module__, peut être de n’importe quel type */
    PyObject *func_annotations; /* Annotations, un dict ou NULL */
    PyObject *func_qualname;    /* Le nom qualifié */
    vectorcallfunc vectorcall;
} PyFunctionObject;

Les frame objects

Lors de l’exécution d’un code object, la VM doit faire le suivi des valeurs des variables et de la pile de valeurs qui changement constamment. Elle doit également se rappeler où elle a arrêté d’exécuter le code object en cours pour en exécuter un autre et où aller au moment du return. CPython stocke ces informations dans un frame object, ou simplement une frame. Une frame fournit un état dans lequel un code object peut être exécuté. Comme on s’habitue de plus en plus au code source, je vous laisse, ici aussi, la définition de la structure des frame objects :

struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* frame précédente, ou NULL */
    PyCodeObject *f_code;       /* segment de code */
    PyObject *f_builtins;       /* table des symboles standards (PyDictObject) */
    PyObject *f_globals;        /* table des symboles globaux (PyDictObject) */
    PyObject *f_locals;         /* table des symboles locaux */
    PyObject **f_valuestack;    /* pointe derrière la dernière variable local */

    PyObject **f_stacktop;
    PyObject *f_trace;          /* Fonction de traçage */
    char f_trace_lines;         /* Émettre les événements de traçage par ligne ? */
    char f_trace_opcodes;       /* Émettre les événements de traçage par opcode ? */

    /* Référence empruntée à un générateur, ou NULL */
    PyObject *f_gen;

    int f_lasti;                /* Last instruction if called */
    /* ... */
    int f_lineno;               /* Numéro de la ligne courante */
    int f_iblock;               /* Index dans `f_blockstack` */
    char f_executing;           /* Est-ce que la frame est toujours en cours d’exécution */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* pour les blocs de `try` et de boucles */
    PyObject *f_localsplus[1];  /* variables locals et sur la pile, dimensionnés dynamiquement */
};

La première frame est créée pour exécuter le code object d’un module. CPython créé une nouvelle frame chaque fois qu’il doit d’exécuter un code object différent. Chaque frame a une référence vers la frame précédente. Ainsi, les frames forment une pile, également connue sous le nom de pile d’appels (« call stack »), avec la frame en cours placé au-dessus. Lorsqu’une fonction est appelée, une nouvelle frame est poussé sur la pile. Au retour de la frame en cours d’exécution, CPython continue l’exécution de la frame précédente en se souvenant de la dernière instruction traitée. D’une certaine façon, la VM CPython ne fait que construire et exécuter des frames. Mais comme nous le verrons bientôt, ce résumé, pour le dire gentillement, cache certains détails.

Thread, interpréteurs et runtime

Nous avons vu les trois concepts importants :

CPython en à trois autres :

Thread state

Un thread state est une structure de données contenant les données spécifiques aux threads, y compris la pile d’appels, un exception state et les paramètres de débogage. Il ne doit pas être confondu avec le thread d’OS, même s’ils sont étroitement liés. Examinons ce qui se passe quand vous utilisez le module standard treading pour exécuter une fonction dans un thread séparé :

from threading import Thread

def f():
    """Perform an I/O-bound task"""
    pass

t = Thread(target=f)
t.start()
t.join()

t.start() créé un thread d’OS en appelant la fonction de l’OS (pthread_create sur les systèmes UNIX et _beginthreadex sous Windows). Le thread nouvellement créé appelle la fonction du module _thread responsable de l’appel de target. En plus de target et de ces arguments, cette fonction reçoit également un nouveau thread state qui sera utilisé à l’intérieur du thread d’OS. Un thread d’OS entre dans la boucle d’évaluation avec son propre thread state, qu’il garde donc toujours à portée de main.

On se rappellera peut-être ici l’existence du fameux GIL (Global Interpreter Lock) qui évite plusieurs threads d’être dans la boucle d’évaluation en même temps. La raison principale étant d’éviter la corruption des états de CPython sans introduire de verrous plus fins. Le Python/C API Reference décrit clairement le fonctionnement du GIL :

L’interpréteur Python n’est pas pleinement thread-safe. Pour pouvoir gérer les programmes Python multi-threadés, il y a un verrou global, appelé le global interpreter lock ou GIL, qui doit être « tenu » par le thread en cours avant de pouvoir accéder de façon sécurisée, aux objets Python. Sans ce verrou, la moindre opération d’un programme multi-thread peut poser problèmes : Par exemple, quand deux threads incrémentent le nombre de référence d’un même objet, le compteur de référence risque d’être incrémenté une seule fois au lieu de deux.

Pour gérer plusieurs threads, il faut une structure de données encapsulant les thread states.

Interpreter state et runtime state

En fait, il y en a deux : L’interpreter state et le runtime state. N’importe quel programme exécuté dispose d’au moins une instance de chacun, et il y a de bonnes raisons à cela, bien que leur nécessité ne soit pas immédiatement évidente.

Un interpreter state est un groupe de threads avec les données spécifiques à ce groupe. Les threads partagent des éléments tels que les modules chargés (sys.modules), les modules standards (builtins .__ dict__) et le système d’importation (importlib).

Le runtime state est une variable globale. Il stocke les données spécifiques au processus. Cela inclut l’état de CPython (est-il initialisé ou non ?) et le mécanisme de GIL.

Généralement, l’ensemble des threads d’un processus appartiennent au même interpréteur. Il existe cependant de rares cas où l’on souhaite créer un sous-interpréteur pour isoler un groupe de threads. mod_wsgi, qui utilise des interpréteurs distincts pour exécuter des applications WSGI, en est un exemple. L’effet le plus évident de cette isolation est que chaque groupe de threads reçoit sa propre version de tous les modules, y compris __main__, qui est namespace global.

CPython ne propose aucun moyen simple de créer des interpréteurs comparables au module threading. La fonctionnalité est prise en charge uniquement via l’API Python/C, mais cela pourrait changer à l’avenir.

Résumé de l’architecture

Faisons un petit résumé de l’architecture de CPython pour voir comment tout s’emboîte. L’interprète peut être vu comme une structure en couches :

Les couches sont représentées par leur structure de données respectives, que nous avons vues plus haut. Ils ne sont cependant pas tous équivalents. Par exemple, le mécanisme d’allocation de mémoire est implémenté à l’aide de variables globales qui ne font pas partie de la structure de données du runtime state, mais bien de la couche d’exécution CPython.

Conclusion

Dans cette partie, nous avons décrit ce que python fait pour exécuter un programme Python. Nous avons vu que cela fonctionne en trois étapes :

La partie de l’interpréteur responsable de l’exécution du bytecode est appelé la machine virtuelle (abrégé VM). La VM de CPython a plusieurs concepts particulièrement importants : Les code objects, les frame objects, les thread states, les interpreter states et le runtime. Ces structures de données forment le noyau de l’architecture de CPython.

Nous n’avons qu’effleuré la surface. Nous avons évité de trop fouiller dans le code source, les étapes d’initialisation et de compilation sortant du cadre de cet article. Au lieu de cela, nous avons commencé par la vision d’ensemble de la VM. De cette façon, je pense, nous pouvons mieux voir les responsabilités de chaque étape. Nous savons maintenant en quoi CPython compile du code source — en code object. La prochaine fois, nous verrons comment il fait cela.

Mise à jour du 4 septembre 2020 : J’ai fait une liste des ressources utilisée pour en savoir plus sur le comportement interne de CPython.

Le second chapitre est disponible.


  1. NdT : Intermediate représentation en anglais, parfois abrégé IR

  2. NdT : Stack-based en anglais. 

  3. NdT : La liste complète des opcodes et leur fonctionnement est disponible ici

  4. NdT : Les « cellules » sont des variables permettant d’avoir des valeurs référencées dans plusieurs portées. Plus d’informations dans la documentation

Dernière mise à jour : jeu. 17 décembre 2020