Защо използвате асинхронна IO рамка на реактивно приложение? Това е въпрос, който често ми задават, когато представям дизайн, който използвам много в приложения, базирани на RxPy. Това е много интересен въпрос, защото отговорът не е очевиден: и асинхронното, и реактивното програмиране са инструменти за програмиране, управлявани от събития. Така че в началото те изглеждат повече като конкуренти, отколкото като съюзници.

Тази статия изяснява какво точно представлява всяка технология и показва защо те блестят, когато се комбинират. Наблягам малко на Python AsyncIO и RxPY, но повечето от следващите обяснения се отнасят за всеки програмен език и рамка.

Асинхронно IO програмиране

Нека започнем с асинхронно IO програмиране. Дълго време той беше пренебрегван от програмистите. Вероятно основната причина беше, че беше трудно да се използва правилно поради базирания на обратно извикване код. Това започна да се променя с поддръжката на фючърси и след това с наличието на синтаксиса async/await в няколко езика за програмиране. Все пак асинхронното IO програмиране с async/await далеч не е естествено за много програмисти. И така, какъв проблем решава, който си струва усилията? Асинхронното IO програмиране обработва IO паралелността по-ефективно от управлението на IO на базата на нишки (Другият начин за работа с IO).

Първото нещо, което трябва да разберете с асинхронните IO рамки е, че - както подсказва името - те са полезни за приложения, които работят с IO ресурси. В повечето случаи това означава приложения, които извършват много мрежови комуникации. Ако дадено приложение е обвързано с процесора, тогава асинхронното програмиране не е адаптираният инструмент за максимизиране на производителността. Въпреки това, ако едно приложение е обвързано с IO, тогава използването на асинхронни IO може да се представи по-добре от многопоточното.

Нека илюстрираме това с две фигури. Ето как едно многонишково приложение се справя с паралелността на IO.

Хоризонталните правоъгълни линии представляват изпълнителни единици (резби). Най-дългата е основната нишка на процеса. Заоблените правоъгълници представляват IO операциите. На тази фигура приложението използва синхронни IO API. В резултат на това, когато IO операция е в ход, процесорът е в застой, чакайки IO операцията да завърши (сивите квадратчета). Тъй като изпълнението на IO операциите може да отнеме много време, се използват множество нишки за паралелно изпълнение на IO операции. Това работи добре до определен момент: Необходима е една нишка за всеки едновременен IO контекст. Когато едно приложение трябва да поддържа десетки хиляди мрежови връзки, тогава трябва да се създадат толкова много нишки. Създаването на много голям брой нишки предполага значително натоварване в повечето операционни системи.

Асинхронните IO API адресират този проблем, като се справят с тази едновременност чрез мултиплексиране:

Тук се използва една нишка и IO операциите се мултиплексират. Когато дадена операция предстои, други IO операции могат да бъдат стартирани и процесорът е достъпен за изчисления. Вече няма нужда от конци. Необходим е обаче някакъв механик, който да поддържа състоянието на текущите операции и да ги възобнови, когато завършат.

Асинхронните IO рамки са проектирани специално за това: Изложете API на високо ниво, така че тези асинхронни операции да са възможно най-лесни за използване. Тези рамки внедряват система за цикъл на събития и API за асинхронен достъп до IO. През повечето време те също така прилагат приложни протоколи като HTTP, WebSocket, MQTT, Kafka.

Реактивно програмиране

Реактивното програмиране е начин за програмиране, управлявано от събития. Основата на реактивните рамки са потоци от събития (често наричани Observables и Items) и API за тяхното манипулиране. Реактивното приложение е накратко изчислителна графика, където всеки възел е изчисление (оператор), а краищата представляват потока от данни.

Нека разгледаме оператора на картата:

Това е много прост оператор, но също така чудесен пример за това как работи реактивното програмиране: елементите влизат, обработват се и се излъчват други елементи, достъпни за други изчислителни единици. Сложни поведения могат да бъдат реализирани чрез комбиниране на оператори и наблюдаеми.

Ако не сте запознати с реактивното програмиране, вижте моята въвеждаща статия по тази тема.

Комбиниране на двете

Сега трябва да е по-ясно, че асинхронното и реактивното програмиране целят решаването на различни проблеми.

