Вся правда о linux epoll

Ну или почти вся…

Я считаю, что проблема в современном интернете — это переизбыток информации разного качества. Найти материал по интересующей теме не проблема, проблема отличить хороший материал от плохого, если у вас мало опыта в данной области. Я наблюдаю картину, когда очень много обзорной информации «по верхам» (практически на уровне простого перечисления), очень мало углубленных статей и совсем нет переходных статей от простого к сложному. Тем не менее именно знание особенностей того или иного механизма и позволяет нам сделать осознанный выбор при разработке.

В статье я постараюсь раскрыть то, что является фундаментальным отличием epoll от других механизмов, то что делает его уникальным, а так же привести статьи, которые просто необходимо прочитать для более глубокого осмысления возможностей и проблем epoll.

Anyone can wield an axe, but it takes a true warrior to make it sing melees melody.

Я предполагаю, что читатель знаком с epoll, по крайней мере прочел страницу man. О epoll, poll, select написано достаточно много, чтобы каждый кто разрабатывал под Linux, хоть раз о нем слышал.

Многа fd

Когда люди говорят о epoll в основном я слышу тезис, что его «производительность выше, когда много файловых дескрипторов».

Сразу хочется задать вопрос — а много это сколько? Сколько нужно соединений, а главное при каких условиях epoll начнет давать ощутимый выигрыш по производительности?

Для тех, кто изучал epoll (материала достаточно много в том числе и научных статей) ответ очевиден — он лучше тогда и только тогда, когда число «ожидающих события» соединений существенно превышает число «готовых к обработке». Отметкой же количества, когда выигрыш становиться настолько существенным, что уже просто мочи нету игнорировать данный факт, считается 10к соединений [4].

Предположение о том, что большинство соединений будет ожидающими исходит из здравой логики и наблюдений за нагрузкой серверов, находящихся в активной эксплуатации.

Если число активных соединений стремиться к общему количеству, ~~никакого выигрыша не будет~~ никакого существенного выигрыша не будет, существенный выигрыш происходит из-за того и только из-за того, что epoll возвращает только дескрипторы требующие внимания, а poll возвращает все дескрипторы, которые были добавлены для наблюдения.

Очевидно, что в последнем случае мы тратим время на обход всех дескрипторов + накладные расходы по копированию массива событий из ядра.

Действительно, в изначальном замере производительности, который прилагался к пачту [9], данный момент не подчеркнут и догадаться можно только по присутствию утилиты deadcon упомянутой в статье (к сожалению, код утилиты pipetest.c утерян). С другой стороны, в других источниках [6, 8] это очень сложно не заметить, так данный факт практически выпячивается.

Сразу возникает вопрос, а что же теперь если не планируется обслуживать такое количество файловых дескрипторов epoll, как бы, и не нужен?

Несмотря на то, что epoll изначально создавался именно для таких ситуаций [5, 8, 9], это далеко не единственное отличие epoll.

EPOLLET

Для начала разберемся в чем же отличие срабатывания по фронту (edge-triggered) от срабатывания по уровню (level-triggered), на данную тему есть очень хорошее высказывание в статье Edge Triggered Vs Level Triggered interrupts — Venkatesh Yadav :

Прерывание по уровню, это как ребенок. Если ребенок плачет, вы должны бросить все чем занимались и бежать к ребенку, чтобы покормить его. Потом вы кладете ребенка обратно в кроватку. Если он опять плачет вы от него никуда не отойдете, а будете пытаться успокоить. И пока ребенок плачет вы не будете от него отходить ни на момент, и вернетесь к работе только когда он успокоится. Но предложим, что вышли в сад (прерывание выключено), когда ребенок начал плакать, потом, когда вы вернулись домой (прерывание включено) первое, что вы сделаете пойдете проверить ребенка. Но вы никогда не узнаете, что он плакал, пока вы были в саду.

Прерывание по фронту — это как электронная няня для глухих родителей. Как только ребенок начинает плакать на устройстве загорается красная лампочка и горит, пока вы не нажмете кнопку. Даже если ребенок начал плакать, но быстро перестал и заснул, вы все равно узнаете, что ребенок плакал. Но если он начал плакать, а вы нажали кнопку (подтверждение прерывания), лампочка не будет гореть даже если он продолжает плакать. Уровень звука в комнате должен упасть, а затем подняться снова, чтобы лампочка загорелась.

