单个 epoll + 线程池与每个线程一个 epoll 这两种架构哪个更适合大量短连接的场景?

Posted analogous_love

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了单个 epoll + 线程池与每个线程一个 epoll 这两种架构哪个更适合大量短连接的场景?相关的知识,希望对你有一定的参考价值。

本文是回答一位知友的提问:

单个 epoll + 线程池与每个线程一个 epoll 这两种架构哪个更适合大量短连接的场景?

不少教程上都提到线程池适合大量的网络短连接的任务场景。但我总感觉这个优势有点站不住脚(单 epoll + 线程池模型),主要考虑到两点:

1. 线程池的实现机制使得需要引入锁管理线程调度,这个开销在 per thread per epoll 模型中是不需要的。

2. 大量的短连接导致需要经常对 epoll 进行添加和删除操作,线程池在进行这个任务是是需要对唯一的 epoll 加锁的(可能有方法不需要加,我还不知道),而 per thread per epoll 没有这个问题,这个在速度上线程池应该也是有损失的。

关于上面这两点疑问各位前辈怎么看?感觉线程池没什么好的应用场景。

正文

虽然你在线程池的用途上有些混乱,但是这个问题其实蛮不错的,所以详细说一下希望对有需要的朋友提供一点帮助。

我们来详细讨论一下:

不管是 per thread per epoll 还是一个 epoll+线程池,应该抓住关键点。

我们一步步地梳理一下逻辑哈:首先假设您的侦听socket只有一个,这个侦听 socket 必然要绑定且只能绑定到一个 epoll 上(不管是侦听 socket 还是普通与客户端连接的 socket 同时绑定到多个epoll 上不仅处理起来麻烦,也是非常不好的做法),所以这里可以有且只有一个线程来对应这个 epoll,我们暂且把这个线程叫做线程 A,把这个 epoll 叫做 epollA; 接着 epollA 检测到新客户端请求连接,并接受客户端连接产生客户端 socket,这个 socket 我们叫做 B、C、D 等等(可能有许多)。这些与客户端连接对应的 socket 挂到哪里去?有两种思路:

第一种思路:挂到原来的 epollA 上,这样的话,线程A不仅要接受客户端连接(侦听 socket 上的事件)),还要处理客户端的来的数据(普通客户端端 socket B、C、D 等等),这种当连接数量比较多、来往数据比较多的时候,可能一个线程 A 忙不过来,效率不行。这种结构如下图所示:

Redis 就是这种单线程模型。

第二种思路:将 socket B、C、D 等以某种策略挂到新的 epoll 上,这些新的 epoll 我们暂且称为 epollB、epollC、epollD,当然分别对应线程 B、线程 C、线程 D 等等(具体数量根据你的需求来确定,但不能无限多,一般也就几个),比如轮询策略,即新来一个 socket B,挂到 epollB 上,接着来了 socket C 挂到 epollC 上,又来了 socket D 挂到 epollD 上,再来了 socket E 又挂到 epollB 上。因为产生 socket B、C、D 是在线程 A,而需要挂到 epollB、epollC、epollD 所在的线程上(在各个 epoll 上面移除 socket 同理),这里挂接和移除操作可能需要锁。这就是所谓的 per thread per epoll,或者叫 per thread per loop(一个线程一个循环),这里就是一组线程了,其中每个线程都有一个 epoll,只不过第一个 epoll 只绑定侦听 socket,其他的 epoll 绑定客户端 socket(当然,如果你觉得第一个 epoll 比较闲,也可以在上面绑定一些客户端 socket)。这两种结构如下图所示:

这两种结构是现在大多数服务器的结构。

说到这里咱们再深入一点,每个线程循环的结构如下:

while (!m_bQuit)

    //步骤一:使用select或者epoll_wait等IO复用技术检测socket上是否有读写或出错事件
    // 对于第一个循环,只检测侦听socket是否有事件
    epoll_or_select_func();

    //步骤二:检测到某些socket上有事件后处理事件,比如收数据,对于第一个循环可能是
    //接受客户端连接,接收完数据解数据包进行业务逻辑处理
    handle_io_events();

    //步骤三:做一些其他事情
    handle_other_things();

这是这个结构的最基本逻辑,在这基础上可以延伸出很多变体,例如:不知道你有没有发现,步骤二中如果解数据包或者业务逻辑处理过程比较耗时(计算密集型),那么会导致 thread 在这个步骤停留时间很长,导致很久以后才能走下一次循环,影响网络数据的检测和收发。所以 handle_io_events() 这个步骤中,我们又可以拆出一部分功能出来,比如将数据解包完后,产生的业务数据再交给另外一批线程(又来一个线程池),这批线程我们叫做业务线程(业务线程具体做什么顾名思义根据你的程序业务来决定),这个过程业务数据从网络线程组(生产者,epoll 线程组)流向业务线程组(消费者)的时候,也要加锁,因为业务线程会不断取出业务数据进行处理。

