Оновлення Python 3.13 — чи близький кінець GIL

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

Привіт! Мене звуть Олексій. Я студент другого курсу магістратури у Київському політехнічному інституті імені Сікорського. Програмую на Python уже шість років і це четверта стаття із серії #найголовніші_фічі, які зʼявляються з новими версіями Python.

З моменту написання моєї статті стосовно оновлення Python 3.12 у лютому 2024, його популярність лише зросла. За даними вересневого рейтингу TIOBE Index for September 2024, приріст склав +6.01% у порівнянні з минулим роком, що ще більше зміцнило позиції цієї мови програмування.

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

Зміни у Python 3.13

Release Date: October 2024

Неочікувана затримка у релізі

Офіційний реліз Python 3.13, спочатку запланований на 1 жовтня, було перенесено на 7 жовтня 2024 року. І варто зазначити, що робота над Python 3.13 почалась ще у травні 2023 року і вже у травні наступного версія 3.13 перестала отримувати нові зміни, окрім як виправлення помилок та покращення документації (детальний графік змін у PEP-719). Як це працює?

Цикл розробки Python має чітко визначені етапи, на кожному з яких змінюються завдання і відповідальності основних розробників. Розробка версій базується на принципі «major.minor.micro» — про семантичне версіонування можна почитати окремо.

Окрім фінальних версій, Python також має попередні, які позначаються як Alpha, Beta та Release Candidate. Вони призначені для тестування досвідченими користувачами, а не для використання у виробничих (production) середовищах.

Етапи розробки

Залежно від етапу, на якому знаходиться поточна версія Python, змінюються і вимоги до внесення змін у вихідний код:

Чітка структура допомагає координувати зусилля команди розробників та гарантує стабільність кожної нової версії Python. То що ж сталося?

Затримка була необхідна у зв’язку з виявленням значного падіння продуктивності, викликаного змінами в інкрементному циклічному збирачі сміття (incremental cyclic garbage collector), впровадженими в попередніх альфа-версіях.

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

Включеним повністю або частково у патч-релізах він не буде. Повернуть новий збирач сміття у наступних мінорних версіях (3.14, 3.15 та інші), оскільки додавання нового функціоналу у патч-релізі порушує процеси. Незважаючи на це, версія 3.13 залишається повнофункціональною, і жодних змін в ABI не було зроблено з часів бета-версії.

Інтерактивний інтерпретатор REPL

Покращений інтерактивний інтерпретатор на основі PyPy.

Тепер, коли користувач запускає REPL, цикл читання-обчислення-друку (Read-eval-print loop) з інтерактивного терміналу, його зустрічає підтримка наступних змін:

  • Багаторядкове редагування зі збереженням історії.

Уявіть, що ви оголошуєте декоратор у REPL:

І тут згадуєте, що помилились і повертаєте у decorator обʼєкт функції func замість wrapper. Звичайно, до цього (за прикладом вище) прийти не легко. Тому щоб показати, що wrapper не викличеться, спробуймо знову і додамо виведення «decorator» перед виконанням функції, що обгортаємо у wrapper:

Виконуємо знову і бачимо лише «Hello World!» — а очікуваного виведення decorator не сталося. Що робити? Звичайно, якщо скажете декілька разів тиснути вгору і змінити return func на return wrapper ↑, будете праві:

Тепер вже інші справа. І все це завдяки підтримці багаторядкового редагування Й збереження історії. Адже треба памʼятати, що до версії 3.13 ви не могли так легко повернутись до оголошення своєї функції чи класу (декоратора у нашому прикладі) і з легкістю доповнити їх усім необхідним.

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

Натиснув один раз:

Натиснув другий раз:

  • Пряма підтримка команд REPL, таких як help(), exit() і quit(), без необхідності викликати їх як функції — help, exit, quit.
  • Підказки та відстеження з кольором, увімкненим за замовчуванням.

  • Інтерактивний перегляд довідки за допомогою F1 з окремою історією команд.

  • Перегляд історії за допомогою клавіші F2.

  • «Режим вставки» з F3, що полегшує вставлення великих блоків коду.

