Асинхронность как основной подход к разработке высоконагруженных приложений
В данной статье освещаются основные подходы к современной разработке высоконагруженных приложений, в частности, предоставляющие основной интерфейс для взаимодействия с пользователем при помощи браузера или иного клиента, в функционал которого заложена отправка http запросов к серверу.
Введение
Понятие высоконагруженного приложения можно интерпретировать как любое приложение или сервис, обслуживающий большое количество одновременных запросов. Результатом данного события является значительная нагрузка на системные ресурсы, такие как использование процессора, потребление памяти, пропускная способность сети и т.д. В соответствии с этими ограничениями возникает вопрос: как обеспечить эффективную и надежную работу при высоких нагрузках?
Об основных подходах и существующих стратегиях, которые повсеместно используются на этапе проектирования будет рассказано далее. А теме асинхронности как одному из лучших прикладных подходов в проектировании веб-приложений и не только уделяется большая часть материала.
Основные термины
Прежде чем приступить к рассмотрению общепринятых методов проектирования, необходимо дать понятия базовым сущностям, которые напрямую связаны с процедурой разработки.
Процесс – запущенная программа и ее данные в памяти. Программа может запускать свои дочерние процессы, для которых предоставляется отдельное место в памяти. Для одного процесса операционная система выделяет пул ресурсов, включая виртуальную память, т.е. память использующуюся, когда программе требуется более того числа оперативной памяти, что есть на компьютере, путем перемещения частей программы на жесткий диск. В эту область памяти загружается транслятор, код и другие необходимые для него компоненты. Этот процесс реализуется автоматически, поэтому не требует никаких усилий от программиста, только аппаратной поддержки от ОС.
Thread – поток выполнения программы, т.е. основные части алгоритма, которые выполняются последовательно при запуске программы. Thread является частью процесса.
GIL (глобальная блокировка интерпретатора) – способ синхронизации потоков, который используется в некоторых интерпретируемых языках программирования, например в Python и Ruby.
Синхронность – концепция по умолчанию, когда каждая последующая операция ожидает окончания предыдущей и не может быть выполнена наперед.
Виды операций:
IO-bound – означает, что скорость выполнения ограничена скоростью ввода/вывода. Например, запись на диск или запрос на сервер.
CPU-bound – означает, что скорость выполнения ограничена характеристиками CPU и оперативной памятью.
Многопроцессорность
Представим, что пользователь локально запускает несколько приложений совместно с теми процессами, которые необходимы для функционирования самой системы, и в результате работы переключается между ними. В данном примере при наличии одноядерного CPU с архитектурой x86 каждый процесс будет выполняться отдельно друг от друга, каждый раз переключаясь между собой. Скорость этих переключений будет зависеть от тактовой частоты – характеристикой CPU наравне с битностью (число бит, которым процессор может обмениваться с оперативной памятью за подход). Чем выше частота, тем быстрее работает процессор, поэтому скорость переключений будет выше.
Проблема здесь заключается в увеличении времени ожидания при большом количестве переключений, и чтобы скорость работы оставалась высокой, необходимо увеличить частоту, что может быть экономически очень затратно. Поэтому в современных процессорах реализовано несколько ядер, каждое из которых может отдельно выполнять свои задачи, т.е. при наличии многоядерного CPU процессы могут работать одновременно. Например, при наличии четырехъядерного CPU параллельно могут работать четыре процесса.
Многопоточность
Логика выполнения программы может быть разбита на несколько потоков в рамках одного процесса совместно с основным потоком, который выполняет ОС. В таком случае потоки будут разделять один пул ресурсов, выделенный ОС. Такая возможность позволяет реализовать одновременно дополнительные вычисления, которые будут выполняться на разных потоках выполнения. В свою очередь, разные потоки могут быть разбиты по разным процессорным ядрам, что повышает производительность и позволяет эффективно задействовать аппаратную часть компьютера. Например, 4 потока на одно ядро совместно с четырьмя разными процессами, каждый из которых выполняется одним из четырех ядер.
Не вдаваясь в детали взаимодействия вычислительных мощностей CPU и потоков, стоит упомянуть о главных недостатках такого подхода в целом.
Во-первых, в большинстве случаев нельзя реализовать 1000 потоков, чтобы сервер мог одновременно обслуживать 1000 человек, т.к. это требует большого количества процессорных ядер, что экономически невыгодно. А наличие меньшего числа потоков может привести к таймаутам, возникающих на стороне клиентов, которые ожидают, когда очередь сервера дойдет до них, если очередь есть.
Во-вторых, при проектировании серверной части с использованием таких языков как JavaScript или Python3, в частности такой его реализацией как CPython, многопоточность не может быть реализована в силу архитектурных особенностей данных ЯП. В CPython присутствует т.н. Global Interpreter Lock, который блокирует интерпретатор при выполнении нескольких потоков. Без GIL CPU-bound операции выполнялись бы в 25 раз медленнее.
Асинхронность
Наличие GIL в CPython никак не мешает использовать данный ЯП в качестве основного инструмента при проектировании серверной части высокоэффективных веб-приложений. Добиться этого позволяет такая концепция программирования как асинхронность.
Здесь уместно упомянуть о синхронном программировании, главными недостатками которого являются риск остановки всей программы при наличии большого времени ожидания одной операции, а также невозможность эффективно использовать то время, когда программа выполняет какую-либо логику, например, получает данные из базы или стороннего API или производит сложные вычисления. В таком случае пользователь веб-сайта во время получения каких-либо данных не смог бы перемещаться в пределах данной страницы во время выполнения запроса.
Представим, что сервер полностью обрабатывает запрос за 300 мс, в рамках этого времени 100 мс он получает ответ из базы данных, а остальные 200 мс ожидает. Асинхронность или по-другому кооперативная многозадачность и позволяет серверу заполнить эти 200 мс чем-то полезным, в т.ч. обрабатывать параллельные запросы в достаточно большом количестве при отсутствии больших вычислительных мощностей.
Асинхронный код работает в рамках одного процесса и в рамках одного потока, убирает блокирующую операцию из основного потока выполнения, позволяя выполнять следующую операцию, ставя предыдущую на второй план, не блокируя процесс ее выполнения. В том числе, можно запустить четыре процесса программы, где каждое ядро выполняет один процесс и один поток, который в свою очередь выполняет несколько операций одновременно, что еще больше повышает эффективность работы.
Стоит понимать, что асинхронность реализуется на уровне ОС, которая предоставляет необходимые сокеты, а синтаксис и способы работы с отдельными субъектами отличаются в разных ЯП.
Для большего понимания абстракции и концепции асинхронного подхода, необходимо разобрать основные сущности, участвующие в работе, посредством практической реализации небольшого мысленного эксперимента и скрипта.
Представим, что мальчик Петя на летние каникулы уехал отдыхать к бабушке в деревню со списком литературы для чтения. Каждая книга из списка является задачей (task), также у Пети может быть свой список дел, которые он хочет выполнить: сходить в лес за грибами, пойти с дедом на охоту и подстрелить фазана, искупаться в озере минимум 50 раз за лето и т.д. Т.к. Петя живет в нормальном асинхронном мире, он может заниматься своими делами в любом порядке, прерываясь по мере необходимости, т.е. может доделать что-то, оставленное на потом. В рамках этого мысленного эксперимента циклом событий (event loop) является Петя, он управляет задачами, пробегается по каждой из них и проверяет, какая из них выполнена. Задачи могут иметь за собой некий алгоритм работы, иными словами, быть функциями, данные функции называются сопрограммами (coroutine), а задачами становятся, когда передаются циклу событий и стоят в очереди на выполнение. Таким образом, в процессе участвуют три сущности: цикл событий, сопрограмма, задача.
Не вдаваясь в подробности синтаксиса ЯП Python3, реализуем две программы и сравним время выполнения каждой. Они обе будут выполнять одни и те же IO-bound задачи – имитировать выполнение какой-либо операции в течение 1 секунды, генерировать 10 случайных строк, каждая из которых представляет собой информацию о пользователях некоего интернет-ресурса, и записывать их в файл формата CSV.
Синхронный код:
import csv
import time
from time import monotonic
from faker import Faker
faker = Faker("ru_RU")
def make_user(uid: int) -> dict[str, str | int]:
"""Функция, имитирующая выполнение какой-либо операции
и создающая словарь, значения которого будут переданы
в объект DictWriter и записаны в файл
"""
time.sleep(1)
return {"id_user": uid, "name": faker.name(), "email": faker.email()}
def main() -> None:
tasks = (make_user(i) for i in range(1, 11))
with open("users.csv", "w", encoding="utf-8") as file:
headings = "id_user", "name", "email"
writer = csv.DictWriter(file, headings)
start = monotonic()
writer.writerows(tasks)
print(f"Uptime = {monotonic() - start} sec")
if __name__ == "__main__":
main()
Асинхронный код:
import asyncio
import csv
from time import monotonic
from faker import Faker
faker = Faker("ru_RU")
async def make_user(uid: int) -> dict[str, str | int]:
"""Корутина, имитирующая выполнение какой-либо операции
и создающая словарь, значения которого будут переданы
в объект DictWriter и записаны в файл
"""
await asyncio.sleep(1)
return {"id_user": uid, "name": faker.name(), "email": faker.email()}
async def main() -> None:
tasks = (asyncio.create_task(make_user(i)) for i in range(1, 11))
with open("users.csv", "w", encoding="utf-8") as file:
headings = "id_user", "name", "email"
writer = csv.DictWriter(file, headings)
start = monotonic()
writer.writerows(await asyncio.gather(*tasks))
print(f"Uptime = {monotonic() - start} sec")
if __name__ == "__main__":
asyncio.run(main())
Результат обеих программ представлен ниже. Их главное отличие во времени работы. Первой синхронной программе на выполнение понадобилось 10 секунд, что отображается в консоли по завершении, а второй асинхронной – 1 секунда (к слову, можно записать и 1000 строк за это то же время).
1,Эрнст Федосьевич Комаров,matveevanisim@example.net
2,Осип Ермолаевич Захаров,lukjan_92@example.com
3,Трифон Вячеславович Овчинников,selivan2007@example.org
4,Виктор Елисеевич Сорокин,filippovroman@example.net
5,Зуева Людмила Николаевна,milen_1973@example.com
6,Кузьмин Климент Гавриилович,kostinanaina@example.com
7,Чернов Самсон Гавриилович,galkinafevronija@example.com
8,Кузьмин Автоном Феодосьевич,avgust_1970@example.org
9,Шестакова Синклитикия Викторовна,foma1982@example.net
10,Агата Сергеевна Белякова,nikitinadam@example.org
Данные примеры лишь иллюстрируют принцип работы асинхронности при написании программ, требующих большую скорость работы в конкретный момент.
Заключение
В данной работе были рассмотрены использующиеся разработчиками основные методы проектирования приложений, которые позволяют создавать надежные сервисы, способные справиться даже с самой высокой нагрузкой и обеспечить оптимальную производительность для конечных пользователей.
Важно упомянуть, что на этапе реализации так же существуют специальные стратегии и способы, позволяющие эффективно управлять ограничениями ресурсов. К примеру, это использование механизмов кэширования для сокращения количества запросов к базе данных, развертывание нескольких серверов для обработки запросов, оптимизация кода для повышения эффективности. Совместно с внедрением алгоритмов распределения нагрузки и использованием асинхронных моделей программирования можно создавать действительно производительные и более совершенные IT продукты.