Python з нуля · Блок 7 · Тема 32

Поки програма чекає

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

У темі 14 ми домовились про дуже просту картину світу: інтерпретатор іде рядками згори вниз, і поки не закінчиться поточний рядок, наступний не почнеться. Ця картина правдива — і саме тому вона інколи дуже дорога. Бо серед рядків трапляються такі, у яких програма нічого не робить. Вона просто стоїть і чекає.

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

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

01 / МотиваціяКуди дівається час програми

Уяви найзвичайніший скрипт: забрати три звіти, скласти їх в один файл. Написаний тими засобами, які ми вже маємо, він виглядає так:

звіт_1 = завантажити("продажі") # 2 секунди звіт_2 = завантажити("склад") # 2 секунди звіт_3 = завантажити("повернення") # 2 секунди print("готово") # разом 6 секунд

Шість секунд. Питання, на якому тримається вся тема: що робить процесор ці шість секунд? Відповідь неприємна — майже нічого. Він передав запит мережевій карті, отримав від операційної системи вказівку «чекай» і зупинився. Одне ядро простоює, решта ядер простоюють теж, вентилятор мовчить.

Щоб відчути масштаб марнування, порівняй два числа. Один такт сучасного процесора триває приблизно 0,3 наносекунди. Одна відповідь від сервера в тій самій країні приходить приблизно за 50 мілісекунд. Це в сто сімдесят мільйонів разів довше. За час одного мережевого запиту процесор устиг би виконати сотні мільйонів операцій — а натомість не виконав жодної.

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

Схема 1 · Шість секунд і дві

Три запити, кожен чекає на відповідь дві секунди. Угорі — по черзі, унизу — одночасно.

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

Запамʼятай цей малюнок — це і є вся ідея асинхронності одним кадром. Асинхронність не робить мережу швидшою, диск моторнішим, а сервер розумнішим. Вона робить одну-єдину річ: дозволяє програмі почати друге очікування, не дочекавшись першого.

02 / Головна межаЧекання проти обчислення

А тепер про непорозуміння, через яке асинхронність псують найчастіше. Звучить воно так: «додам async — програма працюватиме швидше». Це неправда, і важливо зрозуміти чому, бо інакше половину зусиль буде витрачено даремно.

Уся робота програми ділиться на дві категорії, і вони поводяться протилежно:

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

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

Схема 2 · Де асинхронність рятує, а де ні

Ті самі три задачі по дві секунди. Ліворуч вони чекають, праворуч — рахують.

Праворуч нижня смуга не змінилась — і це не помилка малюнка. Задача, яка рахує, не має моменту, коли їй нема чого робити: вона не віддає керування, доки не порахує все. Три обчислення на одному ядрі займуть свої шість секунд у будь-якому порядку. Асинхронність тут не сповільнює — вона просто не має за що зачепитись.
Куди віднести розбір великого JSON? До обчислень. Читання файлу з диска — очікування, а от перетворення двохсот мегабайтів тексту на словники — це вже чиста робота процесора, і вона так само блокує все навколо. Плутанина тут типова: операцію називають «читанням файлу» цілком, хоча дешева її частина — очікування, а дорога — обчислення.

03 / Синтаксисasync def і корутина

Функцію ми оголошували словом def. Тепер зʼявляється друге слово перед ним:

import asyncio async def завантажити(назва): print("почав:", назва) await asyncio.sleep(2) # тут програма чекає print("готово:", назва) return назва.upper()

Це корутинна функція (coroutine function). Зовні вона схожа на звичайну: має імʼя, параметри, тіло, return. Але одну річ вона робить принципово інакше — і саме на цьому спотикаються всі без винятку.

Виклик не запускає тіло

Згадай тему 14: там дужки після імені означали «виконай». Тут — ні. Виклик корутинної функції нічого не виконує. Він створює й повертає обʼєкт:

>>> результат = завантажити("продажі") >>> результат <coroutine object завантажити at 0x7f8a1c0d2a40> >>> type(результат) <class 'coroutine'>

