Данная статья является выжимкой всех впитанных мною знаний о многопоточности и многопроцессорности в Python, а так же разобором такого технического решения, как GIL. Познания в данном вопросе дадут вам неплохой профит на собеседовании на позицию Python-разработчика, да и вообще очень ценится, когда разработчик более углублённо понимает процессы, сокрытые за обычным написанием кода. * Я не буду углублённо рассматривать работу модулей threading и multiprocessing, основное внимание будет уделено самой концепции этих решений и сфере их применения. Так же будут опущены подробности о PEP554 и так далее, всё это легко гуглится, поэтому проблемы быть ну никаких не должно. * Так же буду очень рад, если кто-то поправит и/или дополнит мою информацию в комментариях в случае моей ошибки и/или невнимательности, развиваться и узнавать новое это круто. * И да, дальше ооооооочень много текста, прочтёт до конца тот, кому это реально нужно и интересно, новичкам и приближённым к ним во всю эту тему даже соваться не стоит, никакой значимой пользы вы из этого не извлечёте. Азы - распределение задач Прежде всего дабы понять, что такое вообще многопоточность, многозадачность, параллельность и все эти термины, пройдёмся по азам. Существует несколько подходов по распределения задач при выполнении какой-либо работы: Демонстрация Конкурентность (Concurrency) - это разделение общего времени между несколькими задачами, т.е. все задачи выполняются по очереди и временем выполнения всех задач будет суммарное время выполнения всех задач. Вполне себе можно описать словом многозадачность, но с рассчётом на то, что многозадачность != одновременность выполнения действий. Параллелизм (Parallelism) - это параллельное, т.е. одновременное, выполнение каких-либо действий. Т.е. общая длительность выполнения всех задач будет определяться самой долгой задачей и не будет суммироваться с каждой новой задачей. Демонстрация Типы исполняемых задач Существует 2 типа исполняемых задач в операционных системах: I/O bound - это операции ввода/вывода, т.е. в основном взаимдоействие с диском, сетью и пользователем (считывание данных с файла, запись в файл, получение ответа от сети и так далее), скорость выполнения которых зависит не от процессора, пользователь не введёт своё сообщение, если у нас процессор будет производительнее. CPU bound - это операции, связанные непосредственно с вычислениями и скорость выполнения которых напрямую зависит от вычислительной мощности процессора. Что такое потоки и процессы? Также в операционных системах существует две сущности - поток и процесс, и они взаимосвязаны, но в чём же различие? Демонстрация Процесс - это некоторая сущность, которой выделяются системные ресурсы на время выполнения, экземпляр запущенной программы. При этом, каждый дочерний процесс выполняется в своём собственном пространстве в памяти, в следствие чего просто так один процесс не сможет получить внутренние данные другого. Поток - это по-сути своей то же самое, что и процесс, но отличие в том, что при запуске программы создаётся один главный поток, но при создании дочерних потоков, они не делят себе пространство в памяти, как процессы, а используют общую область, в следствие чего имеют доступ к данным друг-друга. Демонстрация В абсолютном подавляющем числе современных языков имеется возможность для работы как с потоками, так и с процессами, в Python за это отвечают два модуля: threading для взаимодействия с потоками и multiprocessing для взаимодействия с процессами, соответственно. Потоки При использовании многопоточности в нашей программе, велика вероятность возникновения серьёзной проблемы - один из потоков может нарушить работу другого, получив доступ к его памяти, а как уже известно, труда это не составит, ведь они делят одно адресное пространство. Получаем следующую картину: Есть какая-то абстрактная глобальная переменная "а": a = 3 Python a = 3 Код потока №1: if a <= 5: write_to_file(a) Python if a <= 5: write_to_file(a) Код потока №2: a = 1000 Python a = 1000 Как это всё может выполниться: Начинает своё выполнение поток №1 Начинается выполнение инструкции if a <= 5 Переменная проходит эту проверку, т.к. на данный момент a = 3, а 3 <= 5 Пока ничего криминального И начинается п***ец. В это время вмешивается поток №2 и в общем пространстве потоков программы (см. выше) меняет значение переменной на 1000 ещё ДО того, как поток №1 выполнил инструкцию write_to_file(a) Поток №1 продолжает выполнять свой код и вызывает функцию write_to_file(a), которая запишет значение переменной "a" в файл Ожидание: в потоке 1 записалось то значение "а", которое прошло проверку - 3. Реальность: в потоке 1 записалось вовсе не то значение, которое ранее прошло проверку - 1000, логическая проблема потоков налицо. Наглядная демонстрация GIL - зло или необходимость? Выше приведённая фатальная проблема связана с тем, что все потоки имеют равные права доступа к каким-либо переменным в общем адресном пространстве, каждый делает то, что ему заблагорассудится, даже нагло дать по лицу другому потоку, выхватить из его рук переменную, что-то сделать с ней и вернуть обратно, будто так и нужно. Поэтому в Python появилось решение под названием Global Interpreter Lock (GIL) или же Глобальный Блокировщик Интерпретатора. Теперь возвращаемся обратно к GIL. Так вот, его задача состоит в том, чтобы решить проблему с равным доступом к данным у всех потоков - он блокирует работу всех остальных потоков внутри запущенной программы, пока какой-то один поток взаимодействует с кодом Python, с самим интерпретатором, из чего следует эксклюзивность доступа к данным у потока - только он может с ними взаимодействовать, все остальные потоки, которые тоже хотят, должны ждать, пока этот поток закончит все свои дела с этими данными. Такое взаимное исключение работы с каким-либо кодом (т.е. пока работает один, остальные ждут) называется мьютексом (англ. mutex - mutual exclusion, взаимное исключение). Модуль threading даёт нам возможность использовать некий класс Lock (про семафоры и прочие классы можно почитать в документации, эта статья немного о другом), который как раз и выступает блокиратором каких-то данных, данный класс реализует в себе такие методы, как acquire и release. Блокировка мьютекса происходит при помощи функции acquire, которая означает, что поток X сейчас будет работать с интерпретатором, а остальные будут терпеливо ждать, когда он закончит. Разблокировка же мьютекса происходит при помощи функции release, которая возвращает мьютекс на растерзание остальным потокам, которые уже возвращаются к своим замкам и пошло-поехало. При этом важным аспектом сущности Lock является тот факт, что при повторном вызове acquire в одном и том же потоке он будет заново создавать блокировку, а не игнорировать повторную попытку её установки, дабы такого избежать, можно использовать класс RLock, который обращает внимание на то, кто сейчас этим замком владеет. То есть решить проблему выше можно с помощью использование данных замков - обернуть операции по проверке значения в конструкцию acquire - действие - release Абстрактная демонстрация Казалось бы, всё круто классно, эксклюзивные права, замки, никакого несанкционированного вмешательства, но не всё так красочно в нашей жизни. И с таким подходом существует одна проблема при работе с многопоточностью и имя ей - deadlock. Она вызывается тем, что потоки из-за какой-то ошибки в их архитектуре могут взаимно заблокировать друг-друга и никогда не выйти из этого состояния, они заблокируются намертво, оттого и название deadlock. Как выглядит реалистичный deadlock? Самым простым способом будет использование замков. Наглядная демонстрация Отсюда и вытекает главная проблема GIL и причина многих холиваров - потоки в Python не могут выполняться одновременно, каждый поток берёт на себя управление мьютексом и отдаёт его после, что создаёт очередь, т.е. по-сути одновременно работает всего один поток - тот, который взаимодействует с интерпретатором, а все остальные вылавливают момент, чтобы получить себе время на выполнение, когда этот один поток освободится. Получается, что никакой это не параллелизм, а самая что ни на есть конкуррентность. Очень рофельная и показательная гифка о том, что примерно происходит в такие моменты: Важным аспектом так же является то, что такие операции получения и передачи контроля хорошо работают лишь с задачами типа I/O bound, т.е. имеет смысл передавать доступ к другому потоку, когда текущий в нём не нуждается, но с задачами CPU bound такой фокус не прокатит, мы хотим, чтобы какие-то производимые вычисления использовали все доступные ресурсы процессора и не прерывали своё выполнение, чтобы зачем-то отдать управление другому потоку, а затем ждать, пока он их не освободит и снова их взять. Это приводит к тому, что при использовании многопоточности в Python при каких-либо вычислениях, мы не получим совсем никакого прироста, поскольку потоки, занятые вычислениями, плевать хотели на все остальные и не вернут доступ, пока не завершат вычисления, т.е. по-сути все потоки дружно становятся в очередь и делают программу однопоточной. Многозадачность Многозадачность бывает двух типов: Вытесняющая - когда система решает, когда забрать управление у одного потока и передать другому, тут никаких acquire и release нет. Есть только какие-то условия или просто задержка, по которой система передаёт управление другим потокам. Кооперативная - когда сами потоки решают, когда отдавать управление другим потокам, как раз то, что и используется в Python. Но почему используется именно кооперативная многозадачность, раз уж она создаёт такие проблемы с потоками? Дело в том, что когда Python только начинал зарождаться, о многопоточности и многопроцессорности никто и не думал, но в следствие развития, потребовалась реализация такого подхода, но возникла такая проблема, что многие модули на языке Си, из которых используемый нами интерпретатор CPython собран по большей мере, не были с ним совместимы, им требовалась потокобезопасная работа с данными, чтобы пока поток №1 работал с интерпретатором, поток №2 не мог что-то изменить в интерпретаторое или потоке №1 и уронить на лопатки этот самый интерпретатор - так и зародился GIL вместе с кооперативным подходом в многозадачности, всё контролируется внутри самого CPython, чтобы работать, как швейцарские часы и не допускать неожиданных переключений между потоками и неприятных последствий после этого. Уже вышла релизная версия Python 3.9, почему это всё дело ещё не убрали? В таком случае, придётся переписывать код множества модулей на Си и существует огромнейшная вероятность того, что все ранее написанные многопоточные программы и модули на Python не будут совместимы с новым подходом и перестанут корректно работать, а ещё банально GIL, по заявлению создателя Python, никак не влияет на производительность однопоточных программ, да и вообще он был чуть ли не самым лёгким в реализации. Уже были предприняты попытки убрать GIL из Python, но ни к чему положительному это не привело, если бы это могли сделать - это сделали бы (И да, есть форки Python, по крайней мере один, в котором GIL был вырезан с потрохами, но это напротив привело к падению скорости в однопоточных программах на Python, что доказывает сомнительность этой затеи). Но не стоит думать, что всё, что работает в Python, работает через GIL, программисты предусмотрели ситуацию, в которой идёт взаимодействие с данными, которые с интерпретатором ну никак не связано, а, соответственно, GIL тут вообще не упёрся, посему был придуман механизм "поднятия" GIL. Так вот, если какой-то код никак не касается работы интерпретатора и ему вообще не нужен GIL во время работы - он использует механизм поднятия GIL и отключает работу последнего внутри своего кода, а если где-то после GIL нужно будет всё-таки использовать, то используется противоположный механизм, который опускает GIL и подключает его к нужному коду. И вы вполне себе можете взаимодействовать с этими механизмами, если пишете модули на языке Си, которые не работают с интерпретатором CPython. Исходя из этого, можно сделать вывод, что GIL - это не такая уж и страшная вещь, как все говорят, и его вполне себе можно обойти, если потребуется. Например, для этого в Python есть библиотека "ctypes", которая позволяет использовать код Си в обход GIL и выполнять инструкции намного быстрее из-за этого, так же библиотека "numpy" в некоторых случаях (например, перемножение матриц) использует механизм поднятия GIL, в следствие чего вычисления происходят быстрее. Вывод - используйте подходящие библиотеки, обходящие GIL, или пишиште свои, если вы точно понимаете, что делаете, и хотите повысить скорость работы кода. Процессы Но, если вы ещё не забыли после этого адского потока информации, мы в начале упоминали ещё такую вещь, как процессы, которые все имеют собственное пространство в памяти, а значит, проблемы потоков им не страшны. Это действительно так, но существует и проблема большего потребления ресурсов при создании новых процессов, это стоит учитывать, потоки в работе оказываются менее ресурсозатратными, поскольку, как было сказано в отступлении при обсуждении GIL, каждый новый процесс получает себе личный интерпретатор => многопроцессорность = много интерпретаторов одновременно. GIL не распространяется на многопроцессорность в Python, что вполне логично, так как не возникает проблем с непредвиденным взаимодействием одного процесса с данными другого и вообще у каждого процесса свой интерпретатор, зачем волноваться, посему для распараллеливания CPU bound задач можно использовать многопроцессорный подход и модуль multiprocessing. Пример создания процесса, синтаксис очень схож с потоками: from multiprocessing import Process def process1_func(): print('Well, this is process1 callback') if __name__ == '__main__': p = Process(target=process1_func) p.start() p.join() Python from multiprocessing import Process def process1_func(): print('Well, this is process1 callback') if __name__ == '__main__': p = Process(target=process1_func) p.start() p.join() На этом закончу терзать ваши больные мозги и оставлю эту всю информацию на обработку и экспериментирование, неплохой фундамент уже заложен, осталось построить стены с крышей. Цените все языки, используйте многие и оставьте наконец скорость Python в покое и будьте счастливы! Adios!