Мистецтво чекати. Ефективність асинхронності в Python

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

«Відкрий для себе свою відмінність — ту асинхронність, якою ти був благословенний або проклятий — і використай її якнайкраще»
Говард Гарднер

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

Напевно, кожен програміст коли-небудь так чи інакше зустрічався з асинхронністю. Зазвичай усе зводиться до використання двох знайомих слів — async та await. Ми додаємо їх у код із надією, що «воно стане швидше», натискаємо Run, але не завжди бачимо приріст продуктивності. Тож що насправді ховається за цими ключовими словами? Як працює асинхронність у Python, і чи справді вона завжди покращує продуктивність? Коли її використання виправдане, а коли — ні? Чому? Як її застосувати правильно? І навіщо?

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

І одразу заувага. У Python є багато різних реалізацій інтерпретаторів: CPython, Jython, IronPython та PyPy, написані відповідно на C, Java, C# та Python. У даній статті буде йти мова лише про CPython-інтерпретатор.

— Конкурентне виконання
— Корутини
— Механізми очікування
— Цикл подій
— Координація задач
— Gather vs TaskGroup
— Підводні камені: race conditions та memory leaks
— Коли async — не єдиний вибір
— Місце в екосистемі Python
— Синхронність в асинхронності
— Зелені потоки
— Висновки

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

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

Частина 1: GIL у Python. Ключ до стабільності чи ворог продуктивності?
Частина 2: Python без блокувань. Як працюють потоки
Частина 3: Розділяй і володарюй. Як працюють процеси в Python

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

Ми не чекаємо пасивно — ми просто повертаємось до кожної справи саме тоді, коли вона готова до наступного кроку. Це і є асинхронність у чистому вигляді — неблокуюче очікування.

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

У Python асинхронність використовується так само — тоді, коли програма виконує багато операцій, які потрібно «дочекатися» — зокрема мережеві запити, читання з диску, звернення до баз даних чи API, тобто операції вводу-виводу (англ. O/I-bound operations). Натомість вона не підходить для обчислювально складних задач, де процесору потрібно активно працювати — наприклад, для обробки великих масивів даних, машинного навчання або обчислень — відповідно для процесорних задач (англ. CPU-bound operations).

Конкурентне виконання

У попередніх частинах серії ми багато говорили про паралельне виконання у контексті потоків та процесів. Хоча асинхронність і є одним з вирішень питання багатозадачності, вона не про паралельність. Вона про конкурентність. У чому ж різниця?

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

У Python асинхронність працює за принципом кооперативної багатозадачності. Це означає, що кожна задача сама вирішує, коли зробити паузу й дати шанс іншим. Ключовий аспект тут — добровільність — ніби одна задача каже іншій «Я зараз чекаю, ти поки виконуйся». Жоден зовнішній планувальник не примусить задачу зупинитися, якщо вона не дійде стану «Я зараз чекаю». Якщо ж такого «місця очікування» немає — інші задачі просто не отримають ходу. Цикл подій (англ. event loop) не зможе перемкнутись, і вся асинхронна модель «зависне».

Корутини

У вищезазначених роздумах можна сміливо замінити все, що ми називали словом «задача», на такий термін, як корутина, або сопрограма.

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

Отже, функція, що може призупиняти своє виконання, а потім повертатися та продовжувати з того ж місця. Нічого не нагадує?

Корутини в Python — це розширення ідеї генераторів. Згадаймо yield у звичайній функції. Ми можемо «поставити на паузу» виконання, а потім «продовжити з того ж місця». Корутину можна уявити як прокачаний генератор.

Розглянемо маленький приклад генератору чисел Фібоначчі (найпростіша реалізація, навмисно без застосувань оптимізацій):

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

for num in fibonacci():
    if num > 40:
        break
    print(num)

Коли ми викликаємо fibonacci(), Python створює спеціальний генераторний обʼєкт, який ми можемо «крутити» за допомогою next(). Тут yield призупиняє функцію, а при наступному next() виклику вона відновлює роботу з того місця, де зупинилась. І ми отримуємо результат  перші 10 чисел ряду Фібоначчі.

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

Розглянемо приклад:

def double():
    print('Старт функції')
    value = 2 * (yield)
    print(f'Значення = {value}')
    yield value
    print('Кінець функції')

d = double()
next(d)

d.send(20)
d.send(50)

Коли ми викликаємо next(d), виконується код до першого yield, і функція призупиняється. Далі, за допомогою send(20), ми передаємо значення назад у функцію — і вона продовжує виконання з того ж місця, використовуючи це значення в обчисленні. Потім функція знову зупиняється, вже на другому yield, і наступний виклик send(50) просто завершує виконання, оскільки новий yield уже не передбачений. У результаті виникає StopIteration. Якщо ж нічого не передавати, то функція отримає None.

Це і є те саме призупинення та відновлення (англ. suspend/resume) — те, що відбувається в корутинах через await.

У результаті виконання коду отримаємо:

Старт функції
Значення = 40
Кінець функції
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    d.send(50)
StopIteration

Тому можна сказати, що в Python генераторна функція, яка вміє не лише віддавати значення через yield, але й отримувати дані назад, і є корутиною. А призупиненням та відновленням виконання займається цикл подій, а не ми вручну через next().

