Багатопотоковість в Python з прикладом: Вивчайте GIL в Python
Що таке нитка?
Потік — це одиниця виконання одночасного програмування. Багатопотоковість — це техніка, яка дозволяє центральному процесору виконувати багато завдань одного процесу одночасно. Ці потоки можуть виконуватися окремо, спільним використанням ресурсів процесу.
Що таке процес?
Процес — це в основному програма, що виконується. Коли ви запускаєте програму на комп’ютері (наприклад, браузер або текстовий редактор), операційна система створює a процесу.
Що таке багатопотоковість Python?
Багатопотоковість в Python Програмування — це добре відома техніка, за якої кілька потоків у процесі діляться своїм простором даних з основним потоком, що робить обмін інформацією та спілкування в потоках легким і ефективним. Потоки легші за процеси. Кілька потоків можуть виконуватися окремо, спільно користуючись ресурсами процесу. Метою багатопоточності є виконання кількох завдань і функціональних комірок одночасно.
Що таке багатопроцесорність?
Багатопроцесорна дозволяє запускати декілька непов’язаних процесів одночасно. Ці процеси не ділять свої ресурси та спілкуються через IPC.
Python Багатопотоковість проти багатопроцесорності
Щоб зрозуміти процеси та потоки, розглянемо такий сценарій: файл .exe на вашому комп’ютері є програмою. Коли ви відкриваєте його, ОС завантажує його в пам'ять, а ЦП виконує. Екземпляр програми, який зараз виконується, називається процесом.
Кожен процес матиме 2 основні компоненти:
- Кодекс
- Дані
Тепер процес може містити одну або кілька підчастин, які називаються нитки. Це залежить від архітектури ОС. Ви можете розглядати потік як розділ процесу, який може окремо виконуватися операційною системою.
Іншими словами, це потік інструкцій, який може виконуватися ОС незалежно. Потоки в одному процесі спільно використовують дані цього процесу та призначені для спільної роботи для сприяння паралелізму.
Навіщо використовувати багатопотоковість?
Багатопотоковість дозволяє розбити програму на кілька підзавдань і виконувати ці завдання одночасно. Якщо ви належним чином використовуєте багатопотоковість, швидкість вашої програми, продуктивність і візуалізація можуть бути покращені.
Python Багатопотоковість
Python підтримує конструкції як для багатопроцесорної, так і для багатопоточної обробки. У цьому підручнику ви в першу чергу зосередитеся на реалізації багатопотокова програми з python. Є два основні модулі, які можна використовувати для обробки потоків Python:
- Команда нитка модуль, і
- Команда різьблення Модулі
Однак у Python також існує те, що називається глобальним блокуванням інтерпретатора (GIL). Це не дозволяє значно збільшити продуктивність і навіть може зменшити продуктивність деяких багатопоточних програм. Ви дізнаєтеся все про це в наступних розділах цього підручника.
Модулі Thread і Threading
Два модулі, про які ви дізнаєтесь у цьому посібнику, є нитковий модуль і модуль потокової обробки.
Однак модуль потоку вже давно застарів. Починаючи з Python 3, він був позначений як застарілий і доступний лише як __потік для зворотної сумісності.
Ви повинні використовувати вищий рівень різьблення модуль для програм, які ви збираєтеся розгорнути. Модуль потоків розглядався тут лише з навчальною метою.
Модуль ниток
Синтаксис створення нового потоку за допомогою цього модуля такий:
thread.start_new_thread(function_name, arguments)
Добре, тепер ви охопили базову теорію, щоб почати кодувати. Отже, відкрийте свій IDLE або блокнот і введіть наступне:
import time import _thread def thread_test(name, wait): i = 0 while i <= 3: time.sleep(wait) print("Running %s\n" %name) i = i + 1 print("%s has finished execution" %name) if __name__ == "__main__": _thread.start_new_thread(thread_test, ("First Thread", 1)) _thread.start_new_thread(thread_test, ("Second Thread", 2)) _thread.start_new_thread(thread_test, ("Third Thread", 3))
Збережіть файл і натисніть F5, щоб запустити програму. Якщо все зроблено правильно, ви повинні побачити ось результат:
У наступних розділах ви дізнаєтеся більше про умови перегонів і про те, як з ними впоратися
ПОЯСНЕННЯ КОДУ
- Ці оператори імпортують модуль часу та потоку, які використовуються для обробки виконання та затримки Python нитки.
- Тут ви визначили функцію під назвою thread_test, який буде називатися почати_новий_потік метод. Функція запускає цикл while протягом чотирьох ітерацій і друкує назву потоку, який її викликав. Після завершення ітерації друкується повідомлення про те, що потік завершив виконання.
- Це основний розділ вашої програми. Тут ви просто телефонуєте почати_новий_потік метод за допомогою thread_test функція як аргумент. Це створить новий потік для функції, яку ви передаєте як аргумент, і почне її виконання. Зверніть увагу, що ви можете замінити це (ланцюжок_test) з будь-якою іншою функцією, яку ви хочете запустити як потік.
Модуль Threading
Цей модуль є високорівневою реалізацією потоків у Python і фактичним стандартом для керування багатопоточними програмами. Він надає широкий спектр функцій порівняно з модулем ниток.
Ось список деяких корисних функцій, визначених у цьому модулі:
Назва функції | Опис |
---|---|
activeCount() | Повертає кількість Нитка об'єкти, які ще живі |
currentThread() | Повертає поточний об’єкт класу Thread. |
перерахувати () | Перелічує всі активні об’єкти Thread. |
isDaemon() | Повертає true, якщо потік є демоном. |
живий() | Повертає true, якщо потік ще живий. |
Методи класу потоків | |
start () | Починає діяльність потоку. Він має бути викликаний лише один раз для кожного потоку, тому що він викличе помилку під час виконання, якщо викликати кілька разів. |
запустити () | Цей метод позначає активність потоку та може бути перевизначений класом, який розширює клас Thread. |
приєднатися () | Він блокує виконання іншого коду, доки потік, у якому було викликано метод join(), не буде завершено. |
Backstory: The Thread Class
Перш ніж почати кодувати багатопотокові програми за допомогою модуля потоків, дуже важливо знати про клас Thread. Клас потоку є основним класом, який визначає шаблон і операції потоку в Python.
Найпоширеніший спосіб створити багатопотокову програму python — оголосити клас, який розширює клас Thread і замінює його метод run().
Загалом, клас Thread означає послідовність коду, яка виконується окремо нитка контролю.
Отже, під час написання багатопоточної програми ви зробите наступне:
- визначте клас, який розширює клас Thread
- Переосмислити __init__ конструктор
- Переосмислити запустити () метод
Після створення об’єкта потоку, start () метод можна використовувати для початку виконання цієї діяльності та приєднатися () метод можна використовувати для блокування всього іншого коду до завершення поточної дії.
Тепер давайте спробуємо використати модуль threading для реалізації вашого попереднього прикладу. Знову запаліть свій IDLE і введіть наступне:
import time import threading class threadtester (threading.Thread): def __init__(self, id, name, i): threading.Thread.__init__(self) self.id = id self.name = name self.i = i def run(self): thread_test(self.name, self.i, 5) print ("%s has finished execution " %self.name) def thread_test(name, wait, i): while i: time.sleep(wait) print ("Running %s \n" %name) i = i - 1 if __name__=="__main__": thread1 = threadtester(1, "First Thread", 1) thread2 = threadtester(2, "Second Thread", 2) thread3 = threadtester(3, "Third Thread", 3) thread1.start() thread2.start() thread3.start() thread1.join() thread2.join() thread3.join()
Це буде результат, коли ви виконаєте наведений вище код:
ПОЯСНЕННЯ КОДУ
- Ця частина така ж, як і наш попередній приклад. Тут ви імпортуєте модуль часу та потоку, які використовуються для обробки виконання та затримок Python нитки.
- У цьому фрагменті ви створюєте клас під назвою threadtester, який успадковує або розширює Нитка клас потокового модуля. Це один із найпоширеніших способів створення потоків у Python. Однак ви повинні перевизначити лише конструктор і запустити () у вашій програмі. Як ви можете бачити в наведеному вище прикладі коду, __init__ метод (конструктор) перевизначено. Подібним чином ви також перевизначили запустити () метод. Він містить код, який ви хочете виконати всередині потоку. У цьому прикладі ви викликали функцію thread_test().
- Це метод thread_test(), який приймає значення i як аргумент, зменшує його на 1 на кожній ітерації та повторює решту коду, доки i не стане 0. На кожній ітерації він друкує ім’я поточного потоку, що виконується, і переходить у режим сну на секунди очікування (що також береться як аргумент ).
- thread1 = threadtester(1, “First Thread”, 1) Тут ми створюємо потік і передаємо три параметри, які ми оголосили в __init__. Перший параметр – це ідентифікатор потоку, другий параметр – це ім’я потоку, а третій параметр – лічильник, який визначає, скільки разів має виконуватися цикл while.
- thread2.start()T метод start використовується для початку виконання потоку. Всередині функція start() викликає метод run() вашого класу.
- thread3.join() Метод join() блокує виконання іншого коду та чекає, поки не закінчиться потік, у якому він був викликаний.
Як ви вже знаєте, потоки, які знаходяться в одному процесі, мають доступ до пам’яті та даних цього процесу. У результаті, якщо кілька потоків намагаються змінити або отримати доступ до даних одночасно, можуть виникнути помилки.
У наступному розділі ви побачите різні типи ускладнень, які можуть з’явитися, коли потоки отримують доступ до даних і критичного розділу без перевірки наявних транзакцій доступу.
Тупикові ситуації та умови перегонів
Перш ніж вивчати взаємоблокування та умови змагання, буде корисно зрозуміти кілька основних визначень, пов’язаних із паралельним програмуванням:
- Критичний розділ. Це фрагмент коду, який отримує доступ до спільних змінних або змінює їх і повинен виконуватися як атомарна транзакція.
- Перемикання контексту. Це процес, за яким центральний процесор зберігає стан потоку перед переходом від одного завдання до іншого, щоб пізніше його можна було продовжити з тієї самої точки.
Тупикові ситуації
Тупикові ситуації є проблемою, з якою стикаються розробники під час написання паралельних/багатопоточних програм на python. Найкращий спосіб зрозуміти взаємоблокування — це використати приклад класичної задачі з інформатики, відомої як Обідній Philoпроблема sophers.
Постановка проблеми для філософів-обідань така:
П'ять філософів сидять на круглому столі з п'ятьма тарілками спагетті (типу пасти) і п'ятьма виделками, як показано на схемі.
У будь-який момент часу філософ повинен або їсти, або думати.
Крім того, філософ повинен взяти дві виделки, які поряд з ним (тобто ліву і праву виделки), перш ніж він зможе з'їсти спагетті. Проблема тупика виникає, коли всі п'ять філософів одночасно беруть свої праві вилки.
Оскільки кожен із філософів має одну виделку, усі вони чекатимуть, поки інші покладуть свою виделку. В результаті жоден з них не зможе їсти спагетті.
Так само в паралельній системі взаємоблокування виникає, коли різні потоки або процеси (філософи) намагаються отримати спільні системні ресурси (форки) одночасно. У результаті жоден із процесів не має шансу виконатися, оскільки вони чекають іншого ресурсу, який утримує інший процес.
Умови гонки
Умова змагання — це небажаний стан програми, який виникає, коли система виконує дві або більше операцій одночасно. Наприклад, розглянемо цей простий цикл for:
i=0; # a global variable for x in range(100): print(i) i+=1;
Якщо ви створюєте n кількість потоків, які виконують цей код одночасно, ви не можете визначити значення i (яке є спільним для потоків), коли програма завершує виконання. Це пояснюється тим, що в реальному багатопоточному середовищі потоки можуть перекриватися, і значення i, яке було отримано та змінено потоком, може змінитися між ними, коли до нього звертається інший потік.
Це два основних класи проблем, які можуть виникнути в багатопотоковому або розподіленому додатку python. У наступному розділі ви дізнаєтеся, як подолати цю проблему за допомогою синхронізації потоків.
Syncхронізація ниток
Для вирішення умов змагання, взаємоблокувань та інших проблем, пов’язаних із потоками, модуль потоків надає Lock об'єкт. Ідея полягає в тому, що коли потік хоче отримати доступ до певного ресурсу, він отримує блокування для цього ресурсу. Як тільки потік блокує певний ресурс, жоден інший потік не може отримати до нього доступ, доки блокування не буде знято. У результаті зміни в ресурсі будуть атомарними, а умови перегонів будуть уникнені.
Блокування — це низькорівневий примітив синхронізації, реалізований за допомогою __потік модуль. У будь-який момент часу блокування може перебувати в одному з двох станів: замкнений or розблоковано. Він підтримує два методи:
- придбати ()Коли стан блокування розблоковано, виклик методу accept() змінить стан на заблокований і повернеться. Однак, якщо стан заблоковано, виклик accept() блокується, доки метод release() не буде викликаний іншим потоком.
- випуск()Метод release() використовується для встановлення стану unlocked, тобто для зняття блокування. Його можна викликати будь-яким потоком, не обов’язково тим, який отримав блокування.
Ось приклад використання блокувань у ваших програмах. Розпалюйте свій IDLE і введіть наступне:
import threading lock = threading.Lock() def first_function(): for i in range(5): lock.acquire() print ('lock acquired') print ('Executing the first funcion') lock.release() def second_function(): for i in range(5): lock.acquire() print ('lock acquired') print ('Executing the second funcion') lock.release() if __name__=="__main__": thread_one = threading.Thread(target=first_function) thread_two = threading.Thread(target=second_function) thread_one.start() thread_two.start() thread_one.join() thread_two.join()
Тепер натисніть F5. Ви повинні побачити такий результат:
ПОЯСНЕННЯ КОДУ
- Тут ви просто створюєте новий замок, викликаючи threading.Lock() заводська функція. Всередині Lock() повертає екземпляр найбільш ефективного конкретного класу Lock, який підтримується платформою.
- У першому операторі ви отримуєте блокування, викликаючи метод accept(). Коли блокування надано, ви друкуєте «блокування отримано» до консолі. Після завершення виконання всього коду, який ви хочете запустити в потокі, ви знімаєте блокування, викликаючи метод release().
Теорія хороша, але як дізнатися, що замок справді спрацював? Якщо ви подивіться на вихідні дані, ви побачите, що кожна з інструкцій print друкує рівно по одному рядку за раз. Згадайте, що в попередньому прикладі виходи print були випадковими, оскільки кілька потоків отримували доступ до методу print() одночасно. Тут функція друку викликається лише після отримання блокування. Таким чином, результати відображаються по одному та рядок за рядком.
Крім блокувань, Python також підтримує деякі інші механізми для обробки потокової синхронізації, як зазначено нижче:
- RLocks
- Semaphores
- Conditions
- Події, і
- Бар'єри
Глобальне блокування інтерпретатора (і як з цим боротися)
Перш ніж приступати до деталей GIL python, давайте визначимо кілька термінів, які будуть корисні для розуміння майбутнього розділу:
- Код, прив’язаний до ЦП: це відноситься до будь-якої частини коду, яка безпосередньо виконуватиметься ЦП.
- Код, прив’язаний до введення/виведення: це може бути будь-який код, який отримує доступ до файлової системи через ОС
- CPython: це посилання реалізація of Python і може бути описаний як інтерпретатор, написаний на C і Python (мова програмування).
Що таке GIL Python?
Глобальне блокування інтерпретатора (GIL) у python – це блокування процесу або м’ютекс, який використовується під час роботи з процесами. Це гарантує, що один потік може отримати доступ до певного ресурсу одночасно, а також запобігає використанню об’єктів і байт-кодів одночасно. Це сприяє збільшенню продуктивності однопоточних програм. GIL у Python дуже простий і легкий у реалізації.
Блокування можна використовувати, щоб переконатися, що лише один потік має доступ до певного ресурсу в певний момент часу.
Одна з особливостей Python полягає в тому, що він використовує глобальне блокування для кожного процесу інтерпретатора, що означає, що кожен процес розглядає сам інтерпретатор python як ресурс.
Наприклад, припустімо, що ви написали програму на Python, яка використовує два потоки для виконання операцій центрального процесора та операцій введення/виведення. Коли ви виконуєте цю програму, відбувається ось що:
- Інтерпретатор Python створює новий процес і породжує потоки
- Коли потік-1 починає працювати, він спочатку отримає GIL і заблокує його.
- Якщо потік-2 хоче виконати зараз, йому доведеться дочекатися випуску GIL, навіть якщо інший процесор вільний.
- Тепер припустімо, що потік-1 очікує на операцію введення-виведення. У цей час він випустить GIL, і потік-2 отримає його.
- Після завершення операцій введення-виведення, якщо потік-1 хоче виконати зараз, йому знову доведеться чекати, поки GIL буде звільнено потоком-2.
Через це лише один потік може отримати доступ до інтерпретатора в будь-який час, тобто буде лише один потік, який виконує код python у певний момент часу.
Це нормально для одноядерного процесора, оскільки для обробки потоків використовувався б розподіл часу (див. перший розділ цього підручника). Однак у випадку багатоядерних процесорів функція, пов’язана з процесором, що виконується в кількох потоках, матиме значний вплив на ефективність програми, оскільки вона фактично не використовуватиме всі доступні ядра одночасно.
Навіщо потрібен був GIL?
СPython Збирач сміття використовує ефективну техніку керування пам’яттю, відому як підрахунок посилань. Ось як це працює: кожен об’єкт у Python має кількість посилань, яка збільшується, коли йому призначається нове ім’я змінної або додається до контейнера (наприклад, кортежів, списків тощо). Подібним чином, кількість посилань зменшується, коли посилання виходить за межі області або коли викликається оператор del. Коли кількість посилань об’єкта досягає 0, він збирається як сміття, а виділена пам’ять звільняється.
Але проблема полягає в тому, що змінна підрахунку посилань схильна до конкуренції, як і будь-яка інша глобальна змінна. Щоб вирішити цю проблему, розробники python вирішили використовувати глобальне блокування інтерпретатора. Іншим варіантом було додавання блокування до кожного об’єкта, що призвело б до тупикових блокувань і збільшення накладних витрат через виклики accept() і release().
Таким чином, GIL є суттєвим обмеженням для багатопоточних програм python, які виконують важкі операції, пов’язані з процесором (фактично роблячи їх однопоточними). Якщо ви хочете використовувати кілька ядер ЦП у своїй програмі, використовуйте багатопроцесорний модуль замість цього.
Підсумки
- Python підтримує 2 модулі для багатопоточності:
- __потік модуль: забезпечує низькорівневу реалізацію потоків і є застарілим.
- модуль потокової обробки: забезпечує високорівневу реалізацію багатопоточності та є поточним стандартом.
- Щоб створити ланцюжок за допомогою модуля різьблення, необхідно зробити наступне:
- Створіть клас, який розширює Нитка клас.
- Перевизначте його конструктор (__init__).
- Перевизначити його запустити () метод.
- Створіть об'єкт цього класу.
- Потік може бути виконаний викликом start () метод.
- Команда приєднатися () можна використовувати для блокування інших потоків, доки цей потік (той, у якому викликано об’єднання) не закінчить виконання.
- Умова змагання виникає, коли кілька потоків отримують доступ або змінюють спільний ресурс одночасно.
- Цього можна уникнути за допомогою Syncхронізація ниток.
- Python підтримує 6 способів синхронізації потоків:
- Волосся
- RLocks
- Semaphores
- Conditions
- Події, і
- Бар'єри
- Блокування дозволяє входити в критичну секцію лише певному потоку, який отримав блокування.
- Блокування має 2 основні методи:
- придбати (): встановлює стан блокування на заблокований. Якщо викликати заблокований об’єкт, він блокується, доки ресурс не звільниться.
- випуск(): встановлює стан блокування на розблокована і повертається. Якщо викликати розблокований об’єкт, він повертає false.
- Глобальне блокування інтерпретатора – це механізм, за допомогою якого лише 1 CPython процес інтерпретатора може виконуватися за один раз.
- Він використовувався для полегшення функції підрахунку посилань CPythonзбирач сміття s.
- Щоб Python програми з інтенсивними операціями, пов’язаними з ЦП, слід використовувати багатопроцесорний модуль.