epoll IO与C中的工作线程

时间:2014-02-19 21:21:46

标签: c linux multithreading posix epoll

我正在编写一个小型服务器,它将从多个来源接收数据并处理这些数据。收到的消息来源和数据非常重要,但epoll应该能够很好地处理。但是,必须解析所有接收到的数据并运行大量的测试,这些测试非常耗时,并且尽管进行了epoll多路复用,仍会阻塞单个线程。基本上,模式应该如下所示:IO循环接收数据并将其捆绑到作业中,发送到池中可用的第一个线程,捆绑由作业处理,结果传递到IO循环写信给文件。

我决定选择一个IO线程和N个工作线程。使用以下提供的示例可轻松实现用于接受tcp连接和读取数据的IO线程: http://linux.die.net/man/7/epoll

线程通常也很容易处理,但我正在努力将epoll IO循环与线程池以优雅的方式结合起来。我无法找到任何在网上使用epoll的“最佳实践”,但有关同一主题的相关问题很多。

因此我有一些问题希望有人能帮我回答:

  1. 可以(并且应该)eventfd用作IO线程与所有工作者之间双向同步的机制吗?例如,每个工作线程都有一个好主意,让自己的epoll例程等待共享的eventfd(带有结构指针,包含有关作业的数据/信息),即以某种方式使用eventfd作为作业队列?也许还有另一个eventfd将结果从多个工作线程传递回IO线程?
  2. 在IO线程上发出关于套接字的更多数据的信号之后,实际的recv应该发生在IO线程上,还是工作者应该自己收集数据,以便在解析数据帧时不阻塞IO线程等。?在这种情况下,我如何确保安全,例如如果recv在工作线程中读取1,5帧数据而另一个工作线程从同一连接接收最后0.5帧数据?
  3. 如果工作线程池是通过互斥锁等实现的,如果N + 1个线程试图使用相同的锁,那么等待锁会阻塞IO线程吗?
  4. 对于如何使用双向通信(即从IO到工作人员和后面)建立一个围绕epoll的工作线程池,是否有任何良好的实践模式?
  5. 编辑:一种可能的解决方案是从IO循环更新环形缓冲区,更新后通过共享管道向所有工作人员发送环形缓冲区索引(从而将该索引的控制权交给第一个工作人员)从管道中读取索引),让工作者拥有该索引直到处理结束,然后再通过管道将索引号发送回IO线程,从而返回控制权?

    我的应用程序仅限Linux,因此我可以使用仅限Linux的功能,以便以最优雅的方式实现此目的。不需要跨平台支持,但性能和线程安全性是。

3 个答案:

答案 0 :(得分:6)

在我的测试中,每个线程一个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)

关于epoll的一些注释:

  • 使用边缘触发模式,非阻塞套接字,并始终读取EAGAIN;
  • 避免dup(2)系列调用以避免一些意外(epoll注册文件描述符,但实际上会监视文件描述);
  • 您可以安全地epoll_ctl(2)其他线程的epoll实例;
  • struct epoll_event使用较大的epoll_wait(2)缓冲区以避免饥饿。

其他一些说明:

  • 使用accept4(2)保存系统调用;
  • 每个核心使用一个线程(如果是CPU绑定,则每个物理1个,如果I / O绑定,则每个逻辑使用1个);
  • 如果连接数较低,
  • poll(2) / select(2)可能会更快。

我希望这会有所帮助。

答案 1 :(得分:3)

执行此模型时,因为我们只有在完全接收到数据包后才知道数据包大小,遗憾的是我们无法将接收本身卸载到工作线程。相反,我们仍然可以做的最好的事情是接收数据的线程,该数据必须将指针传递给完全接收的数据包。