Щоб вимкнути нову інтерактивну оболонку, установіть змінну середовища PYTHON_BASIC_REPL.

Експериментальна можливість вимкнення глобального блокування інтерпретатора (GIL)

У новій версії Python 3.13 зроблено великий крок до зміни головної родзинки інтерпретатора Python — GIL. Хоча зараз тільки обговорюються шляхи повного позбавлення від GIL, експериментальна можливість вимкнення глобального блокування інтерпретатора уже наявна.

Чи є GIL серйозною проблемою? Згадаймо слова Ларрі Хастінгса на PyCon 2015.

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

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

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

Щоб скористатися вільнопотоковим режимом, необхідно використовувати інший виконуваний файл, зазвичай під назвою python3.13t або python3.13t.exe. Попередньо зібрані бінарні файли, позначені як «вільнопотокові», можуть бути встановлені через офіційні інсталятори для Windows і macOS. Також можливе складання CPython з вихідних кодів за допомогою опції —disable-gil.

Вільнопотокове виконання дозволяє повністю використовувати потужність процесора, дозволяючи потокам виконуватися паралельно на кількох ядрах CPU. Хоча не всі програми автоматично отримають від цього перевагу, програми, розроблені з урахуванням потоків, працюватимуть швидше на багатоядерних системах. Оскільки функція експериментальна, користувачі можуть стикнутися з певними помилками та суттєвим зниженням продуктивності при одноядерній роботі. У вільнопотокових версіях CPython є можливість увімкнути GIL під час виконання, використовуючи змінну середовища PYTHON_GIL або параметр командного рядка -X gil=1.

Щоб перевірити, чи підтримує поточний інтерпретатор вільнопотоковий режим, можна скористатися командою python -VV або перевірити значення в sys.version, де буде вказано «experimental free-threading build». Також доступна нова функція sys._is_gil_enabled(), яка дозволяє перевірити, чи дійсно GIL відключений у поточному процесі.

А тепер погляньмо на приклади з ThreadPoolExecutor та ProcessPoolExecutor:

import concurrent.futures
import contextlib
import time
from typing import Generator




@contextlib.contextmanager
def time_it(what: str) -> Generator[None]:
   t0 = time.monotonic()
   try:
       yield
   finally:
       print(f'{what} took {time.monotonic() - t0}s')




def do_work() -> int:
   with time_it('work'):
       x = 0
       for _ in range(10_000_000):
           x += 1
       return x




def main() -> None:
   with time_it('main'):
       with concurrent.futures.ThreadPoolExecutor(4) as pool:
           futures = [pool.submit(do_work) for _ in range(10)]


           for future in concurrent.futures.as_completed(futures):
               print(f'got {future.result()}')




if __name__ == '__main__':
   main()

Та:

def main() -> None:
   with time_it('main'):
       with concurrent.futures.ProcessPoolExecutor(4) as pool:
           futures = [pool.submit(do_work) for _ in range(10)]


           for future in concurrent.futures.as_completed(futures):
               print(f'got {future.result()}')

Локальні виконання з ThreadPoolExecutor та ProcessPoolExecutor на версії 3.12 або ж 3.13 з GIL дають такі результати для ThreadPoolExecutor:

work took 1.9548044349066913s
got 10000000
work took 2.0012483169557527s
work took 2.093829107005149s
got 10000000
got 10000000
work took 2.3259086270118132s
got 10000000
work took 1.852884995052591s
got 10000000
work took 2.2110034850193188s
work took 2.244857894955203s
got 10000000
got 10000000
work took 2.1200398980872706s
got 10000000
work took 1.4704015370225534s
got 10000000
work took 1.043533139047213s
got 10000000
main took 5.31929242098704s

А також для ProcessPoolExecutor:

