J'ai récemment eu l'opportunité d'écrire une application de couche d'orchestration (BFF) en Python.
Asyncio a été introduit à partir de Python 3.4, et bien que le traitement lié aux E / S puisse être géré efficacement même avec un seul thread, GIL existe toujours pour le traitement lié au processeur, de sorte que le traitement parallèle peut être effectué sous un seul processus. Ce sera restreint.
À partir de là, on peut voir qu'il convient pour gérer plusieurs processus liés aux E / S plutôt que liés au processeur en tant que caractéristique de langage. C'est un facteur important lors de la prise d'une décision de sélection de la langue, mais je pensais qu'il était nécessaire de connaître à nouveau le mécanisme de GIL à cette fin, alors je l'ai étudié.
Qu'est-ce que GIL en premier lieu?
Formellement appelé Global Interpreter Lock, il s'agit d'un mécanisme de verrouillage exclusif trouvé dans des langages tels que Python et Ruby. Regarder ces deux langages seuls peut sembler être caractéristique des langages typés dynamiquement, mais ils sont plutôt impliqués dans la coordination avec le langage C.
Avant d'entrer dans l'explication de GIL, si vous expliquez que "GIL existe en Python", il y a un malentendu, alors reprenons-le un peu plus attentivement.
Il existe plusieurs implémentations du langage Python en premier lieu. Le plus utilisé est CPython implémenté en C, auquel il est probablement fait référence implicitement lorsque les caractéristiques du langage de Python sont décrites.
D'autres exemples typiques sont Jython implémenté en Java et IronPython s'exécutant sur .Net Framework, mais ceux-ci n'ont pas GIL. Pourquoi CPython est-il utilisé lorsque vous entendez cela seul? Vous pensez peut-être que les principales bibliothèques telles que NumPy sont souvent implémentées en C et que CPython est souvent utilisé en raison de la fréquence des mises à jour d'implémentation du langage.
Sur cette base, GIL sera expliqué en fonction des spécifications CPython.
Maintenant, revenons au sujet principal et entrons dans l'explication de GIL, mais grosso modo, ** "Le code d'octet ne peut être exécuté que par un seul thread avec un verrou même sous plusieurs threads, et les autres threads sont en état de veille" ** C'est. Le verrou est libéré à intervalles réguliers et un autre thread qui acquiert nouvellement le verrou exécute le programme.
Le mécanisme de verrouillage sera décrit plus loin, mais pour le moment, il faut reconnaître que le traitement lié au processeur ne peut être exécuté que par un seul thread, ce qui limite la parallélisation du traitement.
Voyons en fait l'effet de GIL dans le code. Tout d'abord, exécutez un programme simple qui compte un grand nombre.
countdown.py
def countdown():
n = 10000000
while n > 0:
n -= 1
if __name__ == '__main__':
start = datetime.now()
t1 = Thread(target=countdown)
t2 = Thread(target=countdown)
t1.start()
t2.start()
t1.join()
t2.join()
end = datetime.now()
print(f"Time: {end - start}")
C'est un programme qui compte à rebours 10 millions avec 2 threads, mais le temps d'exécution était d'environ 1,1 seconde dans mon environnement. Alors qu'en est-il de courir dans un fil?
if __name__ == '__main__':
start = datetime.now()
countdown()
end = datetime.now()
print(f"Time: {end - start}")
Cela s'est terminé en environ 0,53 seconde. Environ la moitié des deux threads signifie que chaque thread ne fonctionne pas en parallèle. En effet, le traitement lié au processeur ne peut être exécuté que par un seul thread.
Mais qu'en est-il du traitement non lié au processeur? Remplacez le compte à rebours par sleep et essayez de l'exécuter en 2 threads.
sleep.py
def sleep():
time.sleep(2.0)
À ce stade, le processus est terminé en environ 2 secondes. Il a fallu 4 secondes en 2 x 2 secondes pour le processeur lié, mais il a fallu une demi-seconde pour dormir. Cela est dû au fait que le verrou a été libéré lors de l'exécution de la mise en veille et que le thread en attente s'est mis en veille immédiatement après, ce qui a été traité sensiblement en parallèle.
À propos, des verrous se produisent lors de l'exécution de bytecodes Python, pas nécessairement lors de l'utilisation du processeur.
Alors, pourquoi existe-t-il un GIL qui limite le traitement parallèle en premier lieu? Ce n'est pas une solution que j'ai obtenue en lisant moi-même le code CPython, mais ce qui suit semble être les principaux facteurs.
Pour les raisons ci-dessus, pour exécuter CPython, un seul thread doit être capable de faire fonctionner du code d'octet, et il existe un mécanisme appelé GIL pour s'en rendre compte.
Cependant, ce n'est pas une caractéristique du langage lui-même, Python, mais est associé à CPython implémenté en C. Par exemple, Jython implémenté en Java n'a pas de GIL car les conflits ne se produisent pas même en multithreading grâce à la gestion des threads par JVM.
Cependant, CPython est probablement beaucoup utilisé, probablement parce qu'il est estimé que les avantages de pouvoir utiliser les ressources du langage C et les mises à jour actives sont plus importants que les avantages d'éviter GIL.
(La description de cet élément est basée sur Understanding the Python GIL)
Le mécanisme GIL de CPython a changé avec la version 3.2, à partir d'une demande de libération de verrou appelée ** gil_drop_request
**.
Par exemple, s'il n'y a qu'un seul thread, l'exécution se poursuivra jusqu'à ce qu'un seul thread termine le traitement. En effet, la demande de déverrouillage n'est arrivée de nulle part.
En revanche, c'est différent lorsqu'il y a plusieurs threads. Suspendre les threads attendez 5 ms par défaut, puis définissez gil_drop_request
sur '1'. Le thread en cours d'exécution libère alors le verrou et le signale.
Lorsqu'un thread en attente d'un verrou reçoit le signal, il acquiert le verrou, mais à ce moment-là, il envoie un signal pour informer qu'il l'a acquis. Le thread qui a libéré le verrou plus tôt reçoit le signal et entre dans l'état suspendu.
(* Toutes les images sont tirées de Understanding the Python GIL)
Après l'expiration du délai, plusieurs threads répéteront l'acquisition et la libération du verrou de la même manière qu'avant, en essayant d'acquérir à nouveau le verrou en définissant gil_drop_request
.
Le thread en attente de verrouillage attend 5ms par défaut, ce que vous pouvez vous référer à l'heure du code Python sys.getcheckinterval ()
.
Vous pouvez également modifier l'intervalle de temps avec sys.setcheckinterval (time)
.
À partir de Python 3.2, il est devenu une méthode de libération de verrou par gil_drop_request
, mais avant cela, le verrou était libéré par unité d'exécution appelée tick.
En passant, cela peut être référencé par sys.getcheckinterval ()
, mais comme il n'est plus utilisé en raison du changement de méthode de verrouillage, le message d'avertissement suivant est affiché.
DeprecationWarning: sys.getcheckinterval() and sys.setcheckinterval() are deprecated. Use sys.getswitchinterval() instead.
Alors pourquoi la méthode de déverrouillage a-t-elle changé?
Comme mentionné précédemment, le thread en attente envoie maintenant une demande de libération de verrou, mais auparavant, le thread en cours d'exécution a libéré le verrou après 100 ticks d'unités d'exécution par défaut. Cependant, cela pose certains problèmes dans une situation multicœur.
Examinons d'abord le cas à noyau unique. Lorsqu'un thread en cours d'exécution libère le verrou, il envoie un signal à l'un des threads en attente. Le thread qui reçoit le signal est mis dans la file d'attente en attente d'être exécuté, mais le planificateur du système d'exploitation sélectionne en fonction de la priorité si le thread qui vient de libérer le verrou ou le thread qui a reçu le signal est exécuté ensuite. Faire.
(Le même thread peut acquérir des verrous successivement, ce qui peut être souhaitable étant donné la surcharge du changement de contexte.)
Cependant, dans le cas du multi-cœur, tant pour les threads exécutables, il y en a plus d'un qui essaie également d'acquérir le verrou, l'un échouera à acquérir le verrou. Tenter d'acquérir un verrou inutilement est en soi une surcharge, et le problème est que les threads en attente peuvent à peine acquérir un verrou.
Les threads en attente ont un certain temps avant de reprendre, il est donc probable que le thread que vous venez de libérer a déjà acquis le verrou lorsque vous essayez de l'acquérir. Il semble qu'un thread puisse garder le verrou pendant plus de dizaines de minutes dans un long processus.
En outre, dans les cas où le traitement des E / S qui se termine immédiatement en raison de la mise en mémoire tampon du système d'exploitation se produit fréquemment, il existe également l'inconvénient que la charge augmente car les verrous sont libérés et acquis les uns après les autres chaque fois que les E / S sont attendues. ..
Compte tenu des problèmes ci-dessus, la méthode actuelle d'envoi de requêtes par les threads en attente est meilleure.
Donc, s'il y a un problème avec le GIL actuel, ce n'est pas le cas. Le matériel Understanding the Python GIL présente deux inconvénients.
Premièrement, s'il existe trois threads ou plus, le thread qui a demandé la libération du verrou peut ne pas être en mesure d'acquérir le verrou et peut être pris par le thread retardé.
(* Cité de Understanding the Python GIL)
Dans l'image ci-dessus, le thread 2 demande le déverrouillage après un délai et le thread 1 signale également le déverrouillage. À l'origine, le thread 2 aurait dû acquérir le verrou, mais entre-temps, le thread 3 a ensuite été mis en file d'attente pour acquérir le verrou de préférence.
De cette manière, en fonction de la synchronisation, l'acquisition de verrouillage peut être polarisée vers un thread spécifique, et le traitement parallèle peut devenir inefficace.
En outre, si un thread lié au processeur et un thread lié aux E / S s'exécutent en même temps, un état inefficace appelé effet de convoi peut se produire.
Du point de vue de l'ensemble du processus, les threads liés aux E / S ont la priorité pour maintenir les verrous, quand les E / S attendent, ils se déplacent vers les threads liés au CPU, et lorsque les E / S sont terminées, ils reçoivent à nouveau des verrous prioritaires. Il est efficace de le laisser. D'un autre côté, si seuls les threads liés à l'UC ont des verrous, le traitement lié aux E / S restera et le temps d'exécution sera prolongé du temps d'attente des E / S.
Cependant, les threads n'ont pas de priorité, vous n'avez donc aucun contrôle sur le thread qui acquiert le verrou de préférence. Si deux threads attendent, le thread lié au processeur peut acquérir le verrou en premier.
De plus, même si les E / S se terminent immédiatement, il est nécessaire d'attendre le délai d'expiration. Si un grand nombre d'attentes d'E / S se produit, le traitement lié au processeur peut s'arrêter en attendant des délais séquentiels, ne laissant que des attentes d'E / S.
C'est ce qu'on appelle l '«effet de convoi», mais comme il ne nécessite que le verrou pour être libéré après un délai d'attente, il peut être inefficace du point de vue de l'optimisation globale.
Comme beaucoup d'entre vous le savent, le traitement lié au processeur peut être exécuté en parallèle en le rendant multi-processus. En effet, chaque processus contient un interprète et GIL existe sur la base d'un interprète.
Essayons d'exécuter la partie qui a été traitée en multi-thread précédemment en multi-processus.
countdown.py
def countdown():
n = 10000000
while n > 0:
n -= 1
if __name__ == '__main__':
start = datetime.now()
t1 = Process(target=countdown)
t2 = Process(target=countdown)
t1.start()
t2.start()
t1.join()
t2.join()
end = datetime.now()
print(f"Time: {end - start}")
Le processus qui a pris environ 1,1 seconde en multi-processus est maintenant d'environ 0,65 seconde en multi-processus. Vous pouvez voir qu'il peut être exécuté en parallèle même avec le CPU lié.
Bien qu'il ait une surcharge plus élevée que les threads, il peut partager des valeurs entre les processus et est utile lors de l'exécution de traitements liés au processeur en parallèle.
Au moment d'écrire ces lignes, le sous-interpréteur a été provisoirement implémenté dans le Python 3.8 qui vient de sortir. Le sous-interpréteur est proposé dans PEP 554 mais n'a pas encore été fusionné.
Comme mentionné précédemment, GIL existe sur la base d'un interprète, mais les sous-interprètes permettent à plusieurs interprètes d'être détenus dans le même processus.
C'est une idée avec un potentiel dans le futur, mais comme CPython a un état dans Runtime, il semble qu'il y ait encore de nombreux problèmes pour maintenir l'état dans l'interpréteur.
Vous pouvez réellement l'utiliser en mettant à niveau vers Python 3.8 et en important _xxsubinterpreters
, mais il peut encore être difficile à utiliser au niveau de la production.
C'est une histoire méthodologique qui s'écarte du point principal de l'explication de GIL, mais dans le cas où plusieurs attentes d'E / S se produisent dans le Python actuel, il peut être plus pratique d'utiliser la boucle d'événements par ʻasyncio`.
Avec le multiplexage d'E / S, ʻasyncio` peut gérer efficacement plusieurs opérations d'E / S dans un seul thread, ce qui est proche des avantages du multithreading.
En plus d'économiser de la mémoire par rapport au multi-threading, il n'est pas nécessaire d'envisager l'acquisition / la libération de verrous de plusieurs threads, et les collouts natifs par async / await peuvent être écrits intuitivement, ce qui réduira la charge de réflexion du programmeur.
Je présenterai les collouts de Python en détail dans un article séparé.
Bien que l'histoire se soit répandue dans la seconde moitié, cet article a décrit en détail des sujets liés à GIL.
Ce n'est peut-être pas un point à prendre en compte tous les jours lors de l'écriture de code au niveau de l'application, mais je pensais que connaître les restrictions qu'il y avait lors de l'exécution d'un traitement parallèle serait utile pour la sélection de la langue, alors écrivez un article. J'ai fait.