До того ж ми можемо дозволити автоматично передавати керування одного генератора іншому, використовуючи yield from. Він самостійно викликає next(), приймає значення через send(), обробляє throw() (для кидання винятків у генераторі) і return (повернення значення у підгенераторі), і повертає фінальний результат.

def main():
    yield from double()

m = main()
next(m)

m.send(20)
m.send(50)

Власне, раніше корутини в Python реалізовувались через генератори з yield from і використанням декоратора @asyncio.coroutine, що давало зрозуміти, що це все ж таки корутина, а не звичайний генератор.

Сучасні корутини мають окремий тип coroutine, але суть залишилася незмінною: можливість призупинити виконання (await) і відновити його пізніше, коли задача буде готова до продовження. Просто коли Python отримав повноцінну підтримку асинхронності з asyncio, зʼявилась потреба у синтаксичному способі делегувати виконання іншій корутині — так само, як yield from делегує іншому генератору. Саме тут зʼявляється await, як сучасний синтаксичний аналог yield from, але лише для обʼєктів типу coroutine, а не генераторів.

Разом з await зʼявляється і ключове слово async, що позначає функцію, яка підтримує асинхронне виконання. Тобто функцію, яку можна призупинити через await і відновити пізніше. Важливо те, що використання async при створенні функції не запускає її автоматично. Воно створює обʼєкт-корутину, який потрібно запустити через цикл подій, до якого ми нарешті дійшли.

Механізми очікування

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

Почнемо з колбеків (англ. callback). Якщо простими словами, колбек це функція, яка викликається у відповідь на певну подію, і є механізмом, що дозволяє відкласти виконання частини логіки до якогось моменту.

Наступний важливий елемент це футури (англ. futures) — це обʼєкти, які представляють результат, що буде доступний у майбутньому. Вони як порожнє місце, яке ще не має значення, але обіцяє його надати. Найкраще порівняння — футура = обіцянка. Коли результат стає доступним (наприклад, прийшла відповідь з мережі), футура позначається як завершена і викликає колбек — саме той, який був зареєстрований, щоб продовжити виконання корутини.

Ще один важливий учасник цієї системи — задачі (англ. tasks). Це обгортки над корутинами, які запускаються у фоновому режимі й одразу передаються під контроль циклу подій. На відміну від звичайної корутини, яка просто «готова» до виконання, задача може почати працювати відразу після створення. Вона стежить за перебігом виконання, зберігає результат або помилку, і дозволяє іншим частинам коду дізнатись, чим усе завершилось.

Важливе уточнення: у Python Task — це підклас Future, тобто кожна таска по суті є футурою, але з додатковою логікою виконання корутин.

Для того, щоб цикл подій міг ефективно «переключатися» між цими задачами, йому потрібна організована система — черга цих задач. Їх кілька.

  1. Черга готових завдань (англ. ready queue) — це основна черга. Тут зберігаються корутини чи їхні колбеки, які вже можуть виконуватись прямо зараз. Цикл подій просто бере їх звідси по черзі й запускає.
  2. Черга відкладених завдань (англ. scheduled queue, інколи також називають delay queue) — сюди потрапляють завдання, які будуть готові пізніше. Цикл подій стежить за часом і, коли настав момент — переносить завдання до черги готових завдань (англ. ready queue).
  3. Черга I/O-завдань — це не зовсім черга в класичному розумінні, а радше реєстр. Тут зберігаються очікування на зовнішні події — наприклад, завершення читання з файлу чи відповіді від сервера. Цим керує окремий механізм — селектор (англ. selector), і як тільки I/O-подія завершена — відповідна задача також перекидається в чергу готових завдань (англ. ready queue).

Зберемо все до купи на прикладі замовлення піци.

  • Футура тут — це обіцянка, що піца буде готова в майбутньому. Коли ми робимо замовлення — створюється обʼєкт футури, який ще не має значення. Він просто каже: «Це майбутня піца. Почекай трохи».
  • Наше замовлення передають на кухню. Там кухар починає його готувати. Це створення задачі — task = asyncio.create_task(make_pizza()). Задача тут є працівником, який бере рецепт і починає його виконувати.
  • Корутина тут це сам рецепт з паузами, вона знає, що треба зробити, і де зупинитися, поки чекаєш на щось (таймер, продукти, розігріту пательню, кур’єра тощо).
  • Ми залишаємо свій номер телефону, щоб нас повідомили, коли піца буде готова. Це і є колбек — інструкція, яку виконають після завершення завдання.
  • У піцерії є менеджер, який стежить, чи закінчилася випічка, чи приїхав курʼєр, чи можна дзвонити клієнту. У Python цим займається селектор — він слухає події і сигналізує, що щось сталося. Він не постійно перевіряє, а спить і прокидається лише тоді, коли щось готове.

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

І все виглядає так:

  • async def → створює корутину;
  • create_task(coroutine()) → обгортає її в task, який є спеціальним типом future;
  • await task → означає «чекати, поки ця future не завершиться».

Цикл подій

Цикл подій (англ. event loop) — це центральний механізм асинхронного виконання в Python, саме він роздає завдання, стежить за їхнім станом і повертається до них, коли ті знову готові до роботи. Цикл подій — розумний адміністратор піцерії, який не готує, але роздає команди, контролює чергу, слухає сигнали, і все тримає під контролем.

