?

Log in

о няньках (программисткое) - Поклонник деепричастий [entries|archive|friends|userinfo]
Anatoly Vorobey

[ website | Website ]
[ userinfo | livejournal userinfo ]
[ archive | journal archive ]

Links
[Links:| English-language weblog ]

о няньках (программисткое) [окт. 24, 2003|04:47 am]
Anatoly Vorobey
После очень долгих поисков обнаружился источник бага в memcached, который мучил нас последние три месяца. Мы уже привыкли называть его коротко просто spinning: когда он происходит, программа начинает поедать близко к 100% CPU, но при этом не падает и даже обрабатывает некоторые запросы, только медленно; ясно видно, что почти всё время проводит в каком-то пустом (ничего полезного не делающем) цикле.

Этот баг происходит очень редко и только на живой инсталляции livejournal.com, т.е. он требует мощный и непредсказуемый поток данных и запросов "живого" сайта. Симулировать его "в неволе" я так и не сумел, хотя потратил почти неделю в августе на всякие эксперименты по бомбардировке memcached симулированным траффиком.


Из-за того, что баг происходит только на живом сайте, наша возможность его отлаживать была и остаётся сильно ограниченной. В конце концов мы сняли несколько отпечатков strace (лог вызовов системных сервисов (system calls) данного процесса), и нашли единственное место в исходниках, которое могло создавать такую картинку; оно оказалось даже и не у нас вовсе, а в libevent, библиотеке, которую мы используем для работы с events.

Пару дней назад Брэд добавил в нужное место libevent немного диагностики и обнаружил, что происходит. Несколько слов о том, как работает libevent: эта библиотека позволяет регистрировать дескрипторы для отслеживания готовности к чтению или записи; потом главная аппликация обычно входит в event loop, в котором она спит всё время, пока не сигнализируется готовность к чтению/записи какого-то дескриптора, после чего вызывается callback, к-й был ассоциирован с данным дескриптором, и он что-то делает. Например, вот как работает memcached: сначала создаётся один сокет в режиме listen, и регистрируется как event; потом программа входит в event loop, из которого уже никогда не выходит. Когда приходит tcp connection от клиента, event сокета сигнализируется, вызывается функция callback, к-я вызывает accept(), создаёт с её помощью сокет для данного подключения, регистрирует и его как отдельный event, и возвращается - когда клиент пошлёт какие-то данные по вновь созданному подключению, будет вызван callback и так далее. Вся работа происходит в колбэках.

libevent сам по себе - всего лишь удобный уровень абстракции; для собственно извещения (notification) о событиях используется один из методов, доступных на данной OS. На всех Юниксах доступны как минимум извещение через select() и извещение через poll() (см. man 2 select и man 2 poll). Но эти два метода слишком малоэффективны, когда есть сотни и тысячи подключений. Поэтому в Линуксе используют epoll (в главном дереве 2.6 и есть патч для 2.4), а в BSDях - kqueue. Для каждого метода извещения есть имплементация его поддержки в libevent, к-я занимается по сути дела переводом конкретной семантики данного метода в общий интерфейс вызовов libevent. Кроме этого, есть также общая для всех и удобная поддержка таймаутов и сигналов.

epoll, которым мы пользуемся для LiveJournal, во многом походит по своей семантике на традиционный poll (только он оптимизирован так, чтобы затраты на него росли линейно не с количеством зарегистрированных событий, как в poll, а с количеством активных в данный момент событий). Когда в нём регистрируется событие, нужно сказать, в чём оно заинтересовано: это дизъюнкция (или "or" по-простому ;)) таких флажков, как POLLIN (готовность к чтению), POLLOUT (готовность к записи), POLLPRI (priority data, для таких относительно эзотерических вещей, как OOB (out-of-band) data в TCP). Когда нужно ждать следующего события, вызывается epoll_wait(); он возвращает список тех событий, которые активизировались, с указанием тех флажков, которые в них "сработали". Файл epoll.c внутри libevent отвечает за перевод этой семантики в интерфейс libevent, в котором события заявляют о своей заинтересованности в чтении (EV_READ) и записи (EV_WRITE). Там есть такой код (немного упрощаю его):
                what = events[i].events;
                if (what & EPOLLIN) {
                        which |= EV_READ;
                }
 
                if (what & EPOLLOUT) {
                        which |= EV_WRITE;
                }
 
                if (!which)
                        continue;

