Qu’est-ce que le Python Global Interpreter Lock (GIL)?

Qu'est-ce que le Python Global Interpreter Lock (GIL)?

Le verrou d'interpréteur global Python ouGIL, en termes simples, est un mutex (ou un verrou) qui permet à un seul thread de détenir le contrôle de l'interpréteur Python.

Cela signifie qu'un seul thread peut être en état d'exécution à un moment donné. L'impact de GIL n'est pas visible pour les développeurs qui exécutent des programmes à un seul thread, mais il peut être un goulot d'étranglement des performances dans le code lié au processeur et à plusieurs threads.

Étant donné que le GIL permet à un seul thread de s'exécuter à la fois, même dans une architecture multi-thread avec plus d'un cœur de processeur, le GIL a acquis la réputation d'être une fonctionnalité «infâme» de Python.

Dans cet article, vous découvrirez comment le GIL affecte les performances de vos programmes Python et comment vous pouvez atténuer l'impact qu'il pourrait avoir sur votre code.

Quel problème le GIL a-t-il résolu pour Python?

Python utilise le comptage de références pour la gestion de la mémoire. Cela signifie que les objets créés en Python ont une variable de comptage de références qui garde une trace du nombre de références qui pointent vers l'objet. Lorsque ce décompte atteint zéro, la mémoire occupée par l'objet est libérée.

Examinons un bref exemple de code pour montrer comment fonctionne le comptage de références:

>>>

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

Dans l'exemple ci-dessus, le nombre de références pour l'objet de liste vide[] était de 3. L'objet liste a été référencé para,b et l'argument passé àsys.getrefcount().

Retour au GIL:

Le problème était que cette variable de comptage de référence avait besoin d'une protection contre les conditions de concurrence où deux threads augmentent ou diminuent sa valeur simultanément. Si cela se produit, cela peut provoquer une fuite de mémoire qui n'est jamais libérée ou, pire encore, libérer incorrectement la mémoire alors qu'une référence à cet objet existe toujours. Cela peut provoquer des plantages ou d'autres bugs «bizarres» dans vos programmes Python.

Cette variable de comptage de référence peut être sécurisée en ajoutantlocks à toutes les structures de données partagées entre les threads afin qu'elles ne soient pas modifiées de manière incohérente.

Mais l'ajout d'un verrou à chaque objet ou groupe d'objets signifie que plusieurs verrous existeront, ce qui peut provoquer un autre problème: les verrous mortels (les verrous mortels ne peuvent se produire que s'il existe plusieurs verrous). Un autre effet secondaire serait une diminution des performances provoquée par l'acquisition et le déverrouillage répétés de verrous.

Le GIL est un verrou unique sur l'interpréteur lui-même qui ajoute une règle selon laquelle l'exécution de tout bytecode Python nécessite l'acquisition du verrou d'interpréteur. Cela empêche les blocages (car il n'y a qu'un seul verrou) et n'introduit pas beaucoup de surcharge de performances. Mais cela rend tout programme Python lié au CPU simple thread.

Le GIL, bien qu'utilisé par des interprètes pour d'autres langues comme Ruby, n'est pas la seule solution à ce problème. Certaines langues évitent l'exigence d'un GIL pour la gestion de la mémoire thread-safe en utilisant des approches autres que le comptage de références, telles que la récupération de place.

D'un autre côté, cela signifie que ces langages doivent souvent compenser la perte des avantages de performances à un seul thread d'un GIL en ajoutant d'autres fonctionnalités d'amélioration des performances comme les compilateurs JIT.

Pourquoi le GIL a-t-il été choisi comme solution?

Alors, pourquoi une approche qui semble si obstruante était-elle utilisée en Python? Était-ce une mauvaise décision des développeurs de Python?

Eh bien, dans leswords of Larry Hastings, la décision de conception du GIL est l'une des choses qui ont rendu Python aussi populaire qu'aujourd'hui.

Python existe depuis l'époque où les systèmes d'exploitation n'avaient pas de concept de threads. Python a été conçu pour être facile à utiliser afin de rendre le développement plus rapide et de plus en plus de développeurs ont commencé à l'utiliser.

De nombreuses extensions étaient en cours d'écriture pour les bibliothèques C existantes dont les fonctionnalités étaient nécessaires en Python. Pour éviter des modifications incohérentes, ces extensions C nécessitaient une gestion de mémoire thread-safe fournie par le GIL.

Le GIL est simple à implémenter et a été facilement ajouté à Python. Il offre une augmentation des performances aux programmes à thread unique car un seul verrou doit être géré.

Les bibliothèques C qui n'étaient pas thread-safe sont devenues plus faciles à intégrer. Et ces extensions C sont devenues l'une des raisons pour lesquelles Python a été facilement adopté par différentes communautés.

Comme vous pouvez le voir, le GIL était une solution pragmatique à un problème difficile auquel les développeurs de CPython étaient confrontés au début de la vie de Python.

L'impact sur les programmes Python multi-thread

Lorsque vous regardez un programme Python typique - ou tout autre programme informatique d'ailleurs - il y a une différence entre ceux qui sont liés au processeur dans leurs performances et ceux qui sont liés aux E / S.

Les programmes liés au CPU sont ceux qui poussent le CPU à sa limite. Cela inclut les programmes qui effectuent des calculs mathématiques comme les multiplications matricielles, la recherche, le traitement d'images, etc.

I/O-bound programs are the ones that spend time waiting for Input/Output which can come from a user, file, database, network, etc. I/O-bound programs sometimes have to wait for a significant amount of time till they get what they need from the source due to the fact that the source may need to do its own processing before the input/output is ready, for example, a user thinking about what to enter into an input prompt or a database query running in its own process.

Voyons un simple programme lié au processeur qui effectue un compte à rebours:

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

L'exécution de ce code sur mon système avec 4 cœurs a donné la sortie suivante:

$ python single_threaded.py
Time taken in seconds - 6.20024037361145

Maintenant, j'ai modifié un peu le code pour faire le même compte à rebours en utilisant deux threads en parallèle:

# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

Et quand je l'ai relancé:

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

Comme vous pouvez le voir, les deux versions prennent presque le même temps pour terminer. Dans la version multi-thread, le GIL a empêché les threads liés au CPU de s'exécuter en parallèle.

Le GIL n'a pas beaucoup d'impact sur les performances des programmes multithread liés aux E / S car le verrou est partagé entre les threads pendant qu'ils attendent les E / S.

Mais un programme dont les threads sont entièrement liés au processeur, par exemple, un programme qui traite une image en plusieurs parties à l'aide de threads, deviendrait non seulement un thread unique en raison du verrouillage, mais verrait également une augmentation du temps d'exécution, comme le montre l'exemple ci-dessus. , par rapport à un scénario où il a été écrit pour être entièrement monothread.

Cette augmentation est le résultat des frais généraux d'acquisition et de libération ajoutés par le verrou.

Pourquoi le GIL n'a-t-il pas encore été supprimé?

Les développeurs de Python reçoivent beaucoup de plaintes à ce sujet, mais un langage aussi populaire que Python ne peut pas apporter un changement aussi important que la suppression de GIL sans causer de problèmes d'incompatibilité descendante.

Le GIL peut évidemment être supprimé et cela a été fait plusieurs fois dans le passé par les développeurs et les chercheurs, mais toutes ces tentatives ont brisé les extensions C existantes qui dépendent fortement de la solution fournie par le GIL.

Bien sûr, il existe d'autres solutions au problème que GIL résout, mais certaines d'entre elles diminuent les performances des programmes liés aux E / S à un seul thread et à plusieurs threads et certains d'entre eux sont tout simplement trop difficiles. Après tout, vous ne voudriez pas que vos programmes Python existants s'exécutent plus lentement après la sortie d'une nouvelle version, non?

Le créateur et BDFL de Python, Guido van Rossum, a donné une réponse à la communauté en septembre 2007 dans son article“It isn’t Easy to remove the GIL”:

"Je serais heureux de recevoir un ensemble de correctifs dans Py3konly if les performances d'un programme à un seul thread (et d'un programme multithread mais lié aux E / S)does not decrease"

Et cette condition n'a été remplie par aucune des tentatives faites depuis.

Pourquoi n'a-t-il pas été supprimé dans Python 3?

Python 3 a eu la chance de démarrer de nombreuses fonctionnalités à partir de zéro et, au cours du processus, a cassé certaines des extensions C existantes qui nécessitaient alors des mises à jour et un portage pour fonctionner avec Python 3. C'est la raison pour laquelle les premières versions de Python 3 ont vu l'adoption plus lente par la communauté.

