Multithreaded Python: glider gennem en I / O flaskehals?

Hvordan udnyttelse af parallelisme i Python kan gøre dine softwarestørrelser hurtigere.

Jeg har for nylig udviklet et projekt, som jeg kaldte Hydra: en multitrådet linkkontrol skrevet i Python. I modsætning til mange Python-webcrawlere, som jeg fandt under undersøgelsen, bruger Hydra kun standardbiblioteker uden eksterne afhængigheder som BeautifulSoup. Det er beregnet til at blive kørt som en del af en CI / CD-proces, så en del af dens succes var afhængig af at være hurtig.

Flere tråde i Python er lidt af et bidende emne (ikke ked af det), idet Python-tolken faktisk ikke lader flere tråde udføre på samme tid.

Pythons Global Interpreter Lock eller GIL forhindrer flere tråde i at udføre Python-bytecodes på én gang. Hver tråd, der ønsker at udføre, skal først vente på, at GIL frigives af den aktuelt udførende tråd. GIL er stort set mikrofonen i et konferencepanel med lavt budget, undtagen hvor ingen kommer til at råbe.

Dette har fordelen ved at forhindre race betingelser. Det mangler dog de ydelsesfordele, der er forbundet med at køre flere opgaver parallelt. (Hvis du vil have en opdatering om samtidighed, parallelisme og multitrådning, se Samtidighed, parallelisme og julemandenes mange tråde.)

Mens jeg foretrækker Go for dets bekvemme førsteklasses primitiver, der understøtter samtidighed (se Goroutines), var projektets modtagere mere komfortable med Python. Jeg tog det som en mulighed for at teste og udforske!

Samtidig udførelse af flere opgaver i Python er ikke umuligt; det tager bare lidt ekstra arbejde. For Hydra er den største fordel ved at overvinde input / output (I / O) flaskehalsen.

For at få websider til at kontrollere, er Hydra nødt til at gå ud på Internettet og hente dem. Sammenlignet med opgaver, der udføres af CPU alene, er det relativt langsommere at gå ud over netværket. Hvor langsomt?

Her er omtrentlige tidspunkter for opgaver, der udføres på en typisk pc:

Opgave Tid
CPU udføre typisk instruktion 1 / 1.000.000.000 sek = 1 nanosek
CPU hentning fra L1-cachehukommelse 0,5 nanosek
CPU forkert forudsigelse 5 nanosek
CPU hentning fra L2-cachehukommelse 7 nanosek
vædder Mutex-lås / oplåsning 25 nanosek
vædder hent fra hovedhukommelsen 100 nanosek
Netværk send 2K bytes over 1Gbps netværk 20.000 nanosek
vædder læses 1 MB sekventielt fra hukommelsen 250.000 nanosek
Disk hent fra ny diskplacering (søg) 8.000.000 nanosek (8 ms)
Disk læses 1 MB sekventielt fra disk 20.000.000 nanosek (20ms)
Netværk send pakke USA til Europa og tilbage 150.000.000 nanosek (150ms)

Peter Norvig offentliggjorde først disse numre for nogle år siden i Teach Yourself Programming om ti år. Da computere og deres komponenter ændres år over år, er de nøjagtige tal vist ovenfor ikke meningen. Hvad disse tal hjælper med at illustrere, er forskellen i størrelsesorden mellem operationer.

Sammenlign forskellen mellem at hente fra hovedhukommelsen og sende en simpel pakke over internettet. Mens begge disse operationer forekommer på mindre end et øjebliks øjne (bogstaveligt talt) fra et menneskeligt perspektiv, kan du se, at afsendelse af en simpel pakke over internettet er over en million gange langsommere end at hente fra RAM. Det er en forskel, at i et enkelt trådsprogram hurtigt kan akkumuleres for at danne generende flaskehalse.

I Hydra er opgaven med at parsere svardata og samle resultater i en rapport relativt hurtig, da det hele sker på CPU'en. Den langsomste del af programmets udførelse, med over seks størrelsesordener, er netværkslatens. Ikke kun har Hydra brug for at hente pakker, men hele websider!

En måde at forbedre Hydras ydeevne på er at finde en måde, hvorpå siden, der henter opgaver, kan udføres uden at blokere hovedtråden.

Python har et par muligheder for at udføre opgaver parallelt: flere processer eller flere tråde. Disse metoder giver dig mulighed for at omgå GIL og fremskynde udførelsen på et par forskellige måder.

Flere processer

For at udføre parallelle opgaver ved hjælp af flere processer kan du bruge Pythons ProcessPoolExecutor. En konkret underklasse Executorfra concurrent.futuresmodulet ProcessPoolExecutorbruger en pulje af processer, der er skabt med multiprocessingmodulet for at undgå GIL.

Denne indstilling bruger medarbejderunderprocesser, der maksimalt standardiserer antallet af processorer på maskinen. Det multiprocessingmodul tillader dig at maksimalt parallelize funktion udførelse tværs processer, som virkelig kan fremskynde beregningsintensive bundet (eller CPU-bundne) opgaver.

Da hovedflaskehalsen for Hydra er I / O og ikke behandlingen, der skal udføres af CPU'en, tjener jeg bedre ved at bruge flere tråde.

Flere tråde

Passende navngivet ThreadPoolExecutorbruger Pythons en pool af tråde til at udføre asynkrone opgaver. Også en underklasse af Executorbruger den et defineret antal maksimale arbejdstråde (mindst fem som standard i henhold til formlen min(32, os.cpu_count() + 4)) og genbruger ledige tråde, inden de starter nye, hvilket gør det ret effektivt.

Her er et stykke Hydra med kommentarer, der viser, hvordan Hydra bruger ThreadPoolExecutortil at opnå parallel multithreaded bliss:

# Create the Checker class class Checker: # Queue of links to be checked TO_PROCESS = Queue() # Maximum workers to run THREADS = 100 # Maximum seconds to wait for HTTP response TIMEOUT = 60 def __init__(self, url): ... # Create the thread pool self.pool = futures.ThreadPoolExecutor(max_workers=self.THREADS) def run(self): # Run until the TO_PROCESS queue is empty while True: try: target_url = self.TO_PROCESS.get(block=True, timeout=2) # If we haven't already checked this link if target_url["url"] not in self.visited: # Mark it as visited self.visited.add(target_url["url"]) # Submit the link to the pool job = self.pool.submit(self.load_url, target_url, self.TIMEOUT) job.add_done_callback(self.handle_future) except Empty: return except Exception as e: print(e) 

Du kan se den fulde kode i Hydras GitHub-lager.

Enkelt tråd til multitråd

Hvis du gerne vil se den fulde effekt, sammenlignede jeg kørselstiderne for at kontrollere min hjemmeside mellem et prototype single-thread-program og det multiheaded - jeg mener multithreaded - Hydra.

time python3 slow-link-check.py //victoria.dev real 17m34.084s user 11m40.761s sys 0m5.436s time python3 hydra.py //victoria.dev real 0m15.729s user 0m11.071s sys 0m2.526s 

Programmet med enkelt tråd, der blokerer for I / O, løb på omkring sytten minutter. Da jeg først kørte den multitrådede version, sluttede den på 1m13.358s - efter noget profilering og tuning tog det lidt under seksten sekunder.

Igen betyder de nøjagtige tider ikke så meget; de varierer afhængigt af faktorer som størrelsen på det sted, der gennemgås, din netværkshastighed og dit programs balance mellem trådstyringens overhead og fordelene ved parallelisme.

Den mere vigtige ting, og det resultat, jeg tager hver dag, er et program, der kører nogle størrelsesordener hurtigere.