GIL у Python: ключ до стабільності чи ворог продуктивності

💡 Усі статті, обговорення, новини про Python — в одному місці. Приєднуйтесь до Python спільноти!

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

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

— Що таке GIL, та чому він існує
— Перемикання потоків
— Перемикання потоків для I/O-bound задач
— Перемикання потоків для CPU-bound задач
— Сигнали та планувальники потоків
— Реалізація GIL
— М’ютекси
— Семафори
— Умовні змінні pthreads
— Механізм роботи
— Висновки

GIL з’явився у Python 1.5 та з того часу укорінився як фундаментальна концепція ядра Python. У Python є багато різних реалізацій інтерпретаторів: CPython, Jython, IronPython та PyPy, написані відповідно на C, Java, C# та Python. GIL існує лише в оригінальному інтерпретаторі CPython. Тож одразу заувага — у статті буде йти мова лише про CPython-інтерпретатор.

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

Проблема GIL стає особливо актуальною для задач, пов’язаних з обчислювальною інтенсивністю (англ. CPU-bound operations), де повна потужність багатоядерного середовища залишається не використаною. У той час як у задачах вводу/виводу (англ. I/O-bound operations) GIL виявляє себе значно краще завдяки своїй здатності звільняти блокування під час очікування зовнішніх операцій.

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

Що таке GIL, та чому він існує

Python Global Interpreter Lock (GIL) — глобальне блокування інтерпретатора Python, що дозволяє лише одному потоку операційної системи виконувати байт-код Python у конкретну точку часу. GIL був створений як навмисне обмеження виконання багатозадачних програм, але чому?

Згадаймо тему пам’яті у Python. Створені об’єкти у Python мають додатковий показник — лічильник посилань, що зберігає кількість посилань на цей об’єкт у купі (англ. heap) — пам’яті Python. Коли цей лічильник зменшується до нуля, пам’ять, виділена під цей об’єкт, очищується. Щоб детальніше ознайомитися з темою пам’яті в Python, рекомендую прочитати мою минулу статтю на цю тему: «Мистецтво управління пам’яттю в Python: розуміння, використання та оптимізація».

Ось невелика візуалізація роботи підрахунку посилань:

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

Можемо бачити результат 3, тобто існує 3 посилання на даний об’єкт у пам’яті. Чому 3? Перший — це змінна ‘а’, другий — змінна ‘b’, третій — аргумент, що було передано до функції sys.getrefcount.

І справа в тому, що при використанні кількох потоків кожен з них зможе мати доступ до лічильника посилань та змінювати його, інкрементуючи чи декрементуючи, під час виконання байт-коду Python, що неодмінно призведе до проблем з очищенням пам’яті. У результаті може статися ситуація, коли об’єкт, що ще має посилання, буде видалено чи навпаки — пам’ять буде перевантажено непотрібними об’єктами, що вже не використовуються.

Також треба зауважити, що при багатопоточному виконанні коду кожен з потоків має одночасний доступ до даних, що може призвести до неочікуваних результатів через проблему гонки потоків (див. у другій частині). Як приклад: один потік читає значення 10, збільшує його на 1 і записує 11, інший потік читає те ж значення 10, також інкрементує його, отримавши 11. У результаті очікуване значення — 12, але ми отримали 11.

Звісно є підходи, щоб захистити лічильник посилань у багатопоточному середовищі, такі як threading.Lock, threading.RLock, queue.Queue, multiprocessing.Value тощо, але таке розв’язання проблеми може призвести до іншої — так званого взаємного блокування (англ. deadlock) та зниження продуктивності загалом. Про це поговоримо у другій статті.

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

Ще однією перевагою GIL є те, що він робить розробку розширень на C набагато простішою. Багато бібліотек на C розраховують на те, що GIL захищає доступ до об’єктів Python. Це дозволяє бібліотекам уникати ручної синхронізації та працювати з об’єктами Python у безпечному середовищі. Тож плюси точно є, а GIL — не є таким страшним чудовиськом, якого неодмінно потрібно побороти, щоб отримати ефективний та оптимальний код.

Перемикання потоків

