Что на самом деле копирует fork() — и почему ваш пул соединений ломается
Copy-on-write копирует таблицу страниц, но файловые дескрипторы и сокеты остаются общими. Разбираем, как это приводит к битым соединениям в PostgreSQL, Redis и HTTP-клиентах.
, отредактировано
Все воркеры Celery одновременно перестали получать соединения с базой. Ни один. Ни ошибок подключения, ни таймаутов запросов — просто тишина. Метрики пула показывали норму. Инфраструктура была в порядке. Но каждая задача падала через 20 секунд с одним и тем же сообщением:
Проблема оказалась не в сети, не в базе и не в нагрузке. Проблема была в одном булевом флаге, который изменил момент открытия пула соединений — и превратил fork() из безопасной операции в мину замедленного действия.
Ключевые выводы:
—fork()копирует память процесса (copy-on-write), но разделяет файловые дескрипторы с ядром
— TCP-сокеты после fork() указывают на один и тот же объект ядра — два процесса пишут в один поток
—threading.Lockломается: futex-ключ привязан к физическому адресу памяти, который меняется при copy-on-write
— Фоновые потоки пула просто не существуют в дочернем процессе —fork()копирует только вызывающий поток
— Правило: никогда не держите открытые соединения передfork()
Инцидент — что случилось
За несколько дней до инцидента в конфигурации изменился один флаг. Нужно было, чтобы обработчики сигналов Django регистрировались внутри воркеров Celery. Существующая настройка пропускала регистрацию, если флаг не был установлен в true.
Флаг установили. Сигналы заработали. Фича уехала в прод.
QA был быстрым. Изменение выглядело изолированным: переключить флаг, убедиться, что сигналы регистрируются в Celery, готово. Никто не искал побочных эффектов, спрятанных в AppConfig.ready().
А потом тихо начало выполняться кое-что ещё. Несколько методов AppConfig.ready(), которые теперь стали активны, делали ORM-запросы при старте: создавали расписания периодических задач, проверяли записи crontab. Рутинные вещи. Ничего опасного на вид.
Но эти запросы открыли пул соединений с базой данных. В мастер-процессе. До fork().
Модель конкурентности Celery по умолчанию — prefork. Не потоки, не async-воркеры. Celery использует fork() — системный вызов POSIX — для создания пула рабочих процессов. Эту деталь легко забыть, когда смотришь на код приложения. Но она становится критически важной, когда открываешь соединения с базой при старте.
В этом и была проблема.
Что копирует fork()
Большинство разработчиков знают поверхностный ответ: fork() создаёт дочерний процесс, который является копией родительского. Но слово «копия» скрывает важное различие, которое ядро проводит между двумя типами ресурсов.
Память — copy-on-write
Когда fork() выполняется, ядро не дублирует RAM немедленно. Вместо этого оно помечает таблицы страниц обоих процессов как read-only, указывая на одни и те же физические страницы. Фактическое копирование происходит только когда один из процессов пишет в страницу: ядро перехватывает page fault, выделяет новую физическую страницу, копирует содержимое и обновляет таблицу страниц. До этого момента оба процесса разделяют физическую память, не зная об этом.
Это эффективно. И именно поэтому Python-объекты, включая внутреннее состояние пула соединений, выглядят целыми в дочернем процессе. Дочерний процесс получает свою копию pool._pool, pool._lock, pool._sched. Байты на месте. Структура на месте.
Но некоторые из этих байтов указывают на ресурсы ядра. А ресурсы ядра не копируются.
Файловые дескрипторы — общие
TCP-сокет — это не Python-объект. Это объект ядра: struct file со счётчиком ссылок, за которым стоит struct sock с буферами отправки и получения, состоянием TCP, sequence numbers. Когда fork() выполняется, ядро вызывает dup_fd() для таблицы файловых дескрипторов родителя: каждый открытый fd дублируется в дочерний процесс, а счётчик ссылок на struct file увеличивается.
Оба процесса теперь держат fd=12. Оба указывают на один и тот же объект сокета в ядре. Один TCP-поток, два читателя и два писателя — без координации между ними.
Проводной протокол PostgreSQL — stateful. Он ожидает последовательные пары запрос-ответ в одном потоке. Два процесса, чередующие байты в одном соединении, не создают два независимых разговора. Они создают мусор.
Блокировки — сломанный futex
threading.Lock построен на pthread_mutex_t, который внутри опирается на futex — механизм ядра Linux, использующий физический адрес памяти целого числа как ключ для очереди ожидания. После fork() copy-on-write может переместить страницу дочернего процесса на новый физический адрес при записи. Futex-ключ дочернего процесса расходится с родительским. futex_wake из одного процесса не будит никого в другом.
POSIX явно говорит об этом: поведение мьютекса после fork() не определено, если он не был создан с атрибутом process-shared. Python-овский Lock этот атрибут не использует.
Фоновые потоки — просто не существуют
fork() дублирует только вызывающий поток. Внутренний планировщик пула — отвечающий за поддержание минимального числа соединений, проверки здоровья, уведомление ожидающих — копируется как Python-объект, но не имеет соответствующего потока ОС. Его TID не существует. Любой путь кода, который зависит от его ожидания или сигнализации, заблокируется навсегда.
Дочерние процессы унаследовали пул, который выглядел целым, но был мёртв. Они вызывали pool.getconn(), пытались захватить сломанный лок, ждали 20 секунд notify, который никогда не придёт, и получали таймаут.
Почему пул соединений ломается после fork()
Пул соединений psycopg (ConnectionPool) хранит три вида ресурсов. Каждый ломается по-своему после fork().
TCP-сокеты разделяются на уровне ядра. Любая попытка использовать их из двух процессов одновременно разрушает поток протокола. На практике дочерние процессы до этого даже не доходили.
threading.Lock опирается на futex, привязанный к физическому адресу. После copy-on-write ключ дрейфует. futex_wake будит очередь по старому адресу — никого в дочернем процессе там нет.
Фоновые потоки не существуют в дочернем процессе. Планировщик пула — Python-объект без ОС-потока. Код, зависящий от его notify, блокируется навечно.
Вот почему раньше всё работало:
До изменения флага AppConfig.ready() пропускал регистрацию периодических задач. ORM не вызывался при старте. Нет запросов — нет пула. Нет пула — нечего наследовать. Каждый дочерний процесс начинал с чистого состояния и лениво создавал собственный пул при первом обращении к базе: собственные сокеты, собственный лок, собственный фоновый поток.
Решение — чистый fork без наследства
Два хука сигналов Celery.
worker_before_create_process срабатывает в родительском процессе после ready(), перед каждым fork(). Он закрывает все соединения с базой по всем алиасам и уничтожает пулы соединений. К моменту выполнения fork() нет открытых TCP-сокетов, нет локов, нет фоновых потоков для наследования.
worker_process_init срабатывает в каждом дочернем процессе после fork() как второй уровень защиты — defense-in-depth, на случай если что-то было пропущено.
После обоих хуков каждый дочерний процесс пуст. Первый ORM-запрос создаёт свежий пул, который целиком принадлежит этому процессу.
Принцип простой: никогда не держите открытые соединения перед fork() — или явно закройте и уничтожьте их до форка. Это правило применимо к любому пулу соединений на основе TCP: psycopg, SQLAlchemy, redis-py. Технология не важна. Важно поведение ядра.
Диаграммы — до и после
До изменения флага: чистый fork, каждый воркер создаёт свой пул
После изменения флага: пул открыт в мастере, fork() распространяет повреждение
После исправления: пул уничтожен перед fork(), каждый воркер стартует чисто
Частые вопросы
Почему метрики пула показывали норму, если всё было сломано?
Потому что метрики пула отражают состояние мастер-процесса, а не дочерних воркеров. Мастер-процесс продолжал нормально работать — его пул был полностью функционален. Дочерние процессы унаследовали копию данных, но не сами ресурсы ядра. С точки зрения инфраструктуры всё выглядело нормально, потому что проблема была внутри процессов.
Это специфично для psycopg или затрагивает другие библиотеки?
Это затрагивает любой пул TCP-соединений. SQLAlchemy, redis-py, пулы HTTP-соединений — все они ломаются после fork() по той же причине: файловые дескрипторы разделяются, локи теряют корректность, фоновые потоки не дублируются. Технология не важна — важно поведение ядра.
Можно ли использовать multiprocessing.Process вместо fork()?
multiprocessing.Process по умолчанию тоже использует fork() на Linux. Можно переключить стратегию запуска на spawn (multiprocessing.set_start_method('spawn')), и тогда дочерний процесс создаётся с нуля, без наследования состояния. Но spawn значительно медленнее, потому что загружает интерпретатор и импортирует модули заново.
Почему QA не поймало эту проблему?
Потому что QA проверяло то, что было изменено: регистрируются ли сигналы в воркерах Celery. Они регистрировались. Тест был полным — и одновременно неправильным. Баг был не в изменении, а во взаимодействии между последовательностью запуска Django и моделью процессов Celery. Две системы, каждая из которых понятна по отдельности, создали неожиданное поведение на стыке.
Выводы
Настоящий урок — не в конкретном фиксе. Он в понимании того, почему пул сломался. Что fork() на самом деле копирует. Что он разделяет. Почему лок, который выглядит целым, может заблокировать дочерний процесс. Почему поток, существующий как Python-объект, может полностью отсутствовать в ОС.
Правила, которые стоит запомнить:
- Никогда не открывайте пул соединений до
fork()— или явно уничтожьте его перед форком fork()копирует память (лениво), но разделяет файловые дескрипторы на уровне ядраthreading.Lockпослеfork()— undefined behavior: futex привязан к физическому адресу, который дрейфует при copy-on-write- Фоновые потоки не дублируются —
fork()копирует только вызывающий поток - Это правило касается любого пула TCP-соединений: psycopg, SQLAlchemy, redis-py — поведение ядра одинаково
Невидимая часть — это взаимодействие между последовательностью запуска Django и моделью процессов Celery. Две системы, каждая из которых хорошо понятна по отдельности, делают неожиданное на стыке.
Адаптированный перевод статьи What fork() Actually Copies Даниэля Бастоса (Daniel Bastos).