Здесь what - это флажки данного события в семантике epoll (или poll: EPOLLIN то же, что стандартный POLLIN итп.), а which - перевод во флажки интерфейса libevent. Если ни одно из двух условий не выполняется, "continue;" ведёт на переход к рассмотру следующего возвращённого события, в цикле.

Брэд обнаружил, что когда мы находимся в состоянии бага spinning, этот "continue;" всё время выполняется, и это в конце концов приводит к повторному вызовы функции epoll_wait(), которая возвращает то же самое, и опять впустую прокручивается этот цикл и эти проверки, и так всё время. Что же возвращает epoll_wait() для данного события? Очевидно, что-то, что не содержит ни POLLIN, ни POLLOUT; оказалось, что это малознакомое POLLERR (ошибка на данном дескрипторе), иногда в совокупности с POLLHUP (hang up - например, закрытие TCP-соединения для сокетов).

Оставалось неясным, почему из ядра всё время возвращается этот POLLERR, особенно учитывая то, что он никому не нужен, и никто его не хотел получать. Стало ясным, как происходит spinning, но не почему он происходит. Сегодня я провёл немало времени, роясь в исходниках ядра, и прояснил ситуацию.

Оказывается, что имплементация epoll в Линуксе силой заставляет аппликацию получать предупреждения о POLLERR и POLLHUP, даже если аппликация не задаёт эти флажки в маске данного события. Вот кусок кода из ядра:
       case EPOLL_CTL_ADD:
               if (!epi) {
                       epds.events |= POLLERR | POLLHUP;

                       error = ep_insert(ep, &epds, tfile, fd);

Функция ep_insert() выполняет главную работу по добавлению события в очередь, но перед этим к маске, полученной при вызове, заботливо добавляются POLLERR и POLLHUP.

Зачем? Почему??

По крайней мере на второй вопрос я смог ответить. Это так в epoll потому, что он эмулирует поведение стандартного poll(); а стандартный poll(), как оказалось, делает то же самое. Вот ссылка на код главной функции, имплементирующей poll(). Там есть такой кусок:
                                 if (file->f_op && file->f_op->poll)
                                         mask = file->f_op->poll(file, *pwait);
                                 mask &= fdp->events | POLLERR | POLLHUP;

Здесь file->f_op->poll() - вызов функции поллинга для данного вида "файла" (есть своя функция для обычных файлов, своя для сетевых сокетов итп.). Эта функция возвращает события, которые присутствуют на данном дескрипторе; тут, казалось бы, надо сделать
mask &= fdp->events;

т.е. убрать из возвращённого те события, которые не находятся в списке, зарегистрированном для данного события аппликацией при вызове poll(). Например, если сокет готов и читать, и писать, но мы хотим только читать, нам нужно вернуть только POLLIN. Но к этой операции заботливо добавлены POLLERR | POLLHUP, чтобы сохранить их и переслать аппликации, даже если она их не просила.

Зачем??

Видимо, кому-то очень свербило в заднице побыть нянькой для программистов. Кто-то решил, что мне, программисту, нельзя доверять, когда я прошу только POLLIN, зная, что с ошибками (POLLERR) и закрытием канала связи (POLLHUP) я разберусь сам, когда мой вызов read() вернёт мне соответствующую ошибку. Нет, мне нужно силой всунуть в зубы эти предупреждения, о которых я заранее не могу знать, что мне их нужно обрабатывать, т.к. я их не просил! В документации, замечу в скобках, ничего об этом нет.

Кстати, как именно возникает этот самый POLLERR, я так и не выяснил, но это уже не так важно. Я проследил все вызовы к функции tcp_poll(), которая возвращает все эти флажки для TCP-сокетов; если сокет находится в общем состоянии ошибки, она возвращает POLLERR, к которому иногда добавляется POLLHUP или POLLIN (в этом последнем случае у нас не возникает проблем). Что это за состояние ошибки, я не знаю, и судя по всему, оно случается довольно редко (грубый разрыв связи со стороны клиента к нему не приводит, скажем). Подробности уже не так интересны: если мы исправляем само состояние spinning, то главная аппликация memcached получит какую-то ошибку на какой-то из своих сетевых вызовов и закроет связь со своей стороны, и всё будет нормально.

Как исправить проблему? Сначала Брэд предложил в случае возвращение POLLERR удалять данный дескриптор из набора дескрипторов, к которым прислушивается epoll. Тогда не будет пустого цикла, жрущего CPU, т.к. более одного раза один и тот же дескриптор POLLERR не вернёт. Но это довольно уродливое решение, т.к. оно рас-синхронизирует событие с точки зрения libevent (к-я продолжает думает, что оно активно) и ядра (где его уже нет), и это приводит к дальнейшим проблемам, напр. ошибке, когда аппликация сама пытается удалить то же событие позже.

У меня была мысль, что нужно расширить libevent так, чтобы он мог эти флажки передавать аппликации. Пусть callback получает событие не только с EV_READ или EV_WRITE, но также и (новыми) EV_ERROR и EV_HUP, скажем, а там уже сам пусть решает, что делать (например, удалить событие и закрыть дескриптор). Но такое решение требует пройтись по всем методам извещения, не только poll/epoll, и внести в них поддержку новых флагов; а ещё неясно даже, все ли они могут их поддержать.

Подумав ещё немного, мы поняли, что это неверное решение. Причина, по которой libevent поддерживает только извещение о чтении и записи, проста. Т.к. аппликация, использующая libevent, ничего не знает о том, какой метод извещения выбрала libevent при запуске программы (она всегда выбирает наиболее эффективный из имеющихся в данной среде), и интерфейс libevent идентичен для всех этих методов, этот интерфейс должен соответствовать самому 'слабому' из них с информационной точки зрения. Таким оказывается select(): он умеет только сообщать о готовности к чтению или записи, никаких аналогов POLLERR или POLLHUP в нём нет, они невозможны просто на уровне интерфейса select(), построенного на бит-масках (см. man 2 select). Значит, все остальные методы должны не "выпендриваться" и тоже ничего больше не возвращать; или надо менять всю архитектуру libevent и вносить в неё awareness о методе извещения на уровне аппликации, чего делать нам совсем не хотелось.

И тут для меня наконец всё прояснилось. Наша проблема в том, что семантика epoll, или poll, богаче, чем то, что нам нужно; нам нужно всего-то "читать" и "писать", а они ещё умеют говорить об ошибках итп. Их богатая семантика происходит от богатой семантики поллинга внутри ядра, а наша бедная семантика происходит от совместимости с бедной по информационному содержанию функции select(). Тогда давайте посмотрим на имплементацию select() внутри ядра - эта имплементация неизбежно использует богатую семантику поллинга и как-то её нивелирует к своей бедной семантике результатов. Будем делать то же самое с нашей стороны в libevent с результатами poll()/epoll, и всё будет хорошо. И действительно, оказалось, что фунцкия do_select() в ядре использует несколько бит-масок для перевода "богатых" по содержимому флажков POLL* в то, что нужно вернуть ей: список дескрипторов для чтения, для записи, и для приоритетных данных. Из определений констант POLLIN_SET и POLLOUT_SET, которые ниже в функции используются для решения вопроса о внесении данного дескриптора в список свободных для чтения/записи, стало ясно: с точки зрения select() POLLERR означает "чтение и запись", а POLLHUP - "чтение". Собственно, теперь, задним умом, понятна логичность этого: правильно написанная аппликация вызовет в случае чтения read(), а в случае записи write() (ну или там recv() и send(), неважно) и получит "настоящую" реакцию в виде ошибки в случае POLLERR или возвращения с 0 байтами, сигнализирующего о закрытии связи, в случае POLLHUP; а т.к. эта аппликация и так умеет с этими ошибками справляться, всё отлично заработает. Так мы и поступим в результате с тем, что нам возвращает epoll или poll внутри libevent.

Да, а исследование исходников FreeBSD обнаружило, что там POLLERR и POLLHUP силой аппликации не впихиваются (это хорошо), да кроме того TCP-код эти события там никогда и не возвращает (это не так хорошо). Что, в свою очередь, помогает объяснить, почему Нильс (разработчик libevent, в основном работающий на FreeBSD) с этой проблемой ни разу не столкнулся. А мы вот не только столкнулись, но ещё и кучу времени на неё угрохали.

Если и есть мораль, то она такова: не надо, дорогие разработчики ядра, напрашиваться ко мне в няньки; но если уж свербит так, что невмоготу, черкните об этом, пожалуйста, пару слов в документации данного вызова.
СсылкаОтветить

Comments:
[User Picture]From: lxe
2003-10-23 08:12 pm
Да. Стыдно, но периодически замечаю такое за собой.
(Ответить) (Thread)
From: (Anonymous)
2003-10-23 08:46 pm
ядреные программисты, документирующие свой код?
бывают же другие глобусы...
(Ответить) (Thread)
From: ex_ilyavinar899
2003-10-23 09:33 pm
Я когда-то году в 1997 ляпнул на форуме Salon Table Talk: I wish the company [Microsoft] gets split up, so the OS developers are forced to document their API, and we application developers won't have to read their source code. Майкл Робинсон потом меня цитировал...
(Ответить) (Thread)
[User Picture]From: muchandr
2004-01-14 10:00 am

Правильно сказал

но если они действительно все продокументируют, им точно хана.
(Ответить) (Parent) (Thread)
[User Picture]From: keen
2003-10-23 11:59 pm

Браво!

для человека, не программирующего каждый день, но немного понимающего, читается просто как увлекательнейший детектив :)
(Ответить) (Thread)
[User Picture]From: _tess
2003-10-24 12:30 am