Если в level-triggered поведении epoll (так же, как и poll/select) разблокируется если дескриптор находится в заданном состоянии и будет полагать его активным пока данное состояние не будет снято, то edge-triggered разблокируется только по изменению текущего данного заказанного состояния.

Это позволяет заняться обработкой события позже, а не сразу по получении (практически прямая аналогия c верхней половиной (top half) и нижней половиной (bottom half) обработчика прерывания).

Конкретный пример с epoll:

Level triggered

  • дескриптор добавлен в epoll с флагом EPOLLIN
  • epoll_wait() блокируется на ожидании события
  • пишем в файловый дескриптор 19 байт
  • epoll_wait() разблокируется с событием EPOLLIN
  • мы ничего не делаем с пришедшими данными
  • epoll_wait() опять разблокируется с событием EPOLLIN

И так будет продолжаться пока мы полностью не считаем или не обнулим данные из дескриптора.

Edge triggered

  • дескриптор добавлен в epoll с флагами EPOLLIN | EPOLLET
  • epoll_wait() блокируется на ожидании события
  • пишем в файловый дескриптор 19 байт
  • epoll_wait() разблокируется с событием EPOLLIN
  • мы ничего не делаем с пришедшими данными
  • epoll_wait() блокируется в ожидании нового события
  • пишем в файловый дескриптор еще 19 байт
  • epoll_wait() разблокируется с новым событием EPOLLIN
  • epoll_wait() блокируется в ожидании нового события

простой пример : epollet_socket.c

Данный механизм сделан, чтобы предотвратить возврат epoll_wait() из-за события которое уже находится в обработке.

Если в случае level при вызове epoll_wait() ядро проверяет не находится ли fd в данном состоянии, то edge пропускает данную проверку и тут же переводит вызваший процесс в состояние сна.

Собственно EPOLLET это то, что делает epoll O(1) мультиплексором для событий.

Небходимо пояснить насчёт EAGAIN и EPOLLET — рекомендация с EAGAIN не относиться к byte-stream, опасность в последнем случае возникает только если вы не вычитали дескриптор до конца, а новые данные не пришли. Тогда в дескрипторе будет висеть «хвост», а нового уведомления вы не получите. С accept() как раз ситуация другая, там вы обязаны продолжать пока accept() не вернет EAGAIN, только в этом случае гарантируется корректная работа.

    // TCP socket (byte stream)
    // читаем fd возвращенный с событием EPOLLIN в режиме срабатывания по фронту
    int len = read(fd, buffer, BUFFER_LEN);
    if(len < BUFFER_LEN) {
        // все хорошо
    } else {
        // нет гарантии что не осталось данных в дескрипторе
        // если что-то осталось то мы останемся висеть на epoll_wait, 
        // если не придут новые данные
    }
    // accept
    // читаем listenfd возвращенный с событием EPOLLIN в режиме срабатывания по фронту
    event.events = EPOLLIN | EPOLLERR;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);
    sleep(5); // за это время к нам поключилось >1 клиентов
    // плохой сценарий 
    while(epoll_wait()) {
        newfd = accept(listenfd, ...); // принимаем подключение от первого клиента
        // все сколько бы не поключилось далее клентов 
        // из epoll_wait мы событий от listenfd больше не получим
    }
    // хороший сценарий
    while(epoll_wait()) {
        while((newfd = accept(...)) > 0)
        {
            // делаем что-нибудь полезное
        }
        if(newfd == -1 && errno = EAGAIN) 
        {
            // все хорошо состояние дескриптора было сброшено
            // мы получим уведомление на следующем соединении
        }
    }

С данным свойством достаточно просто получить голодание (starvation):

  • пакеты приходят в дескриптор
  • читаем пакеты в буфер
  • приходит еще порция пакетов
  • читаем пакеты в буфер
  • приходит еще небольшая порция

Таки образом EAGAIN мы получим не скоро, а можем и вообще не получить.

Таким образом другие файловые дескрипторы не получают времени для обработки, а мы заняты чтением постоянно приходящих, небольших порций данных.

thundering ~~nerd~~ herd

Для того, чтобы перейти к последнему флагу необходимо понять для чего он, собственно, создавался и одна их каких проблем возникла перед разработчиками с эволюцией техники и программного обеспечения.

Thundering herd problem

