При каких условиях два потока CPython могут реально выполнять работу параллельно, несмотря на GIL?
В обычной сборке CPython с GIL два потока могут одновременно выполнять нативный код, если используемая C- или C++-библиотека временно освобождает GIL. Это позволяет эффективно распараллеливать некоторые операции библиотек обработки массивов, изображений или шифрования, но не обычный Python-код: Python-байткод по-прежнему выполняется последовательно относительно GIL.
GIL появился как механизм упрощения потокобезопасности внутреннего состояния CPython: подсчёта ссылок, управления объектами и работы интерпретатора. Благодаря этому расширениям и самому интерпретатору не требовалось защищать каждую внутреннюю операцию отдельными блокировками.
Одновременно модель оставляла возможность C-расширениям освобождать GIL на время операций, которые не обращаются к объектам Python. Это решало практическую проблему: ожидание внешнего ресурса или длительное вычисление в нативном коде не обязано блокировать остальные потоки.
Запуск CPU-bound задачи в нескольких потоках не гарантирует ускорения. Если задача постоянно исполняет Python-байткод, потоки конкурируют за GIL и получают лишь чередование выполнения, а не параллельную обработку на разных ядрах.
Однако безоговорочное утверждение, что потоки никогда не работают параллельно, тоже ошибочно. Нативная библиотека может передать управление нескольким ядрам, а разработчик, не учитывающий это различие, выберет процессы там, где потоки были бы дешевле.
Поток CPython должен удерживать GIL, когда выполняет Python-байткод или обращается к объектам Python. Поэтому два потока не могут одновременно безопасно исполнять обычные инструкции Python в одном интерпретаторе с GIL.
C-расширение может временно освободить GIL перед длительной операцией, если на этом участке не требуется работа с объектами Python. В этот момент другой поток получает возможность исполнять Python-код, а несколько потоков, выполняющих такой нативный код, могут действительно использовать разные ядра процессора.
Освобождение GIL не делает библиотеку автоматически потокобезопасной. Она должна самостоятельно корректно синхронизировать своё внутреннее состояние, а результат зависит от конкретной операции: одна функция библиотеки может освобождать GIL, другая — нет.
Практическое правило таково: для чистого CPU-bound Python-кода обычно рассматривают процессы, поскольку у каждого процесса свой интерпретатор и свой GIL. Для операций, которые большую часть времени проводят в нативном коде и освобождают GIL, потоки могут дать параллелизм с меньшими затратами на обмен данными, чем процессы.
Ожидание файлового или сетевого ввода-вывода также часто сопровождается освобождением GIL, но это не означает ускорения вычислений: потоки просто не блокируют друг друга во время ожидания.
Сервис обрабатывает большие массивы. Первый вариант — запускать обработку в нескольких потоках. Если библиотека выполняет основную работу в нативном коде и освобождает GIL, потоки могут использовать несколько ядер; преимуществами будут общая адресная память и отсутствие сериализации больших массивов. Недостатки — зависимость от реализации библиотеки и необходимость проверить потокобезопасность операций.
Второй вариант — ProcessPoolExecutor. Процессы обеспечивают изоляцию GIL и подходят для чистого Python-кода, но передача больших массивов между процессами требует сериализации, копирования или другой формы межпроцессного обмена.
Выбранное решение следует принимать после проверки профилировщиком и документацией библиотеки. Если узкое место находится внутри нативной операции, используются потоки; если значительная часть времени тратится на Python-байткод, предпочтительнее процессы. Это позволяет избежать как бесполезного усложнения с процессами, так и ложного ожидания ускорения от потоков.
Освобождает ли GIL любой C-код?
Нет. Сам факт, что функция написана на C, ничего не гарантирует. Расширение может удерживать GIL на всём протяжении вызова, особенно если ему часто нужны объекты Python. Нужно смотреть документацию библиотеки или измерять поведение на реальной операции.
Становится ли Python-код параллельным после освобождения GIL библиотекой?
Нет. Пока поток выполняет нативную секцию без GIL, другие потоки могут выполнять Python-байткод, но два потока не начинают одновременно исполнять обычный Python-байткод в том же интерпретаторе с GIL. Параллельность относится к нативной работе и к перекрытию разных участков выполнения.
Почему потоки могут оказаться быстрее процессов даже для тяжёлой обработки?
Если вычисление выполняется нативной библиотекой с освобождением GIL, потоки способны использовать несколько ядер без передачи больших данных между процессами. Они также избегают части затрат на запуск процессов и сериализацию аргументов. Но итог зависит от накладных расходов, размера задач, конкуренции за память и реализации конкретной библиотеки, поэтому ускорение необходимо измерять.