Async Hearts
Некоторое время назад случилось несколько событий, изменивших привычный вид ландшафта веб-разработки на Питоне: Facebook приобрела сервис Friendfeed и сразу же открыла исходный код технологии проекта — http-сервер и микрофреймворк Tornado. Одновременно разработчик Friendfeed опубликовал в своем блоге заметку, в которой привел причины, по которым было решено с нуля разрабатывать собственный асинхронный веб-сервер.
Статья — экскурсия в самое сердце этого и конкурирующего (Twisted.web) проектов, их циклы асинхронной обработки поступающих данных.
Заметка разработчика содержала критику Twisted, популярного фреймворка для построения асинхронных приложений, как неоттестированого и нестабильного; приводились результаты сравнения производительности простого приложения на Twisted.web(подмножество Twisted, специализирующееся на протоколе http и веб-разработке) и Tornado. Естественно, последний в этих тестах оказывался эффективней.
Один из ключевых программистов Twisted не смог остаться в стороне и привел причины, по которым Friendfeed лучше бы не изобретали велосипед и использовали уже имеющийся инструментарий; в следующем же посте указал на другую разработку — Comet-сервер Orbited, который был портирован на Twisted по причинам большей cтабильности и удобства разработки.
С точки зрения веб-разработчика Tornado и Twisted.web не очень сильно отличаются, поскольку являются микрофреймвоками, предоставляющими только самый базовый инструментарий для работы с запросами, авторизацией и так далее, и не могут сравниться с такими гигантами как Django или, если выйти за пределы мира Питона, Ruby on Rails.
Асинхронность
Сердце, душа и главное отличие обоих приложений от конкурентов — асинхронная обработка сервером запросов, позволяющая выигрывать в производительности за счет отказа от переключений контекста, характерных для синхронных серверов, плодящих множество процессов или тредов.
Все действия совершаются одним процессом (тредом) в едином цикле, «цикле событий»(event loop), похожем на те, что встречаются во фреймворках для построения интерфейсов.
Производительность
Как указывалось выше, выигрыш по сравнению с классическими синхронными серверами достигается созданием такого единого цикла вычислений, который бы позволил отказаться от переключений контекста ядра.
Такой цикл присутствует и в Tornado (ioloop), и в Twisted (различные реализации reactor). Попробуем разобраться в каждом из них, определим причины выигрыша по производительности http-сервера Tornado, оценим код и архитектурные решения каждого из асинхронных серверов.
Tornado (ioloop)
Модуль ioloop из Tornado использует по умолчанию механизм epoll для работы с неблокирующими сокетами. Если такового на платформе (собственно, подойдут только Линуксы с версией ядра 2.6 и старше) не предоставляется, то
используется универсальный select.
Реализация главного цикла крайне простая, умещается в пару небольших файлов: epoll.c — обертка для epoll, ioloop.py — реализация цикла.
В epoll.c в функции Питона оборачиваются epoll_create, epoll_ctl, epoll_wait и объявляется модуль epoll. Этот модуль компилируется и используется в случае, если стандартный модуль языка для асинхронной работы с сокетами (модуль select) не поддерживает epoll (не содержит класс epoll).
Итак, сам цикл событий располагается в методе start класса IOLoop модуля ioloop.py. Далее будут приводится части этого метода с несколько расширенными пояснениями:
def start(self): self._running = True while True: # Таймаут по умолчанию между циклами вызовов обработчиков событий # позволяет избежать зависания пула событий poll_timeout = 0.2 # Создаем список обработчиков событий callbacks = list(self._callbacks) for callback in callbacks: # Убираем обработчик из списка неиспользованных и выполняем if callback in self._callbacks: self._callbacks.remove(callback) self._run_callback(callback) # При наличии обработчиков нет необходимости в задержке между циклами if self._callbacks: poll_timeout = 0.0 # Если есть обработчики событий, выполняемые с задержкой во времени, и заданное # время уже прошло - выполняем такие обработчики. if self._timeouts: now = time.time() while self._timeouts and self._timeouts[0].deadline <= now: timeout = self._timeouts.pop(0) self._run_callback(timeout.callback) # следующий комплект событий будет собираться либо стандартное время # задержки, либо, если вызвать отложенный обработчик надо раньше, # через время, установленное для этого обработчика if self._timeouts: milliseconds = self._timeouts[0].deadline - now poll_timeout = min(milliseconds, poll_timeout) # Если какой-то обработчикв процессе решил остановить работу - выходим из цикла if not self._running: break # Дальше в течение заданного времени времени собираются события пула try: event_pairs = self._impl.poll(poll_timeout) except Exception, e: if e.args == (4, "Interrupted system call"): logging.warning("Interrupted system call", exc_info=1) continue else: raise # Для заданных файловых дескрипторов (сокетов) вытаскиваются события и # с ними вызываются их хэндлеры(к примеру, функции, читающие данные из сокетов - fdopen) self._events.update(event_pairs) while self._events: fd, events = self._events.popitem() try: self._handlers[fd](fd, events) except KeyboardInterrupt: raise except OSError, e: if e[0] == errno.EPIPE: # происходит при потере соединения с клиентом pass else: logging.error("Exception in I/O handler for fd %d", fd, exc_info=True) except: logging.error("Exception in I/O handler for fd %d", fd, exc_info=True)
Вот, в общем-то, и все. Циклически вызываются отложенные на определенное время (либо один цикл) вызовы и обработчики поступивших событий. Полученные данные обработчиками читаются/пишутся не полностью, а постепенно, через буферы.
В том же простом и лаконичном стиле написаны все остальные уровни фреймворка: http-сервер, обработчики запросов и отдельных соединений.
Twisted (reactor)
Модуль twisted.internet.reactor из фреймворка представляет собой тот самый цикл событий (event loop), который занимается выполнением обработчиков событий и возможных ошибок.
По умолчанию реактор веб-сервера (как и фреймворк в целом) использует механизм ядра select распределения событий для неблокирующих сокетов; этот механизм универсален для платформ Unix и Win32, хотя и несколько уступает по эффективности реакторам на kqueue(FreeBSD) или epoll(только для Linux)
Рассмотрим работу реактора EPollReactor, как аналога основного механизма, используемого в Tornado (ioloop, работающий с epoll).
Реактор содержит несколько словарей, вокруг которых сконцентрирована вся асинхронная логика цикла. Словари объявляются в конструкторе класса:
class EPollReactor(posixbase.PosixReactorBase): implements(IReactorFDSet) def __init__(self): self._poller = _epoll.epoll(1024) self._reads = {} self._writes = {} self._selectables = {} posixbase.PosixReactorBase.__init__(self)
Здесь создается сам пул событий (_poller); словари(_reads и _writes), содержащие отображения целых чисел файловых дескрипторов на случайные числа. По сути дела это просто множества дескрипторов для чтения (_reads) и записи (_writes) данных.
Интерес представляет сам цикл асинхронной обработки событий, поэтому опустим описание служебных методов, объявляемых в классе реактора (и его базовом классе).
Итерация выборки событий и их обработки выглядит следующим образом(комментарии переведены и по возможности расширены):
def doPoll(self, timeout): if timeout is None: timeout = 1 # преобразуем задержку итерации (время сбора событий) в миллисекунды timeout = int(timeout * 1000) try: # Число отбираемых событий ограничим количеством отслеживаемых # объектов ввода/вывода (число выбрано эвристически) # и временем блокировки цикла, переданным в аргументе вызывающей цикл функцией. l = self._poller.wait(len(self._selectables), timeout) except IOError, err: if err.errno == errno.EINTR: return # В случае прерывания ожидания сигналом - выходим из итерации; # во всех прочих случаях предполагается, что ошибки могли произойти # только на стороне приложения и стоит передать исключение дальше raise # Если во время сбора событий не произошло никаких ошибок, приступаем # к вызову обработчиков событий на дескрипторах. _drdw = self._doReadOrWrite for fd, event in l: try: selectable = self._selectables[fd] except KeyError: pass else: log.callWithLogger(selectable, _drdw, selectable, fd, event)
Методу реактора self._doReadOrWrite (переименованной в _drdw) передается дескриптор, произошедшее на нем событие и обработчик события (если таковой был найден). Заглянем в сам метод:
def _doReadOrWrite(self, selectable, fd, event): why = None inRead = False if event & _POLL_DISCONNECTED and not (event & _epoll.IN): why = CONNECTION_LOST else: try: if event & _epoll.IN: why = selectable.doRead() inRead = True if not why and event & _epoll.OUT: why = selectable.doWrite() inRead = False if selectable.fileno() != fd: why = error.ConnectionFdescWentAway( 'Filedescriptor went away') inRead = False except: log.err() why = sys.exc_info()[1] if why: self._disconnectSelectable(selectable, why, inRead)
Здесь обрабатываются события поступления и записи данных из/в дескриптор, происходит обработка ошибок, если таковые имеются.
Таким образом, на самом низком уровне Tornado и Twisted похожи, отличия начинаются на более высоких уровнях абстракции. Разработка от команды Friendfeed делает над циклом всего несколько простых надстроек (HttpStream -> HttpConnection -> HttpServer и прочие). Циклы здесь основываются только на epoll или select.
Twisted Framework же строится на специальных абстракциях (вроде Deferred); его реакторы реализованы для более широкого спектра решений: poll, epoll, select, kqueue(MacOS и freeBSD), пара инструментов под Win32; есть реакторы, встраивающиеся в циклы фреймворков для построения интерфейсов (PyGTK, wxWidgets).
Выводы
Строго говоря, трудно сравнить универальный сетевой фреймворк и специализированное приложение. Код Tornado значительно проще и лаконичней в целом, больше отвечает принципу pythonic. Озадачивает только отсутствие тестов, что в современной разработке считается неприличным.
С другой стороны, Twisted — универсальный инструмент, который при всех своих действительно широких возможностях сохраняет стройность и последовательность; и в этом смысле его можно сравнить с великолепным Qt( в оригинальной реализации для C++). Http-сервер — всего-лишь частный случай его применения. Код большей
части компонентов фреймворка неплохо оттестирован, предоставляется даже собственный инструмент тестирования (Trial).
Естественно, Twisted, как и всякая обощающая система, уступает в производительности специализированной разработке.
Еще одна причина, по которой Twisted уступает в эффективности Tornado и другому высокопроизводительному асинхронному фреймворку Diesel — более развитая обработка ошибок, которая добавляет надежности, но скрадывает заветные RPS.
Итак, главное преимущество Twisted — универсальность. Tornado — производительность.
Что выбрать? Решайте сами. Оба фреймворка предоставляют веб-программисту весьма спартанский набор средств разработки, однозначно уступая в простоте Django и всеобъемлющей полноте Zope; оба — выигрывают в скорости (до 20-30 процентов прироста по сравнению с решениями на Apache).