Appareil CPython. Rapport Yandex

Nous publions un résumé de la conférence d'introduction du cours vidéo "Développement Backend en Python" . Dans ce Yegor Ovcharenkoegorovcharenko, un chef d'équipe chez Yandex.Taxi, a parlé de la structure interne de l'interpréteur CPython.





- Bref, quel plan aurons-nous? Tout d'abord, nous expliquerons pourquoi nous allons apprendre Python. Voyons ensuite comment l'interpréteur CPython fonctionne plus en profondeur, comment il gÚre la mémoire, comment fonctionne le systÚme de types en Python, les dictionnaires, les générateurs et les exceptions. Je pense que cela prendra environ une heure.



  • Pourquoi Python?
  • Dispositif d'interprĂ©tation
  • Dactylographie
  • Dictionnaires
  • Gestion de la mĂ©moire
  • GĂ©nĂ©rateurs
  • Des exceptions


Pourquoi Python?





* insights.stackoverflow.com/survey/2019

** interprétation trÚs subjective

*** de l' étude

**** interprétation de l' étude




Commençons. Pourquoi Python? La diapositive présente une comparaison de plusieurs langages actuellement utilisés dans le développement backend. Mais en bref, quel est l'avantage de Python? Vous pouvez rapidement écrire du code dessus. Ceci, bien sûr, est trÚs subjectif - les personnes qui écrivent du C ++ ou Go cool peuvent discuter avec cela. Mais en moyenne, écrire en Python est plus rapide.



Quels sont les inconvĂ©nients? Le premier et probablement le principal inconvĂ©nient est que Python est plus lent. Il peut ĂȘtre 30 fois plus lent que les autres langues, voici uneĂ©tudesur ce sujet. Mais sa vitesse dĂ©pend de la tĂąche. Il existe deux classes de tĂąches:



- Tùches liées au processeur, liées au processeur, liées au processeur.



- I / O bound, tùches limitées par les entrées-sorties: soit sur le réseau, soit dans les bases de données.



Si vous rĂ©solvez le problĂšme liĂ© au processeur, alors oui, Python sera plus lent. Si les E / S sont liĂ©es et qu'il s'agit d'une grande classe de tĂąches, alors pour comprendre la vitesse d'exĂ©cution, vous devez exĂ©cuter des tests de performances. Et peut-ĂȘtre qu'en comparant Python Ă  d'autres langages, vous ne remarquerez mĂȘme pas la diffĂ©rence de performances.



De plus, Python est typé dynamiquement: l'interpréteur ne vérifie pas les types au moment de la compilation. Dans la version 3.5, des indices de type sont apparus, vous permettant de spécifier des types de maniÚre statique, mais ils ne sont pas trÚs stricts. Autrement dit, vous détecterez des erreurs déjà en production, et non au stade de la compilation. D'autres langages populaires pour le backend - Java, C #, C ++, Go - ont un typage statique: si vous passez le mauvais objet dans le code, le compilateur vous en informera.



Plus terre à terre, comment Python est-il utilisé dans le développement de produits Taxi? Nous évoluons vers une architecture de microservices. Nous avons déjà 160 microservices, à savoir l'épicerie - 35, 15 d'entre eux en Python, 20 en plus. Autrement dit, nous écrivons maintenant soit uniquement en Python, soit sur des avantages.



Comment choisissons-nous la langue? Le premier concerne les exigences de charge, c'est-à-dire que nous voyons si Python peut le gérer ou non. S'il tire, alors nous regardons la compétence des développeurs d'équipe.



Maintenant, je veux parler de l'interprĂšte. Comment fonctionne CPython?



Dispositif d'interprétation



La question peut se poser: pourquoi avons-nous besoin de savoir comment fonctionne l'interprĂšte. La question est valable. Vous pouvez facilement rĂ©diger des services sans savoir ce qu'il y a sous le capot. Les rĂ©ponses peuvent ĂȘtre les suivantes:



1. Optimisation pour une charge Ă©levĂ©e. Imaginez que vous ayez un service Python. Cela fonctionne, la charge est faible. Mais un jour, la tĂąche vous revient: Ă©crire un stylo, prĂȘt pour une lourde charge. Vous ne pouvez pas vous en sortir, vous ne pouvez pas réécrire l'ensemble du service en C ++. Vous devez donc optimiser le service pour une charge Ă©levĂ©e. Comprendre le fonctionnement de l'interprĂšte peut y contribuer.



2. Débogage de cas complexes. Disons que le service est en cours d'exécution, mais que la mémoire commence à "fuir" dedans. Chez Yandex.Taxi, nous avons eu un tel cas récemment. Le service consommait 8 Go de mémoire toutes les heures et tombait en panne. Nous devons le comprendre. Il s'agit du langage, Python. Une connaissance du fonctionnement de la gestion de la mémoire en Python est requise.



3. Ceci est utile si vous allez écrire des bibliothÚques complexes ou du code complexe.



4. Et en général, il est considéré comme une bonne forme de connaßtre l'outil avec lequel vous travaillez à un niveau plus profond, et pas seulement en tant qu'utilisateur. Ceci est apprécié dans Yandex.



5. Ils posent des questions Ă  ce sujet lors des entretiens, mais ce n'est mĂȘme pas le but, mais vos perspectives informatiques gĂ©nĂ©rales.







Rappelons briÚvement quels sont les types de traducteurs. Nous avons des compilateurs et des interprÚtes. Le compilateur, comme vous le savez probablement, est la chose qui traduit votre code source directement en code machine. Au contraire, l'interpréteur traduit d'abord en bytecode, puis l'exécute. Python est un langage interprété.



Le bytecode est une sorte de code intermédiaire obtenu à partir de l'original. Il n'est pas lié à la plate-forme et s'exécute sur une machine virtuelle. Pourquoi virtuel? Ce n'est pas une vraie voiture, mais une sorte d'abstraction.







Quels types de machines virtuelles existe-t-il? Inscrivez-vous et empilez. Mais ici, nous devons nous souvenir non pas de cela, mais du fait que Python est une machine Ă  pile. Ensuite, nous verrons comment fonctionne la pile.



