У темі 14 ми домовились про дуже просту картину світу: інтерпретатор іде рядками згори вниз, і поки не закінчиться поточний рядок, наступний не почнеться. Ця картина правдива — і саме тому вона інколи дуже дорога. Бо серед рядків трапляються такі, у яких програма нічого не робить. Вона просто стоїть і чекає.
Ця тема — про те, як не стояти. Не про «зробити швидше» взагалі, а про дуже конкретний випадок: коли програма чекає, її можна змусити чекати кількох речей одночасно. Це не потоки, не додаткові ядра й не магія. Це домовленість між кількома шматками коду про те, хто в який момент користується єдиним процесором.
Наскрізний приклад у нас буде один: програма забирає три звіти зі сховища. Кожен звіт готується дві секунди — не тому, що ми щось рахуємо, а тому, що по той бік дроту хтось повільно відповідає.
01 / МотиваціяКуди дівається час програми
Уяви найзвичайніший скрипт: забрати три звіти, скласти їх в один файл. Написаний тими засобами, які ми вже маємо, він виглядає так:
Шість секунд. Питання, на якому тримається вся тема: що робить процесор ці шість секунд? Відповідь неприємна — майже нічого. Він передав запит мережевій карті, отримав від операційної системи вказівку «чекай» і зупинився. Одне ядро простоює, решта ядер простоюють теж, вентилятор мовчить.
Щоб відчути масштаб марнування, порівняй два числа. Один такт сучасного процесора триває приблизно 0,3 наносекунди. Одна відповідь від сервера в тій самій країні приходить приблизно за 50 мілісекунд. Це в сто сімдесят мільйонів разів довше. За час одного мережевого запиту процесор устиг би виконати сотні мільйонів операцій — а натомість не виконав жодної.
І от головне: три очікування по дві секунди не заважають одне одному. Сервер, який готує звіт про склад, нічого не знає про звіт про продажі. Немає жодної причини чекати їх по черзі — крім тієї, що ми написали код у стовпчик.
Схема 1 · Шість секунд і дві
Три запити, кожен чекає на відповідь дві секунди. Угорі — по черзі, унизу — одночасно.
Запамʼятай цей малюнок — це і є вся ідея асинхронності одним кадром. Асинхронність не робить мережу швидшою, диск моторнішим, а сервер розумнішим. Вона робить одну-єдину річ: дозволяє програмі почати друге очікування, не дочекавшись першого.
02 / Головна межаЧекання проти обчислення
А тепер про непорозуміння, через яке асинхронність псують найчастіше. Звучить воно
так: «додам async — програма працюватиме швидше». Це неправда, і важливо
зрозуміти чому, бо інакше половину зусиль буде витрачено даремно.
Уся робота програми ділиться на дві категорії, і вони поводяться протилежно:
- Очікування вводу-виведення (input/output, скорочено I/O). Програма звернулась кудись назовні й чекає: відповіді сервера, прочитаного файлу, рядків із бази, натискання клавіші. Процесор у цей час вільний.
- Обчислення (CPU-bound). Програма перемножує матриці, стискає зображення, рахує хеші, перебирає мільйон варіантів. Процесор у цей час зайнятий на всі сто.
Асинхронність працює тільки з першою категорією. Вона не додає рук — вона прибирає простій. Якщо простою немає, прибирати нічого.
Перевірка «на пальцях», яка майже ніколи не підводить: запусти програму й подивись на завантаження процесора. Одне ядро на сто відсотків, вентилятор гуде — це обчислення, асинхронність не допоможе. Процесор нудьгує, а програма все одно стоїть — це очікування, і от тут є що виграти.
Схема 2 · Де асинхронність рятує, а де ні
Ті самі три задачі по дві секунди. Ліворуч вони чекають, праворуч — рахують.
03 / Синтаксисasync def і корутина
Функцію ми оголошували словом def. Тепер зʼявляється друге слово перед
ним:
Це корутинна функція (coroutine function). Зовні вона схожа на
звичайну: має імʼя, параметри, тіло, return. Але одну річ вона робить
принципово інакше — і саме на цьому спотикаються всі без винятку.
Виклик не запускає тіло
Згадай тему 14: там дужки після імені означали «виконай». Тут — ні. Виклик корутинної функції нічого не виконує. Він створює й повертає обʼєкт:
Жодного почав: у виводі. Тіло не запустилось. У памʼяті лежить
корутина (coroutine) — обʼєкт, усередині якого записано, що треба
зробити, але який ще ніхто не почав робити. Це рецепт, а не страва.
Якщо на цьому все й закінчиться, Python при збиранні сміття скаже вголос:
Побачив таке попередження — ти десь викликав корутинну функцію й забув
await. Це найчастіша одруківка в асинхронному коді, і добре, що вона
хоч попереджає: сама по собі вона нічого не ламає, просто половина програми тихо
не виконується.
yield на вимогу
того, хто читає; у корутини — await на вимогу самої корутини.04 / Запускawait і asyncio.run
Корутину має хтось виконати. Способів рівно два.
Перший — await. Слово await означає:
«запусти цю корутину, а я почекаю її результату; поки я чекаю, керування можна
віддати комусь іншому». Писати його можна тільки всередині
async def. У звичайній функції це синтаксична помилка ще до запуску:
Другий — asyncio.run(). Це двері з синхронного світу
в асинхронний. Функція робить три речі: створює цикл подій, виконує передану корутину
до кінця й закриває цикл. Вона синхронна — її можна писати у звичайному коді:
Правило просте: asyncio.run() викликається один раз
на всю програму, у самому низу, і всередину нього передається одна корутина, яку
прийнято називати main або головна. Усе інше асинхронне
живе вже під нею й запускається через await.
asyncio.run() впаде.
Зошит уже сам працює всередині циклу подій, а другий цикл у тому самому потоці
запустити не можна:
await
можна писати прямо в клітинці, без жодної обгортки. У практиці ми перевіримо це
на власному екрані — і заразом зберемо маленьку функцію, яка запускає корутину
«як у скрипті», в окремому потоці з власним циклом.05 / МеханізмЦикл подій
Час зазирнути під капот, бо без цього async лишається набором заклинань.
Усім керує цикл подій (event loop) — звичайний нескінченний цикл,
який тримає два списки:
- черга готових — задачі, які можна виконувати прямо зараз;
- ті, що чекають — задачі, зупинені на
await, кожна зі своєю умовою пробудження: «коли мине секунда», «коли прийде відповідь».
Один оберт циклу: узяти першу задачу з черги готових і виконувати її тіло звичайним
способом — рядок за рядком — доки вона не дійде до await. У цю мить задача
віддає керування назад у цикл і переїжджає в другий список. Цикл бере наступну готову.
Коли готових не лишилось, цикл просто спить до найближчої події.
Схема 3 · Як влаштований цикл подій
Чотири стани, через які проходить кожна задача. Рух завжди за годинниковою стрілкою.
Найважливіший наслідок цієї будови: перемикання відбувається лише на
await. Не «інколи», не «коли системі заманеться» — рівно там,
де в коді написано це слово, і більше ніде. Такий спосіб називають кооперативною
багатозадачністю: задачі поступаються керуванням добровільно. Якщо якась не поступиться,
змусити її ніхто не зможе — і саме звідси ростуть усі неприємності, про які буде
розділ 07.
Подивись, як три корутини з різними паузами проходять через цикл. Натискай «наступний крок» і стеж за годинником зліва вгорі: перші три кроки відбуваються в один і той самий момент нульового часу.
Інтерактив 1 · Черга задач крок за кроком
Три корутини з паузами 3, 1 і 2 секунди, запущені разом. Рожева риска — поточний час циклу.
Зверни увагу на порядок виводу. Задачі надрукували «почав» у тому порядку, у якому їх передали, а «готово» — у порядку своїх пауз: спершу друга, потім третя, потім перша. Асинхронний код не гарантує порядку завершення. Він гарантує лише те, що кожна задача доробить своє.
06 / Разомgather: запустити багато
Тут ховається пастка, у яку потрапляють майже всі. Ось код, у якому є і
async, і await, і asyncio — і який працює
рівно шість секунд:
Чому так? Бо await — це «почекай тут». Перший рядок зупиняє
головна до кінця першого завантаження. Так, цикл подій у цей час вільний
і міг би зайнятися чимось іншим — але нічого іншого йому не дали. Другого завантаження
ще не існує: воно навіть не створене, бо до другого рядка виконання не дійшло.
Щоб очікування накладались, задачі мають потрапити в цикл усі одразу.
Найкоротший спосіб — asyncio.gather:
Розберімо, що тут сталось. Три виклики в дужках створили три корутини — жодна
з них ще не виконується. gather віддав усі три циклу подій під нагляд,
перетворивши кожну на задачу (Task). Далі один await
чекає всіх трьох одразу.
Дві деталі, які варто знати з першого дня:
gatherповертає список результатів у порядку аргументів, а не в порядку завершення. Навіть якщо «склад» відповів першим, він лишиться другим у списку. Це дуже зручно: розпакуванняа, б, в = звітипрацює передбачувано.- Під капотом кожна корутина обгортається в задачу через
asyncio.create_task(). Цю функцію можна викликати й самому — тоді корутина почне виконуватись у циклі, а ти чекатимеш її результату пізніше й в іншому місці.
Скільки саме економить gather, залежить від двох чисел: скільки задач
і скільки кожна чекає. Покрути обидва повзунки.
Інтерактив 2 · Скільки економить gather
Смуга послідовного варіанта завжди на всю ширину — дивись, яку її частину займає асинхронний.
07 / Пасткаtime.sleep усередині корутини
А тепер найпоширеніша помилка асинхронного коду — і найпідступніша, бо програма від неї не падає. Вона просто мовчки перестає бути асинхронною.
Порівняй два рядки. Виглядають вони майже однаково:
asyncio.sleep — корутина. Вона каже циклу: «постав мене в очікування
на дві секунди й займися кимось іншим». Керування повертається в цикл, решта задач
працює.
time.sleep — звичайна функція з теми
22. Вона зупиняє потік. А потік у нас один, і в ньому крутиться
цикл подій. Отже, зупиняється цикл. Отже, стоять усі задачі: і ті, що вже прокинулись,
і ті, у яких давно прийшла відповідь. Ніхто не може навіть повідомити про це, бо
повідомляти нема кому — цикл спить.
Інтерактив 3 · Один time.sleep спиняє все
Три задачі, кожна з паузою 2 секунди. Перемикач змінює лише те, якою саме функцією зроблено паузу.
asyncio.sleep
він майже весь час вільний і встигає роздати всі три задачі за перші мікросекунди.
З time.sleep він заблокований усі шість секунд поспіль, і за цей час
не може ні прийняти відповідь, ні розбудити задачу, ні навіть обробити натискання
Ctrl+C. Синтаксис коду при цьому лишився асинхронним — а поведінка стала гіршою
за звичайний скрипт.Це стосується не тільки time.sleep. Будь-який
синхронний виклик, який чекає, блокує цикл так само:
requests.get(...)— класика; бібліотека синхронна, іawaitперед нею поставити неможливо;- читання великого файлу звичайним
open(...).read(); - запит через синхронний драйвер бази даних;
input()— чекає людину, а разом із нею чекає весь цикл.
Якщо синхронний виклик прибрати не можна (бібліотека одна й асинхронної версії немає), є рятівний вихід — віддати його справжньому потоку:
asyncio.to_thread запускає функцію в окремому потоці, а корутина
чекає на неї звичайним await, не блокуючи цикл. Це не безкоштовно —
потоки коштують памʼяті, і їх не можна заводити десятки тисяч — але для десятка
викликів це чесне рішення.
08 / Сучасний спосібTaskGroup
gather існує з давніх версій і нікуди не подінеться, але з
Python 3.11 у стандартній бібліотеці зʼявився зручніший інструмент —
asyncio.TaskGroup. Це менеджер контексту, тобто працює через
async with:
Читається це майже як речення: «поки ми всередині блоку — створюємо задачі; на
виході з блоку — чекаємо на всіх». Вихід із async with сам робить
await за тебе, забути про нього неможливо.
Три відмінності від gather, які варті окремої уваги:
- Задачі мають імена в коді.
а,б,в— це обʼєкти задач; результат береться з них методом.result()після виходу з блоку. Не треба тримати в голові, який елемент списку якому аргументу відповідав. - Жодна задача не лишиться безхазяйною. Із блоку неможливо вийти,
поки живі створені в ньому задачі — навіть якщо всередині сталась помилка або
спрацював
return. - Поведінка при помилці визначена й передбачувана. Про це — наступний розділ.
Що обирати? Якщо в тебе Python 3.11 і новіший і задач більш ніж одна —
TaskGroup. gather лишається доречним для короткого
«запустити три штуки й забрати список», а також коли потрібна поведінка
return_exceptions=True, якої в групи немає.
09 / ПомилкиКоли задача в групі падає
У темі 18 ми домовились: виняток летить угору по стеку викликів, доки хтось його не спіймає. З задачами це правило треба уточнити, бо задач багато, а місце виклику одне.
Як поводиться gather
За замовчуванням перший виняток, що стався в будь-якій із задач, негайно летить
у місце, де стоїть await asyncio.gather(...). Але — і це важлива
дрібниця — решта задач не скасовуються. Вони й далі крутяться
в циклі, а їхні результати й помилки нікому не потрібні й тихо зникають.
Якщо треба зібрати все, що вийшло, разом із помилками, є прапорець:
Тепер нічого не летить угору: на місці невдалої задачі в списку лежить сам обʼєкт
винятку. Перевіряти доведеться вручну — isinstance(елемент, Exception).
Зручно, коли часткова відповідь краща за жодну: три звіти з чотирьох — це все ще звіт.
Як поводиться TaskGroup
Група працює строгіше й, як на мене, чесніше. Щойно одна задача падає, група скасовує всі інші, дочікується їхнього скасування й лише тоді випускає помилку назовні. Логіка: якщо частина роботи провалилась, доробляти решту зазвичай безглуздо.
Назовні при цьому летить не сам виняток, а ExceptionGroup — група
винятків. Навіть якщо впала одна-єдина задача. Причина проста: упасти могли дві
одночасно, і викидати одну з них, мовчки викинувши другу, було б брехнею. Ловлять
таку групу зіркою:
except* — синтаксис із Python 3.11, спеціально для груп винятків.
Він відбирає з групи ті винятки, що підходять під тип, і віддає їх у вигляді
меншої групи. Гілок except* може бути кілька, і — на відміну від
звичайного except — спрацювати можуть кілька одразу,
якщо в групі трапились різні типи помилок.
asyncio.CancelledError, який кидається в задачу
в точці її await. З Python 3.8 він успадковується не від
Exception, а від BaseException — саме щоб недбалий
except Exception: посеред корутини не проковтнув скасування й не
перетворив задачу на невбиваєму. Якщо ти ловиш CancelledError навмисно
(наприклад, щоб прибрати за собою), обовʼязково кинь його далі через
raise.10 / МежіКоли асинхронність не потрібна
Асинхронність — не покращення коду, а компроміс. Ти отримуєш накладання очікувань і платиш за нього складністю. Ось випадки, коли платити не варто.
- Програма й так послідовна. Прочитати файл, порахувати, записати результат. Тут просто нема чого накладати — усі кроки залежать один від одного по ланцюжку.
- Робота — це обчислення. Розділ 02 і права половина другої схеми. Виграшу нуль, а коду стало більше.
- Обсяг маленький. Три запити по 50 мілісекунд: послідовно — 150 мс, асинхронно — 50 мс. Зекономлено 0,1 секунди, яких ніхто не помітить, а складність лишиться в коді назавжди.
- Потрібна бібліотека синхронна. Про це нижче окремо — без асинхронного клієнта виграшу не буде взагалі.
І ще одна властивість, про яку варто знати заздалегідь, бо вона впливає на весь
проєкт. Асинхронність заразна: await можна писати лише
всередині async def. Отже, якщо десь глибоко внизу зʼявилась одна
корутина, то функція, яка її викликає, теж має стати async — і та, що
викликає її, теж, і так до самого main. Англійською це називають
«фарбуванням функцій»: у програмі зʼявляються два кольори, і синій код не може
викликати червоний просто так.
Практичний висновок: асинхронність — рішення рівня архітектури, а не локальна оптимізація. Її варто брати, коли одночасних очікувань десятки й сотні: сервер, який тримає тисячу підключень; збирач даних, що опитує двісті адрес; бот, який обслуговує багатьох людей одразу.
11 / Для профіПотоки, процеси, асинхронність
Асинхронність — не єдиний спосіб робити кілька справ. Порівняймо три підходи чесно, бо їх постійно плутають.
| Ознака | Потоки | Процеси | Асинхронність |
|---|---|---|---|
| ядер задіяно | одне (заважає GIL) | скільки процесів | одне |
| хто перемикає | система, будь-коли | система, будь-коли | сама задача, на await |
| ціна одиниці | близько 8 МБ стека | окрема памʼять процесу | кілька кілобайтів |
| скільки тримати | сотні | одиниці-десятки | десятки тисяч |
| спільні дані | спільні, потрібні замки | окремі, потрібен обмін | спільні, замки майже не потрібні |
| для чого | блокуючі бібліотеки | обчислення | багато очікувань |
| головний ризик | гонки даних | дорогий обмін даними | один блокуючий виклик спиняє все |
Один абзац про GIL
У рядку «ядер задіяно» стоїть дивне: потоків багато, а ядро одне. Причина —
глобальне блокування інтерпретатора (Global Interpreter Lock, GIL).
У класичному CPython байткод у кожен момент виконує рівно один потік: замок один
на весь інтерпретатор, і потоки передають його одне одному. Тому потоки не
пришвидшують обчислення — вони лише дозволяють комусь працювати, поки інший чекає
на введення-виведення (на час очікування замок віддається). Обійти GIL можна
процесами: у кожного процесу власний інтерпретатор і власний замок. Саме тому
multiprocessing — інструмент для обчислень, а threading —
для очікувань. У Python 3.13 зʼявилась експериментальна збірка зовсім без GIL, але
за замовчуванням поки що чинна саме описана картина. До асинхронності все це не має
стосунку взагалі: там потік один, ділити замок нема з ким.
Чому бібліотека мусить бути асинхронною
І остання річ, яка розчаровує найболючіше. Слово await не робить
чужий код неблокуючим. Воно вміє лише прийняти керування від того, хто сам його
віддає. Синхронна бібліотека керування не віддає ніколи — вона не вміє, її писали
іншими правилами.
Тому асинхронний виграш можливий лише тоді, коли асинхронний увесь ланцюжок — від твоєї корутини до самого системного виклику:
| Замість | Береш | Що робить |
|---|---|---|
| requests | aiohttp, httpx | HTTP-запити без блокування циклу |
| psycopg (синхронно) | asyncpg | звернення до PostgreSQL |
| open().read() | aiofiles або to_thread | файли без зупинки циклу |
| time.sleep | asyncio.sleep | пауза, що віддає керування |
Найгірший можливий результат — написати повністю асинхронний застосунок і залишити
всередині requests.get(). Формально код асинхронний, фактично він працює
як послідовний, але тепер його ще й важче читати. Перевірка проста: у продуктивному
коді корисно час від часу вмикати
asyncio.get_running_loop().set_debug(True) — цикл сам почне писати
попередження про виклики, що виконувались підозріло довго й не віддавали керування.
12 / ПідсумокЩо лишається в голові
- Асинхронність — про очікування, не про швидкість. Вона накладає паузи одна на одну. Там, де пауз немає, вона не дає нічого.
async defстворює корутину, і виклик її не запускає. Запускаєawaitзсередини абоasyncio.run()зовні.- Цикл подій — один потік і одне ядро. Перемикання відбувається
рівно на
awaitі більше ніде. - Послідовні
awaitнічого не паралелять. Щоб очікування накладались, задачі мають потрапити в цикл разом — черезgatherабоTaskGroup. - Один блокуючий виклик убиває всю вигоду.
time.sleep,requests, синхронний драйвер бази — цикл стоїть, задачі стоять. TaskGroup— стандарт із 3.11. Помилка в одній задачі скасовує решту, а назовні летитьExceptionGroup, який ловлять черезexcept*.
На цьому блок «Сучасний Python» закінчується. Чотири його теми — типи, генератори, декоратори й асинхронність — обʼєднані одним: усі вони не додають нових даних, а змінюють спосіб поводитися з тим, що вже є. Типи описують наміри, генератори керують тим, коли значення зʼявляється, декоратори — тим, що відбувається навколо виклику, асинхронність — тим, у якому порядку виконуються шматки коду.
А тепер згадай праву половину другої схеми — ту, де асинхронність не дала нічого, бо задача рахувала. Це питання лишилось відкритим, і наступний блок починається саме з відповіді на нього. Блок 8 — місток до даних: NumPy, pandas і графіки. І перший же інструмент, NumPy, працює за протилежним принципом: він не переставляє очікування, а виносить сам цикл обчислення з Python у машинний код, де мільйон додавань займає мілісекунди. Ми навчились не марнувати час, коли програма чекає. Далі навчимось не марнувати його, коли вона рахує.
practice.ipynb ти на власному
екрані побачиш RuntimeError від asyncio.run() у зошиті,
зміряєш секундоміром різницю між послідовним await і
gather, підкладеш у корутину time.sleep і побачиш, як
виграш зникає до нуля, перепишеш усе на TaskGroup і зловиш
ExceptionGroup через except*. Мережі не буде — усі
очікування ми імітуємо asyncio.sleep, і кожен висновок закріпимо
через assert.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.