Асин­хрон­ные при­ложе­ния — это типич­ный при­мер того, про что говорят «Новое — это хорошо забытое ста­рое». Ну да, сам по себе под­ход появил­ся еще очень дав­но, ког­да надо было эму­лиро­вать парал­лель­ное выпол­нение задач на одно­ядер­ных про­цес­сорах и ста­рых архи­тек­турах. Но песок — пло­хая замена овсу, «асин­хрон­ность» и «парал­лель­ность» — доволь­но‑таки орто­гональ­ные понятия, и один под­ход задачи дру­гого не реша­ет. Тем не менее асин­хрон­ности наш­лось отличное при­мене­ние в наше высоко­наг­ружен­ное вре­мя быс­трых интернет‑сер­висов с тысяча­ми и сот­нями тысяч кли­ентов, жду­щих обслу­жива­ния одновре­мен­но. Воз­можно, сто­ит разоб­рать­ся получ­ше, как это все работа­ет?
 

Зачем нужна асинхронность?

Ес­ли спус­тить­ся поч­ти на самый низ, к ядру опе­раци­онной сис­темы, то у прог­раммис­та, откро­вен­но говоря, есть толь­ко два вари­анта работы с сокетом — син­хрон­ный и асин­хрон­ный.

С син­хрон­ным в целом все понят­но — при­шел кли­ент, открыл­ся сокет, переда­ли дан­ные, если это все — сокет зак­рылся. В этом слу­чае пока мы не закон­чили локаль­ный диалог с одним кли­ентом — не можем начать его с дру­гим. По такому прин­ципу обыч­но работа­ют прос­тые сер­веры, которым не надо дер­жать сот­ни и тысячи кли­ентов. В слу­чае если наг­рузка воз­раста­ет, но не кри­тич­но — мож­но соз­дать еще один или нес­коль­ко потоков (или даже про­цес­сов) и обра­баты­вать под­клю­чения еще и в них. Это обка­тан­ный годами, ста­биль­но работа­ющий под­ход, который, нап­ример, исполь­зует сер­вер 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, которые внут­ри себя скре­щива­ют сра­зу нес­коль­ко видов асин­хрон­ности и мно­гопо­точ­ности в одном фла­коне. Поэто­му — катего­ричес­ки рекомен­дую про­бовать, оши­бать­ся, радовать­ся и вооб­ще прог­рамми­ровать. Уда­чи!

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии