сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Асинхронность в Python

Потоки, процессы и asyncio

Потоки, процессы и асинхронность

Замерил всё три подхода на одной машине с двумя ядрами. Счётная задача: один поток 406,3 миллисекунды, два потока 836,8 (ускорения нет), два процесса 507,7 - ускорение в 1,6 раза от двойной работы. Цена запуска: процесс 5,5 миллисекунды, поток 0,5 - в одиннадцать раз дешевле.

Стержень: выбор определяется тем, чего в задаче больше - ожидания или счёта; и тем, сколько вы готовы платить за изоляцию.

// Формулировки: «потоки или процессы?», «когда async, а когда потоки?», «сколько воркеров ставить?»

Три инструмента и их цена

Асинхронность: один поток, задания уступают друг другу на ожидании. Дёшево по памяти, тысячи одновременных операций, но требует, чтобы ВЕСЬ код по пути был неблокирующим - одна синхронная строка ложит цикл.

Потоки: настоящие потоки операционной системы, переключается сама система. Работают с обычным синхронным кодом, ускоряют ожидание, но не ускоряют счёт из-за глобальной блокировки. Стоят памяти на стек каждого и требуют думать о гонках.

// Процессы: у каждого свой интерпретатор, своя память, свой замок - настоящая параллельность счёта. Плата: запуск в одиннадцать раз дороже потока (5,5 против 0,5 миллисекунды по моему замеру), память не общая, обмен данными идёт через передачу, а всё передаваемое должно поддаваться упаковке. Мой замер ускорения - 1,6 раза на двух ядрах, а не 2,0: часть времени уходит как раз на запуск и обмен.

асинхронность
один поток, задания уступают на ожидании; весь код должен быть неблокирующим
поток
дёшев в запуске, ускоряет ожидание, но не счёт
процесс
своя память и свой интерпретатор; настоящая параллельность счёта

Как выбирать

Веб-сервис, который ходит в базу и в чужие сервисы, - это ожидание. Тут выигрывает асинхронность, а потоки идут вторым вариантом там, где библиотеки синхронные и переписывать их некогда.

Обработка данных, картинок, моделей - это счёт. Тут нужны процессы, и число их берут по числу ядер. Смешанный случай (запрос ждёт базу, а потом что-то считает) решают разделением: ожидание в асинхронном коде, тяжёлый счёт - в отдельный пул процессов.

// Про число рабочих процессов веб-сервиса. Для счётной нагрузки отправная точка - по числу ядер. Для ожидающей асинхронной - тоже по числу ядер, а масштаб даёт сама асинхронность внутри каждого. Формулы вида «два умножить на ядра плюс один» пришли из синхронных серверов и к асинхронным отношения не имеют. И в контейнере число ядер надо смотреть по ЛИМИТУ, а не по машине: приложение видит все ядра хоста и легко поднимет вдесятеро больше процессов, чем ему выделено.

число рабочих процессов
отправная точка - число доступных ядер, а не универсальная формула
лимит против машины
в контейнере считать надо по выделенным ядрам, а не по хостовым

Что общего и чем это опасно

У потоков общая память. Это удобно (не надо ничего передавать) и опасно (нужны замки, легко получить гонку). У процессов памяти нет общей вовсе - данные передаются копированием, и это отдельная статья расходов: передать большой массив между процессами может стоить дороже, чем посчитать его.

Отсюда типовая ошибка: разложить обработку миллиона мелких элементов по процессам и получить замедление, потому что упаковка и передача съели весь выигрыш. Лечится укрупнением порций: передавать не по элементу, а пачками.

// И отдельная вещь про запуск процессов: способ запуска влияет на поведение. При копировании родителя дочерний процесс наследует всё, включая открытые соединения к базе, и это ломается неожиданно. Поэтому соединения открывают ПОСЛЕ запуска дочерних процессов, а не до.

передача данных
между процессами данные копируются; на мелких порциях съедает выигрыш
наследование соединений
дочерний процесс получает открытые соединения родителя; их открывают после запуска

Как отвечать: «Потоки, процессы или async - как выбрать?»