work took 0.5303010790375993s
work took 0.5309579480672255s
got 10000000
got 10000000
work took 0.5479815760627389s
got 10000000
work took 0.5544647829374298s
got 10000000
work took 0.5440159901045263s
got 10000000
work took 0.5461697049904615s
got 10000000
work took 0.5407423679716885s
got 10000000
work took 0.5399765850743279s
got 10000000
work took 0.49855045799631625s
got 10000000
work took 0.5028546449029818s
got 10000000
main took 1.613782429951243s

Проте у виконанні на версії 3.13 без GIL отримаємо для ThreadPoolExecutor:

work took 0.621877092984505s
got 10000000
work took 0.6247945779468864s
got 10000000
work took 0.6302563319914043s
got 10000000
work took 0.6311170549597591s
got 10000000
work took 0.6294306690106168s
got 10000000
work took 0.6304955079685897s
got 10000000
work took 0.6371516119688749s
got 10000000
work took 0.6388808720512316s
got 10000000
work took 0.6403557029552758s
got 10000000
work took 0.6410054829902947s
got 10000000
main took 1.8992961760377511s

Та для ProcessPoolExecutor:

work took 0.6460460430243984s
got 10000000
work took 0.6548925309907645s
got 10000000
work took 0.688525396049954s
got 10000000
work took 0.6899377160007134s
got 10000000
work took 0.6749819609103724s
work took 0.6663011440541595s
got 10000000
got 10000000
work took 0.6706065980251878s
got 10000000
work took 0.6761910850182176s
got 10000000
work took 0.6525284160161391s
got 10000000
work took 0.6593533849809319s
got 10000000
main took 2.0157317529665306s

Підібʼємо підсумки загального часу виконання:

Ми спостерігаємо значне пришвидшення у виконанні MultiThreading, проте бачимо, що MultiProcessing отримав зворотній ефект — затримку у 20%. З чим це повʼязано та чи буде це виправлено, поки невідомо. Тож це гарна можливість поексперементувати з режимом вільного потокового виконання (free-threaded) самостійно.

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

Експериментальна компіляція Just-In-Time (JIT)

Мотивація зробити Python швидше зрозуміла. Що ж таке цей JIT?

Компілятор JIT (Just-In-Time) — це інструмент, який компілює код Python в машинний код «на льоту», під час виконання програми. Він генерує код дуже швидко і легко підтримується. Це дає змогу значно прискорити виконання програм. Одним із поширених рішень JIT-компіляторів є Numba, але зараз ми говоримо про продовження роботи по інтеграції JIT у CPython.

А як працюватиме JIT-компілятор у Python доки не відомо. PEP-744 говорять про те, що він заснований на архітектурі Copy-and-Patch. Компілює байткод Python у машинний код, використовуючи LLVM. Проте цей PEP-744 досі знаходиться у статусі чорнетки — Draft. І зазначається наступне:

Якщо ви програміст на Python або кінцевий користувач, для вас нічого не змінюється. Ніхто не повинен розповсюджувати вам інтерпретатори CPython із підтримкою JIT, поки це все ще експериментальна функція. Коли вона перестане бути експериментальною, ви, ймовірно, помітите трохи кращу продуктивність і трохи більше використання пам’яті. Ви не повинні відчути жодних інших змін.

Покращення повідомлень помилок

Протягом останніх років я помітив тенденцію — кожної осені нова версія Python дарує задоволення від корисних покращень повідомлень помилок.

Погляньмо на приклад, де виклик функції f() спричиняє помилку через NameError (відсутність змінної string).Тепер у виводі повідомлень про помилку відслідкувати послідовність виконань функцій та дерева помилок не є проблемою.

Приклад:

Наступне покращення у повідомленні про помилку — покращення повідомлення при імпорті стандартного модуля, який має ідентичну назву з тим, що виконується.

Так це виглядало у Python 3.12:

А так у Python 3.13:

Оптимізація документаційних-рядків або doc-рядків

Зменшення використання пам’яті шляхом видалення початкових відступів у рядках документів.

Приклад:

>>> class Fruit:
...     """
...     Fruit class.
...     """
...     
>>> Fruit.__doc__
'\nFruit class.\n'
>>> 

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

>>> message = """
...     Hello
... World
...     """
>>> message
'\n    Hello\nWorld\n    '

Елементи тільки для читання у TypeDicts

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

Приклад з документації:

class Movie(TypedDict):
  title: ReadOnly[str]
  year: int


def mutate_movie(m: Movie) -> None:
  m["year"] = 1999  # Окей
  m["title"] = "The Matrix"  # Помилка типізатора

У цьому випадку поле title позначено як ReadOnly[str], тобто що його значення не можна змінити після створення об’єкта. Якщо хтось все ж спробує це зробити, статичний аналізатор на кшталт mypy повідомить про помилку ще до того, як програма запуститься. Це особливо корисно для API, де важливо, щоб вхідні дані залишалися незмінними, аби уникнути несподіваних збоїв або неправильних змін.

Декоратор deprecated

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

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

Приклад:

from warnings import deprecated
from typing import overload

@deprecated("Use B instead")
class A:
    pass

@deprecated("Use g instead")
def f():
    pass


A()
f()

Старіння версії Python 3.8

Разом з виходом Python 3.13, підтримка якого обіцяється до жовтня 2029 року, ми отримуємо закінчення безпекової (security) підтримки Python 3.8. Чому так?

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

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

А ось так виглядає цей релізний цикл:

Отже, з моменту випуску поточної версії додаткові оновлення та виправлення помилок у Python будуть приходити тільки на версії Python 3.12 та Python 3.13. Тож для розробників, які ще не мігрували проєкти, може бути дійсно важливим почати переїздити з уже далеких 3.6, 3.7 а тепер і 3.8, до підтримуваних версій (краще відразу до 3.12 😁).

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

Висновок

Тож як відбулось оновлення Python 3.13 та що ми в ньому отримали:

  1. Він вийшов пізніше, ніж очікувалося, через складність змін, пов’язаних з експериментальним вимкненням GIL. Цей новий підхід вимагав часу для тестування та стабілізації. Було покращено досвід використання інтерпретатора, що зробило його більш інтерактивним і зручним для користувачів. Додані нові можливості й покращена підтримка кодування «на льоту».
  2. Глобальне блокування інтерпретатора (GIL) більше не є обов’язковим завдяки експериментальній функції. Це дозволяє програмам використовувати багатопотоковість ефективніше, що відкриває двері до кращої продуктивності на багатоядерних процесорах. Помилки тепер відображаються з більшим рівнем деталізації та зрозумілості. Це значно полегшує налагодження і робить Python більш дружнім до розробників.
  3. Оптимізація документування в Python дозволяє використовувати doc-рядки більш ефективно, що впливає як на швидкість виконання коду, так і на зручність роботи з документацією. Нововведення ReadOnly для TypedDict гарантує, що певні поля залишаться незмінними, а це додає безпеки і передбачуваності при роботі зі структурованими даними.
  4. Тепер Python має вбудований декоратор deprecated, який полегшує процес виявлення застарілого коду, дозволяючи розробникам ефективніше оновлювати свої проєкти.

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

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

Дякую за прочитання. Слава Україні 🇺🇦

👍ПодобаєтьсяСподобалось20
До обраногоВ обраному3
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

Цікаво, дякую за розбір

Вже зробили щоб a.get(b).get(d) не видавало помилку якщо, якщо d нема в b, b нема в a?

Вирішується явним заданням другого параметра для .get(), бо по замовчуванню це None, по якому робити .get() не можна:
>>> a.get("b").get("d")
AttributeError: ’NoneType’ object has no attribute ’get’

# Робоча схема
>>> a.get("b", {}).get("d")
None

На жаль, не зовсім.

>>> a = {’b’:None}
>>> print(a.get("b", {}).get("d"))
Traceback (most recent call last):
File "", line 1, in
AttributeError: ’NoneType’ object has no attribute ’get’