Et une derniÚre mise en garde: ici, nous ne parlerons que de CPython. CPython est une implémentation Python de référence, écrite, comme vous pouvez le deviner, en C. Utilisé comme synonyme: quand on parle de Python, on parle généralement de CPython.



Mais il y a aussi d'autres interprĂštes. Il y a PyPy, qui utilise la compilation JIT et accĂ©lĂšre environ cinq fois. Il est rarement utilisĂ©. Je n'ai honnĂȘtement pas rencontrĂ©. Il y a JPython, il y a IronPython, qui traduit le bytecode pour la machine virtuelle Java et pour la machine Dotnet. C'est hors de portĂ©e de la confĂ©rence d'aujourd'hui - pour ĂȘtre honnĂȘte, je ne l'ai pas rencontrĂ©. Jetons donc un coup d'Ɠil Ă  CPython.







Voyons ce qui se passe. Vous avez une source, une ligne, vous voulez l'exécuter. Que fait l'interprÚte? Une chaßne n'est qu'une collection de caractÚres. Pour en faire quelque chose de significatif, vous devez d'abord traduire le code en jetons. Un jeton est un ensemble groupé de caractÚres, un identifiant, un nombre ou une sorte d'itération. En fait, l'interpréteur traduit le code en jetons.







De plus, l'arbre de syntaxe abstraite AST est construit Ă  partir de ces jetons. Aussi, ne vous inquiĂ©tez pas encore, ce ne sont que quelques arbres, dans les nƓuds desquels vous avez des opĂ©rations. Disons que dans notre cas, il y a BinOp, une opĂ©ration binaire. OpĂ©ration - exponentiation, opĂ©randes: le nombre Ă  Ă©lever et la puissance Ă  Ă©lever.



De plus, vous pouvez déjà créer du code en utilisant ces arborescences. Je rate beaucoup d'étapes, il y a une étape d'optimisation, d'autres étapes. Ensuite, ces arbres de syntaxe sont traduits en bytecode.



Voyons plus en détail ici. Bytecode est, comme son nom l'indique, un code composé d'octets. Et en Python, à partir de 3.6, le bytecode est de deux octets.







Le premier octet est l'opĂ©rateur lui-mĂȘme, appelĂ© opcode. Le deuxiĂšme octet est l'argument oparg. On dirait que nous avons d'en haut. Autrement dit, une sĂ©quence d'octets. Mais Python a un module appelĂ© dis, de Disassembler, avec lequel nous pouvons voir une reprĂ©sentation plus lisible par l'homme.



À quoi cela ressemble-t-il? Il y a un numĂ©ro de ligne de la source - le plus Ă  gauche. La deuxiĂšme colonne est l'adresse. Comme je l'ai dit, le bytecode dans Python 3.6 prend deux octets, donc toutes les adresses sont paires et nous voyons 0, 2, 4 ...



Load.name, Load.const sont dĂ©jĂ  les options de code elles-mĂȘmes, c'est-Ă -dire les codes de ces opĂ©rations qui Python devrait s'exĂ©cuter. 0, 0, 1, 1 sont des oparg, c'est-Ă -dire les arguments de ces opĂ©rations. Voyons comment ils sont faits ensuite.



(...) Voyons comment le bytecode est exécuté en Python, quelles structures sont là pour cela.







Si vous ne connaissez pas C, ça va. Les notes de bas de page sont destinées à une compréhension générale.



Python a deux structures qui nous aident à exécuter le bytecode. Le premier est CodeObject, vous pouvez voir son résumé. En fait, la structure est plus grande. C'est du code sans contexte. Cela signifie que cette structure contient, en fait, le bytecode que nous venons de voir. Il contient les noms des variables utilisées dans cette fonction, si la fonction contient des références à des constantes, des noms de constantes, autre chose.







La structure suivante est FrameObject. C'est déjà le contexte d'exécution, la structure qui contient déjà la valeur des variables; références à des variables globales; la pile d'exécution, dont nous parlerons un peu plus tard, et beaucoup d'autres informations. Disons le numéro de l'exécution de l'instruction.



A titre d'exemple: si vous souhaitez appeler une fonction plusieurs fois, alors vous aurez le mĂȘme CodeObject, et un nouveau FrameObject sera créé pour chaque appel. Il aura ses propres arguments, sa propre pile. Ils sont donc interconnectĂ©s.







Quelle est la boucle d'interprétation principale, comment le bytecode est-il exécuté? Vous avez vu que nous avions une liste de ces opcode avec oparg. Comment tout cela est-il fait? Python, comme tout interpréteur, a une boucle qui exécute ce bytecode. Autrement dit, une trame y entre et Python passe simplement par le bytecode dans l'ordre, regarde de quel type d'oparg il s'agit et se dirige vers son gestionnaire en utilisant un énorme commutateur. Un seul opcode est affiché ici par exemple. Par exemple, nous avons ici une soustraction binaire, une soustraction binaire, disons «AB», sera effectuée à cet endroit.



Expliquons comment fonctionne la soustraction binaire. TrÚs simple, c'est l'un des codes les plus simples. La fonction TOP prend la valeur la plus élevée de la pile, la prend de la plus haute, ne la fait pas simplement sortir de la pile, puis la fonction PyNumber_Subtract est appelée. Résultat: la fonction slash SET_TOP est repoussée sur la pile. Si la pile n'est pas claire, un exemple suivra.







TrÚs briÚvement sur le GIL. Le GIL est un mutex au niveau du processus en Python qui prend ce mutex dans la boucle d'interprétation principale. Et ce n'est qu'aprÚs cela que le bytecode commence à s'exécuter. Ceci est fait pour qu'un seul thread exécute le bytecode à la fois afin de protéger la structure interne de l'interpréteur.



Disons, pour aller un peu plus loin, que tous les objets en Python ont un certain nombre de références à eux. Et si deux threads modifient ce nombre de liens, l'interpréteur se cassera. Il existe donc un GIL.



