Пул от нишки и задачи
Накратко: Пулът от нишки е няколко работника пред опашка от задачи, вместо нова нишка за всяка задача; в Python това е ThreadPoolExecutor, а за сметки ProcessPoolExecutor.
Проблем
Нишка за всяка снимка
Магазинът пуска разпродажба. Продавачите качват снимки на продуктите, а за всяка трябва умалено копие за списъка с продукти. Смаляването е работа, която смята: минава през всеки пиксел (виж работа, която чака, и работа, която смята).
Първият вариант пуска нишка за всяка качена снимка:
threads = []for image in uploads: thread = threading.Thread(target=save_thumbnail, args=(image,)) thread.start() threads.append(thread)С 20 снимки при теб всичко минава: „Готови: 20 умалени снимки“. В първия час на разпродажбата идват 10 000 наведнъж.
- Всяка нишка иска свой стек и място в операционната система. Хиляди нишки изяждат паметта, а около лимита на системата
start()пада сRuntimeError: can't start new thread. - Дори да има памет, заради GIL само една нишка смята в даден момент. Останалите 9 999 чакат и се редуват.
Сървърът не забавя, а пада. И падналият сървър не обслужва и купувачите.
Идеята
Няколко работника и опашка
Пул от нишки (thread pool) е няколко работника, създадени веднъж, и опашка от задачи пред тях. Задачата е „смали тази снимка“. Свободният работник взима следващата задача от опашката, свършва я и се връща за нова.
- Броят работници е постоянен. 10 000 снимки не правят 10 000 нишки, а 10 000 задачи в опашката. Задачата е малък обект, нишката е скъпа.
- Работниците се ползват отново. Нишката не се създава и не умира за всяка снимка.
- Опашката поема пика. Когато качванията са повече, отколкото работниците смогват, задачите чакат, вместо да сриват сървъра.
Това е производител и потребител: кодът, който приема качванията, слага задачи в опашката, а работниците са потребителите.
С пула вече се срещна в урока за нишките: ThreadPoolExecutor, submit и Future.result(). Тук ще видим какво става вътре, как да подредиш резултатите, какво става с грешките и колко работника трябват.
Виж го
Трима работника
Смаляването тук е пауза, 2 ms за всеки KB, а размерът идва от името на снимката. Така изходът е един и същ при всяко пускане:
# Смаляване на снимка: тук е пауза, а размерът идва от името.def thumbnail(image): if image.startswith("повредена"): raise ValueError("повреден файл") kb = 20 + 3 * len(image) time.sleep(2 * kb / 1000) return kbОсем снимки, пул от трима работника:
with ThreadPoolExecutor(max_workers=3) as pool: sizes = list(pool.map(thumbnail, images))total = 0for image, kb in zip(images, sizes): print(f"{image}: {kb} KB") total += kbprint(f"Общо: {total} KB")Стъпка по стъпка, с думи
- 8 снимки чакат. Пулът е 3 нишки: ThreadPoolExecutor(max_workers=3).
- map прави всяка снимка задача в опашката. Трите нишки взимат първите три.
- Нишка 3 свършва първа: яке е третата снимка и резултатът отива на третото място. Работникът не спира: взима следващата задача.
- Опашката е празна. Нишка 1 свърши и няма какво да вземе, затова чака. Другите довършват последните снимки.
- map връща размерите по реда на снимките, не по реда на свършване. Общо 379 KB; краят на with затваря пула.
images = ["чанта.jpg", "обувки.jpg", "яке.jpg", "шапка.jpg", "часовник.jpg", "раница.jpg", "колан.jpg", "шал.jpg"]with ThreadPoolExecutor(max_workers=3) as pool: sizes = list(pool.map(thumbnail, images))total = 0for image, kb in zip(images, sizes): print(f"{image}: {kb} KB") total += kbprint(f"Общо: {total} KB")чанта.jpg: 47 KBобувки.jpg: 50 KBяке.jpg: 41 KBшапка.jpg: 47 KBчасовник.jpg: 56 KBраница.jpg: 50 KBколан.jpg: 47 KBшал.jpg: 41 KBОбщо: 379 KBThreadPoolExecutor(max_workers=3)прави три нишки и опашка пред тях.mapправи задача за всяка снимка и връща резултатите по реда на снимките, каквото и да е свършило първо. Ако не ги чакаш всичките, взимаш ги един по един отmap: той чака точно следващия.withзатваря пула накрая: не приема нови задачи, изчаква започнатите и спира нишките.
Нишките тук помагат, защото смаляването е пауза. Истинско смаляване смята, а заради GIL нишките не смятат едновременно. За него има процеси, малко по-надолу.
По-точно, за любопитните
Без max_workers пулът има min(32, os.cpu_count() + 4) нишки. Опашката му е без край: ако качванията винаги идват по-бързо, тя расте, докато паметта свърши.
Как е в другите езици: в Java пулът е ExecutorService (там нишките смятат истински паралелно), в C# басейнът е един за цялата програма, а Node няма готов пул от Worker-и.
Пробвай
Намери грешката
Колега е пренаписал смаляването с пул, а разпродажбата не стана по-бърза. Шест снимки отнемат колкото без пул:
start = time.monotonic()sizes = []with ThreadPoolExecutor(max_workers=3) as pool: for image in images: size = pool.submit(thumbnail, image) sizes.append(size.result())print(f"Отне над 0,5 s: {time.monotonic() - start > 0.5}")Отне над 0,5 s: TrueИзбери реда с грешката.
size.result() чака задачата веднага след submit, още в цикъла. Следващата снимка влиза в пула чак когато предишната е свършила: три нишки, а работи само една. Първо пусни всички (pool.submit в списък), после ги чакай, или направо pool.map.Шестте паузи са 582 ms една след друга. С поправката трите работника ги делят и на моята машина всичко отнема около 0,2 s. Времето зависи от машината, затова програмата печата само „над 0,5 s“: толкова поне отнемат шест паузи една след друга.
Идеята
Резултатите по реда на входа
Задачите свършват в ред, който не знаеш: зависи от снимките, от натоварването, от това кой работник е бил свободен. Затова никога не печатай „по реда на свършване“, ако искаш един и същ изход.
Два начина дават еднакъв резултат при всяко пускане:
- По реда на входа. Пазиш обещанията в списък по реда на задачите и ги чакаш в този ред. Така работи кодът отгоре.
- Сбор или брой. Ако ти трябва само общото, редът няма значение: 47 + 50 е колкото 50 + 47.
Понякога искаш резултатите по реда на свършване: за лента „готови 3 от 8“, или за да покажеш първите готови снимки. as_completed ги дава така. Тогава събираш само неща, които не зависят от реда:
count = 0total = 0with ThreadPoolExecutor(max_workers=3) as pool: futures = [pool.submit(thumbnail, image) for image in images] for future in as_completed(futures): total += future.result() count += 1print(f"Готови: {count} от {len(images)}, общо: {total} KB")Готови: 8 от 8, общо: 379 KBas_completed дава всяко Future, щом е готово. result() на него не чака.Идеята
Грешка в задачата
Една от снимките е повредена и thumbnail хвърля изключение. То не излиза в нишката, която е пуснала задачата: тя отдавна е продължила. Пулът го хваща и го пази в обещанието за резултата. Излиза, когато чакаш този резултат:
images = ["чанта.jpg", "обувки.jpg", "повредена.jpg", "яке.jpg"]with ThreadPoolExecutor(max_workers=3) as pool: futures = [pool.submit(thumbnail, image) for image in images]for image, future in zip(images, futures): try: print(f"{image}: {future.result()} KB") except ValueError as error: print(f"{image}: грешка: {error}")чанта.jpg: 47 KBобувки.jpg: 50 KBповредена.jpg: грешка: повреден файляке.jpg: 41 KBresult() хвърля наново същото изключение (ValueError). Другите снимки не страдат. С pool.map грешката излиза, когато обхождането стигне до повредената снимка, и резултатите след нея се губят. Затова, ако очакваш грешки, ползвай submit и result() за всяка.Пробвай
Къде отиде грешката
Втората снимка е повредена. Какво ще отпечата програмата?
images = ["чанта.jpg", "повредена.jpg", "яке.jpg"]with ThreadPoolExecutor(max_workers=3) as pool: for image in images: pool.submit(thumbnail, image)print(f"Готово: {len(images)} снимки")result(), затова изключението си остава във Future и никой не го вижда. Нищо на екрана, нищо в лога: на снимката просто ѝ няма умаленото копие. Затова пази обещанията и ги чакай.Идеята
Колко работника
Зависи от това какво правят:
- Работа, която смята (смаляване, PDF, компресия): колкото са ядрата. Девети работник на 8 ядра няма къде да върви, само се редува с другите.
- Работа, която чака (имейл, плащане, база): повече. Докато един работник чака мрежата, ядрото е свободно за друг. Ако задачата чака W ms и смята C ms, на ядро се събират около 1 + W / C работника.
def pool_size(cores, wait_ms, compute_ms): return cores * (1 + wait_ms // compute_ms)
print(f"Снимки (само смятане): {pool_size(8, 0, 50)}")print(f"Имейли (90 ms чакане, 10 ms смятане): {pool_size(8, 90, 10)}")Снимки (само смятане): 8Имейли (90 ms чакане, 10 ms смятане): 80Броят ядра дава os.cpu_count(). Формулата е начало, не закон: измери. Ако имейлите ползват един сървър с ограничение от 20 връзки, 80 работника само ще чакат на опашка пред него.
В Python има уловка: заради GIL нишките не смятат едновременно. За работа, която смята, пулът е от процеси: ProcessPoolExecutor, всеки процес със свой Python и свой GIL. Задачите и резултатите минават между процесите като копия:
from concurrent.futures import ProcessPoolExecutor
def checksum(invoice): return sum((invoice * 31 + i) % 256 for i in range(200_000))
if __name__ == "__main__": invoices = [1001, 1002, 1003, 1004] with ProcessPoolExecutor(max_workers=4) as pool: sums = list(pool.map(checksum, invoices)) for invoice, total in zip(invoices, sums): print(f"Фактура {invoice}: {total}")Фактура 1001: 25497376Фактура 1002: 25499360Фактура 1003: 25501344Фактура 1004: 25503328if __name__ == "__main__": пази главния код да не се изпълни наново във всеки процес: на macOS и Windows процесът тръгва, като зарежда файла отначало.
Пробвай
Попълни
Колко работника за задача, която чака waitMs и смята computeMs?
def pool_size(cores, wait_ms, compute_ms):
return cores * (1 + )На ядро се събират 1 + W / C работника: докато един смята, другите чакат. При 90 ms чакане и 10 ms смятане това са 10 на ядро, 80 на 8 ядра.
По-точно, за любопитните
За работа, която чака, в Python има и asyncio: една нишка чака хиляди заявки (урокът async). Пулът от нишки остава за библиотеки, които чакат, без да могат await.
Упражнение
Фактурите на разпродажбата
Реши задачата в редактора си.
След разпродажбата всяка поръчка иска фактура. invoice(amount) прави PDF-а (тук е пауза от 50 ms) и връща сумата с 20% ДДС. Поръчка със сума 0 или по-малко е грешна и функцията хвърля изключение с текст „невалидна сума“.
Поръчките са 1001 до 1008 със суми 50, 125, 80, 0, 240, 35, 90 и 15 €. Направи фактурите в пул от 4 работника. Отпечатай ги по реда на поръчките, а грешната с текста на грешката. Накрая: сборът на добрите фактури и колко са с грешка.
Поръчка 1001: 60 €Поръчка 1002: 150 €Поръчка 1003: 96 €Поръчка 1004: грешка: невалидна сумаПоръчка 1005: 288 €Поръчка 1006: 42 €Поръчка 1007: 108 €Поръчка 1008: 18 €Общо: 762 €, с грешка: 1Още задачи
- Ограничена опашка. Направи пула така, че в опашката да чакат най-много 10 задачи. Какво става с единадесетата? (threading.BoundedSemaphore около submit)
- Напредък. Отпечатвай „готови 3 от 8“ след всяка свършена фактура, а накрая пак подредения списък.
- Таймаут. Фактура, която се прави над 1 секунда, да се брои за грешна, без да спира останалите.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Нишки · Защо нишки · Race condition · Производител и потребител
- Продължава в: async не е нишка · Как се тества конкурентен код · Когато съседът е бавен: timeout, retry, circuit breaker
- Задачи по темата: Нишки и race conditions