Що відбувається, коли ми запускаємо файл з Python-кодом? Насамперед інтерпретатор ініціалізує основний потік виконання, який відповідає за запуск і виконання програми. Основний потік створює об’єкт __main__, що містить глобальний простір імен, і починає виконувати код, читаючи його рядок за рядком, компілюючи у байт-код і передаючи на виконання віртуальній машині Python (абрев. PVM).

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

Під час виконання Python-коду GIL періодично звільняється, що дозволяє іншим потокам отримати доступ до інтерпретатора.

PyObject*
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
    // оголошення локальних змінних тощо
    // обчислювальний цикл
    for (;;) {
        // eval_breaker повідомляє, чи потрібно призупинити виконання байт-коду, наприклад, якщо інший потік запросив GIL
        if (_Py_atomic_load_relaxed(eval_breaker)) {
            // eval_frame_handle_pending() призупиняє виконання байт-коду, звільняє GIL і знову очікує доступності GIL
            if (eval_frame_handle_pending(tstate) != 0) {
                goto error;
            }
        }

        NEXTOPARG(); // отримати наступну інструкцію байт-коду

        switch (opcode) {
            case TARGET(NOP) {
                FAST_DISPATCH(); // наступна ітерація
            }
            case TARGET(LOAD_FAST) {
                FAST_DISPATCH(); // наступна ітерація
            }
            // ще 117 блоків case, що відповідають усім можливим кодам операцій
        }
        // обробка помилок
    }
    // завершення
}

Цей фрагмент коду описує один із центральних механізмів виконання програм у Python — цикл інтерпретації байт-коду. Функція _PyEval_EvalFrameDefault відповідає за обробку цього байт-коду і виконання його інструкцій.

Уявіть, що байт-код — це список завдань, які інтерпретатор виконує послідовно. Цикл, який ми бачимо у коді, бере одну інструкцію за іншою, розшифровує її й виконує відповідну дію. Наприклад, якщо інструкція каже «завантажити значення змінної», цикл шукає це значення у локальній пам’яті. Якщо ж інструкція наказує «виконати додавання», то інтерпретатор виконує відповідну операцію.

Якщо якийсь інший потік хоче виконувати код, цикл зобов’язаний зупинитися, звільнити GIL і дати можливість іншому потоку продовжити. Це і є одна з ролей перевірки eval_breaker у коді. Коли інший потік сигналізує, що йому потрібен GIL, виконання програми призупиняється.

Перемикання потоків для I/O-bound задач

Коли потік виконує операцію вводу/виводу (англ. I/O-bound operation), він автоматично звільняє GIL. Це дозволяє іншим потокам отримати GIL і виконувати свій код, поки перший потік чекає завершення I/O-операції. Коли I/O-операція завершується, потік знову отримує GIL і продовжує виконання.

import threading
import time

def run_io_task(name: str):
    print(f'{name} почав I/O операцію')
    time.sleep(2)
    print(f'{name} закінчив I/O операцію')

threads = [threading.Thread(target=run_io_task, args=(f'Потік-{i}',)) for i in range(4)]

for t in threads:
    t.start()

for t in threads:
    t.join()

Кожен потік викликає time.sleep(2) інструкцію, яка звільняє GIL, оскільки це I/O-операція. Поки один потік чекає завершення sleep, інші потоки можуть виконуватися. У результаті всі потоки працюють ефективно, і програма виконується швидше (приблизно 2 секунди), ніж при послідовному виконанні (приблизно 8 секунд).

Використаймо py-spy — профайлер для Python, який дозволяє відстежувати виконання коду та досліджувати роботу GIL — для аналізу запуску нашого прикладу I/O-задачі. Запустивши py-spy top — python IO_task.py команду, отримуємо:

Total Samples 200
GIL: 0.00%, Active: 24.00%, Threads: 5

  %Own   %Total  OwnTime  TotalTime  Function (filename)                                                                                                                                                  
 18.00%  18.00%   0.340s    0.340s   run_io_task (IO_task.py)
  6.00%   6.00%   0.150s    0.150s   _wait_for_tstate_lock (threading.py)
  0.00%  18.00%   0.000s    0.340s   run (threading.py)
  0.00%  18.00%   0.000s    0.340s   _bootstrap_inner (threading.py)
  0.00%   6.00%   0.000s    0.150s   join (threading.py)
  0.00%   6.00%   0.000s    0.150s   <module> (IO_task.py)
  0.00%  18.00%   0.000s    0.340s   _bootstrap (threading.py)