Vous en serez informĂ© dans la confĂ©rence sur la programmation asynchrone. Comment cela peut-il ĂȘtre important pour vous? Le multithreading n'est pas utilisĂ©, car mĂȘme si vous crĂ©ez plusieurs threads, alors en gĂ©nĂ©ral vous n'en aurez qu'un seul en cours d'exĂ©cution, le bytecode sera exĂ©cutĂ© dans l'un des threads. Par consĂ©quent, utilisez le multitraitement, l'extension sish ou autre chose.







Un exemple rapide. Vous pouvez explorer ce cadre en toute sécurité depuis Python. Il existe un module sys qui a une fonction de soulignement get_frame. Vous pouvez obtenir un cadre et voir quelles variables sont là. Il y a une instruction. C'est plus pour enseigner, dans la vraie vie je ne l'ai pas utilisé.



Essayons de voir comment la pile de machines virtuelles Python fonctionne pour comprendre. Nous avons un code, assez simple, qui ne comprend pas ce qu'il fait.







Sur la gauche se trouve le code. La partie que nous examinons actuellement est surlignée en jaune. Dans la deuxiÚme colonne, nous avons le bytecode de cette piÚce. La troisiÚme colonne contient des cadres avec des piles. Autrement dit, chaque FrameObject a sa propre pile d'exécution.



Que fait Python? Il va simplement dans l'ordre, bytecode, dans la colonne du milieu, s'exécute et fonctionne avec la pile.







Nous avons exécuté le premier opcode appelé LOAD_CONST. Il charge une constante. Nous avons sauté la partie, un CodeObject y est créé et nous avons eu un CodeObject quelque part dans les constantes. Python l'a chargé sur la pile en utilisant LOAD_CONST. Nous avons maintenant un CodeObject sur la pile dans ce cadre. Nous pouvons avancer.







Puis Python exĂ©cute l'opcode MAKE_FUNCTION. MAKE_FUNCTION fait Ă©videmment une fonction. Il s'attend Ă  ce que vous ayez un CodeObject sur la pile. Il effectue une action, crĂ©e une fonction et repousse la fonction sur la pile. Vous avez maintenant FUNCTION au lieu de CodeObject qui se trouvait sur la pile de cadres. Et maintenant, cette fonction doit ĂȘtre placĂ©e dans la variable to_power afin que vous puissiez vous y rĂ©fĂ©rer.







L'opcode STORE_NAME est exécuté, il est placé dans la variable to_power. Nous avions une fonction sur la pile, maintenant c'est la variable to_power, vous pouvez vous y référer.



Ensuite, nous voulons imprimer 10 + la valeur de cette fonction.







Que fait Python? Cela a été converti en bytecode. Le premier opcode que nous avons est LOAD_CONST. Nous chargeons les dix premiers sur la pile. Une douzaine est apparue sur la pile. Nous devons maintenant exécuter to_power.







La fonction est exĂ©cutĂ©e comme suit. S'il a des arguments de position - nous ne regarderons pas le reste pour l'instant - alors Python place d'abord la fonction elle-mĂȘme sur la pile. Ensuite, il met tous les arguments et appelle CALL_FUNCTION avec l'argument nombre d'arguments de fonction.







Nous avons chargé le premier argument sur la pile, c'est une fonction.







Nous avons chargé deux autres arguments sur la pile - 30 et 2. Nous avons maintenant une fonction et deux arguments sur la pile. Le haut de la pile est en haut. CALL_FUNCTION nous attend. Nous disons: CALL_FUNCTION (2), c'est-à-dire que nous avons une fonction avec deux arguments. CALL_FUNCTION s'attend à avoir deux arguments sur la pile, suivis d'une fonction. Nous l'avons: 2, 30 et FUNCTION.



Opcode en cours.







Pour nous, en conséquence, cette pile part, une nouvelle fonction est créée, dans laquelle l'exécution va maintenant avoir lieu.



Le cadre a sa propre pile. Un nouveau cadre a été créé pour sa fonction. Il est encore vide.







Une autre exécution a lieu. C'est déjà plus facile ici. Nous devons élever A au pouvoir. On charge sur la pile la valeur de la variable A - 30 La valeur de la variable power - 2.







Et l'opcode BINARY_POWER est exécuté.







Nous élevons un nombre à la puissance d'un autre et le remettons sur la pile. Il s'est avéré 900 sur la pile de fonctions.



Le prochain opcode RETURN_VALUE renverra la valeur de la pile à l'image précédente.







C'est ainsi que se déroule l'exécution. La fonction est terminée, le cadre sera trÚs probablement effacé s'il n'a pas de références et il y aura deux nombres sur le cadre de la fonction précédente.







Ensuite, tout est Ă  peu prĂšs pareil. L'ajout se produit.







(...) Parlons des types et de PyObject.



Dactylographie







Un objet est une structure sish dans laquelle il y a deux champs principaux: le premier est le nombre de références à cet objet, le second est le type de l'objet, bien sûr, une référence au type de l'objet.



D'autres objets hĂ©ritent de PyObject en l'enfermant. Autrement dit, si nous regardons un flottant, un nombre Ă  virgule flottante, la structure lĂ -bas est PyFloatObject, alors il a un HEAD, qui est une structure PyObject, et, en plus, des donnĂ©es, c'est-Ă -dire double ob_fval, oĂč la valeur de ce flotteur lui-mĂȘme est stockĂ©e.







Et c'est le type d'objet. Nous venons de regarder le type dans PyObject, c'est une structure qui dĂ©signe un type. En fait, il s'agit Ă©galement d'une structure C qui contient des pointeurs vers des fonctions qui implĂ©mentent le comportement de cet objet. Autrement dit, il y a une trĂšs grande structure lĂ -bas. Il a des fonctions spĂ©cifiĂ©es qui sont appelĂ©es si, par exemple, vous souhaitez ajouter deux objets de ce type. Ou vous voulez soustraire, appeler cet objet ou le crĂ©er. Tout ce que vous pouvez faire avec les types doit ĂȘtre spĂ©cifiĂ© dans cette structure.