Розберемо на прикладі, як все працює під капотом.

import asyncio
import aiohttp

async def make_query():
    async with aiohttp.ClientSession() as session:
        resp = await session.get('some-url')
        user = await resp.json()
    return user

async def make_sleep():
    await asyncio.sleep(1)

async def main():
    await asyncio.gather(
        make_query(),
        make_sleep()
    )

asyncio.run(main())

Тож ми одночасно виконуємо дві задачі за допомогою gather: одна надсилає HTTP-запит і чекає на відповідь, інша просто чекає одну секунду. Це все відбувається в функції main, яку запускає asyncio.run.

Тут буде 3 таски. Для спрощення будемо називати їх Main (m), Sleep (s) та Query (q). У Python створювати таски може будь-що, що викликає asyncio.create_task() або ensure_future(). У нашому випадку створювачами тасок є asyncio.gather та asyncio.run.

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

Крок перший. Запуск починається з asyncio.run(main()). Python створює цикл подій і всі потрібні черги: для готових, відкладених і I/O-задач. Корутина Main створюється й одразу обгортається в Task, який додається до Ready queue. Цикл подій готовий розпочати роботу.

Крок другий. Цикл подій починає виконувати Main і доходить до await asyncio.gather(...). На цьому місці таска Main призупиняється, адже вона чекає на результат — а отже, чекає на футуру, яку створює gather. Ця футура буде позначена як завершена, коли всі передані в неї задачі (Sleep та Query) повернуть результат. До цієї футури прикріплюється колбек, який відновить Main — тобто дозволить продовжити виконання після await.

Обидва виклики — Query і Sleep — це корутини, тож gather обгортає їх у Task. Вони додаються до Ready queue. Таска Main, яка чекала на футуру gather-а, залишається призупиненою. Цикл подій переходить до виконання Query і Sleep, по черзі запускаючи їх з Ready queue.

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

У Sleep є один await, тому створюється одна футура, яка потрапляє в Scheduled queue разом із колбеком, який згодом поверне цю таску в Ready queue, коли спрацює таймер (у нашому випадку через 1 секунду).

У Query ситуація складніша: спочатку зустрічається await session.get(...), а потім — await resp.json(). Кожен із цих await — це окрема асинхронна I/O-операція, тому для кожної з них створюється своя футура, яка реєструється в I/O-регістрі селектора. Обидві футури мають окремі колбеки, які повернуть таску Query у Ready queue, щойно відповідна подія буде готова — спочатку коли сервер надішле відповідь, а потім коли буде прочитано її тіло.

На цьому етапі всі три таски Main, Sleep та Query зупинені. Цикл подій у режимі очікування, стежачи за таймерами та I/O через зареєстровані футури.

Крок четвертий. Таймер і селектор завершують свою роботу: події, на які чекали футури, відбулись. Таймер спрацьовує після однієї секунди, і відповідна футура для asyncio.sleep у тасці Sleep позначається як завершена. Паралельно селектор фіксує, що на сокеті завершились I/O-операції — і дві футури у Query також отримують статус done. Селектор використовує механізм мультиплексування: він відстежує кілька джерел подій одночасно, не блокуючи програму, і повідомляє цикл подій саме тоді, коли певна операція вводу/виводу готова до виконання.

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

Крок пʼятий. Обидві дочірні таски Query та Sleep повністю завершують своє виконання. Вони виконали всі свої await і повернули результати. Внаслідок цього футура всередині gather стає виконаною, адже вона чекала саме на завершення цих задач.

Тепер активується колбек, прикріплений до gather().future, — саме той, який Main очікувала під час await asyncio.gather(...). Цей колбек позначає футуру як завершену і повертає призупинену Main таску назад у Ready queue. Вона готова продовжити з того місця, де зупинилася.

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

Крок шостий. Цикл подій виконує завершальні дії. Таска Main успішно закінчує виконання, отримавши результати з дочірніх задач.

А оскільки це була єдина коренева таска, створена через asyncio.run, цикл подій більше не має активних задач у чергах. Він розуміє, що все виконано, й автоматично закривається, прибираючи всі службові структури — черги, таймери, I/O-регістр — і звільняючи ресурси.

Код виконано. Асинхронно. Не паралельно. Конкурентно. Це і є мистецтво чекати. За кожним await стоїть ціла мікромодель керування подіями, яка працює, щоб програма не чекала дарма.

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

Наш приклад малий, тож може бути складно прослідкувати за «вигодою» від того очікування. Але в більш складних програмах вигода стає очевидною. Поки одні задачі очікують відповіді від сервера чи бази даних, інші продовжують виконуватись. Щойно перші отримують результат, вони повертаються до роботи (як у випадку з чайником та макаронами). Програма весь цей час не просто «зависає» в очікуванні, а ефективно використовує ресурси, виконуючи інші задачі. Саме в цьому і полягає перевага — ми не витрачаємо час даремно, а виграємо його.

Координація задач

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

Gather vs TaskGroup

Ми вже зустрілися з gather у минулому прикладі. Є ще один, більш новий інструмент, що дозволяє запускати кілька асинхронних задач одночасно — TaskGroup.

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

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

