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

转:https://www.zhihu.com/question/271561199/answer/374711772

问题:

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

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

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

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


kanmars

越是追寻幸福的人,越是会常常感到孤独

既然你真心诚意的问了,那我就大发慈悲的告诉你。

a)、相关知识可以查看书籍《UNIX环境高级编程》中的重点章节。

b)、第一种方案,单epoll+线程池,是最流行的方案。这是一种叫做reactor的模型

c)、第二种方案,线程池+epoll,即先用一个线程accept到socket,然后分发出去,这是很久很久之前的异步处理模型select

而楼主提的疑问:

1、需要锁。是的,需要锁。但是楼主看一下unix环境编程,会发现锁mutex的开销,还是比较小,可接受的,并无不妥。锁管理线程调度......这个是不存在的,一般用一个先进先出队列解决的么(一个简单的原子性队列)没那么复杂

2、大量的短连接导致需要经常对 epoll 进行添加和删除操作,线程池在进行这个任务是是需要对唯一的 epoll 加锁的(可能有方法不需要加,我还不知道):答复如下:线程上对epoll加锁,是不存在的。一般是epoll在选择到可读可写socket后,为了防止惊群现象,先对socket加锁。如果成功,将socket封装为一个上下文context,再将context交给一个线程去执行。而线程内对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忙不过来,效率不行;第二种思路:将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_otherthings()这个函数立即执行,而循环可能还挂在步骤一的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模式

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

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


陈硕

C++编程 话题的优秀回答者

对于“大量的网络短连接”的场景,现在正确的做法是 SO_REUSEPORT。

你看的是什么“教程”?

陈硕:将同一个listening socket加入多个epoll能否降低响应时间?

发布了9 篇原创文章 · 获赞 4 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/yangzai187/article/details/93983085
今日推荐