Mais pourquoi GIL n'a-t-il pas été supprimé à côté?

La suppression du GIL aurait ralenti Python 3 par rapport à Python 2 dans les performances à un seul thread et vous pouvez imaginer ce que cela aurait entraîné. Vous ne pouvez pas contester les avantages de performance à un seul thread du GIL. Le résultat est donc que Python 3 a toujours le GIL.

Mais Python 3 a apporté une amélioration majeure au GIL existant ...

Nous avons discuté de l'impact de GIL sur les programmes multithreads «uniquement liés au processeur» et «uniquement liés aux E / S», mais qu'en est-il des programmes où certains threads sont liés aux E / S et d'autres sont liés au CPU?

Dans de tels programmes, le GIL de Python était connu pour affamer les threads liés aux E / S en ne leur donnant pas la possibilité d'acquérir le GIL à partir des threads liés au CPU.

Cela était dû à un mécanisme intégré à Python qui obligeait les threads à libérer les GILafter a fixed interval d'utilisation continue et si personne d'autre n'acquit le GIL, le même thread pourrait continuer son utilisation.

>>>

>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

Le problème dans ce mécanisme était que la plupart du temps, le thread lié au CPU réacquérirait le GIL lui-même avant que d'autres threads puissent l'acquérir. Ceci a été recherché par David Beazley et des visualisations peuvent être trouvéeshere.

Ce problème a été résolu dans Python 3.2 en 2009 par Antoine Pitrou qui aadded a mechanism de regarder le nombre de demandes d'acquisition GIL par d'autres threads qui ont été abandonnées et de ne pas permettre au thread actuel de réacquérir GIL avant que d'autres threads aient une chance de s'exécuter .

Comment gérer le GIL de Python

Si le GIL vous pose des problèmes, voici quelques approches que vous pouvez essayer:

Multi-processing vs multi-threading: La méthode la plus courante consiste à utiliser une approche multi-traitement où vous utilisez plusieurs processus au lieu de threads. Chaque processus Python dispose de son propre interpréteur Python et de son propre espace mémoire afin que le GIL ne soit pas un problème. Python a un modulemultiprocessing qui nous permet de créer facilement des processus comme celui-ci:

from multiprocessing import Pool
import time

COUNT = 50000000
def countdown(n):
    while n>0:
        n -= 1

if __name__ == '__main__':
    pool = Pool(processes=2)
    start = time.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end = time.time()
    print('Time taken in seconds -', end - start)

L'exécution de ceci sur mon système a donné cette sortie:

$ python multiprocess.py
Time taken in seconds - 4.060242414474487

Une augmentation des performances décente par rapport à la version multi-thread, non?

Le temps n'est pas tombé à la moitié de ce que nous avons vu ci-dessus car la gestion des processus a ses propres frais généraux. Plusieurs processus sont plus lourds que plusieurs threads, alors gardez à l'esprit que cela pourrait devenir un goulot d'étranglement.

Alternative Python interpreters: Python a plusieurs implémentations d'interpréteur. CPython, Jython, IronPython et PyPy, écrits en C, Java, C # et Python respectivement, sont les plus populaires. GIL n'existe que dans l'implémentation Python d'origine qu'est CPython. Si votre programme, avec ses bibliothèques, est disponible pour l'une des autres implémentations, vous pouvez également les essayer.

Just wait it out: Alors que de nombreux utilisateurs de Python profitent des avantages de performances à thread unique de GIL. Les programmeurs multithreads n'ont pas à s'inquiéter car certains des esprits les plus brillants de la communauté Python travaillent pour supprimer le GIL de CPython. Une de ces tentatives est connue sous le nom deGilectomy.

Le Python GIL est souvent considéré comme un sujet mystérieux et difficile. Mais gardez à l'esprit qu'en tant que Pythonista, vous n'en êtes généralement affecté que si vous écrivez des extensions C ou si vous utilisez le multi-threading lié au CPU dans vos programmes.

Dans ce cas, cet article devrait vous donner tout ce dont vous avez besoin pour comprendre ce qu'est le GIL et comment le gérer dans vos propres projets. Et si vous voulez comprendre le fonctionnement interne de bas niveau de GIL, je vous recommande de regarder le discours deUnderstanding the Python GIL de David Beazley.