Жодного почав: у виводі. Тіло не запустилось. У памʼяті лежить корутина (coroutine) — обʼєкт, усередині якого записано, що треба зробити, але який ще ніхто не почав робити. Це рецепт, а не страва.

Якщо на цьому все й закінчиться, Python при збиранні сміття скаже вголос:

RuntimeWarning: coroutine 'завантажити' was never awaited

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

Для тих, хто читав тему про генератори. Впізнаване відчуття: генератор теж не виконується від виклику, теж повертає обʼєкт і теж уміє зупинитись посеред тіла й потім продовжити з того самого місця. Це не збіг — асинхронність у Python історично виросла саме з генераторів, і механізм паузи в неї той самий. Різниця в тому, хто цю паузу ставить: у генератора — yield на вимогу того, хто читає; у корутини — await на вимогу самої корутини.

04 / Запускawait і asyncio.run

Корутину має хтось виконати. Способів рівно два.

Перший — await. Слово await означає: «запусти цю корутину, а я почекаю її результату; поки я чекаю, керування можна віддати комусь іншому». Писати його можна тільки всередині async def. У звичайній функції це синтаксична помилка ще до запуску:

>>> def звичайна(): ... await asyncio.sleep(1) File "<stdin>", line 2 SyntaxError: 'await' outside async function

Другий — asyncio.run(). Це двері з синхронного світу в асинхронний. Функція робить три речі: створює цикл подій, виконує передану корутину до кінця й закриває цикл. Вона синхронна — її можна писати у звичайному коді:

import asyncio async def головна(): звіт = await завантажити("продажі") print("отримали:", звіт) if __name__ == "__main__": asyncio.run(головна()) # єдина точка входу

Правило просте: asyncio.run() викликається один раз на всю програму, у самому низу, і всередину нього передається одна корутина, яку прийнято називати main або головна. Усе інше асинхронне живе вже під нею й запускається через await.

У зошиті Jupyter asyncio.run() впаде. Зошит уже сам працює всередині циклу подій, а другий цикл у тому самому потоці запустити не можна:
RuntimeError: asyncio.run() cannot be called from a running event loop
Це не поломка й не помилка в твоєму коді. У зошиті цикл уже є, тому await можна писати прямо в клітинці, без жодної обгортки. У практиці ми перевіримо це на власному екрані — і заразом зберемо маленьку функцію, яка запускає корутину «як у скрипті», в окремому потоці з власним циклом.

05 / МеханізмЦикл подій

Час зазирнути під капот, бо без цього async лишається набором заклинань. Усім керує цикл подій (event loop) — звичайний нескінченний цикл, який тримає два списки:

Один оберт циклу: узяти першу задачу з черги готових і виконувати її тіло звичайним способом — рядок за рядком — доки вона не дійде до await. У цю мить задача віддає керування назад у цикл і переїжджає в другий список. Цикл бере наступну готову. Коли готових не лишилось, цикл просто спить до найближчої події.

Схема 3 · Як влаштований цикл подій

Чотири стани, через які проходить кожна задача. Рух завжди за годинниковою стрілкою.

Ключове слово в центрі — «одна». Хоч скільки задач у роботі, виконується завжди рівно одна: потік один, ядро одне. Уся вигода береться з того, що задача, яка чекає, не займає це ядро, а стоїть у нижньому лівому кутку схеми й нікому не заважає.

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

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

Інтерактив 1 · Черга задач крок за кроком

Три корутини з паузами 3, 1 і 2 секунди, запущені разом. Рожева риска — поточний час циклу.

годинник0.0 c
у черзі готових3
сплять0
завершено0
Кроки 1-3 — суть усієї теми. Годинник на них не рухається: три задачі встигли стартувати й заснути в один і той самий нульовий момент, бо старт коштує мікросекунди, а спить кожна сама по собі. Далі цикл просто збирає їх у порядку пробудження: 1,0 → 2,0 → 3,0. Загальний час — три секунди, найдовша з пауз, а не їхня сума.

