Python sous le capot — Chapitre 4 : Comment le bytecode Python est exécuté
Avant-propos (du traducteur)
Ceci est la traduction du quatrième article de Victor Skvortsov du 30 octobre 2020 : Python behind the scenes #4: how Python bytecode is executed.
Avant-propos
Nous avons commencé cette série par un aperçu de la VM CPython. Nous avons vu que pour exécuter un programme Python, CPython le compile d’abord en bytecode, puis nous avons étudié le fonctionnement du compilateur dans la seconde partie. La dernière fois, nous avons parcouru le code source de CPython en partant de la fonction main()
jusqu’à la boucle d’évaluation, l’endroit où le bytecode Python est exécuté. Nous avons étudié tout ça afin de nous préparer à la discussion que nous entamons aujourd’hui. Notre objectif est de comprendre comment CPython exécute le bytecode compilé depuis notre code.
Remarque : Dans cet article, je fais référence à CPython 3.9. Certains détails d’implémentation changeront certainement à mesure que CPython évolue. J’essayerais de suivre les changements importants et d’ajouter des notes de mise à jour.
Le point de départ
Rappelons brièvement ce que nous avons vu dans les parties précédentes. Nous commandons CPython en écrivant du code Python. Cependant, la VM CPython ne comprend que le bytecode. C’est au compilateur de traduire le code Python en bytecode. Le compilateur stocke le bytecode dans un code object, qui est une structure décrivant ce que fait un code block tel qu’un module ou une fonction. Pour exécuter un code object, CPython lui crée un état d’exécution appelé frame object. Il passe ensuite ce frame object à une fonction d’évaluation de frame qui effectue l’exécution attendu. La fonction d’évaluation de frame par défaut est _PyEval_EvalFrameDefault()
, définie dans Python/ceval.c
. Cette fonction implémente le noyau de la VM CPython. À savoir, la logique de l’exécution du bytecode Python. C’est fonction que nous allons étudier aujourd’hui.
Pour comprendre comment fonctionne _PyEval_EvalFrameDefault()
, il est crucial de comprendre ce qu’il reçoit en entré, à savoir un frame object. Un frame object est un objet Python défini par la structure C suivante :
// Le typedef struct _frame PyFrameObject; est placé ailleurs
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 locale */
/* Next free slot in `f_valuestack`. Frame creation sets to `f_valuestack`.
Frame evaluation usually NULLs it, but a frame that yields sets it
to the current stack top. */
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 */
};
Le champ f_code
du frame object pointe vers un code object. Un code object est également un objet Python. Voici sa définition :
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) */
/* Le reste n’est utilisé ni par le hachage ni par les comparaisons, à l’exception de `co_name`,
utilisé par les deux. Ceci est fait pour conserver le nom et le numéro de ligne
pour les trackebacks et les débogueurs ; sinon, la dé-duplication constante
ferait s’effondrer les fonctions/lambdas identiques définies sur des lignes différentes.
*/
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) */
PyObject *co_lnotab; /* string (encoding addr<->lineno mapping) See
Objects/lnotab_notes.txt for details. */
void *co_zombieframe; /* Pour l’optimisation uniquement (voir `frameobject.c`) */
PyObject *co_weakreflist; /* Pour gérer les `weakrefs` vers les code objects */
/* Espace des données supplémentaires relatives au *code object*.
Le type `void*` permet de garder le format privé dans codeobject.c
pour forcer les gens à passer par les API appropriées. */
void *co_extra;
/* Cache just-in-time par opcodes
*
* Pour réduire la taille du cache, on utilise un mappage indirect partant
* de l’index de l’opcode vers le cache object :
* cache = co_opcache[co_opcache_map[next_instr - first_instr] - 1]
*/
// `co_opcache_map` est indexé par (`next_instr - first_instr`).
// * `0` veut dire qu’il n’y a pas de cache pour cet opcode.
// * `n > 0` veut dire qu’il y a un cache dans `co_opcache[n-1]`.
unsigned char *co_opcache_map;
_PyOpcache *co_opcache;
int co_opcache_flag; // utilisé pour déterminer quand créer un cache.
unsigned char co_opcache_size; // longueur de `co_opcache`.
};
Le champ le plus important d’un code object est co_code
. C’est un pointeur vers un objet Python bytes
représentant le bytecode. Le bytecode est une séquence d’instructions à deux octets : Un octet pour un opcode et un octet pour un argument.
Ne vous inquiétez pas si certains membres des structures ci-dessus vous semble mystérieux. Nous verrons à quoi ils servent au fur et à mesure de notre avancé dans la compréhension de l’exécution du bytecode par la VM CPython.
Vue d’ensemble de la boucle d’évaluation
L’exécution du bytecode Python peut vous sembler une évidence. Tout ce que la VM a à faire est d’itérer sur les instructions et d’agir en fonction. Et c’est essentiellement ce que fait _PyEval_EvalFrameDefault()
. Il contient une boucle infinie for (;;)
que nous appelons la boucle d’évaluation. À l’intérieur de cette boucle, il y a un switch
géant, gérant tous les opcodes possibles. Chaque opcode a un case
dédié contenant son code d’exécution. Le bytecode est représenté par un tableau d’entiers non signés en 16 bits, un entier par instruction. La VM garde la trace de la prochaine instruction à exécuter à l’aide de la variable next_instr
, qui est un pointeur vers le tableau d’instructions. Au début de chaque itération de la boucle d’évaluation, la VM calcule l’opcode suivant et son argument en prenant respectivement l’octet de poids faible et l’octet de poids fort de l’instruction suivante1 et incrémente next_instr
. La fonction _PyEval_EvalFrameDefault()
fait près de 3000 lignes, mais la version simplifiée suivante en capture l’essentiel :
PyObject*
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
// ... déclarations et initialisation des variables locales
// ... définitions des macros
// ... gestion de la profondeur des appels
// ... code de traçage et de profilage
for (;;) {
// ... vérifier si l’exécution du bytecode doit être suspendue,
// e.g. un thread a demandé le GIL
// macro `NEXTOPARG()`
_Py_CODEUNIT word = *next_instr; // `_Py_CODEUNIT` est un `typedef` de `uint16_t`
opcode = _Py_OPCODE(word);
oparg = _Py_OPARG(word);
next_instr++;
switch (opcode) {
case TARGET(NOP) {
FAST_DISPATCH(); // plus d’information plus loin
}
case TARGET(LOAD_FAST) {
// ... code du chargement des variables locales
}
// ... 117 `case` de plus, un pour chaque opcode possible
}
// ... gestion des erreurs
}
// ... fin
}
Pour une meilleure vision d’ensemble, voyons plus en détail les morceaux qui ont été masqués.
Les raisons de suspendre la boucle
De temps à autre, le thread en cours cesse d’exécuter son bytecode pour faire autre chose, ou rien du tout. Cela peut se produire dans quatre cas :
- Il y a des signaux à gérer. Lorsque vous enregistrez une fonction en tant que signal handler via
signal.signal()
, CPython stocke cette fonction dans le tableau de handlers. La fonction réellement appelée lorsqu’un thread reçoit un signal estsignal_handler()
(elle est passée à fonction systèmesigaction()
sur les systèmes de type Unix). Lorsqu’elle est appelée,signal_handler()
définit une variable booléenne indiquant que la fonction dans le tableau de handlers correspondant au signal reçu doit être appelée. Périodiquement, le thread principal de l’interpréteur principal appelle les handlers avec la variable booléenne définie. - Il y a des pending calls à faire. Le pending calls est un mécanisme permettant de planifier l’exécution d’une fonction depuis le thread principal. Ce mécanisme est exposé par l’API Python/C via la fonction
Py_AddPendingCall()
. - L’exception asynchrone est levée. L’exception asynchrone est définie dans un thread depuis un autre thread. Cela peut être fait via la fonction
PyThreadState_SetAsyncExc()
fournie par l’API Python/C. - Le thread en courant est invité à lâcher le GIL. Il abandonne alors le GIL et attend de l’acquérir à nouveau le GIL.
CPython dispose d’indicateurs pour chacun de ces événements. La variable indiquant qu’il y a des handlers à appeler est un membre de runtime->ceval
, qui est une structure _ceval_runtime_state
:
struct _ceval_runtime_state {
/* Demande la vérification des signaux. C’est partagé par tous les interpréteurs (voir
bpo-40513). N’importe quel thread de n’importe quel interpréteur peu recevoir un signal, mais seul
le thread principal de l’interpréteur principal peut gérer les signaux : Voir
_Py_ThreadCanHandleSignals(). */
_Py_atomic_int signals_pending;
struct _gil_runtime_state gil;
};
Les autres indicateurs sont des membres de interp->ceval
, qui est une structure _ceval_state
:
struct _ceval_state {
int recursion_limit;
/* Enregistre si le suivi est activé sur au moins un thread. Compte le nombre
de threads pour lesquels `tstate->c_tracefunc` est non-NULL, donc si la
valeur est 0, nous savons que nous n’avons pas à vérifier le champ
`c_tracefunc` du thread. Cela accélère l’instruction if dans
_PyEval_EvalFrameDefault() après `fast_next_opcode`. */
int tracing_possible;
/* Cette variable agrège toutes les requêtes de sortie du *fast path*
dans la boucle d’évaluation. */
_Py_atomic_int eval_breaker;
/* Invitation à lâcher le GIL. */
_Py_atomic_int gil_drop_request;
struct _pending_calls pending;
}
Le résultat (en « ou ») de tous les indicateurs est stocké dans la variable eval_breaker
. Elle indique s’il y a une raison pour que le thread en cours d’exécution arrête son exécution normale de bytecode. Chaque itération de la boucle d’évaluation commence par vérifier si eval_breaker
est vrai. S’il est vrai, le thread regarde les indicateurs pour déterminer exactement ce qui lui est demandé de faire, le fait et continue d’exécuter le bytecode.
GOTO calculés
Le code de la boucle d’évaluation est plein de macros, tel que TARGET()
et DISPATCH()
. Elles ne servent pas à rendre le code plus compact. Elles développent un code différent suivant qu’une optimisation, appelée « GOTO calculés » (alias « threaded code »), est utilisée. Le but de cette optimisation est d’accélérer l’exécution du bytecode en écrivant le code de manière à ce le CPU puisse utiliser son mécanisme de prédiction de branche pour prédire le prochain opcode.
Après avoir exécuté une instruction, la VM effectue une action parmi les trois suivantes :
- Il revient de la fonction d’évaluation. Cela se produit lorsque la VM exécute l’instruction
RETURN_VALUE
,YIELD_VALUE
ouYIELD_FROM
. - Il gère l’erreur et soit, continue l’exécution, soit, revient de la fonction d’évaluation avec l’exception définie. L’erreur peut, par exemple, se produire lorsque la VM exécute l’instruction
BINARY_ADD
et que les objets à ajouter n’implémentent pas les méthodes__add__
et__radd__
. - Il continue l’exécution. Comment faire en sorte que la VM exécute l’instruction suivante ? La solution la plus simple serait d’ajouter l’instruction
continue
à chaquecase
qui ne retourne pas. La vraie solution est, en revanche, un peu plus compliquée.
Pour voir le problème que pose l’instruction continue
, nous devons comprendre en quoi switch
se compile. Un opcode est un entier compris entre 0 et 255. Comme la plage est dense, le compilateur peut créer une table de sauts qui stocke les adresses des blocs de case
et utiliser des opcodes comme index dans cette table. Les compilateurs modernes font en effet cela, de sorte que l’expédition des cas est implémenté via un seul saut indirect. C’est un moyen efficace d’implémenter un switch
. Cependant, placer le commutateur à l’intérieur de la boucle et ajouter des instructions continues crée deux problèmes :
- L’instruction
continue
à la fin d’uncase
ajoute un autre saut. Ainsi, pour exécuter un opcode, la VM doit sauter deux fois : Au début de la boucle puis aucase
suivant. - Dans la mesure où tous les opcodes sont expédiés par un seul saut, un CPU a peu de chance de prédire le prochain opcode. Le mieux qu’il puisse faire est de choisir le dernier opcode ou, éventuellement, le plus fréquent.
L’optimisation peut être activée ou désactivée. Cela dépend du support de l’extension GCC C « labels as values » par le compilateur. Avec l’optimisation activée, certaines macros, telles que TARGET()
, DISPATCH()
et FAST_DISPATCH()
, se développent de façon différente. Ces macros sont énormément utilisées dans le code de la boucle d’évaluation. Chaque expression de case
a une forme TARGET(op)
, où op
est une macro définie sur un littéral entier représentant un opcode. Enfin, chaque case
non retourné se termine par la macro DISPATCH()
ou FAST_DISPATCH()
. Voyons d’abord en quoi ces macros se développent lorsque l’optimisation est désactivée :
for (;;) {
// ... vérifie si l’exécution du bytecode doit être interrompu.
fast_next_opcode:
// Macro `NEXTOPARG()`.
_Py_CODEUNIT word = *next_instr;
opcode = _Py_OPCODE(word);
oparg = _Py_OPARG(word);
next_instr++;
switch (opcode) {
// `TARGET(NOP)` se développe en `NOP`.
case NOP: {
goto fast_next_opcode; // Macro `FAST_DISPATCH()`.
}
// ...
case BINARY_MULTIPLY: {
// ... code de multiplication binaire.
continue; // Macro `DISPATCH()`.
}
// ...
}
// ... gestion des erreurs.
}
La macro FAST_DISPATCH()
est utilisée lorsqu’on ne veut pas suspendre la boucle d’évaluation après avoir exécuté certains opcodes. Pour le reste, l’implémentation est très simple.
Si le compilateur supporte l’extension « labels as values », nous pouvons utiliser l’opérateur unaire &&
sur un label pour obtenir son adresse. Elle est de type void*
, nous pouvons donc la stocker dans un pointeur :
void *ptr = &&my_label;
On peut ensuite accéder au label en déréférençant le pointeur :
goto *ptr;
Cette extension permet d’implémenter une table de sauts en C sous la forme d’un tableau de pointeurs de labels. Et c’est ce que fait CPython :
static void *opcode_targets[256] = {
&&_unknown_opcode,
&&TARGET_POP_TOP,
&&TARGET_ROT_TWO,
&&TARGET_ROT_THREE,
&&TARGET_DUP_TOP,
&&TARGET_DUP_TOP_TWO,
&&TARGET_ROT_FOUR,
&&_unknown_opcode,
&&_unknown_opcode,
&&TARGET_NOP,
&&TARGET_UNARY_POSITIVE,
&&TARGET_UNARY_NEGATIVE,
&&TARGET_UNARY_NOT,
// ... et quelques autres.
};
Voici à quoi ressemble la version optimisée de la boucle d’évaluation :
for (;;) {
// ... vérifie si l’exécution du bytecode doit être interrompu.
fast_next_opcode:
// Macro `NEXTOPARG()`
_Py_CODEUNIT word = *next_instr;
opcode = _Py_OPCODE(word);
oparg = _Py_OPARG(word);
next_instr++;
switch (opcode) {
// `TARGET(NOP)` se développe en `NOP: TARGET_NOP`.
// Où `TARGET_NOP` est un label.
case NOP: TARGET_NOP: {
// Macro `FAST_DISPATCH()`
// Quand le traçage est désactivé.
f->f_lasti = INSTR_OFFSET();
NEXTOPARG();
goto *opcode_targets[opcode];
}
// ...
case BINARY_MULTIPLY: TARGET_BINARY_MULTIPLY: {
// ... code de multiplication binaire.
// DISPATCH() macro
if (!_Py_atomic_load_relaxed(eval_breaker)) {
FAST_DISPATCH();
}
continue;
}
// ...
}
// ... gestion des erreurs.
}
L’extension est prise en charge par les compilateurs GCC et Clang. Il est ainsi for probable que l’optimisation soit activée lorsque vous exécutez python
. La question, bien sûr, est de savoir comment cela affecte la performance. Pour cela, je vais m’appuyer sur le commentaire du code source :
Au moment d’écrire ces lignes, la version « threaded code » est jusqu’à 15-20% plus rapide que la version « switch » normale, suivant le compilateur et l’architecture du processeur.
Cette section devrait vous éclairer sur la façon dont la VM CPython passe d’une instruction à l’autre et de ce qu’elle peut être amenée à faire entre les deux. L’étape logique suivante consiste à étudier plus en profondeur comment la VM exécute une seule instruction. CPython 3.9 a 119 opcodes différents. Bien sûr, nous n’allons pas voir l’implémentation de chaque opcode dans cet article. Nous nous concentrerons plutôt sur les principes généraux que la VM utilise pour les exécuter.
Pile de valeurs
La chose la plus importante, et aussi la plus simple à comprendre, concernant la VM CPython est qu’elle s’appuie sur une pile (« stack-based »). Cela signifie que pour effectuer les calculs, la VM extrait (ou regarde) les valeurs de la pile, effectue le calcul sur celles-ces dernières et pousse le résultat. Voici quelques exemples :
- L’opcode
UNARY_NEGATIVE
extrait la valeur de la pile, génère son opposé et pousse le résultat. - L’opcode
GET_ITER
extrait la valeur de la pile, appelleiter()
dessus et pousse le résultat. - L’opcode
BINARY_ADD
extrait la valeur de la pile, regarde l’autre valeur au-dessus de la pile, ajoute la première valeur à la seconde et remplace la valeur du dessus par le résultat.
La pile de valeurs réside dans un frame object. Il est implémenté sous la forme d’une entrée dans le tableau appelé f_localsplus
. Le tableau est divisé en plusieurs parties pour stocker différentes choses, mais seule la dernière partie est utilisée pour la pile de valeurs. Le début de cette partie est le bas de la pile. Le champ f_valuestack
d’un frame object pointe dessus. Pour localiser le haut de la pile, CPython conserve la variable locale stack_pointer
, qui pointe vers l’emplacement suivant, après le haut de la pile. Les éléments du tableau f_localsplus
sont des pointeurs vers des objets Python (PyObject*
), et la VM CPython utilise ce type de pointeurs pour fonctionner.
Gestion des erreurs et pile de blocs
Not all computations performed by the VM are successful. Suppose we try to add a number to a string like 1 + '41'. The compiler produces the BINARY_ADD opcode to add two objects. When the VM executes this opcode, it calls PyNumber_Add() to calculate the result:
Les calculs effectués par la VM ne sont pas tous couronnés de succès. Supposons que nous essayions d’ajouter un nombre à une chaîne comme 1 + '41'
. Le compilateur produit l’opcode BINARY_ADD
pour ajouter deux objets. Lorsque la VM exécute cet opcode, elle appelle PyNumber_Add()
pour calculer le résultat :
case TARGET(BINARY_ADD): {
PyObject *right = POP();
PyObject *left = TOP();
PyObject *sum;
// ... cas particulier d’addition de string.
sum = PyNumber_Add(left, right);
Py_DECREF(left);
Py_DECREF(right);
SET_TOP(sum);
if (sum == NULL)
goto error;
DISPATCH();
}
Ici, l’important pour nous n’est pas la manière dont PyNumber_Add()
est implémenté, mais que son appel entraîne une erreur. L’erreur signifie deux choses :
PyNumber_Add()
renvoiNULL
.PyNumber_Add()
défini l’exception courante comme étantTypeError
. Cela implique de définirtstate->curexc_type
,tstate->curexc_value
ettstate->curexc_traceback
.
NULL
est l’indicateur d’une erreur. La VM le remarque et accède au label de l’erreur à la fin de la boucle d’évaluation. La suite dépend de si un gestionnaire d’exceptions a été défini ou non. Si ce n’est pas le cas, la VM saute sur l’instruction break
et la fonction d’évaluation renvoie NULL
avec l’exception définie sur le thread state. CPython print les détails de l’exception et quitte. On obtient ce résultat :
$ python -c "1 + '42'"
Traceback (most recent call last):
File "<string>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Mais supposons que nous ayons le meme code dans la clause try
de la déclaration try-finally
. Le code dans finally
sera également exécuté :
$ python -q
>>> try:
... 1 + '41'
... finally:
... print('Hey!')
...
Hey!
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Comment la VM peut-elle continuer son exécution après qu’une erreur se soit produite ? Regardons le bytecode produit par le compilateur pour la déclaration try-finally
:
$ python -m dis try-finally.py
1 0 SETUP_FINALLY 20 (to 22)
2 2 LOAD_CONST 0 (1)
4 LOAD_CONST 1 ('41')
6 BINARY_ADD
8 POP_TOP
10 POP_BLOCK
4 12 LOAD_NAME 0 (print)
14 LOAD_CONST 2 ('Hey!')
16 CALL_FUNCTION 1
18 POP_TOP
20 JUMP_FORWARD 10 (to 32)
>> 22 LOAD_NAME 0 (print)
24 LOAD_CONST 2 ('Hey!')
26 CALL_FUNCTION 1
28 POP_TOP
30 RERAISE
>> 32 LOAD_CONST 3 (None)
34 RETURN_VALUE
Notez la présence des opcodes SETUP_FINALLY
et POP_BLOCK
. Le premier met en place le gestionnaire d’exception et le second le supprime. Si une erreur se produit pendant que la VM exécute les instructions entre eux, l’exécution se poursuit en utilisant l’instruction à l’offset 22, qui est le début de la clause finally
. Sinon, la clause finally
est exécutée après la clause try
(offset 12). Dans les deux cas, le bytecode de la clause finally
est presque identique. La seule différence étant que le gestionnaire relance (RERAISE
) l’exception définie dans la clause try
.
Un gestionnaire d’exceptions est implémenté sous la forme d’une simple struct
C, appelé « bloc » :
typedef struct {
int b_type; /* le type du bloc */
int b_handler; /* où effectuer le saut pour trouver le gestionnaire */
int b_level; /* niveau de la pile de valeurs qu’on souhaite éventuellement extraire */
} PyTryBlock;
La VM conserve les blocs dans la pile de blocs. Mettre en place un gestionnaire d’exceptions signifie qu’on pousse un nouveau bloc sur la pile de blocs. C’est ce que font des opcodes tel que SETUP_FINALLY
. Le label d’erreur pointe vers un morceau de code qui tente de gérer une erreur en utilisant les blocs sur la pile de blocs. La VM déroule la pile de blocs jusqu’à trouver le bloc le plus haut de type SETUP_FINALLY
. Elle restaure le niveau de la pile de valeurs au niveau spécifié par le champ b_level
du bloc, et continue d’exécuter le bytecode avec l’instruction à l’offset b_handler
. C’est essentiellement ainsi que CPython implémente des instructions telles que try-except
, try-finally
et with
.
Il y a une dernière chose à dire sur la gestion des exceptions. Voyons ce qui se passe lorsqu’une erreur se produit pendant que la VM gère une exception :
$ python -q
>>> try:
... 1 + '41'
... except:
... 1/0
...
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 4, in <module>
ZeroDivisionError: division by zero
Comme prévu, CPython print l’exception d’origine. Un tel comportement est implémenté de la sorte : Lorsque CPython gère une exception à l’aide d’un bloc SETUP_FINALLY
, il définit un autre bloc de type EXCEPT_HANDLER
. Si une erreur se produit lorsqu’un bloc de ce type se trouve sur la pile de blocs, la VM obtient l’exception d’origine de la pile de valeurs et la définit comme étant celle actuelle. CPython avait autrefois différents types de blocs, mais maintenant il ne s’agit que de SETUP_FINALLY
et EXCEPT_HANDLER
.
La pile de blocs est implémentée via le tableau f_blockstack
dans frame object. La taille du tableau est définie statiquement à 20. Si vous imbriquez plus de 20 clauses try
, vous obtiendrez SyntaxError: too many statically nested blocks
.
Résumé
Aujourd’hui, nous avons vu que la VM CPython exécute les instructions de bytecode une par une, dans une boucle infinie. La boucle contient une instruction switch
gérant tous les opcodes possibles. Chaque opcode est exécuté dans le bloc case
correspondant. La fonction d’évaluation s’exécute dans un thread, et parfois ce thread suspend la boucle pour faire autre chose. Par exemple, un thread peut avoir besoin de libérer le GIL, afin qu’un autre thread puisse le prendre et continuer à exécuter son bytecode. Pour accélérer l’exécution du bytecode, CPython effectue une optimisation permettant d’utiliser le mécanisme de prédiction de branche du CPU. Un commentaire affirme que cela rend CPython 15 à 20 % plus rapide.
Nous avons également vu deux structures de données cruciales pour l’exécution du bytecode :
- La pile de valeurs, que la VM utilise pour effectuer des calculs.
- La pile de blocs, que la VM utilise pour gérer les exceptions.
La conclusion la plus importante de cet article est la suivante : Si vous souhaitez étudier l’implémentation de certains aspects de Python, la boucle d’évaluation est le point de départ idéal. Vous voulez savoir ce qui se passe lorsque vous écrivez x + y
? Jetez un œil au code de l’opcode BINARY_ADD
. Vous voulez savoir comment l’instruction with
est implémentée ? Cherchez SETUP_WITH
. Intéressé par la sémantique exacte d’un appel de fonction ? C’est l’opcode CALL_FUNCTION
que vous cherchez. Nous appliquerons cette méthode la prochaine fois, quand on se concentrera sur l’implémentation des variables dans CPython.
Mise à jour du 10 novembre 2020 : Dans les commentaires sur HN, on m’a rapporté que les opcodes UNARY_NEGATIVE
et GET_ITER
prennent (peek) la valeur du haut de la pile et la remplacent avec le résultat plutôt que d’opérer à un pop/push. Il va sans dire que les deux approches sont sémantiquement équivalentes. La seule différence est que l’approche pop/push décrémente, puis incrémente stack_pointer
. CPython évite ces opérations redondantes.
-
NdT : Une façon précise de dire « les deux octets suivants », voir la page Wikipédia ↩
Dernière mise à jour : jeu. 17 décembre 2020