Par exemple, regardons int, entiers en Python. Aussi une version trÚs abrégée. Qu'est-ce qui pourrait nous intéresser? Int a tp_name. Vous pouvez voir qu'il y a tp_hash, nous pouvons obtenir hash int. Si nous appelons hash sur int, cette fonction sera appelée. tp_call nous avons zéro, non défini, cela signifie que nous ne pouvons pas appeler int. tp_str - chaßne de caractÚres non définie. Python a une fonction str qui peut convertir en une chaßne.



Il n'est pas apparu sur la diapositive, mais vous savez tous dĂ©jĂ  que int peut toujours ĂȘtre imprimĂ©. Pourquoi zĂ©ro ici? Comme il y a aussi tp_repr, Python a deux fonctions de passage de chaĂźne: str et repr. CoulĂ©e plus dĂ©taillĂ©e Ă  la corde. Il est en fait dĂ©fini, il n'est tout simplement pas apparu sur la diapositive, et il sera appelĂ© si vous conduisez rĂ©ellement Ă  une chaĂźne.



À la toute fin, nous voyons tp_new - une fonction qui est appelĂ©e lorsque cet objet est créé. tp_init nous avons zĂ©ro. Nous savons tous que int n'est pas un type mutable, immuable. AprĂšs l'avoir créé, il ne sert Ă  rien de le changer, de l'initialiser, il y a donc un zĂ©ro.







Regardons Ă©galement Bool par exemple. Comme certains d'entre vous le savent peut-ĂȘtre, Bool en Python hĂ©rite en fait de int. Autrement dit, vous pouvez ajouter Bool, partager les uns avec les autres. Ceci, bien sĂ»r, ne peut pas ĂȘtre fait, mais c'est possible.



Nous voyons qu'il y a un tp_base - un pointeur vers l'objet de base. Tout Ă  part tp_base sont les seules choses qui ont Ă©tĂ© Ă©crasĂ©es. Autrement dit, il a son propre nom, sa propre fonction de prĂ©sentation, oĂč ce n'est pas un nombre Ă©crit, mais vrai ou faux. ReprĂ©sentation sous forme de nombre, certaines fonctions logiques y sont remplacĂ©es. Docstring est le sien et sa crĂ©ation. Tout le reste vient de int.







Je vais vous parler trÚs briÚvement des listes. En Python, une liste est un tableau dynamique. Un tableau dynamique est un tableau qui fonctionne comme ceci: vous initialisez une zone mémoire à l'avance avec une certaine dimension. Ajoutez-y des éléments. DÚs que le nombre d'éléments dépasse cette taille, vous le développez avec une certaine marge, c'est-à-dire non pas d'un, mais d'une valeur de plus d'un, de sorte qu'il y ait un bon point d'asin.



En Python, la taille augmente comme 0, 4, 8, 16, 25, c'est-à-dire selon une sorte de formule qui nous permet de faire l'insertion asymptotiquement pour une constante. Et vous pouvez voir qu'il y a un extrait de la fonction d'insertion dans la liste. Autrement dit, nous effectuons un redimensionnement. Si nous n'avons pas de redimensionnement, nous lançons une erreur et affectons l'élément. En Python, il s'agit d'un tableau dynamique normal implémenté en C.



(...) Parlons briĂšvement des dictionnaires. Ils sont partout en Python.



Dictionnaires



Nous savons tous que dans les objets, toute la composition des classes est contenue dans des dictionnaires. Beaucoup de choses sont basées sur eux. Dictionnaires en Python dans une table de hachage.







En bref, comment fonctionne une table de hachage? Il y a quelques clés: timmy, barry, guido. Nous voulons les mettre dans un dictionnaire, nous exécutons chaque clé via une fonction de hachage. Il s'avÚre un hachage. Nous utilisons ce hachage pour trouver le seau. Un bucket est simplement un nombre dans un tableau d'éléments. La division modulo finale se produit. Si le seau est vide, nous y mettons simplement l'élément souhaité. S'il n'est pas vide et qu'il y a déjà un élément là-bas, alors c'est une collision et nous choisissons le prochain bucket, voyons s'il est libre ou non. Et ainsi de suite jusqu'à ce que nous trouvions un seau gratuit.



Par conséquent, pour que l'opération d'ajout ait lieu dans un délai adéquat, nous devons constamment garder un certain nombre de seaux libres. Sinon, à l'approche de la taille de ce tableau, nous chercherons un bucket gratuit pendant trÚs longtemps, et tout ralentira.



Par conséquent, il est empiriquement accepté en Python qu'un tiers des éléments du tableau sont toujours libres. Si leur nombre est supérieur aux deux tiers, le tableau se développe. Ce n'est pas bon, car un tiers des éléments est gaspillé, rien d'utile n'est stocké.





Lien de la diapositive



Par consĂ©quent, depuis la version 3.6, Python a fait une telle chose. Sur la gauche, vous pouvez voir comment c'Ă©tait avant. Nous avons un tableau clairsemĂ© oĂč ces trois Ă©lĂ©ments sont stockĂ©s. Depuis la version 3.6, ils ont dĂ©cidĂ© de faire d'un tableau aussi fragmentĂ© un tableau rĂ©gulier, mais en mĂȘme temps de stocker les indices des Ă©lĂ©ments du bucket dans un tableau d'indices sĂ©parĂ©.



Si nous regardons le tableau d'indices, alors dans le premier bucket nous avons None, dans le second il y a un élément avec l'index 1 de ce tableau, etc.



Cela a permis, d'une part, de réduire l'utilisation de la mémoire, et d'autre part, nous l'avons également sorti de la boßte gratuitement tableau ordonné. Autrement dit, nous ajoutons des éléments à ce tableau, conditionnellement, avec l'ajout de sish habituel, et le tableau est automatiquement ordonné.