Аналізуючи основні показники, бачимо, що:

  • Total Samples: 200: було зібрано 200 зразків виконання програми за час профілювання;
  • GIL: 0.00%: жоден потік не утримував GIL протягом профілювання, що передбачувано для I/O-bound задач, оскільки вони звільняють GIL під час очікування вводу/виводу;
  • Active: 24.00%: лише 24% часу програма була активною, решта часу пішла на очікування I/O операцій (наприклад, викликів time.sleep, очікування мережевих операцій або інших блокуючих задач);
  • Threads: 5: програма використовувала 5 потоків, це включає основний потік програми та додаткові потоки для виконання I/O задач (4 штуки, що ми створили).

Нумо подивимося на розподіл часу:

  • run_io_task (18.00%): основна функція run_io_task витратила 18% від загального часу, вона є основним місцем виконання I/O-bound задачі, і оскільки GIL не утримується, потоки могли паралельно виконувати інші завдання під час очікування I/O;
  • _wait_for_tstate_lock (6.00%): ця функція відповідає за очікування завершення потоку, вона викликається, коли потік завершує свою роботу, а головний потік викликає join() для синхронізації;
  • run (18.00%) і _bootstrap_inner (18.00%): ці функції — частина реалізації модуля threading і відповідають за запуск потоку, вони витратили 18% часу кожна, що корелює із виконанням I/O задач у потоках;
  • join (6.00%): ця функція використовується для очікування завершення потоків, головний потік витратив 6% часу на join().

Перемикання потоків для CPU-bound задач

Щоб уникнути ситуації, коли один потік монополізує GIL, Python використовує механізм періодичної перевірки. За замовчуванням у CPython після кожних 5 ms (100 «тіків» до Python 3.2) байт-коду інтерпретатор звільняє GIL і дозволяє іншим потокам виконатися. Значення цього інтервалу можна змінити за допомогою функції sys.setswitchinterval().

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

Навіть для виконання чисто CPU-навантажених задач (англ. CPU-bound operations) Python виконує примусове перемикання між потоками. Натомість це не означає, що ці потоки будуть виконуватися паралельно, бо існує GIL, що стає на заваді. Перемикання між потоками створює додаткові накладні витрати. Замість реального паралельного виконання кожен потік отримує свою «чергу» та виконується послідовно.

iimport threading

def run_cpu_task(name: str):
    print(f'{name} починає CPU операцію')
    total = sum(x**2 for x in range(1, 10**8))
    print(f'{name} завершує CPU операцію, результат: {total}')

threads = [threading.Thread(target=run_cpu_task, args=(f'Потік-{i}',)) for i in range(4)]

for t in threads:
    t.start()

for t in threads:
    t.join()

У цьому коді 4 потоки виконують обчислення, але насправді тільки один потік активний у будь-який момент часу. Що важливо зауважити: час виконання при використанні потоків та в однопоточній програмі не різниться (у мене порахувало приблизно за 15 секунд). На більш значних обчисленнях та об’ємах коду запуск CPU-інтенсивних задач за допомогою потоків навпаки може уповільнити виконання через додаткові витрати на створення та підтримку потоків. Тож потоки — поганий інструмент для паралелізму у випадку CPU-навантажених задач, але рішення є — процеси, про які поговоримо у третій частині статті.

Аналогічно минулому прикладу проаналізуємо результати профілювання.

Total Samples 2300
GIL: 99.00%, Active: 107.00%, Threads: 4

  %Own   %Total  OwnTime  TotalTime  Function (filename)                                                                                                                                                  
 69.00%  69.00%   16.43s    16.43s   <genexpr> (CPU_task.py)
 37.00% 106.00%    8.67s    25.10s   run_cpu_task (CPU_task.py)
  1.00%   1.00%    1.12s     1.12s   _wait_for_tstate_lock (threading.py)
  0.00% 106.00%   0.000s    25.10s   run (threading.py)
  0.00%   1.00%   0.000s     1.12s   join (threading.py)
  0.00%   1.00%   0.000s     1.12s   <module> (CPU_task.py)
  0.00% 106.00%   0.000s    25.10s   _bootstrap (threading.py)
  0.00% 106.00%   0.000s    25.10s   _bootstrap_inner (threading.py)

