GIL в Python: что блокирует и как обходят
Взял счётную задачу и замерил три способа. В один поток - 406,3 миллисекунды. Та же работа в два раза больше, разложенная на два потока, - 836,8 миллисекунды, то есть ускорение 0,97 раза: его нет вовсе. А ожидание длиной 300 миллисекунд, повторённое четырежды в четырёх потоках, заняло 300,9 миллисекунды - ускорение 3,99 раза. Одни и те же потоки, противоположный результат.
Стержень: GIL (global interpreter lock) - глобальная блокировка интерпретатора - пускает выполнять код Python только один поток за раз, но на время ожидания она отпускается.
// Формулировки: «что такое GIL?», «почему потоки не ускоряют вычисления?», «когда потоки всё-таки полезны?»
Что именно она блокирует
Это один замок на весь интерпретатор. Чтобы выполнять байт-код Python, поток обязан его держать, поэтому в каждый момент код Python выполняет ровно один поток - сколько бы их ни было и сколько бы ни было ядер.
Но замок отпускается на время любой операции ожидания: чтение из сети, обращение к диску, сон. Отпускают его и многие библиотеки на C во время тяжёлых вычислений внутри себя. Именно поэтому мой замер дал два противоположных результата: счёт в два потока не ускорился вовсе (0,97 раза), а ожидание в четыре потока ускорилось почти вчетверо (3,99).
// Отсюда простое правило выбора. Ждёте - берите потоки или асинхронность, оба работают. Считаете - берите процессы, потому что у каждого процесса свой интерпретатор и свой замок. Считаете в библиотеке, которая внутри уходит в C и отпускает замок, - потоки тоже сработают, но это надо проверять, а не предполагать.
- GIL
- глобальная блокировка интерпретатора (global interpreter lock): код Python выполняет один поток за раз
- отпускание на ожидании
- во время сети, диска и сна замок свободен, и другой поток работает
Почему замок вообще существует
Он сильно упрощает внутреннее устройство интерпретатора: подсчёт ссылок на объекты и сборка мусора работают без отдельных замков на каждый объект. Плата - невозможность параллельного счёта в потоках; выигрыш - простота и скорость однопоточного кода.
Убрать его пытались много раз, и главная сложность не техническая, а совместимость: расширения на C рассчитывают на то, что замок есть. В последних версиях появился режим без глобальной блокировки, но пока он отдельный, экспериментальный, и за параллельность в нём платят замедлением однопоточной работы.
// На собесе это стоит проговорить именно так: замок никуда не делся, обходят его процессами или библиотеками, которые уходят в C. Обещать, что «в новой версии его убрали и всё стало параллельно», не надо - это будет неточно.
- подсчёт ссылок
- механизм учёта объектов; из-за него замок и упрощает интерпретатор
Как это выглядит в веб-сервисе
Обычный запрос к сервису - это на 90% ожидание: сходить в базу, дёрнуть чужой сервис, прочитать кэш. Такая нагрузка замком почти не ограничена, и один процесс с асинхронностью или с потоками держит сотни одновременных запросов.
Проблемы начинаются, когда внутри запроса появляется счёт: разбор большого документа, обработка изображения, сборка ответа на десятки мегабайт, шифрование. Такой запрос держит замок и тормозит ВСЕ остальные запросы этого процесса, даже те, что просто ждут базу.
// Лечится это тремя способами. Первый: несколько рабочих процессов на машине - тогда счёт в одном не мешает другим. Второй: тяжёлое выносят из запроса в фоновую очередь и отвечают пользователю сразу. Третий: конкретную тяжёлую операцию уводят в отдельный пул процессов. Обычно применяют все три сразу.
- рабочие процессы
- несколько процессов приложения на машине; у каждого свой замок
- вынос в фон
- тяжёлую работу делает отдельный обработчик, запрос отвечает сразу
Как отвечать: «Что такое GIL и почему потоки не ускоряют вычисления?»
GIL - это глобальная блокировка интерпретатора, один замок на весь интерпретатор: чтобы выполнять байт-код Python, поток обязан его держать, поэтому код выполняет ровно один поток за раз, сколько бы их ни было и сколько бы ядер ни стояло. Я это мерил: счётная задача в один поток занимает четыреста миллисекунд, а вдвое больше той же работы в двух потоках - восемьсот сорок, то есть ускорения нет вовсе. Но замок отпускается на время ожидания: сеть, диск, сон. Поэтому те же потоки на ожидании дали ускорение почти в четыре раза на четырёх потоках. Отсюда правило: ждёте - потоки или асинхронность, считаете - процессы, у каждого свой интерпретатор и свой замок. И третий случай: библиотеки, которые внутри уходят в C и отпускают замок, - там потоки на счёте тоже работают, но это надо проверять замером, а не предполагать.
Ответ даёт механику, два противоположных замера и правило выбора. Оговорка про библиотеки на C показывает, что человек различает случаи, а не заучил «GIL мешает всему».
На чём валятся
- −Разгоняют счётную задачу потоками и не получают ускорения.
- −Считают, что замок мешает всему, включая сеть и диск.
- −Обещают, что в новых версиях его убрали и всё стало параллельно.
- −Держат тяжёлый счёт внутри запроса и тормозят все остальные запросы процесса.
- −Забывают, что у каждого процесса свой замок, и не поднимают несколько рабочих процессов.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.
- Помогают ли потоки I/O-bound задаче (много сетевых запросов)?A)Помогают лишь процессыB)Нет, GIL блокирует всё наглухоC)Только если запросов меньше числа ядерD)Да: на I/O поток отпускает GIL, другие работают
показать ответ и разбор
+D)Да: на I/O поток отпускает GIL, другие работают// разбор: На блокирующем I/O (сокет, диск) поток отпускает GIL, и пока он ждёт ответа, другие потоки исполняют байткод. Поэтому threading реально ускоряет I/O-bound нагрузку — десятки потоков ждут сеть параллельно. То же делает asyncio, но без стоимости ОС-потоков.
- Тяжёлый numpy-счёт в 4 потока даёт ускорение. Почему, ведь есть GIL?A)numpy обходит интерпретатор целикомB)numpy под капотом запускает процессыC)C-код numpy отпускает GIL на время счётаD)GIL не действует на float-массивы
показать ответ и разбор
+C)C-код numpy отпускает GIL на время счёта// разбор: Многие тяжёлые операции numpy реализованы в C и на время счёта отпускают GIL — тогда потоки считают на разных ядрах параллельно. Общий приём C-расширений: захватить GIL для работы с объектами Python, отпустить на чистый счёт. Для цикла на чистом Python такого не будет.
- Что меняет «free-threaded» сборка CPython (PEP 703, опция в 3.13+)?A)Полностью удаляет asyncioB)Делает GIL обязательным вездеC)Убирает GIL — потоки считают Python параллельноD)Автоматически заменяет процессы на потоки
показать ответ и разбор
+C)Убирает GIL — потоки считают Python параллельно// разбор: Экспериментальная free-threaded сборка (PEP 703, опция в 3.13+) убирает GIL: потоки могут исполнять Python-байткод параллельно на разных ядрах. Плата — усложнение потокобезопасности и возможное замедление однопоточного кода. Пока опционально и не дефолт — полагаться в проде рано.
- Верно ли, что из-за GIL операции над общими данными потокобезопасны и локи не нужны?A)Да, GIL делает всё атомарнымB)Локи в Python вообще не нужныC)Нет: составные операции GIL атомарными не делаетD)Да, для встроенных списков и словарей это так
показать ответ и разбор
+C)Нет: составные операции GIL атомарными не делает// разбор: GIL сериализует байткод, но составная операция (
x += 1— это read-modify-write, несколько байткодов) может прерваться сменой потока между шагами — итог потерянные обновления. Отдельные операции над dict/list атомарны как следствие реализации, но полагаться на это хрупко: для инвариантов нужен Lock. - Что именно защищает GIL в CPython?A)Пользовательские данные от гонок — с ним Lock в коде не нуженB)Память процесса от переполнения при работе многих потоковC)Диск и сеть от одновременного доступа нескольких потоковD)Внутренности интерпретатора, прежде всего счётчики ссылок
показать ответ и разбор
+D)Внутренности интерпретатора, прежде всего счётчики ссылок// разбор: GIL (Global Interpreter Lock) — один мьютекс на интерпретатор: байткод исполняет ровно один поток в момент времени. Он бережёт внутренние структуры CPython, в первую очередь счётчики ссылок, от гонок. Это НЕ делает атомарными ваши составные операции — общий счётчик в коде всё равно требует Lock.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.