前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >谈谈网络通信服务器的结构应该如何设计

谈谈网络通信服务器的结构应该如何设计

作者头像
范蠡
发布2019-11-06 11:18:29
1.1K0
发布2019-11-06 11:18:29
举报

有同学给我留言:

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

  1. 线程池的实现机制使得需要引入锁管理线程调度,这个开销在 per thread per epoll 模型中是不需要的。
  2. 大量的短连接导致需要经常对 epoll 进行添加和删除操作,线程池在进行这个任务是是需要对唯一的 epoll 加锁的(可能有方法不需要加,我还不知道),而 per thread per epoll 没有这个问题,这个在速度上线程池应该也是有损失的。 关于上面这两点疑问前辈怎么看?感觉线程池没什么好的应用场景。

以下是我的回答:

虽然你在线程池的用途上有些混乱,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)。

如果你明白了上面我所说的,咱们再深入一点,每个线程循环的结构如下:

代码语言:javascript
复制
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 和 libuv。当然这些框架可能在上面的结构上稍微再加点东西,比如定时器,这样程序就变成了:

代码语言:javascript
复制
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模式

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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-10-31,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 高性能服务器开发 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档