Бачимо, що:

  • Total Samples: 2300: було зібрано 2300 зразків виконання програми за час профілювання;
  • GIL: 99.00%: GIL утримувався 99% часу, це типово для CPU-bound задач, оскільки потоки виконують інтенсивні обчислення і не звільняють GIL, це означає, що лише один потік виконувався в кожен момент часу;
  • Active: 107.00%: загальна активність програми перевищує 100%, що вказує на те, що потоки намагались виконуватись паралельно, але через GIL це не вдалося, потоки створювали накладні витрати, натомість фактичне виконання було серійним;
  • Threads: 4: у програмі використовувались 4 потоки.

Розподіл часу на цей раз має такі показники:

  • <genexpr> (69.00%): ця функція спожила 69% часу виконання програми, це основне місце виконання обчислень, генераторний вираз виконує інтенсивні обчислення, утримуючи GIL;
  • run_cpu_task (37.00%): основна функція, яка відповідає за запуск обчислювальної задачі, вона викликає генератор (<genexpr>) і утримує GIL під час виконання, її час (37%) включає час виконання обчислень у генераторі та додаткові виклики;
  • _wait_for_tstate_lock (1.00%): ця функція використовується для синхронізації потоків, вона займає невелику частину часу (1%), коли головний потік чекає завершення інших потоків через join();
  • run, _bootstrap, _bootstrap_inner (106.00%) — ці функції — частина модуля threading, які відповідають за запуск потоків, їх загальний час (106%) вказує на те, що накладні витрати на створення та управління потоками значні, але вони не дають реального паралелізму через GIL;
  • join (1.00%): головний потік витратив 1% часу на очікування завершення роботи інших потоків.

Після аналізу результатів могли виникнути питання. По-перше, як так сталося, що показник часу перевищує 100%, до прикладу для функцій run, _bootstrap, _bootstrap_inner? У результатах py-spy загальний відсоток часу, витрачений на функції, може перевищувати 100%. Це трапляється через особливості обчислення загального часу, коли враховується час виконання кожного потоку окремо, навіть якщо вони не виконувались одночасно через обмеження GIL.

По-друге, згадуючи попередній приклад, де ми використали 4 потоки, але насправді отримали 5 через додатковий основний потік, виникає цілком логічне запитання: чому у випадку з CPU-задачею та використанням 4 потоків ми не спостерігали аналогічного +1? Адже головний потік виконання нікуди не зник. У даному випадку головний потік не бере активної участі у виконанні CPU-bound завдань, він лише чекає завершення дочірніх потоків, тому він не враховується як «активний» у контексті аналізу багатопотоковості механізмами py-spy. Але хочу відмітити, що під час виконання програми показник кількості потоків змінювався, періодично я бачила число 5, але в фінальному результаті профілювальник зафіксував 4 потоки — найактивніші.

Сигнали та планувальники потоків

Сигнали в Python є способом взаємодії між процесами, який дозволяє операційній системі або іншим програмам надсилати асинхронні повідомлення програмі, що виконується. Наприклад, коли користувач натискає Ctrl+C, генерується сигнал SIGINT, який зазвичай завершує програму. Варто зазначити, що в Python можна змінити стандартну поведінку сигналу (окрім тих, котрі обробляються безпосередньо операційною системою, до прикладу SIGKILL і SIGSTOP).

Сигнали мають обмеження: вони працюють лише в основному потоці Python. Якщо програма використовує багатопоточність, все одно сигнали можуть надходити й оброблятися виключно в головному потоці. Б’юсь об заклад, у багатьох з вас була проблема із завершенням багатопотокових програм, а саме спробами зупинити виконання коду за допомогою Ctrl+C. І справа в тому, що програма, запущена в кількох потоках, не може бути зупинена за допомогою переривання клавіатурою (англ. keyboard interrupt).

У Python сигнали, такі як SIGINT, обробляються тільки в головному потоці. Якщо головний потік заблокований, наприклад, під час очікування завершення іншого потоку (англ. thread-join) або утримування блокування (англ. lock), обробник сигналу може не отримати можливості виконатися, у результаті програма продовжить працювати, навіть якщо сигнал було надіслано. Скоріш за все, у такому випадку ми всі звернемося до kill —9, який на відміну від Ctrl+C, надішле сигнал SIGKILL, який не може бути перехоплений, проігнорований або змінений програмою, а отже обов’язково виконається та зупинить програму.

