Багатопотоковий Python: ковзання через вузьке місце вводу-виводу?

Як користування перевагами паралелізму в Python може зробити ваше програмне забезпечення на порядок швидшим.

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

Кілька потоків у Python - це трохи приємна тема (не шкода), оскільки інтерпретатор Python насправді не дозволяє виконувати декілька потоків одночасно.

Глобальний інтерпретатор блокування Python, або GIL, перешкоджає виконанню кількома потоками байт-кодів Python одночасно. Кожен потік, який хоче виконати, повинен спочатку дочекатися, поки GIL буде звільнений поточно виконуваним потоком. GIL - це, в основному, мікрофон на низькобюджетній панелі конференцій, за винятком випадків, коли ніхто не отримує крику.

Це має перевагу у запобіганні перегоновим умовам. Однак йому не вистачає переваг у продуктивності, які надає паралельне виконання кількох завдань. (Якщо ви хочете оновити паралелізм, паралелізм та багатопотоковість, див. Паралельність, паралелізм та багато ниток Санта Клауса.)

Хоча я віддаю перевагу Go за його зручні першокласні примітиви, що підтримують паралельність (див. Goroutines), одержувачам цього проекту було зручніше з Python. Я сприйняв це як можливість перевірити та дослідити!

Одночасне виконання декількох завдань у Python не є неможливим; потрібно лише трохи додаткової роботи. Для Hydra головна перевага полягає у подоланні вузького місця вводу / виводу (I / O).

Щоб перевірити веб-сторінки, Гідрі потрібно вийти в Інтернет і забрати їх. Якщо порівнювати із завданнями, які виконує лише центральний процесор, вихід у мережу відбувається порівняно повільніше. Наскільки повільно?

Ось приблизні терміни виконання завдань на типовому ПК:

ЗавданняЧас
ЦПвиконати типову інструкцію1/100000000 сек = 1 наносек
ЦПотримати з кеш-пам'яті L1 -0,5 наносек
ЦПнеправильне прогнозування галузі5 наносек
ЦПотримати з кеш-пам'яті L2 -7 наносек
ОЗПБлокування / розблокування Mutex25 наносек
ОЗПотримати з основної пам'яті100 наносек
Мережанадсилати 2K байтів через мережу 1Gbps20000 наносек
ОЗПчитати 1 Мб послідовно з пам'яті250 000 наносек
Дискотримати з нового розташування диска (шукати)8 000 000 наносек (8 мс)
Дискчитати 1 Мб послідовно з диска20 000 000 наносек (20 мс)
Мережавідправити пакет США до Європи і назад150 000 000 наносек (150 мс)

Пітер Норвіг вперше опублікував ці цифри кілька років тому в "Навчи себе програмувати за десять років". Оскільки комп’ютери та їх компоненти змінюються з кожним роком, точні цифри, наведені вище, не мають значення. Ці цифри допомагають проілюструвати різницю між операціями в порядках величини.

Порівняйте різницю між завантаженням з основної пам'яті та надсиланням простого пакету через Інтернет. Хоча обидві ці операції відбуваються менш ніж за мить (буквально) з людської точки зору, ви бачите, що надсилання простого пакету через Інтернет у мільйон разів повільніше, ніж отримання з оперативної пам'яті. Різниця в тому, що в однопотоковій програмі можна швидко накопичуватись, утворюючи клопітні вузькі місця.

У Hydra завдання аналізу даних відповідей та збору результатів у звіт є відносно швидким, оскільки все це відбувається на центральному процесорі. Найповільнішою частиною виконання програми понад шість порядків є мережева затримка. Hydra потрібно отримувати не тільки пакети, але й цілі веб-сторінки!

Одним із способів поліпшення продуктивності Hydra є пошук способу виконання завдань із вибору сторінки без блокування основного потоку.

Python має кілька варіантів паралельного виконання завдань: кілька процесів або кілька потоків. Ці методи дозволяють обійти GIL і пришвидшити виконання кількома різними способами.

Кілька процесів

Для виконання паралельних завдань за допомогою декількох процесів ви можете використовувати Python's ProcessPoolExecutor. Конкретний підклас Executorвід concurrent.futuresмодуля, ProcessPoolExecutorвикористовує пул процесів породжені з multiprocessingмодулем , щоб уникнути GIL.

Цей параметр використовує робочі підпроцеси, які за замовчуванням встановлюють кількість процесорів на машині. multiprocessingМодуль дозволяє максимально распараллелить виконання функції через процеси, які можуть реально прискорити обчислювальний палітурка (або CPU палітурки) завдання.

Оскільки основним вузьким місцем для Hydra є введення / виведення, а не обробка, яку повинен виконувати центральний процесор, мені краще обслуговувати кілька потоків.

Кілька ниток

Відповідно названий, 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) 

Ви можете переглянути повний код у сховищі GidHub Hydra.

Одинарний потік до багатопоточності

Якщо ви хочете побачити повний ефект, я порівняв час запуску для перевірки мого веб-сайту між прототипом однопоточної програми та багатоголовою - я маю на увазі багатопотоковою - 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 

Однопоточна програма, яка блокує введення / виведення, працювала приблизно за сімнадцять хвилин. Коли я вперше запустив багатопоточну версію, вона закінчилася за 1m13.358s - після деякого профілювання та налаштування це зайняло трохи менше шістнадцяти секунд.

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

Найважливіше, і результат, який я візьму в будь-який день, - це програма, яка працює на кілька порядків швидше.