数据本身可能最好保存在循环缓冲区中,但是我们需要为每个输入源提供一个单独的缓冲区(如果我们得到一个部分数据包,我们可以继续从其他来源接收而不分割数据。剩下的问题是如何告知工作人员新数据包何时准备好,并给他们一个指向所述数据包中数据的指针。因为这里的数据很少,只有一些指针,最优雅的方法是使用posix消息队列。它们为多个发送者和多个接收者提供了写入和读取消息的能力,始终确保每个消息都被接收并且精确地通过一个线程。

对于每个数据源,您将需要一个类似下面的结构,我现在将完成字段目的。

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 message queue向工作线程发送消息,以便它可以处理数据,消息将包含指向DataSource结构的指针,因此它可以对其进行处理,它还需要指向数据包的指针正在研究,它的大小,这些可以在我们收到数据包时计算

工作线程将使用接收的指针进行处理,然后增加SizeLeft,以便接收器线程知道它可以继续填充缓冲区。原子函数将需要处理结构中的大小值,因此我们不会获得具有size属性的竞争条件(因为它可能由工作者和IO线程同时写入,导致丢失写入,请参阅下面的评论),它们列在here中并且非常简单且非常有用。

现在,我已经给出了一些一般背景知识,但将特别提到这些要点:

  1. 使用EventFD作为同步机制在很大程度上是一个坏主意,您会发现自己使用了相当多的不必要的CPU时间,并且很难执行任何同步。特别是如果你有多个线程选择相同的文件描述符,你可能会遇到重大问题。这实际上是一个讨厌的黑客,它有时可以工作,但不能真正替代正确的同步。
  2. 如上所述尝试卸载接收也是一个坏主意,你可以解决复杂IPC的问题,但坦率地说,接收IO不太可能花费足够的时间来拖延你的应用程序,你的IO也可能很多比CPU慢,所以多线程接收将获得很少。 (假设您没有说,有几个10千兆网卡)。
  3. 使用互斥锁或锁是一个愚蠢的想法,它适用于无锁编码,因为(同时)共享数据量很少,你实际上只是在处理工作和数据。这也将提高接收线程的性能,使您的应用程序更具可扩展性。使用这里提到的功能http://gcc.gnu.org/onlinedocs/gcc-4.1.2/gcc/Atomic-Builtins.html,你可以很容易地做到这一点。如果你这样做的话,你需要的是一个信号量,这可以在每次接收到一个数据包并被每个线程锁定时解锁,这个线程启动一个作业,以便在更多数据包准备就绪时允许动态更多线程,这将有比使用互斥锁的自制解决方案要少得多。
  4. 这里任何线程池没有太大的区别,你产生了很多线程,然后让它们全部阻塞在数据消息队列的mq_receive上等待消息。完成后,他们将结果发送回主线程,主线程将结果消息队列添加到其epoll列表中。然后,它可以以这种方式接收结果,对于像指针这样的小数据有效载荷,它非常简单且非常有效。这也将使用很少的CPU,而不是强迫主线程浪费时间管理工作人员。
  5. 最后你的编辑是相当明智的,除了我所建议的事实,消息队列远比管道好,因为它们非常有效地发出事件信号,保证完整的消息读取并提供自动框架。

    我希望这会有所帮助,但是这已经很晚了,所以如果我错过任何内容或者您有任何问题可以随意评论澄清或更多解释。

答案 2 :(得分:0)

我在其他帖子中发布了相同的答案:I want to wait on both a file descriptor and a mutex, what's the recommended way to do this?

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

这是一个非常常见的问题,尤其是在开发网络服务器端程序时。大多数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 ms)。

如果proc()将运行很长时间,我们需要MULTI THREADS,并在一个单独的线程(工作线程)中执行proc()。

我们可以在不阻塞主线程的情况下向工作线程提交任务,使用基于互斥锁的消息队列,它足够快。

然后我们需要一种从工作线程获取任务结果的方法。怎么样?如果我们只是在epoll_wait()之前或之后直接检查消息队列,但是,在epoll_wait()结束后执行检查操作,如果等待所有文件描述符,则epoll_wait()通常会阻塞10微秒(常见情况)不活跃。

对于服务器,10毫秒是相当长的时间!我们可以发信号通知epoll_wait()在生成任务结果时立即结束吗?

是的!我将在我的一个开源项目中描述它是如何完成的。

为所有工作线程创建管道,epoll也在该管道上等待。生成任务结果后,工作线程将一个字节写入管道,然后epoll_wait()将在几乎相同的时间内结束! - Linux管道有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);