Il y a quelques optimisations intĂ©ressantes que Python utilise. Pour que ces tables de hachage fonctionnent, nous avons besoin d'une opĂ©ration de comparaison d'Ă©lĂ©ments. Imaginons que nous mettions un Ă©lĂ©ment dans une table de hachage, puis que nous voulions prendre un Ă©lĂ©ment. Nous prenons le hasch, allons dans le seau. On voit: le seau est plein, il y a quelque chose lĂ -bas. Mais est-ce l'Ă©lĂ©ment dont nous avons besoin? Peut-ĂȘtre qu'il y a eu une collision lorsqu'il a Ă©tĂ© placĂ© et que l'objet est en fait ramenĂ© dans un autre seau. Par consĂ©quent, nous devons comparer les clĂ©s. Si la clĂ© est fausse, nous utilisons le mĂȘme mĂ©canisme de recherche de compartiment suivant que celui utilisĂ© pour la rĂ©solution des collisions. Et passons Ă  autre chose.





Lien de la diapositive



Par consĂ©quent, nous devons avoir une fonction de comparaison clĂ©. En gĂ©nĂ©ral, la fonction de comparaison d'objets peut ĂȘtre trĂšs coĂ»teuse. Par consĂ©quent, une telle optimisation est utilisĂ©e. Tout d'abord, nous comparons les identifiants des articles. L'ID dans CPython est, comme vous le savez, une position en mĂ©moire.



Si les identifiants sont les mĂȘmes, alors ce sont les mĂȘmes objets et, bien sĂ»r, ils sont Ă©gaux. Ensuite, nous retournons True. Sinon, regardez les hachages. Le hachage devrait ĂȘtre une opĂ©ration assez rapide si nous n'avons pas remplacĂ© d'une maniĂšre ou d'une autre. Nous prenons des hachages de ces deux objets et comparons. Si leurs hachages ne sont pas Ă©gaux, alors les objets ne sont certainement pas Ă©gaux, nous retournons donc False.



Et seulement dans un cas trĂšs improbable - si nos hachages sont Ă©gaux, mais nous ne savons pas s'il s'agit du mĂȘme objet - alors seulement nous comparons les objets eux-mĂȘmes.



Une petite chose intéressante: vous ne pouvez rien insérer dans les clés pendant l'itération. C'est une erreur.







Sous le capot, le dictionnaire a une variable appelée version, qui stocke la version du dictionnaire. Lorsque vous modifiez le dictionnaire, la version change, Python comprend cela et vous renvoie une erreur.







À quoi peuvent servir les dictionnaires dans un exemple plus pratique? Chez Taxi, nous avons des commandes et les commandes ont des statuts qui peuvent changer. Lorsque vous modifiez le statut, vous devez effectuer certaines actions: envoyer des SMS, enregistrer des commandes.



Cette logique est Ă©crite en Python. Afin de ne pas Ă©crire un Ă©norme si comme «si l'Ă©tat de la commande est tel ou tel, faites ceci», il y a un certain dict dans lequel la clĂ© est l'Ă©tat de la commande. Et pour VALUE, il y a un tuple, qui contient tous les gestionnaires qui doivent ĂȘtre exĂ©cutĂ©s lors de la transition vers cet Ă©tat. C'est une pratique courante, en fait, c'est un remplacement pour le commutateur.







Quelques autres choses par type. Je vais vous parler d'immuable. Ce sont des types de donnĂ©es immuables, et mutables sont, respectivement, des types mutables: dictats, classes, instances de classe, feuilles, et peut-ĂȘtre autre chose. Presque tout le reste est composĂ© de chaĂźnes, de nombres ordinaires - ils sont immuables. À quoi servent les types mutables? PremiĂšrement, ils facilitent la comprĂ©hension du code. Autrement dit, si vous voyez dans le code que quelque chose est un tuple, comprenez-vous qu'il ne change pas davantage, et que cela vous facilite la lecture du code? comprendre ce qui va se passer ensuite. Dans tuple ds, vous ne pouvez pas saisir d'Ă©lĂ©ments. Vous comprendrez cela et cela vous aidera Ă  vous lire, vous et toutes les personnes qui liront le code pour vous.



Par consĂ©quent, il existe une rĂšgle: si vous ne modifiez pas quelque chose, il est prĂ©fĂ©rable d'utiliser des types immuables. Cela conduit Ă©galement Ă  un travail plus rapide. Il existe deux constantes utilisĂ©es par tuple: pit_tuple, tap_tuple, max et CC. À quoi ça sert? Pour tous les tuples de taille jusqu'Ă  20, une mĂ©thode d'allocation spĂ©cifique est utilisĂ©e, ce qui accĂ©lĂšre cette allocation. Et il peut y avoir jusqu'Ă  deux mille objets de chaque type, beaucoup. C'est beaucoup plus rapide que les feuilles, donc si vous utilisez tuple, vous serez plus rapide.



Il existe Ă©galement des contrĂŽles d'exĂ©cution. De toute Ă©vidence, si vous essayez de brancher quelque chose dans un objet, et qu'il ne prend pas en charge cette fonction, alors il y aura une erreur, une sorte de comprĂ©hension que vous avez fait quelque chose de mal. Les clĂ©s d'un dict ne peuvent ĂȘtre que des objets qui ont un hachage qui ne change pas au cours de leur vie. Seuls les objets immuables satisfont Ă  cette dĂ©finition. Seulement ils peuvent ĂȘtre des clĂ©s dict.







À quoi cela ressemble-t-il en C? Exemple. À gauche, un tuple, Ă  droite une liste rĂ©guliĂšre. Ici, bien sĂ»r, toutes les diffĂ©rences ne sont pas visibles, mais seulement celles que je voulais montrer. Dans la liste du champ tp_hash, nous avons NotImplemented, c'est-Ă -dire que la liste n'a pas de hachage. Dans tuple, il y a une fonction qui vous renverra un hachage. C'est exactement pourquoi tuple, entre autres, peut ĂȘtre une clĂ© de dict, et non list.



La prochaine chose mise en évidence est la fonction d'affectation d'élément, sq_ass_item. Dans la liste c'est, dans tuple c'est zéro, c'est-à-dire que vous ne pouvez naturellement rien assigner à tuple.







Encore une chose. Python ne copie rien tant que nous ne le lui demandons pas. Cela doit Ă©galement ĂȘtre rappelĂ©. Si vous voulez copier quelque chose, utilisez, par exemple, le module de copie, qui a une fonction copy.deepcopy. Quelle est la diffĂ©rence? copy copie l'objet, s'il s'agit d'un objet conteneur, tel qu'une liste de frĂšres. Toutes les rĂ©fĂ©rences qui Ă©taient dans cet objet sont insĂ©rĂ©es dans le nouvel objet. Et deepcopy copie de maniĂšre rĂ©cursive tous les objets dans ce conteneur et au-delĂ .



Ou, si vous souhaitez copier rapidement une liste, vous pouvez utiliser une seule tranche de deux points. Vous en obtiendrez une copie, un tel raccourci est simple.



(...) Parlons ensuite de la gestion de la mémoire.



Gestion de la mémoire







Prenons notre module sys. Il a une fonction qui vous permet de voir s'il utilise une mémoire. Si vous démarrez l'interpréteur et regardez les statistiques des changements de mémoire, vous verrez que vous avez créé beaucoup d'objets, y compris des petits. Et ce ne sont que les objets actuellement créés.



En fait, Python crée beaucoup de petits objets à l'exécution. Et si nous utilisions la fonction standard C malloc pour les allouer, nous nous retrouverions trÚs vite dans le fait que notre mémoire est fragmentée et, par conséquent, l'allocation mémoire est lente.







Cela implique la nĂ©cessitĂ© d'utiliser votre propre gestionnaire de mĂ©moire. Bref, comment ça marche? Python s'attribue des blocs de mĂ©moire, appelĂ©s arena, de 256 kilo-octets chacun. À l'intĂ©rieur, il se dĂ©coupe en pools de quatre kilo-octets, c'est la taille d'une page mĂ©moire. À l'intĂ©rieur des pools, nous avons des blocs de diffĂ©rentes tailles, de 16 Ă  512 octets.



Lorsque nous essayons d'allouer moins de 512 octets à un objet, Python sélectionne à sa maniÚre un bloc qui convient à cet objet et place l'objet dans ce bloc.



Si l'objet est dĂ©sallouĂ©, supprimĂ©, ce bloc est marquĂ© comme libre. Mais il n'est pas donnĂ© au systĂšme d'exploitation, et Ă  l'emplacement suivant, nous pouvons Ă©crire cet objet dans le mĂȘme bloc. Cela accĂ©lĂšre beaucoup l'allocation de mĂ©moire.







Libérer de la mémoire. Plus tÎt, nous avons vu la structure PyObject. Elle a ce refcnt - nombre de références. Cela fonctionne trÚs simplement. Lorsque vous référencez cet objet, Python incrémente le nombre de références. DÚs que vous avez un objet, la référence y disparaßt, vous désallouez le nombre de références.



Ce qui est surligné en jaune. Si refcnt n'est pas nul, alors nous faisons quelque chose là-bas. Si refcnt vaut zéro, alors nous désallouons immédiatement l'objet. Nous n'attendons aucun ramasse-miettes, rien, mais en ce moment nous effaçons la mémoire.



Si vous rencontrez la méthode del, elle supprime simplement la liaison de la variable à l'objet. Et la méthode __del__, que vous pouvez définir dans la classe, est appelée lorsque l'objet est effectivement supprimé de la mémoire. Vous appellerez del sur l'objet, mais s'il a encore des références, l'objet ne sera supprimé nulle part. Et son Finalizer, __del__, ne sera pas appelé. Bien qu'ils soient appelés trÚs similaires.



Une courte démonstration sur la façon dont vous pouvez voir le nombre de liens. Il y a notre module sys préféré, qui a une fonction getrefcount. Vous pouvez voir le nombre de liens vers un objet.







Je vais vous en dire plus. Un objet est fait. Le nombre de liens en est tiré. Détail intéressant: la variable A pointe vers TaxiOrder. Vous prenez le nombre de liens, vous obtiendrez "2" imprimé. Il semblerait pourquoi? Nous avons une référence d'objet. Mais lorsque vous appelez getrefcount, cet objet est enveloppé autour de l'argument à l'intérieur de la fonction. Par conséquent, vous avez déjà deux références à cet objet: la premiÚre est la variable, la seconde est l'argument de la fonction. Par conséquent, "2" est imprimé.



Le reste est trivial. Nous affectons une autre variable Ă  l'objet, nous obtenons 3. Ensuite, nous supprimons cette liaison, nous obtenons 2. Ensuite, nous supprimons toutes les rĂ©fĂ©rences Ă  cet objet, et en mĂȘme temps le finaliseur est appelĂ©, ce qui affichera notre ligne.







(...) Il existe une autre fonctionnalitĂ© intĂ©ressante de CPython, sur laquelle on ne peut pas construire, et il semble que cela ne soit dit nulle part dans la documentation. Les nombres entiers sont souvent utilisĂ©s. Il serait inutile de les recrĂ©er Ă  chaque fois. Par consĂ©quent, les nombres les plus couramment utilisĂ©s, les dĂ©veloppeurs Python ont choisi la plage de –5 Ă  255, ils sont Singleton. Autrement dit, ils sont créés une fois, se trouvent quelque part dans l'interprĂ©teur, et lorsque vous essayez de les obtenir, vous obtenez une rĂ©fĂ©rence au mĂȘme objet. Nous avons pris A et B, les uns, les avons imprimĂ©s, comparĂ© leurs adresses. C'est vrai. Et nous avons, par exemple, 105 rĂ©fĂ©rences Ă  cet objet, simplement parce que maintenant il y en a tellement.



Si nous prenons un certain nombre plus grand - par exemple, 1408 - ces objets ne sont pas égaux pour nous et il y a, respectivement, deux références à eux. En fait, un.