З цим можна боротися: gather можна налаштувати через аргумент return_exceptions=True, але тоді треба вручну перевіряти кожен результат.

На відміну від цього, TaskGroup, новіший інструмент, що з’явився у Python 3.11, забезпечує набагато кращу контрольованість. Він працює як контекстний менеджер і автоматично слідкує за всіма задачами всередині блоку. Якщо одна з них викликає виняток (англ. exception), решта скасовуються, і після виходу з блоку виняток буде «викинутий назовні». Це дозволяє гарантувати, що або виконуються всі задачі, або жодна повністю не завершиться — підхід, відомий як «fail-fast and clean».

Ми можемо це побачити на прикладі:

import asyncio

async def run_task(name: str, delay: int = 1, fail: bool = False):
    try:
        await asyncio.sleep(delay)
        if fail:
            raise ValueError(f'Task {name} failed!')
        print(f'Task {name} done')
    except asyncio.CancelledError:
        print(f'Task {name} was cancelled')
        raise

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(run_task('task_1', delay=3))
            tg.create_task(run_task('task_2', fail=True))
            tg.create_task(run_task('task_3', delay=3))
    except Exception as err:
        print(f'Caught: {err.args}')

asyncio.run(main())

У цьому коді одночасно запускаються три асинхронні задачі. Друга задача виконує sleep(1), після чого кидає виняток ValueError. У цей момент TaskGroup виявляє помилку й ініціює скасування всіх інших ще не завершених задач.

Оскільки перший і третій таски мають затримку 3 секунди, вони ще не встигли завершитись, тому їм надсилається сигнал скасування. Усередині функції run_task() це скасування ловиться через except asyncio.CancelledError (додано, щоб побачити скасування), і ми бачимо повідомлення про те, що задачі було скасовано. У підсумку main() ловить помилку з другого таску і виводить її в except. Такий сценарій наочно демонструє, як TaskGroup зупиняє всі інші задачі, коли хоча б одна завершується з помилкою. І ми отримуємо:

Task task_1 was cancelled
Task task_3 was cancelled
Caught: ('unhandled errors in a TaskGroup', [ValueError('Task task_2 failed!')])

Але якщо перший та третій таски мають коротший час затримки (наприклад, delay=1), то вони можуть встигнути завершитися до того, як відбудеться скасування. Задача може встигнути виконати побічний ефект (наприклад, записати щось у базу даних, відправити лист або повідомлення), цей ефект залишиться, навіть якщо пізніше TaskGroup буде скасовано через іншу помилку. Тож саме у цьому випадку ситуація така ж, як з gather.

У asyncio немає вбудованого механізму для транзакцій або ізольованого виконання. Щоб боротися з такими проблемами, слід використовувати транзакції (наприклад, в БД), або додатково контролювати стан усередині задач.

Підводні камені: race conditions та memory leaks

Памʼятаєте стан гонки (англ. race conditions) як проблему паралельного виконання у багатопоточному коді, коли декілька задач одночасно мають доступ до спільного ресурсу, і результат залежить від того, в якій черговості ці задачі виконаються? Ми це розбирали у другій частині серії, присвяченій потокам.

При використанні асинхронності абсолютно все те саме, тож дублювати інформацію не буду. Достатньо знати, як працює памʼять в Python, ознайомитися можна тут — «Мистецтво управління пам’яттю в Python: розуміння, використання та оптимізація» та прочитати другу частину цієї серії. Єдине логічне уточнення — в асинхронному виконанні стан гонки виникає не через перемикання між потоками, а через перемикання контексту між задачами в непередбачуваний момент, наприклад, під час await, коли одна таска призупиняється, і керування отримує інша. Усе інше повністю ідентичне.

Щоб уникати таких ситуацій, використовуємо asyncio.Lock() для синхронізації доступу до ресурсу, ретельно контролюємо критичні секції коду, де важлива черговість, застосовуємо atomic-операції або транзакції у базах даних і інші особливості та методи боротьби зі станом гонки, що ми вже обговорювали попередньо.

Під час використання асинхронності виникає й інша проблема — витоки памʼяті (англ. memory leaks). Коли обʼєкти залишаються в памʼяті навіть після того, як вже не потрібні, на них десь продовжують існувати посилання — наприклад, з боку незавершених тасок, колбеків або замикань (англ. closure).

Якщо створити багато задач і не дочекатися їх завершення, вони продовжать жити у фоновому режимі. Часто також виникає проблема, коли колбек або логер тримає посилання на обʼєкти довше, ніж потрібно. Замикання (англ. closure) можуть випадково зберігати стан у змінних, які вже мали б бути вивантажені.

Ще один поширений випадок — неявно відкриті ресурси, як-от HTTP-сесії, що не були коректно закриті. І хоча Python має збирач сміття (англ. garbage collector), який прибирає непотрібні обʼєкти, у випадку з асинхронними задачами й колбек-структурами він не завжди може ефективно впоратися, особливо коли є циклічні посилання чи деструктори (див. статтю про памʼять). Саме тому в довготривалих асинхронних сервісах витоки памʼяті можуть накопичуватись поступово, створюючи серйозні проблеми лише з часом.

Коли async — не єдиний вибір

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

