Що таке глобальне блокування інтерпретатора Python (GIL)?

Що таке глобальне блокування інтерпретатора Python (GIL)?
Переклад 12 хв. читання

Глобальне блокування інтерпретатора Python або GIL, простими словами, - це м'ютекс (або замок), який дозволяє лише одному потоку утримувати контроль над інтерпретатором Python.

Це означає, що тільки один потік може перебувати у стані виконання в будь-який момент часу. Для розробників, які виконують однопотокові програми, вплив GIL не помітний, але він може бути вузьким місцем у продуктивності багатопотокового коду, орієнтованого на центральний процесор. Оскільки GIL дозволяє одночасно виконувати лише один потік навіть у багатопотоковій архітектурі з декількома процесорними ядрами, GIL отримав репутацію "сумнозвісної" особливості Python.

У цій статті ви дізнаєтеся, як GIL впливає на продуктивність ваших програм на Python і як можна зменшити його вплив на ваш код.

Яку проблему вирішив GIL для Python?

Для керування пам'яттю Python використовує підрахунок посилань. Це означає, що об'єкти, створені у Python, мають змінну лічильника посилань, яка відстежує кількість посилань, що вказують на об'єкт. Коли цей лічильник досягає нуля, пам'ять, зайнята об'єктом, звільняється.

Розгляньмо короткий приклад коду, щоб продемонструвати, як працює підрахунок посилань:

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

У вищенаведеному прикладі кількість посилань на порожній об'єкт списку [] дорівнювала 3. На цей об'єкт посилалися змінні a, b та аргументу, що був переданий в sys.getrefcount().

Повернемося до GIL:

Проблема полягала у тому, що ця змінна лічильника посилань потребувала захисту від стану гонитви, коли два потоки одночасно збільшують або зменшують її значення. Якщо таке станеться, це може призвести або до витоку пам'яті, яка ніколи не буде звільнена, або, що ще гірше, до некоректного звільнення пам'яті, коли посилання на цей об'єкт все ще існує. Це може призвести до збоїв або інших "дивних" помилок у ваших програмах на Python.

Змінну лічильника посилань можна убезпечити, додавши блокування до всіх структур даних, які використовуються у різних потоках, щоб вони не змінювалися неузгоджено.

Але додавання блокування до кожного об'єкта або групи об'єктів означає, що буде існувати декілька блокувань, що може спричинити іншу проблему - взаємні блокування (взаємні блокування можуть виникати якщо існує більше одного блокування). Іншим побічним ефектом може бути зниження продуктивності, спричинене багаторазовим отриманням та звільненням блокування.

GIL - це єдине блокування самого інтерпретатора, яке додає правило, що для виконання будь-якого байткоду Python потрібно отримати блокування інтерпретатора. Це запобігає взаємним блокуванням (оскільки існує лише одне блокування) і не призводить до значних накладних витрат на продуктивність. Але це фактично перетворює будь-яку Python програму що завантажує процесор в однопотокову.

GIL, хоча і використовується інтерпретаторами в інших мовах, таких як Ruby, не є єдиним рішенням цієї проблеми. Деякі мови уникають необхідності використання GIL для безпечного керування пам'яттю, використовуючи підходи, відмінні від підрахунку посилань, такі як збірка сміття.

З іншого боку, це означає, що таким мовам часто доводиться компенсувати втрату переваг однопотокової продуктивності від GIL, додаючи інші засоби підвищення продуктивності, наприклад, компілятори JIT.

Чому для вирішення проблеми було обрано GIL?

Отже, чому такий, здавалося б, складний підхід був використаний у Python? Чи розробники Python прийняли погане рішення?

Ну, за словами Ларрі Гастінгса, проєктне рішення GIL - це одна з речей, яка зробила Python таким популярним, яким він є сьогодні.

Python існує ще з тих часів, коли операційні системи не мали поняття потоків. Python був розроблений, щоб бути простим у використанні, щоб пришвидшити розробку, і все більше і більше розробників почали його використовувати.

Для наявних бібліотек C писалося багато розширень, функції яких були потрібні в Python. Щоб запобігти непослідовним змінам, ці розширення C потребували безпечного для потоків управління пам'яттю, яке забезпечував GIL.

