Višenitni Python: provlačenje kroz I / O usko grlo?

Kako iskorištavanje paralelizma u Pythonu može ubrzati redoslijed vašeg softvera.

Nedavno sam razvio projekt koji sam nazvao Hydra: provjeravač višetrenih veza napisan na Pythonu. Za razliku od mnogih pretraživača Python stranica koje sam pronašao tijekom istraživanja, Hydra koristi samo standardne knjižnice, bez vanjskih ovisnosti poput BeautifulSoupa. Namijenjen je pokretanju kao dijelu CI / CD postupka, pa je dio njegovog uspjeha ovisio o brzini.

Višestruki niti u Pythonu pomalo su zalogaj (nije žao) jer Python interpreter zapravo ne dopušta izvršavanje više niti istovremeno.

Pythonova globalna interpreterska brava ili GIL sprječava više niti da izvršavaju Python bajtkodove odjednom. Svaka nit koja se želi izvršiti mora prvo pričekati da GIL otpusti trenutno izvršavajuća nit. GIL je uglavnom mikrofon na konferenciji s niskim proračunom, osim tamo gdje nitko ne uspijeva vikati.

To ima prednost u sprečavanju uvjeta utrke. Međutim, nedostaju mu prednosti u izvedbi koje paralelno izvodi više zadataka. (Ako želite osvježiti paralelnost, paralelizam i višenitnost, pogledajte Istodobnost, paralelizam i mnoge niti Djeda Mraza.)

Iako više volim Go zbog njegovih prikladnih prvoklasnih primitiva koji podržavaju istodobnost (vidi Goroutine), primatelji ovog projekta bili su ugodniji za Python. Uzeo sam to kao priliku za testiranje i istraživanje!

Istodobno izvršavanje više zadataka u Pythonu nije nemoguće; potrebno je samo malo dodatnog posla. Za Hydru je glavna prednost u prevladavanju ulazno / izlaznog (I / O) uskog grla.

Da bi provjerila web stranice, Hydra mora izaći na Internet i dohvatiti ih. U usporedbi sa zadacima koje izvodi samo CPU, izlazak preko mreže relativno je sporiji. Koliko sporo?

Evo približnih vremenskih okvira za zadatke koji se izvode na tipičnom računalu:

Zadatak Vrijeme
CPU izvršiti tipične upute 1/1 000 000 000 sek = 1 nanosek
CPU dohvatiti iz predmemorijske memorije L1 0,5 nanosek
CPU pogrešno predviđanje grane 5 nanoseka
CPU dohvatiti iz L2 predmemorije 7 nanoseka
radna memorija Muteks zaključavanje / otključavanje 25 nanoseka
radna memorija dohvatiti iz glavne memorije 100 nanoseka
Mreža poslati 2K bajtova preko 1Gbps mreže 20.000 nanoseka
radna memorija čitati 1MB uzastopno iz memorije 250 000 nanoseka
Disk dohvatiti s novog mjesta na disku (tražiti) 8.000.000 nanoseka (8 ms)
Disk čitati 1MB uzastopno s diska 20.000.000 nanoseka (20 ms)
Mreža pošaljite paket SAD-a u Europu i natrag 150 000 000 nanoseka (150 ms)

Peter Norvig prvi je put objavio ove brojeve prije nekoliko godina u Nauči programiranje za deset godina. Budući da se računala i njihove komponente mijenjaju iz godine u godinu, gore navedeni točni brojevi nisu poanta. Ovi brojevi pomažu ilustrirati razliku u redoslijedima između operacija.

Usporedite razliku između dohvaćanja iz glavne memorije i slanja jednostavnog paketa putem Interneta. Iako se obje ove operacije događaju u manje od treptaja oka (doslovno) iz ljudske perspektive, možete vidjeti da je slanje jednostavnog paketa putem Interneta više od milijun puta sporije od dohvaćanja iz RAM-a. Razlika je u tome što se u programu s jednom niti može brzo nakupiti i stvoriti problematična uska grla.

U Hydri je zadatak raščlanjivanja podataka odgovora i prikupljanja rezultata u izvještaj relativno brz, jer se sve događa na CPU-u. Najsporiji dio izvođenja programa, preko šest redova veličine, je kašnjenje mreže. Ne samo da Hydra treba dohvatiti pakete, već i cijele web stranice!

Jedan od načina poboljšanja Hydrine izvedbe je pronalaženje načina za izvršavanje zadataka dohvaćanja stranice bez blokiranja glavne niti.

Python ima nekoliko mogućnosti za paralelno obavljanje zadataka: više procesa ili više niti. Ove metode omogućuju vam zaobilaženje GIL-a i ubrzavanje izvršenja na nekoliko različitih načina.

Višestruki procesi

Da biste izvršavali paralelne zadatke pomoću više procesa, možete koristiti Python's ProcessPoolExecutor. Konkretan podrazred Executoriz concurrent.futuresmodula, ProcessPoolExecutorkoristi bazen procesa potaknute s multiprocessingmodulom se izbjegla Gil.

Ova opcija koristi radničke potprocese koji maksimalno zadaju broj procesora na stroju. multiprocessingModul vam omogućuje da maksimalno parallelize izvršenje funkcije u svim procesima, što stvarno može ubrzati izračunati vezani (ili CPU-bound) zadataka.

Budući da je glavno usko grlo za Hidru I / O, a ne obrada koju treba izvršiti CPU, bolje mi je koristiti više niti.

Više niti

Prikladno imenovan, Python ThreadPoolExecutorkoristi skup niti za izvršavanje asinkronih zadataka. Također podrazred od Executor, koristi definirani broj maksimalnih radničkih niti (barem pet prema zadanim postavkama, prema formuli min(32, os.cpu_count() + 4)) i ponovno koristi neaktivne niti prije pokretanja novih, što ga čini prilično učinkovitim.

Evo isječka Hydra s komentarima koji pokazuju kako Hydra koristi ThreadPoolExecutorza postizanje paralelnog višenitnog blaženstva:

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

Puni kod možete pogledati u Hydrinom spremištu GitHub.

Jedna nit u višenitke

Ako želite vidjeti puni učinak, usporedio sam vremena izvođenja za provjeru svoje web stranice između prototipa programa s jednom niti i višeglave - mislim višetovne - Hidre.

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 

Program s jednom niti, koji blokira I / O, izvodio se za oko sedamnaest minuta. Kad sam prvi put pokrenuo verziju s više niti, završila je za 1m13.358s - nakon malo profiliranja i podešavanja, trebalo je nešto manje od šesnaest sekundi.

Opet, točna vremena ne znače toliko puno; oni će se razlikovati ovisno o čimbenicima kao što su veličina web mjesta koje se indeksira, brzina vaše mreže i ravnoteža vašeg programa između općih troškova upravljanja nitima i blagodati paralelizma.

Važnija stvar i rezultat koji ću uzeti svaki dan je program koji brže pokreće neke redove veličine.