J'ai essayé d'en savoir le plus possible sur GIL que vous devriez savoir si vous faites un traitement parallèle avec Python

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 (Global Interpolator Lock)?

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.

Comme pour Python, CPython implémenté en langage C est largement utilisé.

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.

À propos de la serrure exclusive de GIL

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.

Pourquoi GIL existe

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.

Ouverture et acquisition de 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.

スクリーンショット 2019-11-09 11.04.04.jpg

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.

スクリーンショット 2019-11-09 11.04.33.jpg (* 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 délai d'expiration peut être modifié

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).

Pourquoi est-ce devenu une méthode d'envoi d'une demande de déverrouillage?

À 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.

Inconvénients du GIL actuel

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.

1. Une acquisition de verrou inéquitable peut se produire

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é.

スクリーンショット 2019-11-09 12.23.39.jpg (* 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.

2. L'inefficacité peut survenir en raison de l'effet de convoi

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.

Effectuer un traitement parallèle dans plusieurs processus

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.

Sous-interpréteur introduit dans Python 3.8

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.

Tirez parti des boucles d'événements avec asyncio

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é.

finalement

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.

Matériel de référence

Recommended Posts

J'ai essayé d'en savoir le plus possible sur GIL que vous devriez savoir si vous faites un traitement parallèle avec Python
J'ai essayé de savoir si ReDoS est possible avec Python
J'ai étudié comment rationaliser le flux de travail avec Excel x Python ④
J'ai essayé de savoir comment rationaliser le flux de travail avec Excel x Python ⑤
J'ai étudié comment rationaliser le flux de travail avec Excel x Python ①
J'ai étudié comment rationaliser le flux de travail avec Excel x Python ③
J'ai essayé de trouver l'entropie de l'image avec python
J'ai essayé de découvrir les grandes lignes de Big Gorilla
J'ai essayé de savoir comment rationaliser le flux de travail avec Excel × Python, mon résumé d'article ★
J'ai essayé d'implémenter le tri par fusion en Python avec le moins de lignes possible
J'ai essayé de résumer les opérations susceptibles d'être utilisées avec numpy-stl
J'ai utilisé Python pour découvrir les choix de rôle des 51 "Yachts" dans le monde.
J'ai essayé de comparer la vitesse de traitement avec dplyr de R et pandas de Python
J'ai essayé de toucher un fichier CSV avec Python
J'ai essayé de résoudre Soma Cube avec python
J'ai essayé de résoudre le problème avec Python Vol.1
J'ai essayé de résumer ce que l'homme fort de python fait dans le quartier des professionnels de la compétition
J'ai essayé de simuler la propagation de l'infection avec Python
J'ai essayé d'analyser les émotions de tout le roman "Weather Child" ☔️
J'ai essayé de trouver la moyenne de plusieurs colonnes avec TensorFlow
[Python] J'ai essayé de visualiser des tweets sur Corona avec WordCloud
J'ai essayé de trouver la différence entre A + = B et A = A + B en Python, alors notez
Une histoire qui n'a pas fonctionné lorsque j'ai essayé de me connecter avec le module de requêtes Python
J'ai essayé de résoudre l'édition du débutant du livre des fourmis avec python
Je veux connaître la météo avec LINE bot avec Heroku + Python
[Python] Un mémo que j'ai essayé de démarrer avec asyncio
Je veux savoir si vous installez Python sur Mac ・ Iroha
J'ai essayé d'améliorer l'efficacité du travail quotidien avec Python
Comment trouver lorsque vous ne connaissez pas le répertoire d'installation Java
les débutants en python ont essayé de le découvrir
J'ai essayé de mettre en œuvre une blockchain qui fonctionne réellement avec environ 170 lignes
Si vous souhaitez inclure awsebcli dans CircleCI, spécifiez la version de python
Conseils (entrée / sortie) à connaître lors de la programmation de compétitions avec Python2
Mayungo's Python Learning Episode 2: J'ai essayé de mettre des caractères avec des variables
J'ai essayé d'obtenir le code d'authentification de l'API Qiita avec Python.
J'ai essayé avec les 100 meilleurs packages PyPI> J'ai essayé de représenter graphiquement les packages installés sur Python
J'ai essayé de rationaliser le rôle standard des nouveaux employés avec Python
J'ai essayé de visualiser le texte du roman "Weather Child" avec Word Cloud
Conseils (structure de contrôle) à connaître lors de la programmation de la compétition avec Python2
J'ai essayé d'obtenir les informations sur le film de l'API TMDb avec Python
Conseils (structure de données) à connaître lors de la programmation de compétitions avec Python2
[Feature Poem] Je ne comprends pas le langage fonctionnel Vous pouvez le comprendre avec Python: Partie 2 La fonction qui génère la fonction est incroyable.
J'ai essayé d'obtenir les informations du site .aspx qui est paginé à l'aide de Selenium IDE aussi sans programmation que possible.
python Je ne sais pas comment obtenir le nom de l'imprimante que j'utilise habituellement.
J'ai utilisé gawk pour connaître la valeur maximale qui entre dans NF.
J'ai essayé d'extraire des expressions uniques avec la bibliothèque de traitement du langage naturel GiNZA
[Python] J'ai essayé de résoudre 100 questions passées que les débutants et les intermédiaires devraient résoudre [Partie 5/22]
[Python] J'ai essayé de résoudre 100 questions passées que les débutants et les intermédiaires devraient résoudre [Partie 7/22]
J'ai essayé d'utiliser la bibliothèque Python "pykakasi" qui peut convertir des kanji en romaji.
J'ai essayé d'expliquer à quoi sert le générateur Python aussi facilement que possible.
J'ai essayé de prédire les chevaux qui seront dans le top 3 avec LightGBM
[Python] J'ai essayé de résoudre 100 questions passées que les débutants et les intermédiaires devraient résoudre [Partie 4/22]
[Python] J'ai essayé de résoudre 100 questions passées que les débutants et les intermédiaires devraient résoudre [Part3 / 22]
J'ai essayé de visualiser facilement les tweets de JAWS DAYS 2017 avec Python + ELK
[Python] J'ai essayé de résoudre 100 questions passées que les débutants et les intermédiaires devraient résoudre [Partie 1/22]
[Python] J'ai essayé d'obtenir le nom du type sous forme de chaîne de caractères à partir de la fonction type
[Python] J'ai essayé le même calcul que la prédiction de LSTM à partir de zéro [Keras]
[Python] J'ai essayé de résoudre 100 questions passées que les débutants et les intermédiaires devraient résoudre [Partie 6/22]