GIL є простим у реалізації і його було легко додати до Python. Він забезпечує збільшення продуктивності однопотокових програм, оскільки потрібно керувати лише одним блокуванням.

Бібліотеки C, які не були безпечними для потоків, стало легше інтегрувати. І ці розширення C стали однією з причин, чому Python був легко прийнятий у різних спільнотах.

Як бачите, GIL був прагматичним рішенням складної проблеми, з якою зіткнулися розробники CPython на ранньому етапі розвитку Python.

Вплив на багатопотокові програми у Python

Якщо розглянути типову програму на Python - або будь-яку іншу комп'ютерну програму - то можна помітити різницю між тими програмами, які за своєю продуктивністю прив'язані до процесора, і тими, які прив'язані до вводу/виводу.

Програми, прив'язані до процесора - це програми, які працюють на межі можливостей процесора. Сюди входять програми, які виконують математичні обчислення, такі як множення матриць, пошук, обробка зображень тощо.

Програми, пов'язані з вводом/виводом - це програми, які витрачають час на очікування вводу/виводу, який може надходити від користувача, файлу, бази даних, мережі тощо. Іноді програмам, прив'язаним до вводу/виводу, доводиться чекати значний проміжок часу, поки вони отримають те, що їм потрібно від джерела, через те, що джерелу може знадобитися виконати власну обробку до того, як ввід/вивід буде готовий, наприклад, користувач думає про те, що ввести у вікно вводу, або запит до бази даних, що виконується у власному процесі.

Розгляньмо просту програму, яка виконує зворотний відлік часу:

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

Запуск цього коду на моїй системі з 4 ядрами показав наступний результат:

$ python single_threaded.py
Time taken in seconds - 6.20024037361145

Тепер я трохи змінив код, щоб зробити такий самий відлік, використовуючи два потоки паралельно:

# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

І коли я запустив його знову:

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

Як ви бачите, обидві версії виконувалися майже за однаковий час. У багатопотоковій версії GIL запобігав паралельному виконанню потоків, прив'язаних до процесора.

GIL не має значного впливу на продуктивність багатопотокових програм, прив'язаних до вводу/виводу, оскільки блокування розподіляється між потоками, поки вони очікують на ввід/вивід.

Але програма, потоки якої повністю прив'язані до процесора, наприклад, програма, яка обробляє зображення частинами за допомогою потоків, не тільки стане однопотоковою через блокування, але й збільшить час виконання, як показано у наведеному вище прикладі, порівняно зі сценарієм, коли вона була написана як повністю однопотокова.

Збільшення часу виконання пов'язане з накладними витратами на отримання та повернення блокування.

Чому GIL все ще не видалено?

Розробники Python отримують багато скарг з цього приводу, але така популярна мова, як Python, не може внести такі значні зміни, як видалення GIL, не викликаючи при цьому проблем зворотної сумісності.

Очевидно, що GIL можна видалити, і це неодноразово робилося в минулому розробниками та дослідниками, але всі ці спроби порушували існуючі розширення C, які значною мірою залежать від рішення, яке надає GIL.

Звичайно, є й інші варіанти розв'язання проблеми, яку вирішує GIL, але деякі з них знижують продуктивність однопотокових та багатопотокових програм, пов'язаних з вводом/виводом, а деякі з них просто надто складні. Зрештою, ви ж не хочете, щоб ваші програми на Python працювали повільніше після виходу нової версії, чи не так?

Творець і BDFL Python, Гвідо ван Россум, дав відповідь спільноті у вересні 2007 року у своїй статті "Видалити GIL нелегко":

"Я б привітав набір патчів у Py3k лише за умови, що продуктивність однопотокової програми (і багатопотокової, але прив'язаної до вводу/виводу) не зменшиться".

І ця умова не була виконана жодною зі спроб, що були зроблені з того часу.

Але чому це не було виправлено в Python 3?

У Python 3 з'явився шанс почати багато функцій з нуля, і в процесі цього були зламані деякі з існуючих розширень C, які потім потребували оновлення та перенесення для роботи з Python 3. Це було причиною того, що ранні версії Python 3 приймалися спільнотою повільніше.

Але чому GIL не був видалений разом з ним?

