Загрузка...

Multithreading and multiprocessing, or why do we need GIL

Thread in Python created by Decoy4298 Nov 15, 2020. 1261 view

  1. Decoy4298
    Decoy4298 Topic starter Nov 15, 2020 217 Feb 18, 2020
    [IMG]

    Данная статья является выжимкой всех впитанных мною знаний о многопоточности и многопроцессорности в Python, а так же разобором такого технического решения, как GIL. Познания в данном вопросе дадут вам неплохой профит на собеседовании на позицию Python-разработчика, да и вообще очень ценится, когда разработчик более углублённо понимает процессы, сокрытые за обычным написанием кода.

    * Я не буду углублённо рассматривать работу модулей threading и multiprocessing, основное внимание будет уделено самой концепции этих решений и сфере их применения. Так же будут опущены подробности о PEP554 и так далее, всё это легко гуглится, поэтому проблемы быть ну никаких не должно.

    * Так же буду очень рад, если кто-то поправит и/или дополнит мою информацию в комментариях в случае моей ошибки и/или невнимательности, развиваться и узнавать новое это круто.

    * И да, дальше ооооооочень много текста, прочтёт до конца тот, кому это реально нужно и интересно, новичкам и приближённым к ним во всю эту тему даже соваться не стоит, никакой значимой пользы вы из этого не извлечёте.


    Азы - распределение задач
    Прежде всего дабы понять, что такое вообще многопоточность, многозадачность, параллельность и все эти термины, пройдёмся по азам.
    Существует несколько подходов по распределения задач при выполнении какой-либо работы:
    [IMG]
    • Конкурентность (Concurrency) - это разделение общего времени между несколькими задачами, т.е. все задачи выполняются по очереди и временем выполнения всех задач будет суммарное время выполнения всех задач. Вполне себе можно описать словом многозадачность, но с рассчётом на то, что многозадачность != одновременность выполнения действий.
    • Параллелизм (Parallelism) - это параллельное, т.е. одновременное, выполнение каких-либо действий. Т.е. общая длительность выполнения всех задач будет определяться самой долгой задачей и не будет суммироваться с каждой новой задачей.
      [IMG]


    [IMG]


    Типы исполняемых задач
    Существует 2 типа исполняемых задач в операционных системах:
    • I/O bound - это операции ввода/вывода, т.е. в основном взаимдоействие с диском, сетью и пользователем (считывание данных с файла, запись в файл, получение ответа от сети и так далее), скорость выполнения которых зависит не от процессора, пользователь не введёт своё сообщение, если у нас процессор будет производительнее.
    • CPU bound - это операции, связанные непосредственно с вычислениями и скорость выполнения которых напрямую зависит от вычислительной мощности процессора.

    [IMG]



    [IMG]


    Что такое потоки и процессы?
    Также в операционных системах существует две сущности - поток и процесс, и они взаимосвязаны, но в чём же различие?

    [IMG]
    • Процесс - это некоторая сущность, которой выделяются системные ресурсы на время выполнения, экземпляр запущенной программы. При этом, каждый дочерний процесс выполняется в своём собственном пространстве в памяти, в следствие чего просто так один процесс не сможет получить внутренние данные другого.
    • Поток - это по-сути своей то же самое, что и процесс, но отличие в том, что при запуске программы создаётся один главный поток, но при создании дочерних потоков, они не делят себе пространство в памяти, как процессы, а используют общую область, в следствие чего имеют доступ к данным друг-друга.
      [IMG]

    В абсолютном подавляющем числе современных языков имеется возможность для работы как с потоками, так и с процессами, в Python за это отвечают два модуля: threading для взаимодействия с потоками и multiprocessing для взаимодействия с процессами, соответственно.


    [IMG]


    Потоки
    При использовании многопоточности в нашей программе, велика вероятность возникновения серьёзной проблемы - один из потоков может нарушить работу другого, получив доступ к его памяти, а как уже известно, труда это не составит, ведь они делят одно адресное пространство.


    Получаем следующую картину:

    Есть какая-то абстрактная глобальная переменная "а":
    Python
    a = 3
    Код потока №1:
    Python
    if a <= 5:
    write_to_file(a)
    Код потока №2:
    Python
    a = 1000

    Как это всё может выполниться:
    1. Начинает своё выполнение поток №1
    2. Начинается выполнение инструкции if a <= 5
    3. Переменная проходит эту проверку, т.к. на данный момент a = 3, а 3 <= 5
    4. Пока ничего криминального
    5. И начинается п***ец.
      В это время вмешивается поток №2 и в общем пространстве потоков программы (см. выше) меняет значение переменной на 1000 ещё ДО того, как поток №1 выполнил инструкцию write_to_file(a)
    6. Поток №1 продолжает выполнять свой код и вызывает функцию write_to_file(a), которая запишет значение переменной "a" в файл

    Ожидание: в потоке 1 записалось то значение "а", которое прошло проверку - 3.
    Реальность: в потоке 1 записалось вовсе не то значение, которое ранее прошло проверку - 1000, логическая проблема потоков налицо.

    [IMG]


    [IMG]


    GIL - зло или необходимость?
    Выше приведённая фатальная проблема связана с тем, что все потоки имеют равные права доступа к каким-либо переменным в общем адресном пространстве, каждый делает то, что ему заблагорассудится, даже нагло дать по лицу другому потоку, выхватить из его рук переменную, что-то сделать с ней и вернуть обратно, будто так и нужно. Поэтому в Python появилось решение под названием Global Interpreter Lock (GIL) или же Глобальный Блокировщик Интерпретатора.



    Теперь возвращаемся обратно к GIL. Так вот, его задача состоит в том, чтобы решить проблему с равным доступом к данным у всех потоков - он блокирует работу всех остальных потоков внутри запущенной программы, пока какой-то один поток взаимодействует с кодом Python, с самим интерпретатором, из чего следует эксклюзивность доступа к данным у потока - только он может с ними взаимодействовать, все остальные потоки, которые тоже хотят, должны ждать, пока этот поток закончит все свои дела с этими данными.

    [IMG]


    [IMG]


    Такое взаимное исключение работы с каким-либо кодом (т.е. пока работает один, остальные ждут) называется мьютексом (англ. mutex - mutual exclusion, взаимное исключение). Модуль threading даёт нам возможность использовать некий класс Lock (про семафоры и прочие классы можно почитать в документации, эта статья немного о другом), который как раз и выступает блокиратором каких-то данных, данный класс реализует в себе такие методы, как acquire и release.

    • Блокировка мьютекса происходит при помощи функции acquire, которая означает, что поток X сейчас будет работать с интерпретатором, а остальные будут терпеливо ждать, когда он закончит.
    • Разблокировка же мьютекса происходит при помощи функции release, которая возвращает мьютекс на растерзание остальным потокам, которые уже возвращаются к своим замкам и пошло-поехало.

    При этом важным аспектом сущности Lock является тот факт, что при повторном вызове acquire в одном и том же потоке он будет заново создавать блокировку, а не игнорировать повторную попытку её установки, дабы такого избежать, можно использовать класс RLock, который обращает внимание на то, кто сейчас этим замком владеет.

    То есть решить проблему выше можно с помощью использование данных замков - обернуть операции по проверке значения в конструкцию acquire - действие - release
    [IMG]

    Казалось бы, всё круто классно, эксклюзивные права, замки, никакого несанкционированного вмешательства, но не всё так красочно в нашей жизни. И с таким подходом существует одна проблема при работе с многопоточностью и имя ей - deadlock. Она вызывается тем, что потоки из-за какой-то ошибки в их архитектуре могут взаимно заблокировать друг-друга и никогда не выйти из этого состояния, они заблокируются намертво, оттого и название deadlock.

    Как выглядит реалистичный deadlock? Самым простым способом будет использование замков.


    [IMG]


    [IMG]


    Отсюда и вытекает главная проблема GIL и причина многих холиваров - потоки в Python не могут выполняться одновременно, каждый поток берёт на себя управление мьютексом и отдаёт его после, что создаёт очередь, т.е. по-сути одновременно работает всего один поток - тот, который взаимодействует с интерпретатором, а все остальные вылавливают момент, чтобы получить себе время на выполнение, когда этот один поток освободится. Получается, что никакой это не параллелизм, а самая что ни на есть конкуррентность.

    Очень рофельная и показательная гифка о том, что примерно происходит в такие моменты:
    [IMG]

    Важным аспектом так же является то, что такие операции получения и передачи контроля хорошо работают лишь с задачами типа 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.


    [IMG]


    Исходя из этого, можно сделать вывод, что GIL - это не такая уж и страшная вещь, как все говорят, и его вполне себе можно обойти, если потребуется. Например, для этого в Python есть библиотека "ctypes", которая позволяет использовать код Си в обход GIL и выполнять инструкции намного быстрее из-за этого, так же библиотека "numpy" в некоторых случаях (например, перемножение матриц) использует механизм поднятия GIL, в следствие чего вычисления происходят быстрее. Вывод - используйте подходящие библиотеки, обходящие GIL, или пишиште свои, если вы точно понимаете, что делаете, и хотите повысить скорость работы кода.


    Процессы
    Но, если вы ещё не забыли после этого адского потока информации, мы в начале упоминали ещё такую вещь, как процессы, которые все имеют собственное пространство в памяти, а значит, проблемы потоков им не страшны. Это действительно так, но существует и проблема большего потребления ресурсов при создании новых процессов, это стоит учитывать, потоки в работе оказываются менее ресурсозатратными, поскольку, как было сказано в отступлении при обсуждении GIL, каждый новый процесс получает себе личный интерпретатор => многопроцессорность = много интерпретаторов одновременно.


    [IMG]


    GIL не распространяется на многопроцессорность в Python, что вполне логично, так как не возникает проблем с непредвиденным взаимодействием одного процесса с данными другого и вообще у каждого процесса свой интерпретатор, зачем волноваться, посему для распараллеливания CPU bound задач можно использовать многопроцессорный подход и модуль multiprocessing.

    Пример создания процесса, синтаксис очень схож с потоками:
    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()

    [IMG]


    На этом закончу терзать ваши больные мозги и оставлю эту всю информацию на обработку и экспериментирование, неплохой фундамент уже заложен, осталось построить стены с крышей.
    Цените все языки, используйте многие и оставьте наконец скорость Python в покое и будьте счастливы! Adios!
     
  2. Abra1kadabra
    Abra1kadabra Nov 15, 2020 Banned 11 Nov 14, 2018
    Это типо работа ZenoPoster . Но под другой обложкой
     
  3. l502l
    l502l Nov 15, 2020 Актуальных контактов нет.
    Много текста.
     
  4. Бот
    Не тот форум чтобы выкладывать такую информацию, тут единицы кто прочтет ее полностью.
     
Top
Loading...