По тому, чего в задаче больше: ожидания или счёта. Я мерил это на одной машине с двумя ядрами. Счётная задача в один поток - четыреста миллисекунд, вдвое больше работы в двух потоках - восемьсот сорок, ускорения нет из-за глобальной блокировки. Та же работа в двух процессах - пятьсот, ускорение в полтора раза. А ожидание в потоках ускоряется почти линейно. Поэтому: ждём - асинхронность или потоки; считаем - процессы. Асинхронность дешевле всех по памяти и тянет тысячи одновременных операций, но требует, чтобы весь код по пути был неблокирующим. Процессы дают настоящую параллельность, но запуск дороже потока раз в одиннадцать, а данные между ними копируются - на мелких порциях передача съедает весь выигрыш. Смешанный случай решаю разделением: ожидание в асинхронном коде, тяжёлый счёт в отдельный пул процессов.

Ответ выбирает по природе задачи, подкрепляет тремя замерами и называет цену каждого варианта. Замечание про копирование данных - то, обо что спотыкаются при первом переходе на процессы.

На чём валятся

  • Берут потоки для счёта и не получают ускорения.
  • Раскладывают по процессам мелкие порции: передача данных съедает выигрыш.
  • Оставляют в асинхронном коде синхронный вызов и теряют всю выгоду.
  • Считают число рабочих процессов по ядрам хоста, а не по лимиту контейнера.
  • Открывают соединения к базе до запуска дочерних процессов.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 14, остальные разбираются в тренажёре.

  1. #thread_vs_process1 / 5
    Как выбрать модель конкаренси по типу нагрузки?
    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-счёте не поможет: он однопоточный.

  2. #thread_vs_process2 / 5
    Почему пул процессов плох для множества мелких коротких задач?
    A)Старт процесса и пиклинг данных съедают выгоду
    B)GIL блокирует процессы между собой
    C)Процессы в принципе не умеют считать параллельно
    D)Процессы делят одну память и мешают
    показать ответ и разбор
    +A)Старт процесса и пиклинг данных съедают выгоду

    // разбор: На мелких задачах накладные доминируют: спавн/форк процесса, сериализация аргументов и результата через pickle, IPC. У потока эти издержки куда меньше (общая память, дешёвый старт). Процессы окупаются на крупных CPU-порциях; мелочь лучше батчить (chunksize) или держать в потоках/async.

  3. #thread_vs_process3 / 5
    Почему в ProcessPoolExecutor нельзя передать лямбду или локальную функцию?
    A)Лямбды медленнее обычных функций
    B)Процессы вообще не умеют исполнять переданные функции
    C)Функция и аргументы пиклятся, а лямбда не пиклится
    D)GIL запрещает лямбды в процессах
    показать ответ и разбор
    +C)Функция и аргументы пиклятся, а лямбда не пиклится

    // разбор: Чтобы отправить работу в другой процесс, Python пиклит вызываемое и аргументы. Лямбды, локальные функции и незакрытые ресурсы (сокеты, соединения) не сериализуются — PicklingError. Отсюда требование: функции верхнего уровня и пиклимые аргументы. У потоков этого нет — общая память.

  4. #thread_vs_process4 / 5
    Держать 10 000 медленных соединений: почему asyncio уместнее пула потоков?
    A)Потоки не отпускают GIL на I/O
    B)asyncio задействует все ядра
    C)Потоки в принципе не приспособлены к сетевому I/O
    D)10k ОС-потоков дороги по памяти и переключениям
    показать ответ и разбор
    +D)10k ОС-потоков дороги по памяти и переключениям

    // разбор: Каждый ОС-поток берёт мегабайты стека и грузит планировщик на переключениях контекста — 10k потоков съедят память и время ядра. asyncio держит 10k ожиданий как лёгкие корутины в одном потоке, мультиплексируя через epoll. Для массового I/O-конкаренси это дешевле на порядок.

  5. #thread_vs_process5 / 5
    ThreadPoolExecutor и ProcessPoolExecutor — один API, когда какой?
    A)Разницы нет — API же один
    B)Thread для I/O-bound, Process для CPU-bound
    C)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-счёта). Код одинаков — выбор пула диктует тип нагрузки.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.