Видалення GIL зробило б Python 3 повільнішим у порівнянні з Python 2 в однопотоковому виконанні, і ви можете уявити, до чого б це призвело. Неможливо сперечатися з перевагами однопотокової продуктивності, які дає GIL. Отже, результатом є те, що Python 3 все ще має GIL.

Але Python 3 приніс значне поліпшення в існуючий GIL - Ми обговорили вплив GIL на багатопотокові програми "тільки з прив'язкою до процесора" та "тільки з прив'язкою до вводу/виводу", але як щодо програм, де деякі потоки прив'язані до вводу/виводу, а деякі - до процесора?

У таких програмах, як відомо, GIL у Python виснажував потоки, прив'язані до вводу/виводу, не даючи їм можливості отримати GIL від потоків, прив'язаних до центрального процесора.

Це відбувалося через механізм, що вбудований у Python, який змушував потоки звільняти GIL після фіксованого інтервалу безперервного використання, і якщо ніхто інший не отримував GIL, той самий потік міг продовжувати його використання.

>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

Проблема цього механізму полягала в тому, що в більшості випадків потік, прив'язаний до центрального процесора, отримував GIL сам, перш ніж інші потоки могли його отримати. Це було досліджено Девідом Бізлі (David Beazley), а візуалізацію можна знайти тут.

Цю проблему було виправлено у Python 3.2 у 2009 році Антуаном Пітру, який додав механізм, що відстежує кількість запитів на отримання GIL від інших потоків, які було обірвано, і не дозволяв поточному потоку повторно отримувати GIL до того, як інші потоки отримають можливість запуститися.

Як працювати з GIL у Python

Якщо GIL викликає у вас проблеми, ось кілька підходів, які ви можете спробувати:

Багатопроцесорна обробка проти багатопоточності: Найпопулярнішим способом є використання багатопроцесорного підходу, коли ви використовуєте декілька процесів замість потоків. Кожен процес Python отримує власний інтерпретатор Python і простір пам'яті, тому GIL не буде проблемою. Python має модуль multiprocessing, який дозволяє легко створювати процеси таким чином:

from multiprocessing import Pool
import time

COUNT = 50000000
def countdown(n):
    while n>0:
        n -= 1

if __name__ == '__main__':
    pool = Pool(processes=2)
    start = time.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end = time.time()
    print('Time taken in seconds -', end - start)

Запуск цієї команди на моїй системі дав такий результат:

$ python multiprocess.py
Time taken in seconds - 4.060242414474487

Непоганий приріст продуктивності порівняно з багатопотоковою версією, чи не так?

Час не зменшився вдвічі, тому що управління процесами має свої накладні витрати. Кілька процесів є важчими, ніж кілька потоків, тому майте на увазі, що це може стати вузьким місцем при масштабуванні.

Альтернативні інтерпретатори Python: Python має декілька реалізацій інтерпретаторів. CPython, Jython, IronPython та PyPy, написані на C, Java, C# та Python відповідно, є найпопулярнішими. GIL існує лише в оригінальній реалізації Python, тобто CPython. Якщо ваша програма з її бібліотеками доступна для однієї з інших реалізацій, ви можете спробувати їх також.

Просто зачекайте: Хоча багато користувачів Python користуються перевагами однопотокової продуктивності GIL. Багатопотокові програмісти можуть не хвилюватися, оскільки деякі з найсвітліших голів у спільноті Python працюють над тим, щоб видалити GIL з CPython. Одна з таких спроб відома як Gilectomy.

Python GIL часто розглядається як загадкова і складна тема. Але майте на увазі, що як Pythonista, ви, як правило, відчуваєте на собі його вплив лише якщо ви пишете розширення для C або якщо ви використовуєте багатопоточність, прив'язану до процесора, у своїх програмах.

У такому випадку ця стаття повинна дати вам все необхідне, щоб зрозуміти, що таке GIL і як з ним працювати у власних проектах. А якщо ви хочете зрозуміти низькорівневу внутрішню роботу GIL, я рекомендую вам подивитися доповідь Девіда Бізлі "Розуміння GIL в Python" (Understanding the Python GIL ).

Джерело: What Is the Python Global Interpreter Lock (GIL)?
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід