Пропустити навігацію EPAM

Asyncio для Python-інженерів: секрети ефективного асинхронного коду

Думка експерта
  • Python

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

Завдяки асинхронному програмуванню, наші програми можуть обробляти численні запити користувачів одночасно, значно зменшуючи час відгуку сервера. Зокрема, використання корутин (coroutine), що визначаються за допомогою async def, дозволяє призупиняти та відновлювати виконання функцій, забезпечуючи ефективне керування ресурсами. У цій статті ми розглянемо, як максимально використати можливості asyncio для створення швидких та ефективних Python-додатків, особливо зосередившись на оптимізації введення-виведення та мережевих запитів.

Основи asyncio: як працює асинхронне програмування в Python

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

Event loop: механізм керування виконанням

Подієвий цикл (event loop) — це серце будь-якого asyncio-додатка. Фактично, він виконує роль диспетчера, який оркеструє всі завдання та операції введення-виведення.

Цикл подій виконує такі основні функції:

  • виконання співпрограм та зворотних викликів;
  • здійснення мережевих операцій введення-виведення;
  • запуск підпроцесів;
  • розподіл завдань через черги;
  • синхронізація конкурентного коду.

Зазвичай, розробники використовують високорівневу функцію asyncio.run() для запуску event loop, не торкаючись його внутрішньої логіки. Однак, для детальнішого керування циклом подій можна використовувати методи run_until_complete() або run_forever().

async/await: як працюють співпрограми

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

  1. Розширені можливості: на відміну від звичайних функцій, корутини можуть призупиняти та відновлювати своє виконання, зберігаючи контекст. Це дозволяє ефективно керувати потоком виконання програми.
  2. Збереження стану: корутини зберігають свій стан між викликами, що робить їх ідеальними для реалізації ітераторів та генераторів.
  3. Взаємодія під час виконання: корутини можуть не лише приймати вхідні аргументи та повертати результат, але й обмінюватися даними під час виконання. Це відкриває широкі можливості для створення ефективних асинхронних алгоритмів.
  4. Гнучкість управління: можливість призупиняти та відновлювати виконання дозволяє створювати складні сценарії асинхронної взаємодії між різними частинами програми.

Співпрограми (coroutines) визначаються за допомогою синтаксису async def та є основою асинхронного програмування в Python. На відміну від звичайних функцій, співпрограми можуть призупиняти своє виконання за допомогою ключового слова await, передаючи контроль назад до event loop.

Важливо розуміти, що виклик співпрограми не запускає її виконання, натомість повертається об’єкт співпрограми. Щоб справді запустити співпрограму, її потрібно «очікувати» за допомогою await або запланувати ЇЇ виконання через event loop.

У контексті корутин «очікування» означає, що виконання корутини призупиняється до завершення певної асинхронної операції, але при цьому не блокується весь потік виконання програми. Якщо проводити аналогію з polling'ом, то при polling'у програма періодично перевіряє, чи завершилася певна операція. У випадку з await, Python автоматизує цей процес, ефективно «опитуючи» статус асинхронної операції без явних циклів у коді.

Як зазначено вище, при використанні await виконання поточної співпрограми призупиняється до завершення очікуваної операції. Таким чином програма залишається активною та не блокується, оскільки event loop може виконувати інші завдання під час очікування.

ПРИЄДНУЙСЯ ДО НАШОЇ КОМАНДИ

asyncio.create_task() vs asyncio.gather()

Для ефективного керування кількома співпрограмами asyncio пропонує два основні інструменти:

asyncio.create_task() перетворює співпрограму на об’єкт Task і автоматично планує її виконання в event loop. Ця функція дозволяє запускати операції «у фоні», повертаючи контроль керування відразу. Важливо зберігати посилання на створений об’єкт Task, інакше він може бути зібраний збирачем сміття.

asyncio.gather() призначений для конкурентного запуску кількох співпрограм та отримання їхніх результатів. Він приймає довільну кількість об’єктів awaitable, запускає їх паралельно та повертає список результатів у тому ж порядку, у якому були передані аргументи. Окрім того, gather() дозволяє скасувати всі завдання одночасно.