Re: Браво!

И как увлекательная научная фантастика для человека ничего не понимающего:)
(Ответить) (Parent) (Thread)
[User Picture]From: avnik
2003-10-24 02:27 am
А меня вот всегда интересовала такая тяга людей к научной фантастике ;)
(Ответить) (Parent) (Thread)
[User Picture]From: evr
2003-10-24 12:59 am
Простите меня, авва, но кто о чем, а голый о бане.
А неотправленные по почте комменты все еще стоят в очереди или уже частично утеряны?
(Ответить) (Thread)
[User Picture]From: avva
2003-10-24 03:16 pm
В очереди.
(Ответить) (Parent) (Thread)
[User Picture]From: tejblum
2003-10-24 01:26 am
Хохма в том, что такое повежение poll, насколько я помню, не изобретение Linux'а, а прежусмотрено стандартом. Во всяком случае во FreeBSD'шном man'е для POLLERR, POLLHUP и POLLNVAL указано "This flag is always checked, even if not present in the events bitmask." Неужели это действительно не написано в документации Linux'а?
(Ответить) (Thread)
[User Picture]From: tejblum
2003-10-24 02:01 am
Заглянул на Linux. В man poll сказано:

The field events is an input parameter, a bitmask specifying the events the application is interested in. The field revents is an output parameter, filled by the kernel with the events that actually occurred, either of the type requested, or of one of the types POLLERR or POLLHUP or POLLNVAL. (These three bits are meaningless in the events field, and will be set in the revents field whenever the corresponding condition is true.)
(Ответить) (Parent) (Thread)
[User Picture]From: sartoris
2003-10-24 03:25 am
У меня тоже самое... Могу проконстатировать совершенно безобразное и некорректное построение фразы:)
(Ответить) (Parent) (Thread)
[User Picture]From: sartoris
2003-10-24 03:32 am
Это отличный пример того, что меня не прекращает утомлять в програмировании. С любым "закрытым" интерфейсом цель состоит в том, чтобы упростить использование той или иной функциональной наполняющей для аппликационного шелла. (Шелл в данном случае я употребляю в широком смысле этого слова). Всегда возникают такого рода диллемы. Что делать с ошибками? Есть ошибки, которые разумеется можно игнорировать, есть "критические" ошибки, есть просто некие внештатные ситуации, которые невозможно ни проигнорировать, ни сделать с ними что-либо. Очень часто в таких случаях ответвенность валится на вызывающий код, в надежде что "там-то уж разберемся". Вот это-то и злит, потому что очень часто приходится совершать вертикальные изменения в коде из-за неправильно принятых на этом этапе решений.

