После очень долгих поисков обнаружился источник бага в 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) с этой проблемой ни разу не столкнулся. А мы вот не только столкнулись, но ещё и кучу времени на неё угрохали.
Если и есть мораль, то она такова: не надо, дорогие разработчики ядра, напрашиваться ко мне в няньки; но если уж свербит так, что невмоготу, черкните об этом, пожалуйста, пару слов в документации данного вызова. |