Nous avons un peu parlĂ© d'allocation et de libĂ©ration de mĂ©moire. Parlons maintenant du ramasse-miettes. Pourquoi est-ce? Il semblerait que nous ayons un certain nombre de liens. Une fois que personne n'a rĂ©fĂ©rencĂ© l'objet, nous pouvons le supprimer. Mais nous pouvons avoir des liens circulaires. Un objet peut par exemple se rĂ©fĂ©rer Ă  lui-mĂȘme. Ou, comme dans l'exemple, il peut y avoir deux objets, chacun faisant rĂ©fĂ©rence Ă  un voisin. C'est ce qu'on appelle un cycle. Et puis ces objets ne peuvent jamais donner une rĂ©fĂ©rence Ă  un autre objet. Mais en mĂȘme temps, par exemple, ils ne sont pas accessibles depuis une autre partie du programme. Nous devons les supprimer car ils sont inaccessibles, inutiles, mais ils ont des liens. C'est exactement Ă  quoi sert le module de ramasse-miettes. Il dĂ©tecte les cycles et supprime ces objets.



Comment ça marche? Je vais d'abord parler briÚvement des générations, puis de l'algorithme.







Pour optimiser la vitesse du ramasse-miettes en Python, il est gĂ©nĂ©rationnel, c'est-Ă -dire qu'il fonctionne en utilisant des gĂ©nĂ©rations. Il y a trois gĂ©nĂ©rations. À quoi servent-ils? Il est clair que les objets qui ont Ă©tĂ© créés assez rĂ©cemment sont plus susceptibles d'ĂȘtre inutiles que les objets de longue durĂ©e. Disons que vous crĂ©ez quelque chose au cours des fonctions. TrĂšs probablement, il ne sera pas nĂ©cessaire lors de la sortie de la fonction. C'est la mĂȘme chose avec les boucles, avec des variables temporaires. Tous ces objets doivent ĂȘtre nettoyĂ©s plus souvent que ceux qui existent depuis longtemps.



Par conséquent, tous les nouveaux objets sont placés dans la génération zéro. Cette génération est nettoyée périodiquement. Python a trois paramÚtres. Chaque génération a son propre paramÚtre. Vous pouvez les obtenir, importer le garbage collector, appeler la fonction get_threshold et obtenir ces seuils.



Par défaut, il y en a 700, 10, 10. Qu'est-ce que 700? Il s'agit du nombre de créations d'objets moins le nombre de suppressions. DÚs qu'il dépasse 700, un garbage collection de nouvelle génération est lancé. Et 10, 10 est le nombre de garbage collection dans la génération précédente, aprÚs quoi nous devons démarrer le garbage collection dans la génération actuelle.



Autrement dit, lorsque nous effacerons la génération zéro 10 fois, nous commencerons la construction dans la premiÚre génération. AprÚs avoir nettoyé la premiÚre génération 10 fois, nous commencerons la construction dans la deuxiÚme génération. En conséquence, les objets se déplacent de génération en génération. S'ils survivent, ils passent à la premiÚre génération. S'ils ont survécu à un garbage collection dans la premiÚre génération, ils sont déplacés vers la seconde. A partir de la deuxiÚme génération, ils ne se déplacent plus nulle part, ils y restent pour toujours.







Comment fonctionne le garbage collection en Python? Disons que nous démarrons le garbage collection à la génération 0. Nous avons des objets, ils ont des cycles. Il y a un groupe d'objets sur la gauche qui se réfÚrent les uns aux autres, et le groupe sur la droite se réfÚre également les uns aux autres. Un détail important - ils sont également référencés à partir de la génération 1. Comment Python détecte-t-il les boucles? Tout d'abord, une variable temporaire est créée pour chaque objet et le nombre de références à cet objet y est écrit. Cela se reflÚte sur la diapositive. Nous avons deux liens vers l'objet en haut. Cependant, un objet de la génération 1 est référencé de l'extérieur. Python s'en souvient. Ensuite (important!) Il parcourt chaque objet de la génération et supprime, décrémente le compteur du nombre de références au sein de cette génération.







Voici ce qui s'est passé. Pour les objets qui se réfÚrent uniquement entre eux au cours d'une génération, cette variable est automatiquement devenue nulle par construction. Seuls les objets référencés de l'extérieur ont une unité.



Que fait Python ensuite? Lui, puisqu'il y en a un ici, comprend que ces objets sont référencés de l'extérieur. Et nous ne pouvons supprimer ni cet objet ni celui-ci, sinon nous aurons une situation invalide. Par conséquent, Python transfÚre ces objets à la génération 1, et tout ce qui reste dans la génération 0, il supprime, nettoie. Tout sur le ramasse-miettes.







(...) Passez. Je vais vous parler trÚs briÚvement des générateurs.



Générateurs







Malheureusement, il n'y aura pas d'introduction aux gĂ©nĂ©rateurs ici, mais essayons de vous dire ce qu'est un gĂ©nĂ©rateur. C'est une sorte de fonction, relativement parlant, qui se souvient du contexte de son exĂ©cution en utilisant le mot yield. À ce stade, il renvoie une valeur et se souvient du contexte. Vous pouvez alors vous y rĂ©fĂ©rer Ă  nouveau et obtenir la valeur qu'il donne.



Que pouvez-vous faire avec les générateurs? Vous pouvez donner un générateur, cela vous renverra des valeurs, souvenez-vous du contexte. Vous pouvez retourner le générateur. Dans ce cas, l'exécution StopIteration sera lancée, la valeur à l'intérieur de laquelle contiendra la valeur, dans ce cas Y.



Fait moins connu: vous pouvez envoyer des valeurs au générateur. Autrement dit, vous appelez la méthode send sur le générateur, et Z - voir l'exemple - sera la valeur de l'expression yield que le générateur appellera. Si vous souhaitez contrÎler le générateur, vous pouvez y passer des valeurs.



Vous pouvez Ă©galement y lancer des exceptions. La mĂȘme chose: prenez un objet gĂ©nĂ©rateur, lancez-le. Vous y jetez une erreur. Vous aurez une erreur Ă  la place du dernier rendement. Et fermez - vous pouvez fermer le gĂ©nĂ©rateur. Ensuite, l'exĂ©cution GeneratorExit est dĂ©clenchĂ©e et le gĂ©nĂ©rateur ne devrait rien donner d'autre.







