open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 05 · 02

Потоки против процессов: выбор по нагрузке, гонки под GIL и дедлок fork+threads

ThreadPoolExecutor — для I/O-bound, ProcessPoolExecutor — для CPU-bound, минус налог pickle в обе стороны. GIL не отменяет гонки: check-then-act и += всё ещё требуют блокировок. fork копирует замки без потоков-владельцев, поэтому fork+потоки — дедлок; spawn — безопасный дефолт.

PY Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Сервис отчётов работал год: Flask-приложение с фоновым потоком, сбрасывающим метрики, плюс ProcessPoolExecutor для рендера PDF. Потом он переехал с маковского ноутбука в Linux-контейнер и начал зависать — не падать, а зависать — примерно раз на двести рендеров. py-spy dump на застрявшем воркере показал вечное ожидание на внутреннем замке logging, в дочернем процессе ровно с одним потоком. Причина — хрестоматийная, хоть и не та, о которой думают при слове «fork»: на Linux multiprocessing по умолчанию использовал fork, который клонирует память родителя в случайный момент — включая замок logging, который как раз держал поток метрик. Ребёнок наследует запертый замок, но не поток, который его отпер бы. Первый рендер в таком ребёнке трогает logging, ждёт замок, владельца которого в этом процессе не существует, и ждёт до отката деплоя. Одна строка — mp_context=multiprocessing.get_context("spawn") — закрыла трёхдневное расследование.

Выбор по нагрузке: где какой пул действительно выигрывает

Когда тянетесь к concurrent.futures, первый вопрос — не «сколько воркеров?», а «что воркер делает, пока ждёт?». Ответ определяет, какой пул вас спасёт, а какой тихо сделает всё хуже.

concurrent.futures даёт обоим пулам один интерфейс, но физика разная. ThreadPoolExecutor делит память процесса: отправка задачи стоит микросекунды, передача воркеру DataFrame на 500 МБ не стоит ничего — это ссылка. Прошлый урок объяснил ограничение: потоки перекрываются только там, где GIL отпущен, поэтому пул потоков — для I/O-bound работы: HTTP-вызовы, запросы к БД, чтение из S3 — там, где воркеры живут заблокированными в ядре. Размер прощает ошибки (дефолт — min(32, cpu_count + 4)); для I/O можно держать десятки потоков на ядро. ProcessPoolExecutor покупает настоящий CPU-параллелизм, платя ренту сериализацией: каждый аргумент и каждый результат пересекают границу процесса через pickle. Этот налог — самая частая причина, по которой «распараллеленный» код становится медленнее. Честные числа: pickle ~100 МБ обычных объектов Python занимает порядка секунды в каждую сторону, так что отправка большого DataFrame воркеру с 200 мс вычислений — чистый проигрыш в секунды. Рабочий паттерн: посылать маленькие описания работы (путь к файлу, диапазон ID), позволять воркеру самому грузить данные, возвращать маленькие результаты. И батчевать — executor.map(fn, items, chunksize=100) амортизирует пер-задачные IPC-накладные, которые иначе доминируют, когда каждый элемент стоит микросекунды.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import multiprocessing as mp

# I/O-bound: потоки. 32 воркера, большую часть времени заблокированных в ядре.
with ThreadPoolExecutor(max_workers=32) as pool:
    pages = list(pool.map(fetch_url, urls))          # реально перекрываются

# CPU-bound: процессы. Передавай пути, а не данные — аргументы идут через pickle.
ctx = mp.get_context("spawn")                        # explicit start method
with ProcessPoolExecutor(max_workers=8, mp_context=ctx) as pool:
    results = list(pool.map(render_pdf, paths, chunksize=4))

GIL не делает ваш код потокобезопасным

Опасная полуправда: «потоки в Python не могут гнаться из-за GIL». GIL гарантирует один байткод за раз — но count += 1 это три байткода (загрузить, сложить, сохранить), и 5-мс переключение может приземлиться между любыми двумя. Два потока читают одно значение, оба прибавляют единицу, оба записывают: инкремент потерян. В быстром тесте два потока с миллионом незащищённых += каждый на 3.9 и раньше заметно недобирают до 2 000 000 — современный CPython (3.10+) переключается только между целыми строками байткода, и именно этот демо-пример обычно проходит, что только хуже: гонка осталась, просто стала реже и невоспроизводимой в тестах. Главный убийца — check-then-act: if key not in cache: cache[key] = expensive() — два потока проходят проверку до того, как любой из них запишет, и дорогой вызов исполняется дважды (или хуже — наружу торчит полуинициализированное состояние). Отдельные операции dict и list в CPython атомарны, но любая последовательность операций, которая должна быть согласованной, требует threading.Lock. Дисциплина та же, что в любом языке: найдите инварианты, охватывающие больше одной операции, и охраняйте их; GIL меняет вероятность переплетения, но никогда — его возможность.

import threading

cache, lock = {}, threading.Lock()

def get_config(key):
    with lock:                      # guard the whole check-then-act
        if key not in cache:
            cache[key] = load_from_disk(key)   # runs exactly once per key
        return cache[key]
Викторина

Два потока выполняют `balance -= amount` над общим счётом под GIL. Нужна ли блокировка?

fork против spawn: как рождается дочерний процесс

Если вы когда-нибудь катили сервис, который работал на маке и молча дедлочился на Linux в проде, — метод запуска первое место, куда стоит смотреть. Разобраться в разнице занимает пять минут; отлаживать альтернативу без этого понимания команда из вступления потратила три дня.

