Потоки, процессы и asyncio
Замерил всё три подхода на одной машине с двумя ядрами. Счётная задача: один поток 406,3 миллисекунды, два потока 836,8 (ускорения нет), два процесса 507,7 - ускорение в 1,6 раза от двойной работы. Цена запуска: процесс 5,5 миллисекунды, поток 0,5 - в одиннадцать раз дешевле.
Стержень: выбор определяется тем, чего в задаче больше - ожидания или счёта; и тем, сколько вы готовы платить за изоляцию.
// Формулировки: «потоки или процессы?», «когда async, а когда потоки?», «сколько воркеров ставить?»
Три инструмента и их цена
Асинхронность: один поток, задания уступают друг другу на ожидании. Дёшево по памяти, тысячи одновременных операций, но требует, чтобы ВЕСЬ код по пути был неблокирующим - одна синхронная строка ложит цикл.
Потоки: настоящие потоки операционной системы, переключается сама система. Работают с обычным синхронным кодом, ускоряют ожидание, но не ускоряют счёт из-за глобальной блокировки. Стоят памяти на стек каждого и требуют думать о гонках.
// Процессы: у каждого свой интерпретатор, своя память, свой замок - настоящая параллельность счёта. Плата: запуск в одиннадцать раз дороже потока (5,5 против 0,5 миллисекунды по моему замеру), память не общая, обмен данными идёт через передачу, а всё передаваемое должно поддаваться упаковке. Мой замер ускорения - 1,6 раза на двух ядрах, а не 2,0: часть времени уходит как раз на запуск и обмен.
- асинхронность
- один поток, задания уступают на ожидании; весь код должен быть неблокирующим
- поток
- дёшев в запуске, ускоряет ожидание, но не счёт
- процесс
- своя память и свой интерпретатор; настоящая параллельность счёта
Как выбирать
Веб-сервис, который ходит в базу и в чужие сервисы, - это ожидание. Тут выигрывает асинхронность, а потоки идут вторым вариантом там, где библиотеки синхронные и переписывать их некогда.
Обработка данных, картинок, моделей - это счёт. Тут нужны процессы, и число их берут по числу ядер. Смешанный случай (запрос ждёт базу, а потом что-то считает) решают разделением: ожидание в асинхронном коде, тяжёлый счёт - в отдельный пул процессов.
// Про число рабочих процессов веб-сервиса. Для счётной нагрузки отправная точка - по числу ядер. Для ожидающей асинхронной - тоже по числу ядер, а масштаб даёт сама асинхронность внутри каждого. Формулы вида «два умножить на ядра плюс один» пришли из синхронных серверов и к асинхронным отношения не имеют. И в контейнере число ядер надо смотреть по ЛИМИТУ, а не по машине: приложение видит все ядра хоста и легко поднимет вдесятеро больше процессов, чем ему выделено.
- число рабочих процессов
- отправная точка - число доступных ядер, а не универсальная формула
- лимит против машины
- в контейнере считать надо по выделенным ядрам, а не по хостовым
Что общего и чем это опасно
У потоков общая память. Это удобно (не надо ничего передавать) и опасно (нужны замки, легко получить гонку). У процессов памяти нет общей вовсе - данные передаются копированием, и это отдельная статья расходов: передать большой массив между процессами может стоить дороже, чем посчитать его.
Отсюда типовая ошибка: разложить обработку миллиона мелких элементов по процессам и получить замедление, потому что упаковка и передача съели весь выигрыш. Лечится укрупнением порций: передавать не по элементу, а пачками.
// И отдельная вещь про запуск процессов: способ запуска влияет на поведение. При копировании родителя дочерний процесс наследует всё, включая открытые соединения к базе, и это ломается неожиданно. Поэтому соединения открывают ПОСЛЕ запуска дочерних процессов, а не до.
- передача данных
- между процессами данные копируются; на мелких порциях съедает выигрыш
- наследование соединений
- дочерний процесс получает открытые соединения родителя; их открывают после запуска
Как отвечать: «Потоки, процессы или async - как выбрать?»
По тому, чего в задаче больше: ожидания или счёта. Я мерил это на одной машине с двумя ядрами. Счётная задача в один поток - четыреста миллисекунд, вдвое больше работы в двух потоках - восемьсот сорок, ускорения нет из-за глобальной блокировки. Та же работа в двух процессах - пятьсот, ускорение в полтора раза. А ожидание в потоках ускоряется почти линейно. Поэтому: ждём - асинхронность или потоки; считаем - процессы. Асинхронность дешевле всех по памяти и тянет тысячи одновременных операций, но требует, чтобы весь код по пути был неблокирующим. Процессы дают настоящую параллельность, но запуск дороже потока раз в одиннадцать, а данные между ними копируются - на мелких порциях передача съедает весь выигрыш. Смешанный случай решаю разделением: ожидание в асинхронном коде, тяжёлый счёт в отдельный пул процессов.
Ответ выбирает по природе задачи, подкрепляет тремя замерами и называет цену каждого варианта. Замечание про копирование данных - то, обо что спотыкаются при первом переходе на процессы.
На чём валятся
- −Берут потоки для счёта и не получают ускорения.
- −Раскладывают по процессам мелкие порции: передача данных съедает выигрыш.
- −Оставляют в асинхронном коде синхронный вызов и теряют всю выгоду.
- −Считают число рабочих процессов по ядрам хоста, а не по лимиту контейнера.
- −Открывают соединения к базе до запуска дочерних процессов.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 14, остальные разбираются в тренажёре.
- Как выбрать модель конкаренси по типу нагрузки?A)asyncio одинаково подходит и под CPU-, и под I/O-нагрузкуB)I/O-bound → потоки/asyncio, CPU-bound → процессыC)Процессы — они универсально быстрее потоковD)CPU-bound → потоки, I/O-bound → процессы
показать ответ и разбор
+B)I/O-bound → потоки/asyncio, CPU-bound → процессы// разбор: Эвристика: на I/O-bound (сеть, диск, БД) GIL отпускается на ожидании — берут потоки или asyncio (дёшево держать тысячи ожиданий). На CPU-bound Python-счёте GIL мешает — нужны процессы (multiprocessing) или вынос счёта в C. asyncio на CPU-счёте не поможет: он однопоточный.
- Почему пул процессов плох для множества мелких коротких задач?A)Старт процесса и пиклинг данных съедают выгодуB)GIL блокирует процессы между собойC)Процессы в принципе не умеют считать параллельноD)Процессы делят одну память и мешают
показать ответ и разбор
+A)Старт процесса и пиклинг данных съедают выгоду// разбор: На мелких задачах накладные доминируют: спавн/форк процесса, сериализация аргументов и результата через pickle, IPC. У потока эти издержки куда меньше (общая память, дешёвый старт). Процессы окупаются на крупных CPU-порциях; мелочь лучше батчить (chunksize) или держать в потоках/async.
- Почему в
ProcessPoolExecutorнельзя передать лямбду или локальную функцию?A)Лямбды медленнее обычных функцийB)Процессы вообще не умеют исполнять переданные функцииC)Функция и аргументы пиклятся, а лямбда не пиклитсяD)GIL запрещает лямбды в процессахпоказать ответ и разбор
+C)Функция и аргументы пиклятся, а лямбда не пиклится// разбор: Чтобы отправить работу в другой процесс, Python пиклит вызываемое и аргументы. Лямбды, локальные функции и незакрытые ресурсы (сокеты, соединения) не сериализуются — PicklingError. Отсюда требование: функции верхнего уровня и пиклимые аргументы. У потоков этого нет — общая память.
- Держать 10 000 медленных соединений: почему asyncio уместнее пула потоков?A)Потоки не отпускают GIL на I/OB)asyncio задействует все ядраC)Потоки в принципе не приспособлены к сетевому I/OD)10k ОС-потоков дороги по памяти и переключениям
показать ответ и разбор
+D)10k ОС-потоков дороги по памяти и переключениям// разбор: Каждый ОС-поток берёт мегабайты стека и грузит планировщик на переключениях контекста — 10k потоков съедят память и время ядра. asyncio держит 10k ожиданий как лёгкие корутины в одном потоке, мультиплексируя через epoll. Для массового I/O-конкаренси это дешевле на порядок.
ThreadPoolExecutorиProcessPoolExecutor— один API, когда какой?A)Разницы нет — API же одинB)Thread для I/O-bound, Process для CPU-boundC)Process по умолчанию — он безопаснееD)Thread для CPU, Process для I/Oпоказать ответ и разбор
+B)Thread для I/O-bound, Process для CPU-bound// разбор: concurrent.futures даёт единый интерфейс (submit/map), но модель разная: ThreadPoolExecutor — потоки (дёшево, общая память, хорош для I/O под отпускаемый GIL), ProcessPoolExecutor — процессы (обходят GIL, платят пиклингом, хорош для CPU-счёта). Код одинаков — выбор пула диктует тип нагрузки.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.