Місце в екосистемі Python

Асинхронний код у Python виконується в одному потоці, керованому одним процесом. Уся координація між задачами не потребує створення нових потоків — це просто набір корутин, якими керує цикл подій.

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

Тож можемо доповнити схему з минулої статті, додавши асинхронність:

Тож асинхронність = один потік = один цикл подій. Паралельно можуть працювати інші потоки, виконувати свої дії, це доволі часта ситуація. Наприклад, в одному потоці запускається FastAPI, а в інших потоках працюють фонові задачі, логування або обробка синхронних запитів через, наприклад, ThreadPoolExecutor. Важливо: у кожному потоці може бути лише один цикл подій.

Тож коли що краще застосувати?

З процесами все плюс-мінус очевидно. Їх використовують, коли задачі є CPU-bound, тобто обчислювально важкі. Кожен процес має власний інтерпретатор і обхід GIL, тому обчислення дійсно можуть виконуватись паралельно. Це ідеально для обробки великих масивів даних, тренування моделей чи складної аналітики.

А ось потоки і асинхронність — обидва для I/O-bound задач, і треба обирати. Зазвичай асинхронність краще використовувати, коли є багато однотипних I/O-запитів — наприклад, обробка великої кількості HTTP-запитів, робота з вебсокетами, завантаження файлів, або коли ми створюємо високонавантажений сервер, який має обслуговувати багато підключень одночасно без затримок.

Натомість потоки краще підходять для I/O-задач, які не мають асинхронного API (наприклад, робота з деякими бібліотеками баз даних, старими драйверами, файловими системами), використовують блокуючі виклики, які складно переписати на async, поєднують I/O з невеликими обчисленнями або логікою, що неефективно вписується в async.

До того ж не забуваємо про памʼять — якщо важлива ефективність памʼяті, то один потік з одним циклом подій може «коштувати» менше, ніж підтримка кількох потоків.

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

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

Синхронність в асинхронності

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

Щоб цього уникнути, таку функцію можна запустити в окремому потоці за допомогою asyncio.to_thread() - це дозволить іншим асинхронним задачам працювати паралельно.

На відміну від старішого підходу через loop.run_in_executor(), to_thread() має простий і зрозумілий синтаксис. Його особливо зручно застосовувати в сучасному Python-коді, де частина логіки вже написана як async, але є потреба викликати блокуючі функції.

import asyncio
import time

def peel_carrot():
    time.sleep(2)

async def boil_water():
    await asyncio.sleep(1)

async def main():
    task1 = asyncio.create_task(boil_water())
    task2 = asyncio.to_thread(peel_carrot)

    await asyncio.gather(task1, task2)

asyncio.run(main())

Хоча to_thread() і використовує потоки, варто памʼятати, що він не вирішує проблем із GIL у випадку важких обчислень. Для цього краще використовувати multiprocessing. Але коли йдеться про I/O-завдання або неінтенсивні блокуючі дії, asyncio.to_thread() - це просте і ефективне рішення.

Зелені потоки

Хоча сьогодні asyncio став стандартом для асинхронного програмування в Python, існують й альтернативні моделі, які можуть працювати як кооперативна багатозадачність. Бібліотеки на кшталт gevent та eventlet дозволяють писати асинхронний код у звичному синхронному стилі без async і await, використовуючи динамічне перекриття (англ. monkey-patching) — перевизначати стандартні функції вводу/виводу під час виконання. Це інша парадигма, яка була популярною до появи asyncio і все ще знаходить своє місце в окремих проєктах, вона має назву «зелені потоки» (англ. green threads).

Це легковісні потоки, які не є потоками операційної системи, вони виконуються й керуються всередині інтерпретатора Python, зазвичай за допомогою бібліотек C (наприклад, libev або libuv).

Головна ідея цієї моделі — кооперативна багатозадачність: код, який «засинає» (наприклад, при очікуванні I/O), добровільно віддає управління іншій задачі. Щоб зробити це непомітним для розробника, gevent та eventlet «патчать» стандартні блокуючі бібліотеки Python, замінюючи їх на неблокуючі. Наприклад, time.sleep, socket, ssl та інші — все це перетворюється на кооперативну версію. Завдяки цьому ми пишемо звичайний синхронний код, який під капотом виконується асинхронно.

Це виглядає зручно, бо не треба думати про await, async, таски чи цикл подій. Але є й серйозні обмеження:

  • Масштабованість хороша, але видимість виконання — низька: важко зрозуміти, де саме відбувається перемикання задач.
  • Патчинг може бути непрозорим і непередбачуваним, особливо при роботі з нестандартними або C-бібліотеками.
  • Модель не сумісна з asyncio. Це інша екосистема.
  • GIL усе ще тут — зелені потоки не обійдуть його.

У підсумку, gevent і eventlet зручні, коли потрібна простота і зворотна сумісність із синхронним кодом. Але сьогодні більшість сучасних систем переходить на asyncio, бо воно більш контрольоване, прозоре і стандартизоване.

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

За допомогою star-history зробила діаграму популярності альтренатив.

tornadoweb/tornado — одна з перших асинхронних бібліотек, ще до asyncio, з власним циклом подій і вебсервером.

