Содержание статьи
Асинхронные приложения — это типичный пример того, про что говорят «Новое — это хорошо забытое старое». Ну да, сам по себе подход появился еще очень давно, когда надо было эмулировать параллельное выполнение задач на одноядерных процессорах и старых архитектурах. Но песок — плохая замена овсу, «асинхронность» и «параллельность» — довольно-таки ортогональные понятия, и один подход задачи другого не решает. Тем не менее асинхронности нашлось отличное применение в наше высоконагруженное время быстрых интернет-сервисов с тысячами и сотнями тысяч клиентов, ждущих обслуживания одновременно. Возможно, стоит разобраться получше, как это все работает?
Зачем нужна асинхронность?
Если спуститься почти на самый низ, к ядру операционной системы, то у программиста, откровенно говоря, есть только два варианта работы с сокетом — синхронный и асинхронный.
С синхронным в целом все понятно — пришел клиент, открылся сокет, передали данные, если это все — сокет закрылся. В этом случае пока мы не закончили локальный диалог с одним клиентом — не можем начать его с другим. По такому принципу обычно работают простые серверы, которым не надо держать сотни и тысячи клиентов. В случае если нагрузка возрастает, но не критично — можно создать еще один или несколько потоков (или даже процессов) и обрабатывать подключения еще и в них. Это обкатанный годами, стабильно работающий подход, который, например, использует сервер Apache, — никаких неожиданностей, данные от клиентов обрабатываются в порядке строгой очереди, а в случае запуска какого-то «долгого» кода — например, каких-то вычислений или хитрого запроса в БД — это все никак не влияет на других клиентов.
Но есть проблема: сервер — это не Лернейская гидра, он не может плодить потоки и процессы вечно — есть же, в конце концов, вполне ощутимые ресурсы, которые тратятся при каждом таком действии, и имеется верхний порог использования этих ресурсов. И вот тогда все вдруг вспомнили про асинхронность и системные вызовы для неблокирующего ввода-вывода. Зачем плодить кучу сокетов и потоков, выедать ресурсы, если можно данные от многих клиентов сразу одновременно слушать на одном сокете?
Все началось с системных вызовов
Собственно, вариантов системных вызовов для неблокирующей работы с сетевым вводом-выводом не так уж и много (хотя они слегка и разнятся от платформы к платформе). Самый первый, базовый, можно сказать ветеран — это системный вызов select(), который появился еще в бородатые восьмидесятые годы вместе с первой версией того, что сейчас называется POSIX-сокетами (то есть сокетами в понимании большинства современных серверных систем), а тогда называлось Berkeley sockets, сокетами Беркли.
По большому счету, во времена описания системного вызова select() вообще мало кто задумывался о том, что когда-то приложения могут стать НАСТОЛЬКО высоконагруженными. Фактически все, что этот вызов умеет делать, — принимать фиксированное количество (по умолчанию не более 1024) однозначно описанных в программе файловых дескрипторов и слушать события на них. При готовности дескриптора к чтению или записи запустится соответствующий колбэк-метод в коде.
Почему select() все еще существует и используется?
Ну, во-первых, он существует именно потому, что существует, как бы каламбурно это ни звучало, — select() поддерживается практически всеми мыслимыми и немыслимыми программными платформами, которые вообще подразумевают сетевое взаимодействие. А во-вторых, есть, скажем так, «городская легенда», что в силу простой, как топор, реализации этот системный вызов на части архитектур (к которым не относятся ни широко используемые персональные компьютеры, ни даже серверы) обладает феноменальной точностью обработки тайм-аутов (вплоть до наносекунд). Возможно, при работе в области космических исследований или ядерной энергетики это спасет чью-то жизнь? Кто знает.
Потом кто-то задумался о том, что неплохо бы все-таки научиться делать действительно по-взрослому высоконагруженные сетевые приложения, и появился системный вызов poll(). Кстати, в Linux он существует довольно давно, а вот в Windows его не было до выпуска Windows Vista. Вместо разрозненных сокетов этот вызов принимает на вход структуру со списком дескрипторов (фактически произвольного размера, без ограничений) и возможных событий на них. Затем система начинает в цикле бегать по этой структуре и отлавливать события.
Главный минус вызова poll() (хотя это, несомненно, был большой шаг вперед по сравнению с select()) — обход структуры с дескрипторами с точки зрения алгоритмики линеен, то есть осуществляется за O(n). Причем это касается не только отслеживания событий, но и реакции на них, да еще и надо передавать информацию туда-обратно из kernel space в user space.
А вот дальше в каждой операционной системе решили пойти своим путем. Нельзя сказать, что подходы конкретно различаются, но все-таки реализовать кросс-платформенную асинхронную работу с сокетами в своей программе стало чуточку сложнее. Под Windows появился API работы с так называемыми IO Completion Ports, в BSD-системах добавили механизм kqueue/kevent, а в Linux, начиная с ядра 2.5.44, стал работать системный вызов epoll. Отлов асинхронных событий на сокетах (как бы тавтологично это ни звучало) стал асинхронным сам по себе, то есть вместо обхода структур операционная система умеет подавать сигнал о событии в программу практически моментально после того, как это событие произошло. Кроме того, сокеты для мониторинга стало можно добавлять и убирать в любой момент времени. Это и есть те самые технологии, которые сегодня широко используются в большинстве сетевых фреймворков.
Зоопарк event loop’ов
Основная идея программирования с использованием вышеописанных штук состоит в том, что на уровне приложения реализуется так называемый event loop, то есть цикл, в котором непосредственно происходит отлов событий и дергаются callback’и. Под *nix-системами давненько уже существуют обертки, которые позволяют максимально упростить работу с сокетом и абстрагировать написанный код от низкоуровневой системной логики. Например, существует известная библиотека libevent, а также ее младшая сестра libev. Эти библиотеки собираются под разные системы и позволяют использовать самый совершенный из доступных механизмов мониторинга событий.
Я буду приводить в пример большей частью пакеты для сетевого программирования на языке Python, ибо их действительно там целый зоопарк на любой вкус, а еще они популярны и широко используются в различных проектах. Даже в самом языке довольно давно уже существуют встроенные модули asyncore и asynchat, которые, хоть и не умеют работать с epoll (только select/poll), вполне подходят для написания своих реализаций протоколов.
Одна из проблем сетевых библиотек заключается в том, что в каждой из них написана своя имплементация event loop’а, поэтому, даже несмотря на общий подход, перенос, скажем, плагина для Twisted (Reactor) на Tornado (IOLoop) или наоборот может оказаться вовсе не тривиальной задачей. Эту проблему призван решить новый встроенный модуль в Python 3.4, который называется asyncio и, вопреки расхожему мнению, не является сетевой библиотекой или веб-фреймворком в полном смысле слова, а является именно что встроенной в язык реализацией event loop’а. Эта штука как раз и призвана сплотить сторонние библиотеки вокруг одной общей стабильной технологии. Если хочется немного подробностей и независимых впечатлений об asyncio — милости прошу сюда.
Для Tornado уже существует реализация поддержки event loop’а из asyncio, и, более того, она не так давно вышла из состояния беты. Посмотреть можно здесь. Для Twisted релиз asyncio тоже не оказался неожиданностью, и его разработчики даже написали своеобразный шутливый некролог для проекта, в котором, напротив, уверяют, что это вовсе не конец, а очень даже начало новой эпохи развития.
Если говорить уж совсем откровенно, то понятие асинхронного ввода-вывода необязательно должно относиться именно к сетевому сокету. Системы семейства *nix следуют принципу, согласно которому взаимодействие фактически с любым устройством или сервисом происходит через file-like объект. Примерами таких объектов могут служить UNIX-сокеты или, скажем, ноды псевдофайловой системы /dev, через которые осуществляется обмен информацией с блочными устройствами. Соответственно, говоря об event loop’ах, мы можем подразумевать не только сетевое взаимодействие, но и просто любой асинхронный I/O. А чтобы было что потрогать руками — советую глянуть, например, вот на этот встроенный модуль из Python 3.4.
Запутанная история
В современном мире фреймворк Twisted выглядит таким своеобразным мамонтом, legacy-архаизмом, который впитал в себя все попытки предоставить удобный интерфейс для написания сетевых приложений. Тем не менее интерфейс получился действительно удобный, с реализацией отложенного выполнения кода и прочими плюшками, когда никакого Node.js еще не существовало и в помине.
Как я уже упомянул выше, реализация event loop’а в Twisted называется Reactor. Суть работы с ним состоит в том, что мы регистрируем callback’и, которые выполняются в глобальном цикле в виде реакции на какие-то события. Выглядеть это может, например, так:
from twisted.internet import reactor
class Countdown(object):
counter = 5
def count(self):
if self.counter == 0:
reactor.stop()
else:
print(self.counter)
self.counter -= 1
reactor.callLater(1, self.count) # регистрируем callback
# Передаем реактору метод, который нужно дернуть на старте
reactor.callWhenRunning(Countdown().count)
print('Start!')
reactor.run() # поехали!
print('Stop!’)
"""
Start!
5
4
3
2
1
Stop!
"""
Причем, даже если внутри callback возникнет исключение, то реактор все равно продолжит работать, после того как залогирует трейсбэк и запустит зарегистрированные обработчики ошибок.
Кстати, нельзя не уточнить, что Twisted по умолчанию однопоточный, то есть вся эта развесистая петрушка реализована на внутренней магии вокруг системных вызовов для асинхронного I/O. Но на случай крайней нужды в нем есть и своя реализация ThreadPool, которая добавляет возможность работы нескольких потоков.
Сюда идет Tornado
Если Twisted все-таки представляет собой больше сетевую библиотеку, чем веб-фреймворк, то c Tornado все ровно наоборот. Этот пакет был разработан теми же товарищами, которые делали FriendFeed, — а это, на минуточку, реально высоконагруженный веб-проект с миллионами посетителей в день. Отличие выражается еще и в том, что в нем есть небольшой встроенный шаблонизатор и поддержка всяких «интернетных» технологий вроде работы с куками и генератора HTTP-ответов в разных форматах.
Кстати, Tornado появился немного раньше, чем в Python появилась встроенная поддержка epoll (обертки вокруг сетевых системных вызовов находятся в модуле select), поэтому он поставляется с небольшой библиотекой для этого вызова, написанной в несколько строчек кода на C в виде импортируемого модуля. Эта обертка используется на старых версиях Python, но, говорят, в некоторых случаях специально руками собранный с ней пакет работает чуточку быстрее, чем на ванильной реализации. Да, еще одна городская легенда.
Twisted возник на горизонте раньше Tornado, однако тогда еще не начался этот бум на асинхронные веб-приложения, а когда он все-таки пришел, то Twisted оказался слегка не у дел в этой сфере, потому что изначально смотрел немного в другую сторону. Это выразилось в том, что веб-приложения на Twisted сейчас в основном пишут только приверженцы старой школы, а для Tornado появилось довольно большое число библиотек, которые добавляют, например, асинхронную работу с базами данных и key-value хранилищами, удобную интеграцию с фронтенд-технологиями наподобие SockJS и SocketIO и все такое прочее. В результате он сейчас является прямым конкурентом Node.js, только из мира Python.
В качестве примера асинхронного подхода рассмотрим такой код:
import time
import tornado.web
from tornado.ioloop import IOLoop
from tornado import gen
@gen.coroutine # Регистрируем в event loop’е метод как корутину
def async_sleep(seconds):
# Да, это вам не обычный sleep(). Мы добавляем таск с тайм-аутом в IOLoop для текущего обработчика
yield gen.Task(IOLoop.instance().add_timeout, time.time() + seconds)
class TestHandler(tornado.web.RequestHandler):
@gen.coroutine
def get(self):
for i in xrange(100):
print i
yield async_sleep(1)
self.write(str(i)) # Асинхронно пишем в сокет
self.finish() # Завершаем составление ответа
application = tornado.web.Application([
(r"/test", TestHandler),
])
application.listen(9999)
IOLoop.instance().start() # Поехали!
Про то, что такое корутины и как они работают, можно прочитать в моей статье в октябрьском номере. Этот код можно считать примером простейшего асинхронного приложения на Tornado — запускается сервер на 9999-м порту, который при заходе по URL "/test" запускает отложенную таску, в которой каждую секунду шлет следующее число из счетчика в сокет, при этом не забывая обрабатывать другие подключения.
Освещаем события
Асинхронные серверы — это круто, но как же насчет асинхронных клиентов? Такие тоже писать довольно легко. В Python это можно делать с использованием одной из двух довольно известных библиотек — gevent и eventlet. На их основе создаются отличные скоростные парсеры и системы мониторинга, которые по-настоящему быстро опрашивают тысячи серверов.
Нет, на самом деле серверы с их помощью тоже можно писать. Например, в известной облачной open source платформе OpenStack eventlet используется как база при построении REST-сервисов в некоторых подпроектах. Но в этих библиотеках также присутствует действительно хорошая инфраструктура для написания клиентов.
Gevent работает в основном с библиотекой libevent (или, в новых версиях, libev), а eventlet может при желании работать и просто с epoll. Основная задача этих модулей — создание удобной инфраструктуры для работы с корутинами и запуск тасков в «зеленом» режиме, то есть реализация кооперативной многозадачности за счет быстрого переключения контекста.
Например, в eventlet есть механизм манкипатчинга стандартной библиотеки Python, который позволяет при использовании, скажем, модуля threading, подменять потоки на корутины. То есть в общем случае написанная в многопоточном стиле программа становится асинхронной.
В качестве примера кода с переключением контекста приведу код из примеров стандартной библиотеки gevent:
import gevent
def foo():
print('Running in foo')
gevent.sleep(0)
print('Explicit context switch to foo again')
def bar():
print('Explicit context to bar')
gevent.sleep(0)
print('Implicit context switch back to bar')
gevent.joinall([
gevent.spawn(foo), # Превращаем наши методы в «корутины»
gevent.spawn(bar),
]) # и дожидаемся выполнения
"""
Running in foo
Explicit context to bar
Explicit context switch to foo again
Implicit context switch back to bar
"""
А вот первый же пример простейшего асинхронного клиента на eventlet (его и другие примеры можно найти на официальном сайте):
import eventlet
# Патченная версия модуля из стандартной библиотеки
from eventlet.green import urllib2
urls = [
"https://www.google.com/intl/en_ALL/images/logo.gif",
"http://python.org/images/python-logo.gif",
"http://us.i1.yimg.com/us.yimg.com/i/ww/beta/y3.gif",
]
def fetch(url):
print("opening", url)
body = urllib2.urlopen(url).read()
print("done with", url)
return url, body
pool = eventlet.GreenPool(200) # Пул асинхронных обработчиков
for url, body in pool.imap(fetch, urls):
print("got body from", url, "of length", len(body))
Основной и главной проблемой этих модулей можно назвать то, что в силу их завязанности на код на C и хитрости реализации их до сих пор в нормальном виде не портировали ни на PyPy, ни на Python 3, есть только прототипы.
И что в итоге?
С одной стороны, асинхронный подход к программированию крайне полезен при решении целого ряда задач, особенно в сфере веб-разработки. С другой — он зачастую требует вывернуть мозг наизнанку, так как любая непродуманная строчка кода может привести к блокировке всего и вся. Но, несомненно, знание того, как работают подобные вещи, может быть очень полезным для современного разработчика, особенно в свете развития таких языков, как Go и Erlang, которые внутри себя скрещивают сразу несколько видов асинхронности и многопоточности в одном флаконе. Поэтому — категорически рекомендую пробовать, ошибаться, радоваться и вообще программировать. Удачи!