Тож коли надходить сигнал, інтерпретатор Python виконує перевірку (англ. check) після кожних 5 ms доти, доки не активується головний потік. Оскільки обробники сигналів можуть виконуватись виключно в головному потоці, інтерпретатор часто змушений вимикати та вмикати GIL, допоки головний потік не отримає управління.

Така хаотичність при перемиканні між потоками після отримання сигналу пов’язана з планувальниками потоків у Python. У Python немає засобів для визначення того, який потік має виконуватися наступним. Відсутні такі механізми, як пріоритети, витісняюча багатозадачність чи кругова черга (англ. round-robin) тощо. Управління виконанням потоків повністю покладається на операційну систему. Це одна з причин непередбачуваної поведінки сигналів: інтерпретатор не контролює порядок запуску потоків, а лише перемикає їх якомога частіше, сподіваючись, що головний потік отримає можливість виконатися.

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

Реалізація GIL

Підсумовуючи все вищевикладене, хочу зібрати загальну картину роботи GIL як м’ютекс-механізму, що базується на POSIX-семафорах, умовних змінних pthreads, та поєднати це з питанням, як саме використовуються сигнали для управління блокуванням.

Спершу поговоримо про терміни у минулому реченні, заглибившись на рівень нижче того, як функціонує GIL.

М’ютекси

Головна мета м’ютексів (від англ. mutex — mutual exclusion, взаємовиключення) — не дозволити більше ніж одному потоку одночасно отримати доступ до певного ресурсу, щоб уникнути змагань (англ. race conditions) і забезпечити коректність програми. Про гонки потоків неодмінно поговоримо, але у наступній частині.

М’ютекс працює через низькорівневі операції блокування. У сучасних операційних системах ці операції реалізовані апаратно, щоб бути максимально швидкими і безпечними. Наприклад, для Unix-подібних систем використовуються примітиви pthread_mutex_lock і pthread_mutex_unlock, а в Windows — критичні секції через EnterCriticalSection і LeaveCriticalSection. Коли потік викликає функцію блокування м’ютекса, операційна система перевіряє, чи доступний цей м’ютекс. Якщо так, то потік отримує доступ і продовжує роботу. В іншому випадку операційна система поміщає потік у чергу очікування.

Семафори

Семафори — це механізми синхронізації, які обмежують кількість потоків, що можуть одночасно отримати доступ до спільного ресурсу. Вони працюють на основі лічильника:

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

Семафор, на відміну від м’ютекса, дозволяє виконання декількох потоків одночасно, що не відповідає потребам GIL, оскільки Python-код у CPython має бути строго послідовним.

У POSIX-системах семафори часто лежать в основі умовних змінних і м’ютексів, які прямо використовуються в GIL. Наприклад, коли потік блокується через те, що GIL утримується іншим потоком, умовна змінна може викликати операції, засновані на семафорах, щоб поставити цей потік у режим очікування. Коли потік, який утримує GIL, звільняє його, умовна змінна сигналізує про це, використовуючи той самий семафор для розблокування іншого потоку.

Семафори також можуть бути залучені в ситуаціях, де необхідно забезпечити таймаут очікування потоків. Наприклад, якщо потік довго чекає доступу до GIL, а інтерпретатор вирішує, що його потрібно «розбудити», це може бути зроблено через механізм сигналів, які в свою чергу можуть активувати семафор.

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

Умовні змінні pthreads

Умовні змінні pthreads — це механізм синхронізації в POSIX, який дозволяє потокам чекати певної умови або сигналу від інших потоків. Вони зазвичай використовуються разом із м’ютексами для координування доступу до спільних ресурсів. На концептуальному рівні умовна змінна працює як точка синхронізації між потоками. Потоки можуть «засинати», чекаючи виконання певної умови, і «прокидатися», коли інший потік сигналізує, що ця умова виконана. Наприклад, якщо один потік заповнює буфер даними, а інший обробляє ці дані, умовна змінна може бути використана для координації, щоб обробка почалася тільки тоді, коли буфер буде заповнений.