multiprocessing создаёт воркеров через метод запуска, и этот выбор — вопрос корректности. fork (исторический дефолт Linux) клонирует родительский процесс в момент вызова: мгновенно, без реимпорта, все загруженные данные доступны copy-on-write. Его фатальное взаимодействие — баг из вступления: fork() копирует всё адресное пространство, включая каждый удерживаемый замок, но в ребёнка переживает только вызывающий поток. Любой замок, который в момент fork держал другой поток — замок logging, замок аллокатора внутри C-библиотеки, мьютекс пула соединений, — заперт в ребёнке навсегда, и первое касание дедлочится. Правило: fork и потоки несовместимы, а потоки за вашей спиной заводит почти всё (хендлеры logging, grpc, boto3, OpenBLAS). CPython 3.12 начал выдавать DeprecationWarning при fork в многопоточном процессе, и дефолтный метод уходит от голого fork (macOS перешёл на spawn ещё в 3.8; Linux переходит на forkserver в 3.14). spawn запускает свежий интерпретатор и импортирует ваш модуль — медленно (сотни мс на воркера), требует, чтобы всё отправляемое было picklable, а точка входа была под if __name__ == "__main__": (иначе каждый ребёнок заново исполняет создание пула: процессная бомба), — зато иммунен к дедлокам унаследованных замков. forkserver — средний путь: чистый однопоточный серверный процесс форкает воркеров по запросу. Продакшен-инструкция: задавайте метод запуска явно — mp.get_context("spawn") — и никогда не полагайтесь на платформенный дефолт, потому что именно он и поменялся между ноутбуком и контейнером.

Викторина

Linux-сервис с фоновым потоком логирования добавляет fork-овый ProcessPoolExecutor. Воркеры изредка вечно зависают на первой строчке лога. Почему?

Общее состояние между процессами: лучше никак

Процессы по умолчанию не делят ничего, а лазейки дороги или остры. multiprocessing.Queue и результаты пулов перемещают данные через pickle. Объекты Manager() проксируют каждое обращение через серверный процесс — удобно и примерно в 1000 раз медленнее локального dict. multiprocessing.shared_memory и Value/Array дают настоящие общие буферы, но возвращают все гонки из раздела про потоки — уже без случайной защиты GIL, — так что им нужен multiprocessing.Lock. Архитектура, переживающая продакшен: воркеры — функции без состояния над своими аргументами; общее состояние живёт в том, что для него построено (Redis, база), или сводится к финальному merge в родителе. Если на горячем пути рука тянется к Manager().dict() — дизайн сообщает, что хотел потоков. Или другой декомпозиции.

Почему это работает

Почему налог pickle неизбежен для процессов и отсутствует для потоков? Виртуальная память. Потоки живут в одном адресном пространстве: ссылка, переданная воркеру, указывает на те же физические страницы. Процессы по дизайну получают раздельные адресные пространства — эта изоляция и есть то, что вы покупаете, — поэтому объект приходится разворачивать в байты, копировать через pipe и собирать на той стороне. Copy-on-write у fork смягчает цену для данных, существовавших до форка (дети читают родительские страницы бесплатно, пока никто не пишет — правда, обновления refcount в CPython пишут в заголовок каждого объекта и съедают выгоду), но всё отправленное после старта воркеров идёт через сериализацию. Поэтому и выигрывает паттерн «воркер сам грузит свои данные»: он превращает IPC-копию в локальное чтение.

Вспомните перед уходом
  1. 01
    Коллега распараллелил pandas-пайплайн через ProcessPoolExecutor, и он стал медленнее. Назовите две самые вероятные цены и реструктуризацию, которая их чинит.
  2. 02
    Объясните механизм дедлока fork+threads и продакшен-правила, которые его предотвращают.
Итог

Один интерфейс — две физики. ThreadPoolExecutor делит процесс: отправка — микросекунды, данные ходят по ссылке, и это правильный инструмент для I/O-bound работы, потому что блокирующие вызовы отпускают GIL; но чистопайтоновский счёт сериализуется, а GIL никогда не охранял инварианты: += — это load/add/store, check-then-act гонится, и современный CPython лишь делает гонки реже и невоспроизводимее — многооперационные инварианты берут threading.Lock. ProcessPoolExecutor покупает настоящий CPU-параллелизм и берёт pickle на каждом пересечении границы — порядка секунды на 100 МБ в каждую сторону, — поэтому выигрышная форма: маленькие аргументы внутрь (пути, ID), воркеры сами грузят данные, маленькие результаты наружу, и chunksize для батчевания мелких задач. Создание процессов управляется методом запуска: fork быстр и copy-on-write, но клонирует удерживаемые замки без потоков-владельцев — классический перемежающийся дедлок ребёнка, как только в родителе есть потоки (logging, grpc, boto3 заводят их молча); spawn стартует чистый интерпретатор ценой реимпорта, picklability и обязательного if __name__ == "__main__"; forkserver форкает от чистого однопоточного шаблона. Дефолты различаются по платформам и эпохам — macOS spawn с 3.8, Linux уходит от голого fork в 3.14, DeprecationWarning за fork-при-потоках с 3.12, — поэтому фиксируйте явно: mp.get_context("spawn"). Межпроцессное общее состояние — запах: Manager-прокси в ~1000 раз медленнее dict, shared_memory воскрешает гонки без GIL-защиты; держите воркеров stateless и сливайте в конце. Теперь, когда видите ProcessPoolExecutor в PR, вы сразу спрашиваете: что передаётся аргументом, каков метод запуска, и есть ли в родителе фоновые потоки.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.