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

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

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

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

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

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

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

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

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

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

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

Шість секунд і дві

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Запуск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() падає — і це не твоя помилка. Jupyter (як і IPython, і будь-яке середовище, що саме вміє чекати на await) запускає цикл подій ще до того, як ти написав перший рядок. А asyncio.run() починає з того, що створює новий цикл, — і другий цикл у тому самому потоці не запускається:
RuntimeError: asyncio.run() cannot be called from a running event loop
Той самий рядок у файлі .py працює нормально, бо там циклу ще немає. Отже правило: спершу зʼясуй, чи цикл уже запущений. У звичайному скрипті його немає, і двері всередину відчиняє asyncio.run(). У середовищі з уже запущеним циклом дверей не треба — await пишеться прямо в клітинці, без жодної обгортки. А якщо код усе-таки мусить запуститись «як у скрипті», його виносять в окремий потік із власним циклом: там asyncio.run() знову законний, бо потік інший. Перевірити, де ти опинився, можна одним рядком — asyncio.get_running_loop() або поверне цикл, або скаже RuntimeError: no running event loop.

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

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

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

Як влаштований цикл подій

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

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

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

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

Черга задач крок за кроком

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

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

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

Разом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, залежить від двох чисел: скільки задач і скільки кожна чекає. Покрути обидва повзунки.

Скільки економить gather

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

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

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

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

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

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

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

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

Один time.sleep спиняє все

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

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

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

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

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

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

Сучасний спосіб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, якої в групи немає.

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

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

Як поводиться 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.

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

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

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

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

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

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

ОзнакаПотокиПроцесиАсинхронність
ядер задіяноодне (заважає 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) — цикл сам почне писати попередження про виклики, що виконувались підозріло довго й не віддавали керування.

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

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

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