Многонишков Python: прескачане през I / O пречка?

Как да се възползвате от паралелизма в Python може да направи вашите софтуерни порядъци по-бързи.

Наскоро разработих проект, който нарекох Hydra: многопоточна проверка на връзки, написана на Python. За разлика от много обхождащи сайтове на Python, които открих по време на изследването, Hydra използва само стандартни библиотеки, без външни зависимости като BeautifulSoup. Той е предвиден да се изпълнява като част от CI / CD процес, така че част от успеха му зависи от бързината.

Няколко нишки в Python са малко хаплива тема (не съжалявам), тъй като интерпретаторът на Python всъщност не позволява на няколко нишки да се изпълняват едновременно.

Глобалното заключване на интерпретатора на Python, или GIL, предотвратява едновременното изпълнение на множество нишки на байт кодове на Python. Всяка нишка, която иска да се изпълни, трябва първо да изчака GIL да бъде освободен от текущо изпълняващата се нишка. GIL е почти микрофонът в нискобюджетен конферентен панел, освен там, където никой не може да вика.

Това има предимството да предотврати състезателни условия. На него обаче липсват предимствата на производителността, предоставени при паралелно изпълнение на множество задачи. (Ако искате опресняване на паралелизма, паралелизма и многопоточността, вижте Паралелност, паралелизъм и многото нишки на Дядо Коледа.)

Докато предпочитам Go за удобните първокласни примитиви, които поддържат едновременност (вижте Goroutines), получателите на този проект се чувстваха по-удобно с Python. Приех го като възможност да тествам и изследвам!

Едновременното изпълнение на множество задачи в Python не е невъзможно; просто отнема малко допълнителна работа. За Hydra основното предимство е в преодоляването на тесните места за вход / изход (I / O).

За да може уеб страниците да бъдат проверени, Hydra трябва да излезе в Интернет и да ги вземе. В сравнение със задачи, които се изпълняват само от процесора, излизането през мрежата е сравнително по-бавно. Колко бавно?

Ето приблизителни срокове за задачи, изпълнявани на типичен компютър:

ЗадачаВреме
процесоризпълнете типична инструкция1/1 000 000 000 сек = 1 наносек
процесоризвличане от кеш памет L10,5 наносек
процесорклонова грешка5 наносек
процесоризвличане от L2 кеш памет7 наносек
RAMЗаключване / отключване на Mutex25 наносек
RAMизвличане от основната памет100 наносек
Мрежаизпраща 2K байта през 1Gbps мрежа20 000 наносек
RAMчетете 1MB последователно от паметта250 000 наносек
Дискизвличане от ново местоположение на диска (търсене)8 000 000 наносекунди (8 ms)
Дискчетете 1MB последователно от диска20 000 000 наносекунди (20 ms)
Мрежаизпрати пакет САЩ до Европа и обратно150 000 000 наносекунди (150 ms)

Питър Норвиг публикува тези цифри за пръв път преди няколко години в „Научи себе си за програмиране след десет години“. Тъй като компютрите и техните компоненти се сменят с всяка година, точните цифри, показани по-горе, не са важни. Това, което тези числа помагат да се илюстрира, е разликата в порядъка на величината между операциите.

Сравнете разликата между извличането от основната памет и изпращането на обикновен пакет през интернет. Докато и двете операции се случват за по-малко от мига на окото (буквално) от човешка гледна точка, можете да видите, че изпращането на обикновен пакет през Интернет е над милион пъти по-бавно от извличането от RAM. Разликата е, че в една нишка програма може бързо да се натрупва, за да формира обезпокоителни тесни места.

В Hydra задачата да се анализират данните за отговорите и да се съберат резултатите в отчет е относително бърза, тъй като всичко се случва на процесора. Най-бавната част от изпълнението на програмата с над шест порядъка е латентността на мрежата. Hydra се нуждае не само от пакети, но и от цели уеб страници!

Един от начините за подобряване на производителността на Hydra е да се намери начин за изпълнение на задачите за извличане на страници, без да се блокира основната нишка.

Python има няколко опции за паралелно изпълнение на задачи: множество процеси или множество нишки. Тези методи ви позволяват да заобиколите GIL и да ускорите изпълнението по няколко различни начина.

Множество процеси

За да изпълнявате паралелни задачи с помощта на множество процеси, можете да използвате Python's ProcessPoolExecutor. Конкретен подклас на Executorот concurrent.futuresмодула, ProcessPoolExecutorизползва набор от процеси, породени с multiprocessingмодула, за да се избегне GIL.

Тази опция използва работни подпроцеси, които по подразбиране задават броя на процесорите на машината. В multiprocessingмодула, който позволява постигане на максимално паралелизация изпълнение функция през процеси, които наистина може да се ускори изчислителни обвързан (или CPU обвързани) задачи.

Тъй като основното тесно място за Hydra е I / O, а не обработката, която трябва да се извършва от процесора, по-добре съм да се обслужвам, като използвам множество нишки.

Множество нишки

С подходящо име, Python ThreadPoolExecutorизползва набор от нишки за изпълнение на асинхронни задачи. Също така подклас от Executor, той използва определен брой максимални работни нишки (поне пет по подразбиране, съгласно формулата min(32, os.cpu_count() + 4)) и използва повторно неактивни нишки, преди да стартира нови, което го прави доста ефективен.

Ето фрагмент от Hydra с коментари, показващи как Hydra използва ThreadPoolExecutorза постигане на паралелно многонишко блаженство:

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

Можете да видите пълния код в хранилището на Hydra GitHub.

Една нишка към многопоточност

Ако искате да видите пълния ефект, сравних времената за проверка на уебсайта си между прототипна програма с една нишка и многоглавата - имам предвид многонишкова - 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 

Програмата с една нишка, която блокира I / O, се изпълнява за около седемнадесет минути. Когато за първи път стартирах мултипоточната версия, тя завърши за 1m13.358s - след известно профилиране и настройка отне малко под шестнадесет секунди.

Отново, точните времена не означават толкова много; те ще варират в зависимост от фактори като размера на обхождания сайт, скоростта на вашата мрежа и баланса на вашата програма между режийните разходи за управление на нишките и предимствата на паралелизма.

По-важното и резултатът, който ще взема всеки ден, е програма, която изпълнява някои порядъци по-бързо.