А вообще эта импелементация EPOLL имеет одно всего-лишь преимущество. Она предотвращает вызов read/write на сокет, которому уже ничего не светит. В этом смысле это хорошая оптимизация. Токма документировать её надо было по человечески:)
(Ответить) (Thread)
From: (Anonymous)
2003-10-24 04:23 pm
вместо одного лишнего вызова при завершении добавляется проверка
при каждом вводе/выводе. заодно и код по обработке проверки во внутреннем цикле.

мордой об стол за такие оптимизации
(Ответить) (Parent) (Thread)
[User Picture]From: msh
2003-10-24 04:12 am
Вообще-то, не нужно было рыться в ядре, достаточно просто посмотреть
в man poll

The field revents is an output parameter, filled by the 
kernel with  the  events  that  actually  occurred,
either of the type requested, or of one of the types POLLERR 
or POLLHUP or POLLNVAL. 


более того, это поведение требуется POSIX (IEEE Std 1003.1, 2003 Edition)

In addition, poll() shall set the POLLHUP, POLLERR, 
and POLLNVAL flag in revents if the condition is true, 
even if the application did not set the corresponding bit 
in events.
(Ответить) (Thread)
[User Picture]From: avva
2003-10-24 02:55 pm
Да, верно. Причём я читал man poll вчера раз пять, но каждый раз глаза перепрыгивали через этот кусок. Ну да я не очень расстраиваюсь, я вообще люблю в ядре рыться ;)

