Een uitgebreide gids voor de concurrent.futures-module in Python, die ThreadPoolExecutor en ProcessPoolExecutor vergelijkt voor parallelle taakuitvoering, met praktische voorbeelden.
Gelijktijdigheid ontsluiten in Python: ThreadPoolExecutor vs. ProcessPoolExecutor
Python, hoewel een veelzijdige en veelgebruikte programmeertaal, heeft bepaalde beperkingen als het gaat om echte parallelle verwerking vanwege de Global Interpreter Lock (GIL). De concurrent.futures
-module biedt een high-level interface voor het asynchroon uitvoeren van aanroepbare objecten, en biedt een manier om enkele van deze beperkingen te omzeilen en de prestaties voor specifieke soorten taken te verbeteren. Deze module biedt twee belangrijke klassen: ThreadPoolExecutor
en ProcessPoolExecutor
. Deze uitgebreide gids zal beide verkennen, hun verschillen, sterke en zwakke punten belichten en praktische voorbeelden geven om u te helpen de juiste executor voor uw behoeften te kiezen.
Gelijktijdigheid en parallelle verwerking begrijpen
Voordat we in de details van elke executor duiken, is het cruciaal om de concepten gelijktijdigheid en parallelle verwerking te begrijpen. Deze termen worden vaak door elkaar gebruikt, maar ze hebben verschillende betekenissen:
- Gelijktijdigheid: Gaat over het tegelijkertijd beheren van meerdere taken. Het gaat over het structureren van uw code om meerdere dingen schijnbaar tegelijkertijd af te handelen, zelfs als ze in werkelijkheid op één processor-core worden afgewisseld. Denk aan een chef-kok die meerdere potten op een enkele kookplaat beheert – ze koken niet allemaal op *exact* hetzelfde moment, maar de chef-kok beheert ze allemaal.
- Parallelle verwerking: Betreft het daadwerkelijk gelijktijdig uitvoeren van meerdere taken, meestal door gebruik te maken van meerdere processorkernen. Dit is alsof er meerdere chefs zijn, die elk tegelijkertijd aan een ander deel van de maaltijd werken.
De GIL van Python voorkomt grotendeels echte parallelle verwerking voor CPU-gebonden taken bij het gebruik van threads. Dit komt omdat de GIL slechts één thread tegelijk de controle over de Python-interpreter laat behouden. Voor I/O-gebonden taken, waarbij het programma het grootste deel van zijn tijd besteedt aan het wachten op externe bewerkingen zoals netwerkverzoeken of schijfgelezen, kunnen threads echter nog steeds aanzienlijke prestatieverbeteringen opleveren door andere threads te laten draaien terwijl er één wacht.
De `concurrent.futures`-module introduceren
De concurrent.futures
-module vereenvoudigt het proces van het asynchroon uitvoeren van taken. Het biedt een high-level interface voor het werken met threads en processen, en abstraheert veel van de complexiteit die betrokken is bij het rechtstreeks beheren ervan. Het kernconcept is de 'executor', die de uitvoering van ingediende taken beheert. De twee belangrijkste executors zijn:
ThreadPoolExecutor
: Gebruikt een pool van threads om taken uit te voeren. Geschikt voor I/O-gebonden taken.ProcessPoolExecutor
: Gebruikt een pool van processen om taken uit te voeren. Geschikt voor CPU-gebonden taken.
ThreadPoolExecutor: Threads gebruiken voor I/O-gebonden taken
De ThreadPoolExecutor
maakt een pool van worker threads om taken uit te voeren. Vanwege de GIL zijn threads niet ideaal voor rekenintensieve bewerkingen die profiteren van echte parallelle verwerking. Ze blinken echter uit in I/O-gebonden scenario's. Laten we bekijken hoe u deze kunt gebruiken:
Basisgebruik
Hier is een eenvoudig voorbeeld van het gebruik van ThreadPoolExecutor
om meerdere webpagina's gelijktijdig te downloaden:
import concurrent.futures
import requests
import time
urls = [
"https://www.example.com",
"https://www.google.com",
"https://www.wikipedia.org",
"https://www.python.org"
]
def download_page(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status() # HTTPError veroorzaken voor slechte antwoorden (4xx of 5xx)
print(f"Gedownload {url}: {len(response.content)} bytes")
return len(response.content)
except requests.exceptions.RequestException as e:
print(f"Fout bij het downloaden van {url}: {e}")
return 0
start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
# Dien elke URL in bij de executor
futures = [executor.submit(download_page, url) for url in urls]
# Wacht tot alle taken zijn voltooid
total_bytes = sum(future.result() for future in concurrent.futures.as_completed(futures))
print(f"Totaal aantal gedownloade bytes: {total_bytes}")
print(f"Benodigde tijd: {time.time() - start_time:.2f} seconden")
Uitleg:
- We importeren de benodigde modules:
concurrent.futures
,requests
entime
. - We definiëren een lijst met URL's om te downloaden.
- De functie
download_page
haalt de inhoud van een bepaalde URL op. Foutafhandeling is opgenomen met behulp van `try...except` en `response.raise_for_status()` om potentiële netwerkproblemen op te vangen. - We maken een
ThreadPoolExecutor
met maximaal 4 worker threads. Het argumentmax_workers
bepaalt het maximum aantal threads dat gelijktijdig kan worden gebruikt. Als u dit te hoog instelt, wordt de prestaties niet altijd verbeterd, vooral bij I/O-gebonden taken waarbij netwerkbandbreedte vaak de bottleneck is. - We gebruiken een list comprehension om elke URL in te dienen bij de executor met behulp van
executor.submit(download_page, url)
. Dit retourneert eenFuture
-object voor elke taak. - De functie
concurrent.futures.as_completed(futures)
retourneert een iterator die futures oplevert zodra ze zijn voltooid. Dit voorkomt dat u wacht tot alle taken zijn voltooid voordat de resultaten worden verwerkt. - We herhalen de voltooide futures en halen het resultaat van elke taak op met behulp van
future.result()
, waarbij de totale bytes worden opgeteld die zijn gedownload. Foutafhandeling binnen `download_page` zorgt ervoor dat individuele mislukkingen het hele proces niet laten crashen. - Ten slotte drukken we het totale aantal gedownloade bytes en de benodigde tijd af.
Voordelen van ThreadPoolExecutor
- Vereenvoudigde gelijktijdigheid: Biedt een schone en gebruiksvriendelijke interface voor het beheren van threads.
- I/O-gebonden prestaties: Uitstekend voor taken die veel tijd besteden aan het wachten op I/O-bewerkingen, zoals netwerkverzoeken, bestandslezingen of database-query's.
- Verminderde overhead: Threads hebben over het algemeen minder overhead in vergelijking met processen, waardoor ze efficiënter zijn voor taken die frequente context switching vereisen.
Beperkingen van ThreadPoolExecutor
- GIL-beperking: De GIL beperkt echte parallelle verwerking voor CPU-gebonden taken. Slechts één thread kan tegelijkertijd Python-bytecode uitvoeren, waardoor de voordelen van meerdere cores worden tenietgedaan.
- Complexiteit bij het debuggen: Het debuggen van multithreaded applicaties kan een uitdaging zijn vanwege race conditions en andere problemen met gelijktijdigheid.
ProcessPoolExecutor: Multiprocessing ontketenen voor CPU-gebonden taken
De ProcessPoolExecutor
overwint de GIL-beperking door een pool van worker-processen te creëren. Elk proces heeft zijn eigen Python-interpreter en geheugenruimte, waardoor echte parallelle verwerking op multi-core systemen mogelijk is. Dit maakt het ideaal voor CPU-gebonden taken die zware berekeningen met zich meebrengen.
Basisgebruik
Beschouw een rekenintensieve taak zoals het berekenen van de som van kwadraten voor een groot aantal getallen. Zo gebruikt u ProcessPoolExecutor
om deze taak te paralleliseren:
import concurrent.futures
import time
import os
def sum_of_squares(start, end):
pid = os.getpid()
print(f"Proces-ID: {pid}, Som van kwadraten berekenen van {start} tot {end}")
total = 0
for i in range(start, end + 1):
total += i * i
return total
if __name__ == "__main__": #Belangrijk om recursief spawnen in sommige omgevingen te voorkomen
start_time = time.time()
range_size = 1000000
num_processes = 4
ranges = [(i * range_size + 1, (i + 1) * range_size) for i in range(num_processes)]
with concurrent.futures.ProcessPoolExecutor(max_workers=num_processes) as executor:
futures = [executor.submit(sum_of_squares, start, end) for start, end in ranges]
results = [future.result() for future in concurrent.futures.as_completed(futures)]
total_sum = sum(results)
print(f"Totale som van kwadraten: {total_sum}")
print(f"Benodigde tijd: {time.time() - start_time:.2f} seconden")
Uitleg:
- We definiëren een functie
sum_of_squares
die de som van kwadraten berekent voor een bepaald bereik van getallen. We nemen `os.getpid()` op om te zien welk proces elk bereik uitvoert. - We definiëren de bereikgrootte en het aantal processen dat moet worden gebruikt. De lijst
ranges
wordt gemaakt om het totale bereik in kleinere stukken te verdelen, één voor elk proces. - We creëren een
ProcessPoolExecutor
met het opgegeven aantal worker-processen. - We dienen elk bereik in bij de executor met behulp van
executor.submit(sum_of_squares, start, end)
. - We verzamelen de resultaten van elke future met behulp van
future.result()
. - We tellen de resultaten van alle processen op om het uiteindelijke totaal te krijgen.
Belangrijke opmerking: Bij gebruik van ProcessPoolExecutor
, vooral op Windows, moet u de code die de executor maakt, insluiten in een if __name__ == "__main__":
-blok. Dit voorkomt recursief proces-spawnen, wat kan leiden tot fouten en onverwacht gedrag. Dit komt omdat de module opnieuw wordt geïmporteerd in elk onderliggend proces.
Voordelen van ProcessPoolExecutor
- Echte parallelle verwerking: Overwint de GIL-beperking, waardoor echte parallelle verwerking op multi-core systemen mogelijk is voor CPU-gebonden taken.
- Verbeterde prestaties voor CPU-gebonden taken: Er kunnen aanzienlijke prestatiewinsten worden behaald voor rekenintensieve bewerkingen.
- Robuustheid: Als één proces crasht, hoeft het niet per se het hele programma te laten crashen, aangezien processen van elkaar geïsoleerd zijn.
Beperkingen van ProcessPoolExecutor
- Hogere overhead: Het creëren en beheren van processen heeft een hogere overhead in vergelijking met threads.
- Inter-proces communicatie: Het delen van gegevens tussen processen kan complexer zijn en vereist inter-proces communicatie (IPC)-mechanismen, die overhead kunnen toevoegen.
- Geheugenvoetafdruk: Elk proces heeft zijn eigen geheugenruimte, wat de algehele geheugenvoetafdruk van de applicatie kan vergroten. Het doorgeven van grote hoeveelheden gegevens tussen processen kan een knelpunt worden.
De juiste executor kiezen: ThreadPoolExecutor vs. ProcessPoolExecutor
De sleutel tot het kiezen tussen ThreadPoolExecutor
en ProcessPoolExecutor
ligt in het begrijpen van de aard van uw taken:
- I/O-gebonden taken: Als uw taken het grootste deel van hun tijd besteden aan het wachten op I/O-bewerkingen (bijvoorbeeld netwerkverzoeken, bestandslezingen, database-query's), is
ThreadPoolExecutor
over het algemeen de betere keuze. De GIL is in deze scenario's minder een knelpunt en de lagere overhead van threads maakt ze efficiënter. - CPU-gebonden taken: Als uw taken rekenintensief zijn en meerdere cores gebruiken, is
ProcessPoolExecutor
de juiste keuze. Het omzeilt de GIL-beperking en maakt echte parallelle verwerking mogelijk, wat resulteert in aanzienlijke prestatieverbeteringen.
Hier is een tabel die de belangrijkste verschillen samenvat:
Functie | ThreadPoolExecutor | ProcessPoolExecutor |
---|---|---|
Gelijktijdigheidsmodel | Multithreading | Multiprocessing |
GIL-impact | Beperkt door GIL | Omzeilt GIL |
Geschikt voor | I/O-gebonden taken | CPU-gebonden taken |
Overhead | Lager | Hoger |
Geheugenvoetafdruk | Lager | Hoger |
Inter-proces communicatie | Niet vereist (threads delen geheugen) | Vereist voor het delen van gegevens |
Robuustheid | Minder robuust (een crash kan het hele proces beïnvloeden) | Robuster (processen zijn geïsoleerd) |
Geavanceerde technieken en overwegingen
Taken met argumenten indienen
Beide executors stellen u in staat om argumenten door te geven aan de functie die wordt uitgevoerd. Dit wordt gedaan via de methode submit()
:
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(my_function, arg1, arg2)
result = future.result()
Uitzonderingen afhandelen
Uitzonderingen die binnen de uitgevoerde functie worden veroorzaakt, worden niet automatisch doorgegeven aan de hoofdthread of het hoofdproces. U moet ze expliciet afhandelen bij het ophalen van het resultaat van de Future
:
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(my_function)
try:
result = future.result()
except Exception as e:
print(f"Er is een uitzondering opgetreden: {e}")
`map` gebruiken voor eenvoudige taken
Voor eenvoudige taken waarbij u dezelfde functie wilt toepassen op een reeks invoer, biedt de methode map()
een beknopte manier om taken in te dienen:
def square(x):
return x * x
with concurrent.futures.ProcessPoolExecutor() as executor:
numbers = [1, 2, 3, 4, 5]
results = executor.map(square, numbers)
print(list(results))
Het aantal workers beheren
Het argument max_workers
in zowel ThreadPoolExecutor
als ProcessPoolExecutor
bepaalt het maximum aantal threads of processen dat gelijktijdig kan worden gebruikt. Het kiezen van de juiste waarde voor max_workers
is belangrijk voor de prestaties. Een goed uitgangspunt is het aantal CPU-cores dat beschikbaar is op uw systeem. Voor I/O-gebonden taken kunt u echter baat hebben bij het gebruik van meer threads dan cores, aangezien threads kunnen overschakelen naar andere taken terwijl ze wachten op I/O. Experimenteren en profiling zijn vaak nodig om de optimale waarde te bepalen.
De voortgang bewaken
De module concurrent.futures
biedt geen ingebouwde mechanismen om de voortgang van taken rechtstreeks te bewaken. U kunt echter uw eigen voortgangstracking implementeren met behulp van callbacks of gedeelde variabelen. Bibliotheken zoals `tqdm` kunnen worden geïntegreerd om voortgangsbalken weer te geven.
Voorbeelden uit de praktijk
Laten we een aantal realistische scenario's bekijken waarin ThreadPoolExecutor
en ProcessPoolExecutor
effectief kunnen worden toegepast:
- Web scraping: Het gelijktijdig downloaden en parseren van meerdere webpagina's met behulp van
ThreadPoolExecutor
. Elke thread kan een andere webpagina afhandelen, waardoor de algehele scraapsnelheid wordt verbeterd. Houd rekening met de servicevoorwaarden van de website en vermijd het overbelasten van hun servers. - Beeldverwerking: Het toepassen van beeldfilters of transformaties op een grote set afbeeldingen met behulp van
ProcessPoolExecutor
. Elk proces kan een andere afbeelding afhandelen, waarbij meerdere cores worden benut voor snellere verwerking. Overweeg bibliotheken zoals OpenCV voor efficiënte beeldmanipulatie. - Gegevensanalyse: Complexe berekeningen uitvoeren op grote datasets met behulp van
ProcessPoolExecutor
. Elk proces kan een subset van de gegevens analyseren, waardoor de totale analysetijd wordt verkort. Pandas en NumPy zijn populaire bibliotheken voor gegevensanalyse in Python. - Machine learning: Machine learning-modellen trainen met behulp van
ProcessPoolExecutor
. Sommige machine learning-algoritmen kunnen effectief worden geparalleliseerd, waardoor de trainingstijden worden verkort. Bibliotheken zoals scikit-learn en TensorFlow bieden ondersteuning voor parallelle verwerking. - Videocodering: Videobestanden converteren naar verschillende formaten met behulp van
ProcessPoolExecutor
. Elk proces kan een ander videosegment coderen, waardoor het algehele coderingsproces sneller verloopt.
Globale overwegingen
Bij het ontwikkelen van gelijktijdige applicaties voor een wereldwijd publiek, is het belangrijk om het volgende te overwegen:
- Tijdzones: Houd rekening met tijdzones bij het omgaan met tijdgevoelige bewerkingen. Gebruik bibliotheken zoals
pytz
om tijdzoneconversies af te handelen. - Locales: Zorg ervoor dat uw applicatie verschillende locales correct afhandelt. Gebruik bibliotheken zoals
locale
om getallen, datums en valuta op te maken volgens de locale van de gebruiker. - Karaktercoderingen: Gebruik Unicode (UTF-8) als de standaardkaraktercodering om een breed scala aan talen te ondersteunen.
- Internationalisering (i18n) en lokalisering (l10n): Ontwerp uw applicatie zo dat deze gemakkelijk kan worden geïnternationaliseerd en gelokaliseerd. Gebruik gettext of andere vertaalbibliotheken om vertalingen voor verschillende talen te leveren.
- Netwerklatentie: Houd rekening met netwerklatentie bij communicatie met externe services. Implementeer de juiste timeouts en foutafhandeling om ervoor te zorgen dat uw applicatie bestand is tegen netwerkproblemen. De geografische locatie van servers kan de latentie aanzienlijk beïnvloeden. Overweeg om Content Delivery Networks (CDN's) te gebruiken om de prestaties voor gebruikers in verschillende regio's te verbeteren.
Conclusie
De module concurrent.futures
biedt een krachtige en handige manier om gelijktijdigheid en parallelle verwerking in uw Python-applicaties te introduceren. Door de verschillen tussen ThreadPoolExecutor
en ProcessPoolExecutor
te begrijpen en door de aard van uw taken zorgvuldig te overwegen, kunt u de prestaties en responsiviteit van uw code aanzienlijk verbeteren. Vergeet niet om uw code te profileren en te experimenteren met verschillende configuraties om de optimale instellingen voor uw specifieke use case te vinden. Houd ook rekening met de beperkingen van de GIL en de potentiële complexiteit van multithreaded en multiprocessing-programmering. Met zorgvuldige planning en implementatie kunt u het volledige potentieel van gelijktijdigheid in Python ontsluiten en robuuste en schaalbare applicaties creëren voor een wereldwijd publiek.