На практиці умовні змінні реалізуються як черга потоків, що очікують сигналу. Коли потік викликає функцію pthread_cond_wait, він блокується і додається до цієї черги, звільняючи при цьому м’ютекс, щоб інші потоки могли працювати зі спільними ресурсами. Інший потік, коли умова виконана, викликає pthread_cond_signal або pthread_cond_broadcast. Перший сигнал пробуджує лише один потік із черги, а другий — усі потоки.

Особливість умовних змінних у тому, що вони дозволяють ефективно використовувати процесорний час. Потік, що чекає сигналу, не працює в режимі активного опитування (англ. active polling), а переходить у стан очікування на рівні операційної системи, звільняючи ресурси. Це досягається через низькорівневу підтримку з боку ядра ОС, яка дозволяє переводити потік у режим «сну» до отримання сигналу.

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

Механізм роботи

Тож, підсумовуючи, на глибокому рівні механізм роботи GIL реалізовано через примітиви синхронізації операційної системи. У CPython це зазвичай м’ютекси для блокування та семафори для сигналізації між потоками. У сучасних версіях Python (починаючи з Python 3.2) GIL реалізовано через м’ютекс і умови (англ. pthreads condition variables) для підвищення продуктивності.

Коли потік починає виконання, він захоплює GIL за допомогою м’ютекса. Цей м’ютекс дозволяє тільки одному потоку виконувати інструкції інтерпретатора Python, блокуючи інші потоки, які очікують доступу до GIL. При виконанні байт-коду в CPython використовується внутрішній лічильник інструкцій (за замовчуванням 5 ms). Потік не може продовжувати виконання далі, ніж дозволяє цей лічильник, доки не відбудеться перевірка, чи потрібне звільнення GIL.

Базуючись на минулих розділах, підсумуємо, потік вивільняє GIL, коли:

  • той завершує виконання;
  • викликає операції вводу/виводу (англ. I/O-bound operations), які відбуваються поза інтерпретатором Python;
  • інтерпретатор вирішує, що потік повинен «поступитися місцем» іншому потоку (використовується механізм переривань).

На останній ілюстрації видно, що після 5 ms потік зобов’язаний вивільнити GIL. Це реалізовано через сигналізацію іншим потокам. Такий підхід забезпечує синхронізацію між потоками, але створює додаткові затримки через передачу сигналів та перемикання контексту. Потік, який вивільняє GIL, переходить у стан очікування, фактично «засинає», звільняючи ресурс для іншого потоку.

Умовні змінні у цьому процесі використовуються для того, щоб потік, який очікує, міг бути розблокований лише за сигналом. Таке розблокування запобігає активному циклу очікування (англ. busy waiting), що могло б надмірно навантажувати процесор. Але перемикання супроводжуються додатковими витратами, оскільки такі дії включають системні виклики та доступ до структур ядра ОС.

Висновки

Отже, GIL — ключова особливість CPython, яка визначає поведінку багатопотокових задач. Він слугує механізмом синхронізації, що забезпечує безпеку доступу до пам’яті під час виконання інтерпретатора, але водночас створює значні обмеження для паралельного виконання обчислювальних задач.

Він є компромісом між продуктивністю, безпекою пам’яті та простотою реалізації інтерпретатора, а, отже, GIL — це не просто обмеження, а важливий елемент, що формує унікальність Python і визначає його найкращі сфери застосування.

Як на практиці обійти перепони, що створює GIL, синхронізувати виконання потоків та уникнути помилок при виборі підходу розпаралелювання своїх задач, поговоримо у другій частині статті. Яким чином управляти процесами, як інструментом оптимізації CPU-навантажених задач — у третій частині.

Наступні частини будуть більш практичні, але основані на вище викладеній інформації. Сподіваюсь, було корисно та цікаво. Дякую за увагу! Не прощаємось.

👍ПодобаєтьсяСподобалось30
До обраногоВ обраному19
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Частина 2 вже опублікована. Усі, хто хотів прочитати продовження, читайте за посиланням:
Python без блокувань. Як працюють потоки

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

Точнее будет сказать не Python-кода, а Python/C API.

Тож потоки — поганий інструмент для паралелізму у випадку CPU-навантажених задач, але рішення є — процеси, про які поговоримо у третій частині статті

см. hashlib, PIL, numpy, там отпускается GIL и прекрасно CPU-bound задача выполняется «параллельно», те же треды покажут себя лучше нежели процессы, но тут нужно профилировать зачастую.

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