Зверни увагу на порядок виводу. Задачі надрукували «почав» у тому порядку, у якому їх передали, а «готово» — у порядку своїх пауз: спершу друга, потім третя, потім перша. Асинхронний код не гарантує порядку завершення. Він гарантує лише те, що кожна задача доробить своє.

06 / Разомgather: запустити багато

Тут ховається пастка, у яку потрапляють майже всі. Ось код, у якому є і async, і await, і asyncio — і який працює рівно шість секунд:

async def головна(): а = await завантажити("продажі") # чекаємо 2 секунди б = await завантажити("склад") # ще 2 секунди в = await завантажити("повернення") # ще 2 секунди return [а, б, в]

Чому так? Бо await — це «почекай тут». Перший рядок зупиняє головна до кінця першого завантаження. Так, цикл подій у цей час вільний і міг би зайнятися чимось іншим — але нічого іншого йому не дали. Другого завантаження ще не існує: воно навіть не створене, бо до другого рядка виконання не дійшло.

Щоб очікування накладались, задачі мають потрапити в цикл усі одразу. Найкоротший спосіб — asyncio.gather:

async def головна(): звіти = await asyncio.gather( завантажити("продажі"), завантажити("склад"), завантажити("повернення"), ) return звіти # дві секунди замість шести

Розберімо, що тут сталось. Три виклики в дужках створили три корутини — жодна з них ще не виконується. gather віддав усі три циклу подій під нагляд, перетворивши кожну на задачу (Task). Далі один await чекає всіх трьох одразу.

Дві деталі, які варто знати з першого дня:

Скільки саме економить gather, залежить від двох чисел: скільки задач і скільки кожна чекає. Покрути обидва повзунки.

Інтерактив 2 · Скільки економить gather

Смуга послідовного варіанта завжди на всю ширину — дивись, яку її частину займає асинхронний.

послідовно
асинхронно
швидше в
зекономлено
Постав одну задачу — і виграш зникає. Асинхронність нічого не прискорює всередині окремої задачі: одне очікування триває стільки, скільки триває. Виграш береться виключно з накладання очікувань, тому він росте рівно так само, як кількість задач. Модель тут ідеальна: у житті додаються витрати на перемикання — але вони мікросекундні проти секундних пауз, тому картина майже не змінюється.

07 / Пасткаtime.sleep усередині корутини

А тепер найпоширеніша помилка асинхронного коду — і найпідступніша, бо програма від неї не падає. Вона просто мовчки перестає бути асинхронною.

Порівняй два рядки. Виглядають вони майже однаково:

await asyncio.sleep(2) # «розбуди мене через 2 секунди» time.sleep(2) # «нікого не чіпати 2 секунди»

asyncio.sleep — корутина. Вона каже циклу: «постав мене в очікування на дві секунди й займися кимось іншим». Керування повертається в цикл, решта задач працює.

time.sleep — звичайна функція з теми 22. Вона зупиняє потік. А потік у нас один, і в ньому крутиться цикл подій. Отже, зупиняється цикл. Отже, стоять усі задачі: і ті, що вже прокинулись, і ті, у яких давно прийшла відповідь. Ніхто не може навіть повідомити про це, бо повідомляти нема кому — цикл спить.

Інтерактив 3 · Один time.sleep спиняє все

Три задачі, кожна з паузою 2 секунди. Перемикач змінює лише те, якою саме функцією зроблено паузу.

загальний час
цикл заблоковано
задач одночасно
виграш від async
Нижня смуга — стан самого циклу подій. З asyncio.sleep він майже весь час вільний і встигає роздати всі три задачі за перші мікросекунди. З time.sleep він заблокований усі шість секунд поспіль, і за цей час не може ні прийняти відповідь, ні розбудити задачу, ні навіть обробити натискання Ctrl+C. Синтаксис коду при цьому лишився асинхронним — а поведінка стала гіршою за звичайний скрипт.

Це стосується не тільки time.sleep. Будь-який синхронний виклик, який чекає, блокує цикл так само:

Якщо синхронний виклик прибрати не можна (бібліотека одна й асинхронної версії немає), є рятівний вихід — віддати його справжньому потоку:

рядки = await asyncio.to_thread(важка_синхронна_функція, аргумент)

asyncio.to_thread запускає функцію в окремому потоці, а корутина чекає на неї звичайним await, не блокуючи цикл. Це не безкоштовно — потоки коштують памʼяті, і їх не можна заводити десятки тисяч — але для десятка викликів це чесне рішення.

08 / Сучасний спосібTaskGroup

gather існує з давніх версій і нікуди не подінеться, але з Python 3.11 у стандартній бібліотеці зʼявився зручніший інструмент — asyncio.TaskGroup. Це менеджер контексту, тобто працює через async with:

async def головна(): async with asyncio.TaskGroup() as група: а = група.create_task(завантажити("продажі")) б = група.create_task(завантажити("склад")) в = група.create_task(завантажити("повернення")) # сюди виконання потрапить лише тоді, # коли всі три задачі завершаться return [а.result(), б.result(), в.result()]

Читається це майже як речення: «поки ми всередині блоку — створюємо задачі; на виході з блоку — чекаємо на всіх». Вихід із async with сам робить await за тебе, забути про нього неможливо.

Три відмінності від gather, які варті окремої уваги:

Що обирати? Якщо в тебе Python 3.11 і новіший і задач більш ніж одна — TaskGroup. gather лишається доречним для короткого «запустити три штуки й забрати список», а також коли потрібна поведінка return_exceptions=True, якої в групи немає.

09 / ПомилкиКоли задача в групі падає

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

Як поводиться gather

За замовчуванням перший виняток, що стався в будь-якій із задач, негайно летить у місце, де стоїть await asyncio.gather(...). Але — і це важлива дрібниця — решта задач не скасовуються. Вони й далі крутяться в циклі, а їхні результати й помилки нікому не потрібні й тихо зникають.

Якщо треба зібрати все, що вийшло, разом із помилками, є прапорець:

результати = await asyncio.gather( завантажити("продажі"), завантажити("зламаний"), завантажити("склад"), return_exceptions=True, ) # [ 'ПРОДАЖІ', ValueError('немає такого звіту'), 'СКЛАД' ]

Тепер нічого не летить угору: на місці невдалої задачі в списку лежить сам обʼєкт винятку. Перевіряти доведеться вручну — isinstance(елемент, Exception). Зручно, коли часткова відповідь краща за жодну: три звіти з чотирьох — це все ще звіт.

Як поводиться TaskGroup

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

Назовні при цьому летить не сам виняток, а ExceptionGroup — група винятків. Навіть якщо впала одна-єдина задача. Причина проста: упасти могли дві одночасно, і викидати одну з них, мовчки викинувши другу, було б брехнею. Ловлять таку групу зіркою:

try: async with asyncio.TaskGroup() as група: група.create_task(завантажити("продажі")) група.create_task(завантажити("зламаний")) except* ValueError as помилки: for e in помилки.exceptions: print("не вдалося:", e)

except* — синтаксис із Python 3.11, спеціально для груп винятків. Він відбирає з групи ті винятки, що підходять під тип, і віддає їх у вигляді меншої групи. Гілок except* може бути кілька, і — на відміну від звичайного exceptспрацювати можуть кілька одразу, якщо в групі трапились різні типи помилок.

Для тих, хто вже пише код. Скасування задачі всередині влаштоване як виняток asyncio.CancelledError, який кидається в задачу в точці її await. З Python 3.8 він успадковується не від Exception, а від BaseException — саме щоб недбалий except Exception: посеред корутини не проковтнув скасування й не перетворив задачу на невбиваєму. Якщо ти ловиш CancelledError навмисно (наприклад, щоб прибрати за собою), обовʼязково кинь його далі через raise.

10 / МежіКоли асинхронність не потрібна

Асинхронність — не покращення коду, а компроміс. Ти отримуєш накладання очікувань і платиш за нього складністю. Ось випадки, коли платити не варто.

