epoll IO с рабочими потоками в C

Я пишу небольшой сервер, который будет получать данные из различных источников и обрабатывать эти данные. Источники и полученные данные значительны, но не более чем epoll должен быть в состоянии справиться довольно хорошо. Однако все полученные данные должны быть проанализированы и выполнены через большое количество тестов, что занимает много времени и блокирует один поток, несмотря на мультиплексирование epoll. В принципе, шаблон должен быть примерно следующим: IO-loop получает данные и связывает их в задание, отправляет в первый поток, доступный в пуле, пакет обрабатывается заданием, и результат передается пакету в цикл ввода-вывода для записи в файл.

Я решил пойти на один поток ввода-вывода и N рабочих потоков. Поток ввода-вывода для приема tcp-соединений и чтения данных легко реализовать с помощью примера, приведенного в: http://linux.die.net/man/7/epoll

поток также обычно достаточно прост в обращении, но я изо всех сил пытаюсь объединить цикл epoll IO с threadpool в элегантной манере. Я не могу найти никакой "лучшей практики" для использования epoll с рабочим пулом в интернете, но довольно много вопросов по той же теме.

поэтому у меня есть вопрос, я надеюсь, что кто-то может помочь мне ответить:

  1. может (и должен) eventfd использоваться как механизм для двухсторонней синхронизации между потоком ввода-вывода и всеми рабочими? Например, хорошо ли для каждого рабочего потока иметь свой собственный epoll рутинное ожидание общего eventfd (с указателем структуры, содержащим данные/информацию о задании), т. е. использование eventfd в качестве очереди заданий? Также, возможно, есть другой eventfd для передачи результатов обратно в поток ввода-вывода из нескольких рабочих потоков?
  2. после ИО нить сигналит об этом больше данных на сокете, если фактические приема происходят на Ио-нить, или если работник по приему данные на свои, чтобы не блокировать поток ввода-вывода при анализе данных периодах и т. д.? В в этом случае, как я могу обеспечить безопасность, например, если recv считывает 1,5 кадра данных в рабочем потоке, а другой рабочий поток получает последний 0,5 кадра данных из того же соединения?
  3. если пул рабочих потоков реализован через мьютексы и такие, будет ли ожидание блокировок блокировать поток ввода-вывода, если N + 1 потоков пытаются использовать ту же блокировку?
  4. существуют ли какие-либо шаблоны хорошей практики для создания пула рабочих потоков вокруг epoll с двусторонней связью (т. е. как от IO до рабочих, так и обратно)?

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

мое приложение только для Linux, поэтому я могу использовать функциональность только для Linux, чтобы достичь этого самым элегантным способом. Кросс-платформенная поддержка не требуется, но производительность и безопасность резьбы.

3 ответов


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