twisted/twisted — потужний фреймворк з глибоким контролем над мережевими подіями, працює на основі колбеків.

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

dabeaz/curio — експериментальний проєкт Девіда Бізлі, повністю async/await без сумісності зі старими бібліотеками.

python-trio/trio — пропонує простіший і безпечніший підхід до async-програмування, з акцентом на структуру та обробку помилок.

agronholm/anyio — абстракція над asyncio, trio і curio; дозволяє писати код, сумісний із кількома бекендами.

MagicStack/uvloop — заміна стандартного циклу подій з asyncio на швидший, написаний на Cython, сумісний API.

Ці інструменти розширюють екосистему асинхронності й часто надихають стандартний Python на нові зміни.

Висновки

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

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

На цьому серію закінчено, юхув! Звісно ж раджу прочитати і минулі частини.

Частина 1: GIL у Python. Ключ до стабільності чи ворог продуктивності?
Частина 2: Python без блокувань. Як працюють потоки
Частина 3: Розділяй і володарюй. Як працюють процеси в Python

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

І памʼятайте, Python — це інструмент, а справжня магія завжди в руках розробника. Дякую за увагу! Не прощаємось.

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

Відмінна як ця окрема стаття, так і вся серія. Дякую за вашу роботу.

Сподіваюся, що попереду буде і стаття по
multiple Interpreters, було б цікаво прочитати від вас.

Дякую за позитивний відгук!

Був в мене опит з асинхронністю на FastAPI.
Потрібно було зробити апкку, яка має 3 арі по вебсокетах для 3х сутностей
1) клієнт(SSR Next.js)
2) сапорт(SPA React)
3) виконавець(SPA React)),
Так же для кожної сутноті має бути http арі, яке відправляє івенти по певній логіці на 3 сутності.
Відповідно арі по вебсокетах це типу шлюз доставки повідомлень різних типів івентів(тобто відкрили сторінку, проінітилось з’єднання по вебсокетах і повідомлення які приходять взалежності від івент ід делегуються хендлеру який процесить такий івент)

Коротенький R&D показав що задачка реалізуєма і по класиці: локально все працює!

Так як в на python маємо GIL то бек uvicorn+gunicorn+nginx і отримуємо n-воркерів (копій FastAPI аплікух) і між ними потрібно синхронізація для вебсокетів бо вебсокет з’єднання одного користувача може бути на різних процесах, для цього використав aioredis з Pub/Sub. Але спочатку це все жило на кубернетусі і поки жило то вроді як все ок)))

Нагрузки на таку апку постійні і одночасно десь до 30 користувачів але через пару днів стукає девопс і каже що в нього чогось поди відвалються, і показує метрики: пода за 26 годин виїдає 1.5гб оперативки і крашиться. Рішення просте 1гб виїло кілаєм поду і стартуєм нову і по вебсокетам івент пінг/понг кожні 30сек, якщо на пінг не прийшов понг то фронти роблять реконект — костилі крутяться, а деви з девопсами мутяться!

Пройшло десь рік і рішили трохи на інфрі затягнути пояси, прийшлось підіймати це на IaaS (Hetzner): локально та на стейджі все ок, а от в проді постійно відвалювася редіс зі своїм Pub/Sub та ще й з такими помилками що особо не гуглиться, поковирявся я з цим і на вихідних з chat gpt переписав апку на golang(зовнішній сторедж в цьому варіанті не потрібен бо вебсокет зєднання кладуться в глобальні змінні і за допомогою RWMutex в горутінах достаються конкретні зєднання яким потрібно відправити івент)+nginx і все працює стабільно і без проблем.

Посил коментаря: в мові Python можна підчас R&D знайти солюшен і зробити реалізацію але чи буде вона оптимальною і ефективною то це виливається в розуміння ОБМЕЖЕНЬ реалізації самої мови та її механізмів! Тому, маючи GIL і відсутність фонових задач яку приходиться вирішувати через брокери з celery або аналогами, варто розуміти що реалізація асинхронності в Python як було сказано в статті не конфліктує з GIL і мабуть можна сказати що неповноцінна бо хоть і є стандарти але ті ж бібліотекти обгортаються синхроний IO, а так же із за того що асинронність є механізмом кооперації то хто як хоче так і лячкає і в результаті баланс при якому корутіни мають збільшити нагрузку яку може один процес хендлити ми отримуємо деградацію. Виходить що при асинхроності потрібно думати про баланс, слідкувати за тим щоб корутіни були короткі по виконанню і тоді буде виграш.

Скидаю капелюха, десь на DOU має бути колекція найкращих праць, й ця серія однозначно заслужила бути там, в «золотому фонді», лише додам єдиний нюанс:

У Query ситуація складніша: спочатку зустрічається await session.get(...), а потім — await resp.json(). Кожен із цих await — це окрема асинхронна I/O-операція, тому для кожної з них створюється своя футура, яка реєструється в I/O-регістрі селектора. Обидві футури мають окремі колбеки, які повернуть таску Query у Ready queue, щойно відповідна подія буде готова — спочатку коли сервер надішле відповідь, а потім коли буде прочитано її тіло.