Отже, create_task() використовується для незалежного запуску окремих завдань, тоді як gather() ідеально підходить, коли потрібно запустити кілька операцій одночасно та дочекатися їх результатів.

Що таке awaitable-об'єкти та як їх використовувати

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

1.    Звідки вони беруться:

a.    корутини (async def функції);

b.    таски (asyncio.Task об'єкти);

c.     ф'ючерси (asyncio.Future об'єкти).

2.    Як отримати awaitable-об'єкт:

·         Створення асинхронної функції:

async def my_coroutine():

    return "Result"

·         використання asyncio.create_task():

task = asyncio.create_task(my_coroutine())

·         створення Future-об'єкта:

future = asyncio.Future()

4. Використання awaitable-об'єктів:

async def main():

    result = await my_coroutine()

    task_result = await asyncio.create_task(my_coroutine())

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

Матеріали та методи: як тестувати ефективність asyncio

Для об’єктивної оцінки ефективності asyncio необхідна відповідна методологія тестування. Зосередимось на вимірюванні реальних переваг асинхронного підходу порівняно з традиційним синхронним кодом.

Тестове середовище: Python 3.11 + aiohttp 3.8

У нашому випадку експерименти проводяться на базі Python 3.11, який пропонує значні вдосконалення в роботі з asyncio. Для асинхронних HTTP-запитів використаємо бібліотеку aiohttp версії 3.8, яка є провідним інструментом для створення як клієнтських, так і серверних асинхронних додатків.

Варто зазначити, що для максимальної швидкодії ми встановили додаткові компоненти:

pip install aiohttp[speedups]

Цей пакет включає aiodns та Brotli, які значно пришвидшують DNS-резолвінг (процес перетворення доменних імен на IP-адреси) та стиснення даних. Особливо рекомендуємо aiodns, оскільки він суттєво покращує швидкість з’єднання.

Методика вимірювання: time.perf_counter() + py-spy

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

Дослідження проведемо за таким алгоритмом:

  1. Фіксація початкового часу: t1 = time.perf_counter()
  2. Виконання тестового коду
  3. Фіксація кінцевого часу: t2 = time.perf_counter()
  4. Розрахунок: execution_time = t2 — t1

Для глибшого аналізу поведінки асинхронного коду можна використати py-spy — потужний профілювальник, написаний на Rust. Особливість py-spy полягає в надзвичайно низьких накладних витратах, оскільки він не виконується в тому ж процесі, що і профільована програма. Завдяки цьому профілювання абсолютно безпечне навіть для виробничого середовища.

Команда py-spy record --output profile.svg --pid <PID> дозволяє створити візуалізацію flame graph, що наочно демонструє, де саме програма витрачає найбільше часу.

Порівняння із синхронним кодом: HTTP-запити через requests

Насамперед порівняємо швидкість виконання однакових завдань за допомогою синхронної бібліотеки requests та асинхронної aiohttp. Під час виконання 150 HTTP-запитів синхронний код показує результат орієнтовно 29 секунд, тоді як асинхронний код із використанням aiohttp впорається з тими ж запитами за 8 секунд.

Однак, найбільш дивовижні результати з’являться після оптимізації асинхронного коду за допомогою asyncio.ensure_future та asyncio.gather. Ця комбінація дозволяє виконати ті самі 150 запитів лише за 1,53 секунди, що в 19 разів швидше порівняно із синхронним підходом.

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

Результати: як підвищити продуктивність async-коду

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

await asyncio.sleep() vs time.sleep(): 0 % блокування

Головна відмінність між цими функціями полягає в тому, що time.sleep() блокує весь потік виконання, тоді як await asyncio.sleep() повертає керування циклу подій. Під час використання time.sleep(1) в асинхронному коді відбувається повне блокування програми, що нівелює всі переваги асинхронності.

Розглянемо приклад двох функцій, які виконуються з різними методами затримки:

async def test_async():

for _ in range(3):

print(«Асинхронний тест»)

await asyncio.sleep(1) # Неблокуючий виклик

async def main():

await asyncio.gather(test_async(), test_async()) # Займає лише 3 секунди

Під час заміни await asyncio.sleep(1) на time.sleep(1) загальний час виконання збільшиться вдвічі, оскільки блокуючий виклик не дає змогу іншим корутинам працювати паралельно.

