«Что такое GIL и как с ним жить» спрашивают почти на каждом Python-собесе от middle и выше. Нужно не только дать определение, но и выбрать инструмент под задачу: потоки, процессы или asyncio.
Что такое GIL
GIL (Global Interpreter Lock) это блокировка в CPython, из-за которой в один момент времени байткод Python выполняет только один поток процесса. Она упрощает управление памятью: счётчики ссылок объектов можно менять без отдельных блокировок на каждом объекте.
Важные уточнения:
- GIL отпускается при блокирующем вводе-выводе (сеть, диск,
time.sleep), поэтому потоки хорошо работают для I/O-bound задач. - Многие C-расширения (numpy, hashlib на больших данных, сжатие) отпускают GIL во время тяжёлой работы.
- Для CPU-bound кода на чистом Python потоки не дают ускорения: работает один поток, остальные ждут, плюс накладные расходы на переключение.
- GIL не делает ваш код потокобезопасным. Он защищает внутренности интерпретатора, а не ваши инварианты:
counter += 1это несколько байткод-операций, и поток может переключиться посередине.
Free-threaded CPython
PEP 703 добавил сборку CPython без GIL. В 3.13 она появилась как экспериментальная (python3.13t), в 3.14 стала официально поддерживаемой (PEP 779), но по-прежнему не сборкой по умолчанию. В ней потоки по-настоящему параллельны на нескольких ядрах, а однопоточный код немного медленнее; C-расширения должны явно поддерживать этот режим. На собесе достаточно знать, что это есть и что стандартный CPython пока с GIL.
Потоки: threading
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=20) as pool:
pages = list(pool.map(fetch, urls))
Подходят для параллельных HTTP-запросов, работы с БД, файлами. Общая память, поэтому общие изменяемые данные защищают threading.Lock, или передают через потокобезопасную queue.Queue.
Процессы: multiprocessing
Каждый процесс это отдельный интерпретатор со своим GIL и своей памятью, поэтому CPU-bound код масштабируется на ядра.
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
results = list(pool.map(resize_image, paths))
Цена: запуск процесса дороже потока, данные передаются через сериализацию (pickle), поэтому функция и аргументы должны быть picklable, а гонять большие объекты туда-сюда дорого. На macOS и Windows процессы стартуют методом spawn, поэтому код запуска обязательно под if __name__ == '__main__':. На Linux по умолчанию был fork, а с 3.14 по умолчанию forkserver.
Как выбрать
| Задача | Инструмент |
|---|---|
| Много ожидания сети, тысячи соединений | asyncio |
| Ожидание I/O, синхронные библиотеки | потоки (ThreadPoolExecutor) |
| Тяжёлые вычисления на Python | процессы (ProcessPoolExecutor) |
| Тяжёлые вычисления в numpy и подобном | часто хватает одного потока, библиотека сама параллелит |
| Фоновые задачи сервиса | очередь задач (Celery и аналоги) |
Синхронизация
LockиRLock(повторно входимый тем же потоком).Semaphoreограничивает число одновременных операций.Event,Conditionдля сигналов между потоками.queue.Queueдля схемы «производитель-потребитель».
Взаимная блокировка (deadlock) возникает, когда потоки берут несколько блокировок в разном порядке. Правило: единый порядок захвата и with lock: вместо ручных acquire/release.
Частые ошибки
- Ожидание ускорения CPU-bound кода от потоков.
- Мнение «раз есть GIL, гонок не бывает».
- Передача в процесс лямбды или открытого соединения (не сериализуется).
- Запуск пула процессов без
if __name__ == '__main__'. - Слишком много потоков: каждый занимает память под стек.
Что спросят на собесе
- Что такое GIL, зачем он, когда отпускается.
- Ускорят ли потоки вычисление и запросы к сети, почему.
- Потоки, процессы или asyncio: как выбрать.
- Может ли быть гонка при наличии GIL, пример.
- Что изменилось с free-threaded сборкой.