По-моему, практика всё равно весьма порочная, хоть и POSIXом легитимизированная.

(Ответить) (Parent) (Thread)
[User Picture]From: msh
2003-10-24 03:29 pm
На самом деле,

Вот это вот порочная практика:

                what = events[i].events;
                if (what & EPOLLIN) {
                        which |= EV_READ;
                }
 
                if (what & EPOLLOUT) {
                        which |= EV_WRITE;
                }
 
                if (!which)
                        continue;


Описывается анекдотом "Мальчик? Нет? А кто??"

Известно, что в poll событие само не сбрасывается, то есть любое полученное от poll событие должно быть обработано, оно никуда не денется. В этом коде никак не обрабатываются события отличные от EPOLLIN и EPOLLOUT, это совсем не робастно.

Вообще, реализация events в общем виде вопрос очень непростой и я бы на select как на минимальное общее не ориентировался - он вообще можно сказать obsoleted нынче

(это у меня больная тема, я сейчас как раз пишу очередную улучшенную версию эмулятора windows events на linux ;-)
(Ответить) (Parent) (Thread)
From: ex_sighup150
2003-10-24 04:29 am
Мораль есть и такая: своевременное чтение документации спасает от многих часов ненужной работы ;)

http://www.opengroup.org/onlinepubs/007904975/functions/poll.html
Вот там вот, где "even if the application did not set the bits".
(Ответить) (Thread)
[User Picture]From: avva
2003-10-24 02:50 pm
Ну, меня бы это максимум спасло от чтения исходников ядра, что не заняло много часов ;)

Исходный-то баг был в библиотеке, которую не мы писали.
(Ответить) (Parent) (Thread)
[User Picture]From: sartoris
2003-10-24 04:34 am

off-topic - насчет почты

Толя, а что действительно с вашим мыло-агентом? Комменты не шлются и по всей видимости лежат мертвым грузом в кью... Нужна помощь? В sendmail и postfix разбираюсь и с удовольствием помогу.
(Ответить) (Thread)
[User Picture]From: avva
2003-10-24 02:54 pm

Re: off-topic - насчет почты

У нас postfix бежит, но, очевидно, не хватает мощности двух серверов, которые работают почтальонами и по совместительству ещё кем-то. С текущим потоком они обычно справляются, а вот как организовался backlog, не смогли его разгрузить. Наши админы должны были ещё вчера утром всё наладить, но затормозили как-то совсем
некрасиво. Брэд сейчас с этим разбирается и выделяет ещё машины для почты. Спасибо, помощь не нужна ;)
(Ответить) (Parent) (Thread)
[User Picture]From: sartoris
2003-10-24 03:07 pm

Re: off-topic - насчет почты

1) Объем выходящей почты (средний + пик)?
2) DNS запросы postfix? (сколько и какие DNS сервера?)
3) connection caching?
4) можно на ты - мы знакомы давно:)

У меня postfix на чем-то мелком компе (конец 99го, средней руки десктоп Dell) выплёвывал до 20 тысяч сообщений в час. Это не предел и всё зависит от специфики (queueing optimization и.т.д.) - но вообщем-то считалось в то время рекордом.

Если всё-таки возникнет желание воспользоваться помощью - прошу в мыло.
(Ответить) (Parent) (Thread)