селектор не містить одночасного чекання на session.get(’some-url’) та resp.json() як то показано на картинці,
бо спочатку ми почекаємо тільки на завершення resp = await session.get(’some-url’),
а до наступного user = await resp.json() ми дійдемо вже тільки тоді,
коли вже отримали resp (тобто «перше чекання» вже більше не буде в селекторі)
(між ними для ілюстрації можна було б вткнути print)
тобто наступний «авейт» стейтмент не виконується, поки не поверне ще з попереднього й попереднє «вже доавейтане» не стане доступним (інакше й як би ми могли б викликати метод resp.json() якщо отой resp ще не було отримано/присвоєно?)

І ще важлива одна заувага: весь зміст катавасії з async/await якраз в тому щоб «вивернути вкладені асинхронні колбеки навпаки» й заставити асинхронной код виглядати синхронно, лінійно, так, якби то «нормальний» послідовний код без callback hell, й не створюючи «важких, stackful» потоків операційної системи.

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

import asyncio

async def async_test(n):
    print(f"async_test запуск з аргументом n={n}")
    return 42 * n

async def main():
    print(f"той async_test={async_test} то не корутина, а функція, що вертає корутину");
    cr = async_test(2)
    print("ми зробили корутину, але код в async_test(2) ще не виконався");
    
    print(f"cr={cr} то власне і є корутина (ще так і не виконувалася)")
    print("await примусить її виконатися:")
    
    res = await cr
    print(f"результат є тільки тепер res={res}")
    
    print("а тепер все разом, нова корутина з аргументом 3 і виклик")
    val = await async_test(3)
    print(f"тут виклик з аргументом 3 вже відбувся, val={val}")
    print("й навіть без проміжної змінної й прямо всередині виразу")
    print(f"отут вираз і результат-> {await async_test(4) * 10}")

asyncio.run(main())

вивід рану:

той async_test=<function async_test at 0x7ded1ec64400> то не корутина, а функція, що вертає корутину
ми зробили корутину, але код в async_test(2) ще не виконався
cr=<coroutine object async_test at 0x7ded1ee420c0> то власне і є корутина (ще так і не виконувалася)
await примусить її виконатися:
async_test запуск з аргументом n=2
результат є тільки тепер res=84
а тепер все разом, нова корутина з аргументом 3 і виклик
async_test запуск з аргументом n=3
тут виклик з аргументом 3 вже відбувся, val=126
й навіть без проміжної змінної й прямо всередині виразу
async_test запуск з аргументом n=4
отут вираз і результат-> 1680
тут async_test то не корутина, а «функція, що зробить корутину, коли її викликати», результат виклику, змінна cr, буде якраз корутиною, на яку й треба «поавейтати», щоб власне отримати результат.

І тут якраз видно що await «заразний» й та функція, яка робить await має бути async щоб її також хтось авейтив і тд аж до asyncio.run(main()) включно (тобто «з звичайного сріда» який «не евентлупиться» власне поавейтати awaitом не вийде).

Мудрі академіки поумнічали б тут щось про «монади» й все таке «замудре й незрозуміле», але воно й досить інтуітивно без того: якщо якась АПІшка є async (або в нас щось добуте таке, що потребує авейту), то вгору колстеком від того місця, де ми поавейтали все також «заасікавейчується» (не зовсім, бо також можна з повернути «авейтабл штуку таку як cr» як звичайне значення, запостити її в якусь чергу й витягнути «з іншого боку» і тд, але це вже окрема розмова), але суть саме така

Але це все дрібниці, головне: якщо писати код одразу з await async_test(4) чи await asyncio.sleep(delay) чи await session.get(’some-url’) то «вся магія асинхронності» як насправді воно працює — прихована, й візуально це виглядає як звичайний синхронний код, який зручно аналізувати, де можна писати цикли, юзати with (а також синтаксичний цукор async with, async for) й то все виглядає як «нормальний код» а не адське спагетті тих «академічних» альтернатив, де «активні об’єкти» з «актор модел» та іншим академічним трешом типу «communicating sequential processes» що красиві в теорії, але в «бойовому коді» зробленому «за канонами» ті «академіки» cамі не можуть розібратися й віддебагати що вони там накрутили (провокую «академіків» на флейм, або ж статтю відповідь, щоб показати «справжню красу науки»))

І тут якраз видно що await «заразний» й та функція, яка робить await має бути async щоб її також хтось авейтив і тд аж до asyncio.run(main()) включно (тобто «з звичайного сріда» який «не евентлупиться» власне поавейтати awaitом не вийде).

можна зробити task.wait() зі звичайного стека потока тоді він (стек поток) просто синхронно залочиться до отримання результату

можна зробити task.wait() зі звичайного стека потока тоді він (стек поток) просто синхронно залочиться до отримання результату

це task.wait(), напевно, можна зробити в якійсь інші бібліотеці, бо той Task, що йде з Пайтоном «в коробці» не вміє у wait (або ж я щось пропустив)...

Тому треба робити asyncio.run_coroutine_threadsafe, воно народить concurrent.futures.Future й тоді його можна буде «почекати» з «нормального сріда» методом future.result()...

(маніпулюючи футурами головне не переплутати «нормальні» Future що взято з concurrent.futures з тими евентлупними asyncio.Future, на які вже почекати з іншого сріда не можна, жахлива катавасія).