如果你能清晰明白地看到这里,说明你大致明白了一个不错的服务器框架是怎么回事了。

如果你有兴趣,咱们可以再进一步:

由于 CPU 核数有限,当线程数量超过 CPU 核数时,各个线程(网络线程和业务线程)也不是真正地并行执行,那么即使开了一组业务线程也不一定能真正地并发执行,那么我们不如就在网络线程里面处理。

上文也说了不能在步骤二的 handle_io_events(),但是我们可以放到 handle_other_things() 中处理呀,但是这里有个疑问,我产生了一个业务任务需要 handle_other_things()这个函数立即执行,而循环可能还挂在步骤一的 select 或者 epoll_wait 上,怎么办?没关系,我们可以使用一些"技术"手段立即唤醒他们,比如给 epoll 或者 select 额外绑定一些“功能” socket,Linux 还可以绑定 eventfd 或者 socketpair。当我们网络数据解包后产生业务任务后,只要往这些 socket 或者eventfd 上随便写一个数据,epoll_wait 或 select 因为检测到这些“功能” socket 可读事件就会立刻返回了,接下来的流程就走到 handle_other_things(),对我们的业务任务进行处理了。

特别说明一下:

这种所谓的技巧在handle_other_things()里面不会有耗时的任务的才可以替代专门开业务线程,如果有耗时操作还是老老实实开业务线程吧。

这就是目前主流的网络库的设计思想和基本框架原理,如 libevent 和 muduo。当然这些框架可能在上面的结构上稍微再加点东西,比如定时器,这样程序就变成了:

while (!m_bQuit)

    //步骤一:检测是否有定时器到期并处理定时器事件
    check_and_handle_timers(); 

    //步骤二:使用select或者epoll_wait等IO复用技术检测socket上是否有读写或出错事件
    // 对于第一个循环,只检测侦听socket是否有事件
    epoll_or_select_func();

    //步骤三:检测到某些socket上有事件后处理事件,比如收数据,对于第一个循环可能是
    //接受客户端连接,接收完数据解数据包进行业务逻辑处理
    handle_io_events();

    //步骤四:做一些其他事情
    handle_other_things();

之所以把定时器放在最前面是为了尽量减少定时器的事件的过期时间间隔。

说了这么多,希望你能理解:

  1. per thread per loop 思想

  2. 何时该用线程池

  3. 这个框架的优点与瓶颈所在

更具体的做法,您可以参考这里:

服务器端编程心得(一)-- 主线程与工作线程的分工

服务器端编程心得(二)-- Reactor模式

服务器端编程心得(三)-- 一个服务器程序的架构介绍

当然,如果您对网络编程或者高性能服务器开发感兴趣,可以关注我的微信公众号『高性能服务器开发』与我进一步沟通交流~

可以从哪里系统地学习到上述知识?

有同学私信问我,你这些知识从哪里学习的呢?

如果你是网络编程零基础或者觉得自己网络编程存在夹生饭问题,推荐看看尹圣雨的《TCP/IP 网络编程》,这本书同时兼顾 Windows 和 Linux 两个平台,使用的是 C 语言和操作系统的 Socket API,通过这本书你能学会常用的操作系统 Socket API 和常用的网络模型,认真学完之后,你不会再纠结同步异步、阻塞非阻塞等概念。

TCP/IP 网络编程

链接: https://pan.baidu.com/s/1RXQb9qmxttzbu5J_FEtQUg 提取码: la6n

接着如果你想编写高性能的网络框架或者高效的服务,推荐游双老师的《Linux 高性能服务器编程》一书。

链接: https://pan.baidu.com/s/1eZzdGFEjEEDIPkqyj9JalA 提取码: f77r

这两本书的获取方式可以参见这里:

计算机必看经典书籍(含下载方式)​

当然,我自己也出版了一本书《C++ 服务器开发精髓》,在这本书凝聚了我从客户端到服务器、从 Windows 到 Linux 的经验总结,你还将从本书中系统地学习到 C++ 开发编译调试完整技术链、多线程编程技术、作者精心凝炼的20多个网络编程重难点知识、网络故障排查与定位知识、如何设计可兼容可扩展的通信协议、如何设计高性能网络框架、如何设计高性能服务框架、如何开发服务常用组件等知识。

在 2021 年写一本 C++ 图书是一种什么体验?​

以上是关于单个 epoll + 线程池与每个线程一个 epoll 这两种架构哪个更适合大量短连接的场景?的主要内容,如果未能解决你的问题,请参考以下文章

单个 epoll + 线程池与每个线程一个 epoll 这两种架构哪个更适合大量短连接的场景?

单个 epoll + 线程池与每个线程一个 epoll 这两种架构哪个更适合大量短连接的场景?

Nginx 的线程池与性能剖析

Nginx 的线程池与性能剖析转载

拥有多个线程池与单个线程池相比有啥好处?

线程池线程池与工作队列