Проблема громоподобного стада

Представьте огромное количество процессов, ждущих события. Если событие наступает, их всех разбудят и начнётся борьба за ресурсы, хотя требуется только один процесс, который займётся дальнейшей обработкой наступившего события. Остальные процессы снова будут спать.

ИТ терминология — Василий Алексеенко

Нас в данном случае интересует проблема распределенных по потокам accept() и read() в связке с epoll.

accept

Собственно, с блокирующимся вызовом accept() никаких проблем давно уже нет. Ядро само позаботится, что только один процесс был разблокирован по данному событию, а все входящие соединения сериализуются.

А вот с epoll такой фокус не пройдет. Если у нас сделан listen() на неблокирующем сокете при установке соединения будут разбужены все epoll_wait() ожидающие событие от данного дескиптора.

Конечно accept() получится сделать только одному потоку, остальные получат EAGAIN, но это напрасная трата ресурсов.

Более того EPOLLET нам так же не поможет, поскольку нам неизвестно сколько именно соединений находится в очереди на подсоединение (backlog). Как мы помним при использовании EPOLLET обработка сокета должна продолжаться до возврата с кодом ошибки EAGAIN, поэтому есть шанс, что все accept() будут обработаны одним потоком, и остальным работы не достанется.

И это опять приводит нас к ситуации, когда соседний поток был разбужен зря.

Так же мы можем получить starvation другого рода — у нас будет загружен только один поток, а остальные не получат соединений для обработки.

EPOLLONESHOT

До версии 4.5 единственным корректным способом обработки распределенного по потокам epoll на неблокирующий listen() дескриптор с послежующим вызовом accept(), было задание флага EPOLLONESHOT, что опять приводило нас к тому, что accept() обрабатывался одновременно только в одном потоке.

В кратце — в случае применения EPOLLONESHOT событие ассоциированное с конкретным дескриптором сработает только один раз, после чего необходимо заново взвести флаги с помощью epoll_ctl().

EPOLLEXCLUSIVE

Здесь нам на помощь приходит EPOLLEXCLUSIVE и level-triggered.

EPOLLEXCLUSIVE разблокирует один ожидающий epoll_wait() за раз на одно событие.

Схема достаточно простая (на самом деле нет):

  • У нас N потоков, ожидающих событие на подключение
  • К нам подсоединяется первый клиент
  • Поток 0 разброкируется и начнет обработку, остальные потоки останутся заблокированными
  • К нам подсоединяется второй клиент, если поток 0 все еще занят обработкой, то разблокируется поток 1
  • Продолжаем далее пока не исчерпан пул потоков (никто не ожидает события на epoll_wait())
  • К нам подсоединяется очередной клиент
  • И его обработку получит первый поток, который вызовет epoll_wait()
  • Обработку второго клиента получит следующий поток, который вызовет epoll_wait()

Таким образом все обслуживание равномерно распределено по потокам.

$ ./epollexclusive --help  
    -i, --ip=ADDR specify ip address  
    -p, --port=PORT specify port  
    -n, --threads=NUM specify number of threads to use # количество потоков сервера - клиенты n*8
    -t, --thunder not adding EPOLLEXCLUSIVE # с этим флагом воспроизведется thunder herd
    -h, --help prints this message
$ sudo  taskset -c 0-7 ./epollexclusive -i 10.56.75.201 -p 40000 -n 8 2>&1

код примера: epollexclusive.c (будет работать только с версией ядра от 4.5)

Получаем pre-fork модель на epoll. Такая схема хорошо применима для TCP поключений с малым временем жизни (short life-time TCP connections).

read

А вот с read() в случае с byte-streaming, EPOLLEXCLUSIVE, как и EPOLLET нам не помогут.

По понятным причим без EPOLLEXCLUSIVE мы level-triggered использовать не можем, совсем. С EPOLLEXCLUSIVE все не лучше, так как мы может получиться посылку, размазанную по потокам, к тому же с неизвестным порядком пришедших байт.

C EPOLLET ситуация такая же.

И здесь выходом будет EPOLLONESHOT с реинициализацией по завершению работы. Так, как только один поток будет работать с данным файловым дескриптором и буфером:

  • дескриптор добавлен в epoll с флагами EPOLLONESHOT | EPOLLET
  • ждем на epoll_wait()
  • читаем из сокета в буфер пока read() не вернет EAGAIN
  • пере инициализируем с флагами EPOLLONESHOT | EPOLLET