сами данные, вероятно, лучше всего хранятся в круговом буфере, однако нам понадобится отдельный буфер для каждого источника ввода (если мы получим частичный пакет, мы можем продолжить получения из других источников без разделения данных. Оставшийся вопрос заключается в том, как сообщить работникам, когда новый пакет готов, и дать им Указатель на данные в указанном пакете. Потому что здесь мало данных, просто некоторые указатели, самый элегантный способ сделать это будет с очередями сообщений POSIX. Эти обеспечивают способность для множественных отправителей и множественных приемников написать и прочитать сообщения, всегда обеспечивающ что каждое сообщение получено и точно 1 нитка.

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

struct DataSource
{
    int SourceFD;
    char DataBuffer[MAX_PACKET_SIZE * (THREAD_COUNT + 1)];
    char *LatestPacket;
    char *CurrentLocation
    int SizeLeft;
};

SourceFD, очевидно, является дескриптором файла для рассматриваемого потока данных, DataBuffer-это то, где содержимое пакетов хранится во время обработки, это круговой буфер. Указатель LatestPacket используется для временного удержания указателя на наиболее возмущенный пакет в случае, если мы получаем частичный пакет и переходим на другой источник перед передачей пакетов. CurrentLocation хранит, где заканчивается последний пакет, чтобы мы знали, где разместить следующий или где продолжить в случае частичного получения. Размер слева-это комната, оставленная в буфере, это будет использоваться, чтобы сказать, можем ли мы поместить пакет или нужно вернуться к началу.

таким образом, функция приема будет эффективно

  • скопируйте содержимое пакета в буфер
  • переместить CurrentLocation, чтобы указать на конец пакета
  • SizeLeft обновлены с учетом уменьшена буфер
  • если мы не можем поместить пакет в конец буфера, мы цикл вокруг
  • если там нет места, либо мы попробуем еще раз немного позже, перейдя к другому источнику тем временем
  • если у нас было частичное хранилище приема, указатель LatestPacket указывает на начало пакета и переходит к другому потоку, пока мы не получим отдых
  • Отправить сообщение с помощью очередь сообщений posix для рабочего потока, чтобы он мог обрабатывать данные, сообщение будет содержать указатель на структуру источника данных, чтобы он мог работать над ним, ему также нужен указатель на пакет, над которым он работает, и его размер, они могут быть вычислены, когда мы получим пакет

рабочий поток будет выполнять свою обработку с использованием полученных указателей, а затем увеличит SizeLeft, чтобы поток приемника знал он может продолжать заполнять буфер. Атомарные функции будут необходимы для работы над значением размера в структуре, поэтому мы не получаем условия гонки со свойством size (как это возможно, это написано работником и потоком ввода-вывода одновременно, вызывая потерянные записи, см. Мой комментарий ниже), они перечислены здесь и просты и чрезвычайно полезны.

теперь, я дал некоторые общие предпосылки, но рассмотрим приведенные моменты в частности:

  1. использование EventFD в качестве механизма синхронизации в значительной степени плохая идея, вы окажетесь, используя изрядное количество ненужного времени процессора, и это очень трудно выполнить синхронизацию. В частности, если у вас несколько потоков выбирают один и тот же файловый дескриптор, у вас могут возникнуть серьезные проблемы. Это на самом деле неприятный хак, который будет работать иногда, но не является реальной заменой правильной синхронизации.
  2. также плохая идея попробовать и как объяснялось выше, вы можете обойти проблему со сложным IPC, но, честно говоря, маловероятно, что получение ввода-вывода займет достаточно времени, чтобы остановить ваше приложение, Ваш IO также, вероятно, намного медленнее, чем CPU, поэтому получение с несколькими потоками мало выиграет. (это предполагает, что вы не говорите, есть несколько сетевых карт 10 gigabit).
  3. использование мьютексов или блокировок-глупая идея здесь, она гораздо лучше вписывается в безблокировочное кодирование, учитывая низкое количество (одновременно) общих Дейта, ты просто раздаешь работу и данные. Это также повысит производительность потока приема и сделает ваше приложение гораздо более масштабируемым. Используя функции, упомянутые здесь http://gcc.gnu.org/onlinedocs/gcc-4.1.2/gcc/Atomic-Builtins.html Вы можете сделать это красиво и легко. Если вы сделали это таким образом, вам понадобится семафор, его можно разблокировать каждый раз, когда пакет получен и заблокирован каждым потоком, который запускает задание, чтобы динамически разрешить больше потоков, если больше пакетов готовы, что будет иметь гораздо меньше накладных расходов, чем доморощенное решение с мьютексами.
  4. здесь нет большой разницы для любого пула потоков, вы создаете много потоков, а затем все они блокируются в mq_receive в очереди сообщений данных, чтобы ждать сообщений. Когда они закончат, они отправят свой результат обратно в основной поток, который добавляет очередь сообщений результатов в свой список epoll. Затем он может получать результаты таким образом, это просто и очень эффективно для небольших данных полезные нагрузки как указатели. Это также будет использовать небольшой процессор и не заставлять основной поток тратить время на управление рабочими.

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

Я надеюсь, что это поможет, однако уже поздно, так что если я что-то упустил или у вас есть вопросы, не стесняйтесь комментировать разъяснение или более подробное объяснение.


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

ваши рабочие могут выглядеть примерно так:

for (;;) {
    nfds = epoll_wait(worker->efd, &evs, 1024, -1);

    for (i = 0; i < nfds; i++)
        ((struct socket_context*)evs[i].data.ptr)->handler(
            evs[i].data.ptr,
            evs[i].events);
}

и каждый файловый дескриптор, добавленный в экземпляр epoll, может иметь struct socket_context связанные с ним:

void listener_handler(struct socket_context* ctx, int ev)
{
    struct socket_context* conn;

    conn->fd = accept(ctx->fd, NULL, NULL);
    conn->handler = conn_handler;

    /* add to calling worker's epoll instance or implement some form
     * of load balancing */
}

void conn_handler(struct socket_context* ctx, int ev)
{
    /* read all available data and process. if incomplete, stash
     * data in ctx and continue next time handler is called */
}

void dummy_handler(struct socket_context* ctx, int ev)
{
    /* handle exit condition async by adding a pipe with its
     * own handler */
}

мне нравится эта стратегия потому что:

  • очень простая конструкция;
  • все потоки идентичны;
  • работники и соединения изолированы--отсутствие наступать на пальцы ноги или вызывать read(2) в неправильном работника;
  • блокировки не требуются (ядро получает беспокоиться о синхронизации на accept(2));
  • несколько естественно сбалансированная нагрузка, так как ни один занятой работник не будет активно бороться на accept(2).

и некоторые примечания на запрос:

  • используйте режим edge-triggered, неблокирующие сокеты и всегда читайте до EAGAIN;
  • избежать dup(2) семья звонков, чтобы избавить себя от некоторых сюрпризов (epoll регистрирует файл дескрипторов, но на самом деле часы файлом описания);
  • вы можете epoll_ctl(2) экземпляры epoll других потоков безопасно;
  • использовать большие struct epoll_event буфер для epoll_wait(2) чтобы избежать голод.

некоторые замечания:

  • использовать accept4(2) для сохранения системного вызова;
  • используйте один поток на ядро (1 для каждого физического, если CPU-bound, или 1 для каждого логического, если I / O-bound);
  • poll(2)/select(2) вероятно, быстрее, если количество соединений низкое.

надеюсь, это поможет.


я отправляю тот же ответ в другой пост:Я хочу дождаться как файлового дескриптора, так и мьютекса, каков рекомендуемый способ сделать это?

==========================================================

Это очень распространенная проблема, особенно при разработке сетевой серверной программы. Основной вид большинства серверных программ Linux будет выглядеть так:

epoll_add(serv_sock);
while(1){
    ret = epoll_wait();
    foreach(ret as fd){
        req = fd.read();
        resp = proc(req);
        fd.send(resp);
    }
}

он однопоточный (основная резьба), основанный epoll основы сервера. Проблема в том, что он однопоточный, а не многопоточный. Это требует, чтобы proc () никогда не блокировался или не выполнялся в течение значительного времени (скажем, 10 мс для общих случаев).

Если proc() будет когда-либо работать в течение длительного времени, нам нужно несколько потоков и выполняет proc () в отдельном потоке (рабочий поток).

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

затем мы нужен способ получить результат задачи из рабочего потока. Как? Если мы просто проверим очередь сообщений непосредственно, до или после epoll_wait (), однако, действие проверки будет выполняться после epoll_wait () до конца, и epoll_wait () обычно блокируется в течение 10 микро секунд(общие случаи), если все файловые дескрипторы не активны.

для сервера, 10 мс-это очень долго! Можем ли мы сигнализировать epoll_wait () немедленно завершить работу при создании результата задачи?

Да! Я опишу как это сделано в одном из моих проектов с открытым исходным кодом.

создайте трубу для всех рабочих потоков, и epoll также ожидает эту трубу. После того, как результат задачи генерируется, рабочий поток записывает один байт в канал, то epoll_wait() закончится почти в то же время! - Linux pipe имеет задержку от 5 до 20 США.

В моем проекте SSDB(протокол Redis, совместимый с базой данных NoSQL на диске), я создаю SelectableQueue для передачи сообщений между основным потоком и рабочий поток. Так же, как его имя, SelectableQueue имеет дескриптор файла, который может быть ждать epoll.

SelectableQueue:https://github.com/ideawu/ssdb/blob/master/src/util/thread.h#L94

использование в основном потоке:

epoll_add(serv_sock);
epoll_add(queue->fd());
while(1){
    ret = epoll_wait();
    foreach(ret as fd){
        if(fd is worker_thread){
            sock, resp = worker->pop_result();
            sock.send(resp);
        }
        if(fd is client_socket){
            req = fd.read();
            worker->add_task(fd, req);
        }
    }
}

использование в рабочем потоке:

fd, req = queue->pop_task();
resp = proc(req);
queue->add_result(fd, resp);