чистий Python-код

Что такое «чистый» Python код? Вот это можно назвать чистым?

gist.github.com/...​49e76cfde4af16180fa6bfba0

Під «чистим Python-кодом» мала на увазі код, що виконується інтерпретатором без C-оптимізованих бібліотек. Якщо GIL не відпускається, потоки неефективні для CPU-bound задач, і процеси дають кращий результат. У випадках, де GIL звільняється (як у NumPy, PIL тощо), треди можуть бути корисними, але тут без профілювання не обійтися.

Вот это больше похоже на правду :)

Ну и процессы тоже не панацея, это стоит понимать, особенно на задачи с общим стейтом, все это синхронизировать как-то через пайп.. может даже последовательно запустив окажется быстрее.

Раз уж на то пошло, то можно еще осветить такую тему как sub-interpreters, в сравнении с тредами/процессами. Я бы почитал за это с удовольствием.

Дякую за статтю! Є деякі неточності, які можна уточнити або виправити.

Кожен потік викликає time.sleep(2) інструкцію, яка звільняє GIL, оскільки це I/O-операція.

Sleep — це не I/O операція.

На більш значних обчисленнях та об’ємах коду запуск CPU-інтенсивних задач за допомогою потоків навпаки може уповільнити виконання через додаткові витрати на створення та підтримку потоків. Тож потоки — поганий інструмент для паралелізму у випадку CPU-навантажених задач, але рішення є — процеси, про які поговоримо у третій частині статті.

Процеси більш затратні по ресурсам, ніж потоки.

Така хаотичність при перемиканні між потоками після отримання сигналу пов’язана з планувальниками потоків у Python.

Тут, мабуть, помилка перекладу, і мається на увазі «пов’язана з плануванням потоків у Python» бо, як далі написано, планування потоків виконує операційна сисистема, а не python.

time.sleep() - це I/O операція. Коли визивається sleep, то CPU не виконує жодних дій і ми отримуємо «сплячий» потік з розблокованим GIL, що дає змогу перемкнутися на інший потік в одному процесі.

Дай визначення I/O операції. Ти підміняєш поняття: пов’язуєш розблокування GIL із I/O, хоча ці дві речі не пов’язані напряму.
Коли потік викликає sleep(), це не означає, що CPU «не виконує жодних дій». Це означає лише, що цей конкретний потік переходить у стан очікування, а планувальник ОС миттєво переключає CPU на виконання інших потоків або процесів.

Згоден, віднести sleep() до I/O не можна.
Мабуть, більш правильно було сказати, що він імітує I/O.

Дай визначення I/O операції.

Ну... можна сказати що при I/O операції ми даємо запит до іншої підсистеми, та очікуємо на результат його виконання. У разі sleep ми даємо запит до підсистеми таймера, та очікуємо 0 біт, або None.

А ваше визначення I/O операції?

Якщо брати контекст мови python, то I/O операції — це операції з потоками (streams) даних.

ми даємо запит до іншої підсистеми, та очікуємо на результат його виконання. У разі sleep ми даємо запит до підсистеми таймера, та очікуємо 0 біт, або None.

У разі sleep cpython інтерпретатор робить system call nanosleep. З такою логікою всі системні виклики, включаючи, getpid і виклики для менеджменту пам’яті, можна назвати I/O операціями.

Дякую за коментарі та увагу до статті!
Так, дійсно, time.sleep() не є I/O-операцією як такою, бо відповідно немає вводу/виводу, тож некоректно написала. Малося на увазі, що веде себе так само, вивільняючи GIL.
Щодо затрат на процеси та потоки — звісно, процеси вимагають більших затрат через створення окремого адресного простору, його підтримку та передачу даних між процесами. Темі потоків присвячена друга частина статті, процесам — третя, тож там я більше розкрила деталей.

Для всіх читачів, кого зацікавить продовження: наступна частина вийде вже за тиждень, а ще за тиждень — третя. Щоб не пропустити, слідкуйте за оновленнями DOU у LinkedIn, Telegram, Facebook та інших платформах. Або просто поверніться до цієї статті згодом — тут з’являться посилання на наступні частини, щойно вони будуть опубліковані.
Дякую за увагу до статті!

Підписатись на коментарі