Це слабке місце Python, не дочекаюся коли вже виправлять.

Вже зробили щоб a.get(b).get(d) не видавало помилку якщо, якщо d нема в b, b нема в a

Це трошки хибне твердження, бо:
a = {} # Тут b немає
a = {’b’: None} # Але тут b є.

None це також значення, яке не має реалізації .get(). Тому при виклику цього методу ви отримуєте

AttributeError: ’NoneType’ object has no attribute ’get’

>>> None
>>> type(None)

>>> isinstance(None, object)
True
>>> None.get("d")
Traceback (most recent call last):
File "", line 1, in
AttributeError: ’NoneType’ object has no attribute ’get’

None це також значення, яке не має реалізації .get()

шкода

Це слабке місце Python, не дочекаюся коли вже виправлять.

Я не бачу тут слабкого місця, більше того не розумію навіщо. У більшості розробників такої проблеми просто не виникне. У 0.0..01% випадків нескладно написати потрібний метод, або класс від dict.

Знову ж таки, як ви хочете виправити це, коли в продакшені може буди купа коду, який очікує саме появу AttributeError? Бо це сама логічна поведінка. Це як хотіти прибрати NullPointerException у Java через чиєсь хотіння просто переривати виконання оператора, та переходити на наступний.

Ну, до речі, було б цікаво побачити це якимось контекстним впливом у дусі:

with none_allows_get():
  ... код ...

де модифікатор робить, що для None є get(), який завжди повертає None.

Але це більше буде розвагою ніж серйозною доробкою.

тоді вже, імхо, прикрутити модну останінм часом null-safe версію foo?.bar()

Ну це вже вимагатиме нового сінтаксису. Я не берусь гадати, який він буде у випадку Python, тому вирішив обмежитись малим варіантом.

ваш варіант взагалі через декоратор зафігачити можна. мабуть.

Стандартні типи, як None, не можна змінювати. Тому не вийде без підтримки в рантаймі.

я мав на увазі щось таке (тут код від чатжепете, сам я востаннє пітона років десять тому трогав)

def none_safe(func):
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except AttributeError as e:
            if "'NoneType' object has no attribute 'get'" in str(e):
                return None
            else:
                raise
    return wrapper

upd. хоча з декоратором незручно буде, бо ним тільки цілу функцію можна огорнути.

Тоді простіше просто зробити окремо

def none_get(src, key):
  if src is None:
    return None
  return src.get(key)

і замінити всі a?[b]?[c] на none_get(none_get(a, b), c).

Для a?.b?.c вже є готове getattr(): буде getattr(getattr(a, b, None), c, None).

Читати складніше, згоден.

AttributeError: ’NoneType’ object has no attribute ’get’

Тобто ви навмисно записали туди None замість відсутности значення взагалі — і щось хочете?

Ну тоді можна дуже просто інакше:

print((a.get('b') or {}).get('d'))

Це слабке місце Python, не дочекаюся коли вже виправлять.

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

Ба більше, якраз той метод що в Python краще коли видається якесь None/null/etc. — це з мого досвіду після Perl, в якому видається undef, і перевіряти яке в рази громіздкіше.

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

Google пішов війною на GIL бо воно їм коштує собівартості та серверного часу, тому напевно цей механізм з рештою приберуть.
Навіть Тім ідейний автор GIL, як і фактично архітект CPython — вилетів з комьюніті. Також вони навіть свою команду — яка захищала той GIL, зворотні сумісності і т.д. розпустили.
От тепер активно роблять в Python, те що є в Java, за яку довго судились. Покращують : збірку сміття, JIT, багато-задачність і т.д.

А вилетів хіба через GIL?

Кому цікаво детальніше про історію відсторонення Tim Peters почитати тут dou.ua/forums/topic/49978

Ну... Якби він вилетів через GIL, то було б розумно знайти інший привід.

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