І ще одна властивість, про яку варто знати заздалегідь, бо вона впливає на весь проєкт. Асинхронність заразна: await можна писати лише всередині async def. Отже, якщо десь глибоко внизу зʼявилась одна корутина, то функція, яка її викликає, теж має стати async — і та, що викликає її, теж, і так до самого main. Англійською це називають «фарбуванням функцій»: у програмі зʼявляються два кольори, і синій код не може викликати червоний просто так.

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

11 / Для профіПотоки, процеси, асинхронність

Асинхронність — не єдиний спосіб робити кілька справ. Порівняймо три підходи чесно, бо їх постійно плутають.

ОзнакаПотокиПроцесиАсинхронність
ядер задіяноодне (заважає GIL)скільки процесіводне
хто перемикаєсистема, будь-колисистема, будь-колисама задача, на await
ціна одиниціблизько 8 МБ стекаокрема памʼять процесукілька кілобайтів
скільки триматисотніодиниці-десяткидесятки тисяч
спільні даніспільні, потрібні замкиокремі, потрібен обмінспільні, замки майже не потрібні
для чогоблокуючі бібліотекиобчисленнябагато очікувань
головний ризикгонки данихдорогий обмін данимиодин блокуючий виклик спиняє все

Один абзац про GIL

У рядку «ядер задіяно» стоїть дивне: потоків багато, а ядро одне. Причина — глобальне блокування інтерпретатора (Global Interpreter Lock, GIL). У класичному CPython байткод у кожен момент виконує рівно один потік: замок один на весь інтерпретатор, і потоки передають його одне одному. Тому потоки не пришвидшують обчислення — вони лише дозволяють комусь працювати, поки інший чекає на введення-виведення (на час очікування замок віддається). Обійти GIL можна процесами: у кожного процесу власний інтерпретатор і власний замок. Саме тому multiprocessing — інструмент для обчислень, а threading — для очікувань. У Python 3.13 зʼявилась експериментальна збірка зовсім без GIL, але за замовчуванням поки що чинна саме описана картина. До асинхронності все це не має стосунку взагалі: там потік один, ділити замок нема з ким.

Чому бібліотека мусить бути асинхронною

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

Тому асинхронний виграш можливий лише тоді, коли асинхронний увесь ланцюжок — від твоєї корутини до самого системного виклику:

ЗамістьБерешЩо робить
requestsaiohttp, httpxHTTP-запити без блокування циклу
psycopg (синхронно)asyncpgзвернення до PostgreSQL
open().read()aiofiles або to_threadфайли без зупинки циклу
time.sleepasyncio.sleepпауза, що віддає керування

Найгірший можливий результат — написати повністю асинхронний застосунок і залишити всередині requests.get(). Формально код асинхронний, фактично він працює як послідовний, але тепер його ще й важче читати. Перевірка проста: у продуктивному коді корисно час від часу вмикати asyncio.get_running_loop().set_debug(True) — цикл сам почне писати попередження про виклики, що виконувались підозріло довго й не віддавали керування.

12 / ПідсумокЩо лишається в голові

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

А тепер згадай праву половину другої схеми — ту, де асинхронність не дала нічого, бо задача рахувала. Це питання лишилось відкритим, і наступний блок починається саме з відповіді на нього. Блок 8 — місток до даних: NumPy, pandas і графіки. І перший же інструмент, NumPy, працює за протилежним принципом: він не переставляє очікування, а виносить сам цикл обчислення з Python у машинний код, де мільйон додавань займає мілісекунди. Ми навчились не марнувати час, коли програма чекає. Далі навчимось не марнувати його, коли вона рахує.

Далі в практиці. У practice.ipynb ти на власному екрані побачиш RuntimeError від asyncio.run() у зошиті, зміряєш секундоміром різницю між послідовним await і gather, підкладеш у корутину time.sleep і побачиш, як виграш зникає до нуля, перепишеш усе на TaskGroup і зловиш ExceptionGroup через except*. Мережі не буде — усі очікування ми імітуємо asyncio.sleep, і кожен висновок закріпимо через assert.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.