struct epoll_event

typedef  union  epoll_data {
    void *ptr;
    int  fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct  epoll_event {
    uint32_t events; /* Epoll  events */
    epoll_data_t  data; /* User  data  variable */
};

Данный пункт, пожалуй, единственное в статье моё личное ИМХО. Возможность использовать указатель или число является полезным. Например с использованием указателя при использовании epoll позволяет делать трюк наподобие этого :

#define  container_of(ptr, type, member) ({ \
    const  typeof( ((type *)0)->member ) *__mptr = (ptr); \
    (type  *)( (char *)__mptr - offsetof(type,member) );})

struct  epoll_client {
    /** some  usefull  associated  data...*/
    struct  epoll_event  event;
};

struct  epoll_client* to_epoll_client(struct  epoll_event* event)
{
    return  container_of(event, struct  epoll_client, event);
}

struct  epoll_client  ec;

...
epoll_ctl(efd, EPOLL_CTL_ADD, fd, &ec.e);
...

epoll_wait (efd, events, 1, -1);
struct  epoll_client* ec_ = to_epoll_client(events[0].data.ptr);

Я думаю все знают откуда пришел данный прием.

Заключение

Я надеюсь, что нам удалось приоткрыть тему epoll. Тем кто желает использовать данный механизм осознанно, просто необходимо прочитать статьи в списке литературы [1, 2, 3, 5].

На основе данного материала (а еще лучше вдумчиво прочитав материалы из списка литературы) вы можете сделать многопоточный pre-fork (заблаговременное порождение процесса) lockfree (без блокировочный) сервер или пересмотреть существующие стратегии на базе особенных свойств epoll()).

epoll один из уникальных механизмов, который людям, выбравшим своей стезей программирования под Linux, надо знать, так как именно они дают серьёзное преимущество над другими операционными системами), и, возможно, позволит отказаться от кросс-платформенности для конкретного данного случая (пусть будет работать только под Linux но будет делать это хорошо).

Рассуждения об «специфичности» задачи

Прежде чем кто-то скажет о специфичности данных флагов и моделях использования, я хочу задать вопрос:

«А ничего, что мы пытаемся обсуждать специфичность для механизма, который создавался для специфичных задач изначально [9, 11]? Или у нас обслуживание даже 1к соединений вполне повседневная задача для программиста?»

Я не понимаю концепцию «специфичности задачи», это мне напоминает разного рода крики про полезность и бесполезность различных преподаваемых дисциплин. Позволяя себе рассуждать таким образом, мы присваиваем себе право решать за других, какая именно информация им полезна, а какая бесполезна, при этом, заметьте, не участвуя в процессе образования в целом.

Для скептиков пара ссылок:

Увеличиваем производительность с помощью SO_REUSEPORT в NGINX 1.9.1 — VBart
Learning from Unicorn: the accept() thundering herd non-problem — Chris Siebenmann
Serializing accept(), AKA Thundering Herd, AKA the Zeeg Problem — Roberto De Ioris
How does epoll’s EPOLLEXCLUSIVE mode interact with level-triggering?

Список литературы

  1. Select is fundamentally broken — Marek
  2. Epoll is fundamentally broken 1/2 — Marek
  3. Epoll is fundamentally broken 2/2 — Marek
  4. The C10K problem — Dan Kegel
  5. Poll vs Epoll, once again — Jacques Mattheij
  6. epoll — I/O event notification facility — The Mann
  7. The method to epoll’s madness — Cindy Sridharan

Benchmarks

  1. https://www.kernel.org/doc/ols/2004/ols2004v1-pages-215-226.pdf
  2. http://lse.sourceforge.net/epoll/index.html
  3. https://mvitolin.wordpress.com/2015/12/05/endurox-testing-epollexclusive-flag/

Эволюция epoll

  1. https://lwn.net/Articles/13918/
  2. https://lwn.net/Articles/520012/
  3. https://lwn.net/Articles/520198/
  4. https://lwn.net/Articles/542629/
  5. https://lwn.net/Articles/633422/
  6. https://lwn.net/Articles/637435/

Постскриптум

Большое спасибо Сергею (@dlinyj) и Петру Овченкову за ценные дискусии, замечания и помощь!