Ici, je voulais juste parler de son fonctionnement en CPython. Vous avez en fait une trame d'exécution dans votre générateur. Et comme nous nous en souvenons, FrameObject contient tout le contexte. De là, il semble clair comment le contexte est préservé. Autrement dit, vous avez juste un cadre dans le générateur.







Lorsque vous exĂ©cutez une fonction de gĂ©nĂ©rateur, comment Python sait-il que vous n'avez pas besoin de l'exĂ©cuter, mais crĂ©ez un gĂ©nĂ©rateur? Le CodeObject que nous avons examinĂ© a des indicateurs. Et lorsque vous appelez une fonction, Python vĂ©rifie ses indicateurs. Si l'indicateur CO_GENERATOR est prĂ©sent, il comprend que la fonction n'a pas besoin d'ĂȘtre exĂ©cutĂ©e, mais seulement de crĂ©er un gĂ©nĂ©rateur. Et il le crĂ©e. Fonction PyGen_NewWithQualName.







Comment se dĂ©roule l'exĂ©cution? Depuis GENERATOR_FUNCTION, le gĂ©nĂ©rateur appelle d'abord GENERATOR_Object. Ensuite, vous pouvez appeler GENERATOR_Object en utilisant next pour obtenir la valeur suivante. Comment se passe le prochain appel? Sa trame est extraite du gĂ©nĂ©rateur, elle est stockĂ©e dans la variable F. Et envoyĂ©e Ă  la boucle principale de l'interprĂ©teur EvalFrameEx. Vous ĂȘtes exĂ©cutĂ© comme dans le cas d'une fonction normale. Le mapcode YIELD_VALUE est utilisĂ© pour retourner, mettre en pause l'exĂ©cution du gĂ©nĂ©rateur. Il se souvient de tout le contexte dans le cadre et cesse de s'exĂ©cuter. C'Ă©tait l'avant-dernier sujet.



(...) Un bref récapitulatif de ce que sont les exceptions et comment elles sont utilisées en Python.



Des exceptions







Les exceptions sont un moyen de gérer les situations d'erreur. Nous avons un bloc d'essai. Nous pouvons écrire dans try ces choses qui peuvent lever des exceptions. Disons que nous pouvons générer une erreur en utilisant le mot augmenter. Avec l'aide de except, nous pouvons intercepter certains types d'exceptions, dans ce cas SomeError. Avec sauf nous attrapons toutes les exceptions sans expression. Le bloc else est moins utilisé, mais il existe et ne sera exécuté que si aucune exception n'a été levée. Le bloc finally s'exécutera de toute façon.



Comment fonctionnent les exceptions dans CPython? En plus de la pile d'exécution, chaque trame a également une pile de blocs. Il vaut mieux utiliser un exemple.











Une pile de blocs est une pile sur laquelle sont Ă©crits des blocs. Chaque bloc a un type, Handler, un handler. Handler est l'adresse de bytecode Ă  laquelle sauter pour traiter ce bloc. Comment ça marche? Disons que nous avons du code. Nous avons fait un bloc try, nous avons un bloc except dans lequel nous interceptons les exceptions RuntimeError, et un bloc finally, qui devrait ĂȘtre dans tous les cas.



Tout cela dégénÚre en ce bytecode. Au tout début du bytecode sur le bloc try, nous voyons deux deux opcode SETUP_FINALLY avec des arguments à 40 et à 12. Ce sont les adresses des gestionnaires. Lorsque SETUP_FINALLY est exécuté, un bloc est placé sur la pile de blocs, qui dit: pour me traiter, allez dans un cas à la 40e adresse, dans l'autre - à la 12e.



12 en bas de la pile est sauf, la ligne contenant le else RuntimeError. Cela signifie que lorsque nous avons une exception, nous allons regarder la pile de blocs à la recherche d'un bloc avec le type SETUP_FINALLY. Trouvez le bloc dans lequel il y a une transition vers l'adresse 12, allez-y. Et là, nous avons une comparaison de l'exception avec le type: nous vérifions si le type de l'exception est RuntimeError ou non. S'il est égal, nous l'exécutons, sinon, nous sautons ailleurs.



ENFIN est le bloc suivant dans la pile de blocs. Il sera exécuté pour nous si nous avons une autre exception. Ensuite, la recherche se poursuivra sur cette pile de blocs, et nous arriverons au bloc SETUP_FINALLY suivant. Il y aura un gestionnaire qui nous indique, par exemple, l'adresse 40. Nous sautons à l'adresse 40 - vous pouvez voir à partir du code qu'il s'agit d'un bloc finally.







Cela fonctionne trÚs simplement en CPython. Nous avons toutes les fonctions qui peuvent déclencher des exceptions renvoyant un code de valeur. Si tout va bien, 0 est renvoyé. S'il s'agit d'une erreur, -1 ou NULL est renvoyé, selon le type de fonction.



Prenez une telle barre latérale à C. Nous voyons comment la division se produit. Et il y a une vérification que si B est égal à zéro et que nous ne voulons pas diviser par zéro, alors nous nous souvenons de l'exception et retournons NULL. Une erreur s'est donc produite. Par conséquent, toutes les autres fonctions qui sont plus élevées sur la pile d'appels doivent également jeter NULL. Nous allons voir cela dans la boucle principale de l'interpréteur et sauter ici.







C'est le déroulement de la pile. Tout est comme je l'ai dit: nous parcourons toute la pile de blocs et vérifions que son type est SETUP_FINALLY. Si c'est le cas, sautez par-dessus Handler, trÚs simple. C'est en fait tout.



Liens



Interpréteur général:

docs.python.org/3/reference/executionmodel.html

github.com/python/cpython

leanpub.com/insidethepythonvirtualmachine/read



Gestion de la mémoire:

arctrix.com/nas/python/gc

rushter.com/blog/python -memory-managment

instagram-engineering.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172

stackify.com/python-garbage-collection



Exceptions:

bugs.python.org/issue17611



All Articles