Python
9 October 2009

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).

+50
9.1k 60
Comments 29
Top of the day