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

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, остальные разбираются в тренажёре.

  1. #gil1 / 5
    Помогают ли потоки I/O-bound задаче (много сетевых запросов)?
    A)Помогают лишь процессы
    B)Нет, GIL блокирует всё наглухо
    C)Только если запросов меньше числа ядер
    D)Да: на I/O поток отпускает GIL, другие работают
    показать ответ и разбор
    +D)Да: на I/O поток отпускает GIL, другие работают

    // разбор: На блокирующем I/O (сокет, диск) поток отпускает GIL, и пока он ждёт ответа, другие потоки исполняют байткод. Поэтому threading реально ускоряет I/O-bound нагрузку — десятки потоков ждут сеть параллельно. То же делает asyncio, но без стоимости ОС-потоков.

  2. #gil2 / 5
    Тяжёлый 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 такого не будет.

  3. #gil3 / 5
    Что меняет «free-threaded» сборка CPython (PEP 703, опция в 3.13+)?
    A)Полностью удаляет asyncio
    B)Делает GIL обязательным везде
    C)Убирает GIL — потоки считают Python параллельно
    D)Автоматически заменяет процессы на потоки
    показать ответ и разбор
    +C)Убирает GIL — потоки считают Python параллельно

    // разбор: Экспериментальная free-threaded сборка (PEP 703, опция в 3.13+) убирает GIL: потоки могут исполнять Python-байткод параллельно на разных ядрах. Плата — усложнение потокобезопасности и возможное замедление однопоточного кода. Пока опционально и не дефолт — полагаться в проде рано.

  4. #gil4 / 5
    Верно ли, что из-за GIL операции над общими данными потокобезопасны и локи не нужны?
    A)Да, GIL делает всё атомарным
    B)Локи в Python вообще не нужны
    C)Нет: составные операции GIL атомарными не делает
    D)Да, для встроенных списков и словарей это так
    показать ответ и разбор
    +C)Нет: составные операции GIL атомарными не делает

    // разбор: GIL сериализует байткод, но составная операция (x += 1 — это read-modify-write, несколько байткодов) может прерваться сменой потока между шагами — итог потерянные обновления. Отдельные операции над dict/list атомарны как следствие реализации, но полагаться на это хрупко: для инвариантов нужен Lock.

  5. #gil5 / 5
    Что именно защищает GIL в CPython?
    A)Пользовательские данные от гонок — с ним Lock в коде не нужен
    B)Память процесса от переполнения при работе многих потоков
    C)Диск и сеть от одновременного доступа нескольких потоков
    D)Внутренности интерпретатора, прежде всего счётчики ссылок
    показать ответ и разбор
    +D)Внутренности интерпретатора, прежде всего счётчики ссылок

    // разбор: GIL (Global Interpreter Lock) — один мьютекс на интерпретатор: байткод исполняет ровно один поток в момент времени. Он бережёт внутренние структуры CPython, в первую очередь счётчики ссылок, от гонок. Это НЕ делает атомарными ваши составные операции — общий счётчик в коде всё равно требует Lock.

дальше

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

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