asyncio.Semaphore() для обмеження паралелізму

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

semaphore = asyncio.Semaphore(5) # Максимум 5 одночасних операцій

async def fetch_with_limit(url):

async with semaphore: # Автоматично звільняє семафор

# Виконуємо запит до ресурсу

return await fetch_data(url)

Семафор працює як лічильник, який зменшується при кожному виклику acquire() та збільшується при кожному виклику release(). Якщо лічильник досягає нуля, наступний запит на acquire() призупиняється до звільнення ресурсу.

aiohttp TCPConnector(limit=100): контроль з’єднань

Бібліотека aiohttp дає змогу керувати кількістю одночасних з’єднань за допомогою класу TCPConnector:

connector = aiohttp.TCPConnector(limit=50)

async with aiohttp.ClientSession(connector=connector) as session:

# Працюємо з session

За замовчуванням ліміт становить 100 з’єднань, але його можна змінювати відповідно до потреб. Крім того, існує параметр limit_per_host, який обмежує кількість одночасних з’єднань до одного хоста.

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

Обмеження та типові помилки при використанні asyncio

Працюючи з asyncio, інженери неминуче стикаються з кількома типовими проблемами. Розуміння цих обмежень допоможе писати більш ефективний та безпечний асинхронний код.

RuntimeWarning: coroutine was never awaited

Це попередження виникає, коли співпрограма була створена, але не запущена на виконання.

Найпоширеніші причини:

  • виклик асинхронної функції без await: do_something_async() замість await do_something_async()
  • не використання asyncio.create_task() для планування співпрограми;
  • неправильне вкладення співпрограм на різних рівнях.

async def main():

# Не буде виконано:

fetch_data() # RuntimeWarning!

 

 # Правильно:

await fetch_data() # або

asyncio.create_task(fetch_data())

Коли ви викликаєте асинхронну функцію, вона повертає об’єкт співпрограми, який потрібно явно запустити. Інакше Python тільки створює співпрограму, але ніколи її не виконує.

loop.run_until_complete() vs asyncio.run()

asyncio.run() — рекомендований спосіб запуску асинхронного коду з версії Python 3.7:

asyncio.run(main())

Ця функція створює новий цикл подій, запускає співпрограму та закриває цикл після завершення.

Однак, важливо знати її обмеження:

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

Натомість loop.run_until_complete() корисний, коли потрібно більше контролю:

loop = asyncio.get_event_loop()

loop.run_until_complete(main())

loop.close()

Небезпека блокуючих викликів: time.sleep(), requests.get()

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

Наприклад:

  • time.sleep() блокує весь цикл на вказаний час;
  • requests.get() або інші синхронні I/O операції блокують виконання;
  • тривалі CPU-інтенсивні обчислення без передачі керування.

Цикл подій у Python непреемптивний — поки виконується одне завдання, інші чекають.

Тому, замість блокуючих викликів, варто використовувати:

  • await asyncio.sleep() замість time.sleep()
  • aiohttp або httpx замість requests
  • loop.run_in_executor() для запуску блокуючих операцій в окремому потоці:

result = await loop.run_in_executor(None, blocking_function, arg1, arg2)

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

Висновок

Практичні експерименти підтверджують значні переваги asyncio для розробки високопродуктивних Python-додатків. Тести продемонстрували прискорення виконання HTTP-запитів майже в 19 разів порівняно із синхронним кодом.

Ключові висновки:

  • Event loop забезпечує ефективне керування асинхронними операціями;
  • правильне використання asyncio.gather() та create_task() суттєво підвищує продуктивність;
  • семафори та TCP-конектори дозволяють точно контролювати навантаження;
  • уникнення блокуючих викликів критично важливе для оптимальної роботи.

Безперечно, asyncio став незамінним інструментом для створення масштабованих додатків. Проте максимальна ефективність досягається лише за умови глибокого розуміння внутрішніх механізмів та дотримання належних практик асинхронного програмування.

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

Підписатися на новини

Чудово! Ми вже готуємо добірку актуальних новин для вас :)

Вибачте, щось пішло не так. Будь ласка, спробуйте ще раз.

* Обов'язкові поля

*Будь ласка, заповніть обов’язкові поля