весь зміст катавасії з async/await якраз в тому щоб «вивернути вкладені асинхронні колбеки навпаки» й заставити асинхронной код виглядати синхронно, лінійно, так, якби то «нормальний» послідовний код без callback hell

коли я розібрався мене здивувало як мало людей на справді розуміють за що воно вот єто всьо взагалі треба ціль яка мета

візуально це виглядає як звичайний синхронний код, який зручно аналізувати, де можна писати цикли, юзати with (а також синтаксичний цукор async with, async for) й то все виглядає як «нормальний код» а не адське спагетті тих «академічних» альтернатив
(провокую «академіків» на флейм, або ж статтю відповідь, щоб показати «справжню красу науки»)

та щас тіко сапожки почищу ))

коли я розібрався мене здивувало як мало людей на справді розуміють за що воно вот єто всьо взагалі треба ціль яка мета

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

...а всі «корутини» з світу асінків та авейтів побудовано приблизно за тим же принципом тільки з різними ступенями зручності...

оці мови де «два світи» з «нормальних» срідів та «вивернутих колбеків», з починаючи від Promises/A+ й оте от все далі, включно з C# та C++ ними «корутинами», їм до go з його «грутинами» та до Lua — як до неба рачки, але, все ж, краще ніж callback hell чи рубати руцями на стейти мануально вкодованих FSMок й слідкувати за утвореним «спагетті» самому (продукти типу QP фреймворка — теж велика біда, де cast на castі castом поганяє, й ніхто не може дотриматися достатньо гігієни, щоб розділити граф FSMки та власне код)

та щас тіко сапожки почищу ))

пшепрашам, не віднайшов того пана гуглом в списках академіків, але може кандидат наук, чи й доктор, то одразу додаю пересторогу: не всі учені однакові, й з деякими можна цілком продуктивно співпрацювати, а мій «флейм» з початкового посту передовсім щодо тих, хто називає стейти S1, S2 і тд, евенти E1, E2, й уявлення про те, як то мапиться на реки живе лише у них в голові, ще й жодного коментіка не напише, й для кого ті, що «не осягли величі науки» й не мають статей на скопусі, то все «плебеї», «неуки» та решта негарних епіпетів (звісно, я, вимагаючи коментарів, називати стейти та евенти «людськими іменами», подібними на то, що пише в СРС та СДД, щоб була якась «трейсабіліті» — то я для них головний «неук»)...

Дякую за схвальний відгук і доповнення.
Щодо селектора — дійсно, await session.get(...) і await resp.json() - це окремі I/O-операції, які не очікуються одночасно, і кожна реєструється в селекторі окремо. Спершу чекаємо на відповідь, і лише потім читаємо тіло.
У статті я свідомо це спростила, щоб не перевантажувати основне пояснення. Можливо, справді варто додати цей нюанс у вигляді хоча б примітки — дякую, що підсвітили.

Монументальна праця (і ця стаття, і попередні). Обов’язково опублікуйте англійською.

Дякую, дуже круті статті!

Дякую за схвальний відгук!

Мав досвід з gevent, мені він дуже подобався, якби не рандомні проблеми (в основному блокування, про які дізнаєшся не одразу) зі сторонніми лібами, іноді треба було городити костилі. А так, підхід дуже зручний.

асинхронність у Python — це не про швидкість у класичному сенсі

бо асинхронність воно не за швидкість саме by design за швидкість як то масштабування то вже паралельність а асинхронність то таки да лише за не простій у простому простої процесора циклів

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

здебільшого ж воно за те як не чекати доки закипить один чайник два чайники десять чайників десять тисяч чайників ))

бо сам чайник якраз кипить паралельно він робить це сам собою і не потребує власних ресурсів на те щоб його кипіти

... як то а от паралельно як то саме одночасно чистити моркву та прибирати зі столу так вже не вийде селяві просто бізнес ))

корутіна то є не зовсім пауза функції бо ж фактично пауза самої функції виникає на виклик будь якої іншої функції у процесі виконання першої

але корутіна дозволяет повернення свого результату (функції) без завершення (повного) свого виконання і відповідно продовження свого виконання на наступний виклик з того самого місця і стану як було до повернення

як то

int foo()
  yield 1
  yield 2
  yield 3
  yield 4
  int i = 4
  yield i++

і тоді

while
  print foo()

відповідно 1 2 3 4 і так далі

а от async/await то вже просто геть інша історія

Тобто функцію, яку можна призупинити через await і відновити пізніше.

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

більше того концепт async/await навіть не передбачає обов’язкової «призупинки» та саме чекання бо як скажімо результат уже готовий то виклик просто повертає результат вже готовий без усякого чекання та зупинки бо чого ж чекати власне результат же ж уже готовий

як то буквально то у середині тої async функції яку треба викликати стоїть якась проста умова

if result
  return result
else
  await long_call_to_grabdpa_village

як то вже ці лонг коли можна завернути у всякі таски треди тощо а сам async/await просто дає механізм ховаючи усе під капотом передачу керування та результату виконання як синтаксичний мусор

Виглядає так ніби ви плануєте написати книгу на цю тему

Ахах, все може бути, але поки не планувала

Раптом що — ось інструкція як з ГуглДоку згенерувати eBook github.com/...​lishing_from_GoogleDoc.md

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