Когда на компьютере работает сразу несколько программ, кажется, что все задачи выполняются одновременно — как пловцы на соревнованиях, у каждого из которых своя дорожка, где он там себе плывёт. Но на самом деле процессор просто очень быстро переключается между задачами. Это можно сравнить с одновременным приготовлением нескольких блюд: помешиваем одно, солим второе, накрываем крышкой третье, снова помешиваем первое и так далее. В итоге блюда готовятся параллельно. Так работает многопоточность.
Но в Python есть специальный механизм, который не допускает многопоточность, — GIL. Разбираемся, как именно это происходит и зачем и можно ли обойти это ограничение.
Как работают программы: процессы и потоки
Каждая запущенная на компьютере программа создаёт как минимум один процесс — экземпляр этой программы, который выполняется отдельно и использует выделенные специально для него ресурсы процессора, например процессорное время, память и кэш.
Процессы работают независимо друг от друга. Если один процесс выходит из строя или завершает свою работу, это не отражается на других процессах, потому что каждый использует какой-то свой, изолированный ресурс компьютера. За счёт этого система работает стабильно: проблемы в одном процессе не приводят к сбою в ней или других программах. Например, в современных браузерах на каждую вкладку запускается отдельный процесс. Если одна зависнет, браузер и остальные вкладки продолжат работать.
Сами процессы делятся на потоки — части программы, каждая из которых отвечает за что-то своё (или за общее, просто работают параллельно). Потоки делят между собой одни и те же ресурсы, выделенные на один процесс. Сбой в работе одного потока сразу повлияет на работу всего процесса. Сломался один поток — сломался весь процесс.
Состояние гонки
Каждая программа может запускать несколько процессов, каждый из которых может создавать десятки и сотни потоков. Если учесть все запущенные процессы и потоки, на всём компьютере в одно и то же время могут быть активны тысячи потоков — та самая многопоточность.
Чтобы эффективно использовать процессорное время и ресурсы, операционная система периодически замораживает неактивные потоки и размораживает те, что готовы к выполнению. Так происходит много раз за секунду, но строгого порядка выполнения потоков нет. Отсутствие такого порядка может привести к состоянию гонки (race condition).
Состояние гонки — это ситуация, когда два или более потока обращаются к одному и тому же ресурсу и одновременно совершают операции над ним. В итоге действия потоков могут пересекаться, и результат оказывается непредсказуемым.
Вот как может выглядеть состояние гонки для двух потоков:
- Программа создаёт два потока, которые будут работать с внутренней переменной x.
- Каждый поток должен увеличить значение x на единицу и больше не трогать эту переменную. Логика такая: если х = 0, то поток присваивает х значение 1, а если х = 1, то поток присваивает х значение 2. В итоге после работы потоков в переменной х должно получиться 2.
- Поток 1 проснулся, прочитал значение переменной, увидел, что x = 0, но пока не изменил значение и заснул (если совсем технически — операционная система разморозила этот поток, а потом снова заморозила, но для простоты у наc будет «уснул» и «проснулся»).
- Поток 2 проснулся, прочитал значение переменной, тоже увидел, что x = 0, и заснул.
- Поток 1 проснулся, он помнит, что значение х = 0, поэтому он изменил значение x и заснул. Теперь x = 1,5.
- Поток 2 проснулся и тоже помнит, что x = 0. Поток 2 не перепроверяет значение, потому что на это тоже нужно время, а сделать можно только что-то одно (а проверку он уже сделал раньше). Поэтому поток 2 тоже присваивает x значение 1.
- В итоге вместо ожидаемого значения x = 2 мы получили x = 1.
Что получается: оба потока на самом деле не работают одновременно (хотя со стороны кажется именно так), а просыпаются и засыпают по команде операционной системы. Но если в потоках не предусмотрена синхронизация или проверка общих данных, то общий результат может получиться непредсказуемым.
Что такое GIL
GIL — это глобальный блокировщик интерпретатора Python (Global Interpreter Lock). Он позволяет только одному потоку выполнять Python-код в любой момент, чтобы не дать двум и более потокам одновременно получить доступ к важным объектам. Проще говоря — не допустить состояние гонки. Если попытаться принудительно разделить выполнение программы на потоки и запустить их одновременно, GIL не позволит это сделать.
Чтобы было понятнее, что происходит дальше, поясним один термин: «поток захватывает GIL». Это значит, что поток получает добро на своё выполнение, а GIL блокирует все остальные потоки и запрещает им работать, пока тот поток не освободит GIL. Когда GIL свободен, его может захватить любой поток — как именно и в каком порядке, зависит от логики.
Как это может выглядеть на практике, когда GIL работает, например, с четырьмя потоками:
- Программа создаёт четыре потока, каждый из которых получает задание увеличить переменную x на единицу.
- Поток 0 захватывает GIL и начинает работу с переменной. На этом этапе только этот поток выполняет Python-код. Поток 0 видит, что x = 0, увеличивает значение и освобождает GIL. Теперь x = 1.
- Поток 1 захватывает GIL и начинает работу с переменной. На этом этапе только этот поток выполняет Python-код. Поток 1 видит, что x = 1, увеличивает значение и освобождает GIL. Теперь x = 2.
- Поток 4 захватывает GIL и начинает работу с переменной. На этом этапе только этот поток выполняет Python-код. Поток 4 видит, что x = 2, увеличивает значение и освобождает GIL. Теперь x = 3.
- Поток 3 захватывает GIL и начинает работу с переменной. На этом этапе только этот поток выполняет Python-код. Поток 3 видит, что x = 3, увеличивает значение и освобождает GIL. Теперь x = 4.
- В итоге мы получаем ожидаемый результат x = 4.
Получается, что работа с переменной x происходит последовательно. Все четыре потока выполняются поочерёдно, используя GIL для доступа к Python-коду:
Работу GIL можно сравнить с действиями инструктора-спасателя на водной горке в аквапарке. Представим, что все желающие спуститься с горки — это потоки, которым нужно получить доступ к горке, одному и тому же важному ресурсу. Инструктор-спасатель пропускает к спуску первого человека из очереди, готовит его, а затем следит, как он спускается в бассейн. После того как инструктор-спасатель убедился, что с этим человеком всё в порядке, он пропускает к спуску следующего.
В чём польза GIL
Если посмотреть на всё со стороны разработчиков интерпретатора Python, то GIL сильно упрощает разработку и развитие этого языка программирования. Без GIL пришлось бы создать сложное управление синхронизацией для всех общих данных, чтобы избежать состояния гонки и других проблем многопоточности.
Если программа не обращается к внешним ресурсам, с GIL не нужно использовать мьютексы (от mutual exclusion — «взаимное исключение»). Мьютекс — это механизм синхронизации, который не допускает одновременного доступа нескольких потоков к общему ресурсу в многопоточных программах. Звучит похоже на GIL, но мьютексы работают на уровне кода или данных, а не всего интерпретатора.
Добавив GIL, создатель Python Гвидо ван Россум фактически обезопасил всех Python-разработчиков от состояния гонки и других ошибок синхронизации в коде, которые могут возникнуть при параллельном выполнении потоков. В итоге Python получился проще других языков программирования и на нём проще писать потокобезопасный Python-код, который корректно работает в многопоточной среде. Это обеспечивает безопасность потоков при работе с механизмами и данными, которые считаются непотокобезопасными:
- Менеджер памяти — запрет одновременного доступа потоков к нему позволяет избегать проблем с утечками и повреждением памяти.
- Подсчёт ссылок — он используется для управления временем жизни объектов. У каждого объекта есть специальный счётчик, который отслеживает, сколько раз этот объект используется. GIL делает так, что, когда создаётся или удаляется ссылка на объект, счётчик ссылок увеличивается или уменьшается корректно.
- Сборщик мусора — с GIL он запускается только одним потоком, так что в структуре данных не возникает конфликтующих изменений.
- Коллекции данных (списки, кортежи, словари и множества) — GIL помогает избежать конфликтов при доступе и изменении в таких данных.
Можно ли написать на Python код, в котором потоки будут выполняться параллельно
Можно — если использовать инструменты, с которыми обходятся ограничения GIL.
Встроенные модули и процессы. В самом Python есть множество инструментов для обхода GIL, например с помощью многопроцессорности, асинхронного программирования или параллелизма.
Модуль multiprocessing
позволяет создать несколько процессов — каждый с собственным интерпретатором и GIL. Это даёт возможность использовать несколько ядер процессора, и при выполнении кода каждый процесс будет работать независимо.
Библиотека asyncio
для асинхронного программирования позволяет выполнять задачи параллельно, используя асинхронные функции.
Модуль concurrent.futures
предоставляет высокоуровневый интерфейс для параллелизма, который упрощает работу с потоками и процессами. Включает модули для многопоточности и многопроцессорности.
Библиотеки на других языках. Чаще всего это библиотеки для анализа данных и машинного обучения. Хотя для этих задач Python остаётся самым популярным языком программирования, из-за GIL и других встроенных однопоточных механизмов он довольно медленный. Поэтому инструменты для сложных операций с большими данными пишут на языках, где параллельные потоки разрешены, а потом подключают их к Python в виде библиотек. Так устроены NumPy
, SciPy
и Pandas
.
Работа с подсистемами ввода-вывода, или IO-bound-операции. Выполнение запросов к сайтам, веб-приложениям, базам данных, жёсткому диску и другим внешним системам отключает GIL после выполнения запроса на время ожидания ответа. Это логично: ведь вся работа происходит на другом железе и не может повлиять на потоки программы.
Этой лазейкой пользуются в асинхронном программировании, запуская другие задачи, пока основной процесс ждёт ответа от какой-то системы.
Тогда почему никто не любит GIL?
GIL добавили в Python в 1992 году. В то время большинство программ были однопоточными, а первый двухъядерный процессор появился только в 2004 году.
Но сейчас несколько ядер процессора позволяют запустить несколько одновременных потоков, а GIL не разрешает это сделать. Например, с GIL плохо совмещаются такие задачи:
- распараллеливание кода в моделях искусственного интеллекта;
- работа с несколькими устройствами (например, в медицине);
- сложные задачи для графических процессоров;
- разработка Python-совместимых API без необходимости делать их совместимыми с GIL.
Чтобы это обойти, часть кода приходится писать на C++ или пользоваться процессами. Первый вариант подходит не всем, потому что использование других языков усложняет работу (а ещё их нужно знать, чтобы писать на них код). А параллельные процессы используют больше ресурсов, чем параллельные потоки.
При этом, если просто взять и убрать GIL из Python, никакой выгоды не будет, потому что ключевые механизмы языка не умеют работать с потоками одновременно. Как только два или больше потоков попадут в состояние гонки, система это заметит, остановится и начнёт синхронизироваться. В результате работа только замедлится.
Кроме того, GIL был частью интерпретатора Python в течение многих лет и используется в большинстве существующих приложений и библиотек на Python. Если удалить GIL, в них придётся внести много значительных изменений, а это может вызвать проблемы с совместимостью и потребовать дополнительных усилий по переносу кода.
Где решение
У Python есть документы под общим названием «Предложения по развитию Python», или PEP (Python Enhancement Proposal). В этих документах предлагают новые функции или улучшения для языка, документируют процессы и стандарты и обсуждают ключевые изменения и будущие версии Python. Самый известный из PEP-документов — PEP-8, в котором написаны стандарты для удобного читаемого кода.
Для GIL есть PEP-703. Он появился благодаря разработчику Сэму Гроссу, который написал дополнение Nogil. В 2021 году Гросс встретился с разработчиками ядра Python и рассказал в общих чертах, как работает Nogil. В сети есть текстовая версия этой встречи. Смысл дополнения в том, что все четыре критически важных механизма Python будут заменены на потокобезопасные. Это большая и сложная тема, поэтому мы раскроем её в отдельной статье.