PythonProgrammatiePython Developer

Waarom worden meerdere **threads** in één **Python**-proces sequentieel uitgevoerd op één CPU-core, ondanks dat het host-systeem meerdere cores beschikbaar heeft?

Slaag voor sollicitatiegesprekken met de Hintsage AI-assistent

Antwoord op de vraag

Python's Global Interpreter Lock (GIL) is een mutex die de toegang tot Python-objecten beschermt, zodat slechts één thread op elk moment Python-bytecode uitvoert. Deze ontwerpbeslissing is genomen in CPython om het geheugenbeheer te vereenvoudigen en racecondities op objectreferentietellingen te voorkomen. Gevolg: zelfs op multi-core processors kunnen threads geen Python-code parallel uitvoeren; in plaats daarvan schakelen ze snel tussen uitvoering op een enkele core, waardoor CPU-gebonden multithreading ineffectief is.

import threading import multiprocessing import time def cpu_intensive_task(n): """Pure Python CPU-bound operatie""" count = 0 for i in range(n): count += i ** 2 return count # Bewezen beperking van threading start = time.time() threads = [threading.Thread(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f"Threading tijd: {time.time() - start:.2f}s") # Output toont ~4x de tijd van een enkele thread vanwege GIL-contestatie start = time.time() processes = [multiprocessing.Process(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for p in processes: p.start() for p in processes: p.join() print(f"Multiprocessing tijd: {time.time() - start:.2f}s") # Output toont ~1x de tijd van een enkele thread (4x versnelling)

Situatie uit het leven

Probleem: Ons analytics platform moest 10GB aan logbestanden verwerken met complexe regex-extracties en statistische berekeningen. Het engineeringteam implementeerde een threading-gebaseerde werkpool met concurrent.futures.ThreadPoolExecutor met 16 threads op een 16-core server. Tot onze verbazing bleef het CPU-gebruik op 6-7% (één core) en het verwerken duurde 3 uur, terwijl sequentiële verwerking 45 minuten kostte. De GIL dwong sequentiële uitvoering plus voegde overhead van thread-switching toe.

Oplossing 1: Geoptimaliseerde threading met C-extensies We evalueerden de mogelijkheid om zware berekeningen naar NumPy-bewerkingen te verplaatsen en C-versnelde bibliotheken te gebruiken die de GIL tijdens uitvoering vrijgeven.

Voordelen: Minimale codewijzigingen vereist; gedeeld geheugen elimineert serialisatiekosten; lagere geheugengebruik aangezien threads adresruimte delen.

Nadelen: Beperkt tot bewerkingen die door NumPy worden ondersteund; aangepaste algoritmen vereisen nog steeds Python-bytecode-executie; debugging van interacties met C-extensies voegt complexiteit toe.

Oplossing 2: Procesgebaseerde parallelisme met multiprocessing We overwegen over te schakelen naar multiprocessing.Pool of concurrent.futures.ProcessPoolExecutor, waarbij aparte Python-interpreter worden gestart.

Voordelen: Werkelijke parallelisme dat alle CPU-cores benut; lineaire schaalbaarheid voor CPU-gebonden taken; isolatie voorkomt volledige GIL-contentie.

Nadelen: Hoger geheugengebruik (elk proces laadt een aparte Python-interpreter ~50-100MB); gegevens moeten worden gepickle/unpickled voor inter-process communicatie; opstartlatentie overhead van processen.

Gekozen oplossing: We selecteerden multiprocessing met chunked data verwerking. Logs werden in 16 segmenten verdeeld, verwerkt door ProcessPoolExecutor, en resultaten samengevoegd. De chunkingstrategie minimaliseerde pickle-overhead door de frequentie van IPC te verminderen.

Resultaat: De verwerkingstijd daalde van 3 uur naar 4 minuten (45x versnelling). Het CPU-gebruik bereikte 98% over alle 16 cores. Het geheugengebruik steeg met 800MB per proces (12.8GB totaal), wat acceptabel was op onze 128GB-server. We implementeerden een singleton van de procespool om opstartkosten over meerdere batchjobs te spreiden.

Wat kandidaten vaak missen


Waarom heeft de GIL geen invloed op de prestaties van I/O-gebonden threading?

Veel kandidaten geloven ten onrechte dat threads volledig nutteloos zijn in Python. De GIL wordt vrijgegeven tijdens I/O-bewerking (netwerkverzoeken, schijflezingen, databasequery's) en bij het aanroepen van C-extensies die dit expliciet vrijgeven (zoals NumPy matrixbewerkingen). Wanneer een thread blokkeert voor I/O, kunnen andere threads Python-code uitvoeren. Daarom blijft threading zeer effectief voor gelijktijdige I/O-afhandeling, zoals webscraping of het beheren van duizenden gelijktijdige verbindingen in asyncio-gebaseerde servers.


Hebben alternatieve Python-implementaties zoals PyPy of Jython een GIL?

Kandidaten gaan vaak ervan uit dat het verwijderen van de GIL gewoon een kwestie is van het gebruik van een andere interpreter. PyPy (de JIT-gecompileerde Python) implementeert ook een GIL om thread veiligheid te handhaven, hoewel het verschillende objectmodel de thread-switching efficiënter kan maken. Maar Jython (dat draait op de JVM) en IronPython (dat draait op de .NET CLR) hebben geen GIL omdat zij afhankelijk zijn van de garbage collection en threading primitives van de onderliggende virtuele machine, wat echte thread-level parallelisme mogelijk maakt op JVM-threads.


Kun je de GIL handmatig vrijgeven zonder nieuwe processen op te starten?

Veel ontwikkelaars zijn zich niet bewust van handmatig GIL-beheer in C-extensies. Bij het schrijven van Cython of C-code kun je de GIL expliciet vrijgeven met behulp van Py_BEGIN_ALLOW_THREADS en Py_END_ALLOW_THREADS macro's rond langdurige berekeningen. Bovendien heeft Python 3.12+ de per-interpreter GIL (PEP 684) geïntroduceerd, die sub-interpreters met aparte GILs binnen één proces mogelijk maakt, hoewel dit het experimentele interpreters-module vereist en geen objecten tussen interpreters rechtstreeks deelt.