От една страна, асинхронното програмиране е начин за максимално използване на IO с минимални системни разходи. Асинхронните рамки прилагат IO мултиплексиране чрез специални цикли на събития. Някои рамки предоставят изрична поддръжка за потоци, но с ограничени API. Въпреки това, те обикновено предоставят много реализации на мрежови протоколи, проектирани с мисъл за ефективност и лекота на използване. Така че основно асинхронните рамки са проектирани да се справят със страничните ефекти (IO) по оптимизиран начин.

От друга страна, реактивното програмиране е начин за писане на изчислителни графики. Повечето, ако не всички от тези рамки са силно повлияни от функционалното програмиране. В резултат на това те осигуряват ограничена подкрепа за странични ефекти. Това не означава, че не можете да използвате IO с тях, но че те имат ограничена вградена поддръжка на IO. Реактивните програмни рамки превъзхождат справянето с потоци от събития. Те съдържат много оператори и могат лесно да бъдат разширени с персонализирани оператори.

Така че в крайна сметка тези две технологии, които първоначално изглеждаха подобни, са много различни и се допълват. Сега нека се върнем към първоначалния въпрос и да видим защо използването на двете е добър дизайн. Ако имате ограничено използване на IO във вашето приложение, може би никога не сте се притеснявали за това. Въпреки това, ако вашето приложение извършва значително количество IO операции, вероятно сте търсили дизайн, който разделя потока от данни от страничните ефекти (IO). Разделянето им е интересно по няколко причини:

  • Улеснява тестването. Не са необходими подигравки, просто излъчвате събития и проверявате изходните събития. Можете също да тествате всяко състояние на грешка.
  • Може да действа като IO абстракция, улеснявайки миграцията от една библиотека към друга.

Освен това, в реактивно приложение със сигурност искате да използвате асинхронни API за IO: Използването на блокиращи API ще блокира потока от данни. Блокирането на потока от данни ще блокира цялото приложение, премахвайки причината да използвате реактивно програмиране. Многонишковостта може да бъде решение, но както беше обяснено преди, има някои ограничения и също така добавя сложност към кода. Все пак това е идеално решение в много ситуации.

Ако се стремите да внедрите „реактивна система“, тогава трябва да използвате асинхронни IO. Тук комбинацията от двата свята изглежда като добро съвпадение. За съжаление прилагането му не е технически очевидно.

Нека сега разгледаме RxPy и AsyncIO като пример. AsyncIO е асинхронната рамка на стандартната библиотека на Python. Той е изцяло базиран на синтаксиса async/await и много базирани на AsyncIO пакети предоставят реализации за различни мрежови протоколи (можете да намерите няколко от тях тук). RxPY е Python имплементацията на ReactiveX. Той съдържа известна поддръжка за AsyncIO, но доста ограничена. Обикновено малко оператори приемат съпрограми като аргументи. В резултат на това е трудно да се смесва RxPY код с AsyncIO код. Това ограничение е шанс да се направи ясно разделение между страничните ефекти и чистия код. CycleJS беше първата рамка, която предложи такъв дизайн. Оттогава бяха внедрени други подобни рамки, включително една, която разработих специално за RxPY и AsyncIO: Cyclotron.

Дизайнът е този:

В горната част е чист поток от данни, приложението RxPY. В долната част са страничните ефекти. Тук може да се внедри AsyncIO код. И двете части комуникират заедно чрез наблюдаеми и елементи. можете да намерите повече информация за този дизайн в друга статия.

В приложение, което използва много различни мрежови протоколи, този дизайн позволява добавяне на функции с минимална сложност за всеки от тях. Ето структура, която използвам много за прости микроуслуги:

Такава услуга консумира събития от някои теми на Kafka, обработва ги и излъчва други събития по други теми на Kafka. Това приложение прави много различни мрежови IO: потребител на Kafka, производител на Kafka, HTTP клиент (с дълго запитване) за Consul, HTTP сървър за Prometheus. Тъй като всички IO са изложени с удобни за RxPy API (т.е. наблюдаеми), съжителството е естествено и ефективно.

Заключение

Надявам се, че разликата между асинхронното програмиране и реактивното програмиране вече е ясна. Тези две технологии определено не се конкурират, а се допълват. Асинхронните рамки са предназначени да се справят с IO, докато реактивните рамки са проектирани да се справят с потоци от събития. За щастие Python осигурява страхотна поддръжка и за двамата. Така че нека използваме най-доброто от всеки един!

Първоначално публикувано в https://blog.oakbits.com на 21 октомври 2020 г.