Netty如何做到单机百万并发?(二)

简介: Netty如何做到单机百万并发?(二)

select 模型

此模型是 IO 多路复用的最早期使用的模型之一,距今已经几十年了,但是现在依旧有不少应用还在采用此种方式,可见其长生不老。

首先来看下其具体的定义(来源于 man 二类文档):

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);

这里解释下其具体参数:

  • 参数一:nfds, 也即 maxfd,最大的文件描述符递增一。这里之所以传最大描述符,为的就是在遍历 fd_set 的时候,限定遍历范围。
  • 参数二:readfds, 可读文件描述符集合。
  • 参数三:writefds, 可写文件描述符集合。
  • 参数四:errorfds, 异常文件描述符集合。
  • 参数五:timeout, 超时时间。在这段时间内没有检测到描述符被触发,则返回。

下面的宏处理,可以对 fd_set 集合(准确的说是 bitmap,一个描述符有变更,则会在描述符对应的索引处置 1)进行操作:

  • FD_CLR(inr fd,fd_set* set) : 用来清除描述词组 set 中相关 fd 的位,即 bitmap 结构中索引值为 fd 的值置为 0。
  • FD_ISSET(int fd,fd_set *set): 用来测试描述词组 set 中相关 fd 的位是否为真,即 bitmap 结构中某一位是否为 1。
  • FD_SET(int fd,fd_set*set): 用来设置描述词组 set 中相关 fd 的位,即将 bitmap 结构中某一位设置为 1,索引值为 fd。
  • FD_ZERO(fd_set *set): 用来清除描述词组 set 的全部位,即将 bitmap 结构全部清零。

首先来看一段服务端采用了 select 模型的示例代码:

//创建server端套接字,获取文件描述符
    int listenfd = socket(PF_INET,SOCK_STREAM,0);
    if(listenfd < 0) return -1;
    //绑定服务器
    bind(listenfd,(struct sockaddr*)&address,sizeof(address));
    //监听服务器
    listen(listenfd,5); 
    struct sockaddr_in client;
    socklen_t addr_len = sizeof(client);
    //接收客户端连接
    int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
    //读缓冲区
    char buff[1024]; 
    //读文件操作符
    fd_set read_fds;  
    while(1)
    {
        memset(buff,0,sizeof(buff));
        //注意:每次调用select之前都要重新设置文件描述符connfd,因为文件描述符表会在内核中被修改
        FD_ZERO(&read_fds);
        FD_SET(connfd,&read_fds);
        //注意:select会将用户态中的文件描述符表放到内核中进行修改,内核修改完毕后再返回给用户态,开销较大
        ret = select(connfd+1,&read_fds,NULL,NULL,NULL);
        if(ret < 0)
        {
            printf("Fail to select!\n");
            return -1;
        }
        //检测文件描述符表中相关请求是否可读
        if(FD_ISSET(connfd, &read_fds))
        {
            ret = recv(connfd,buff,sizeof(buff)-1,0);
            printf("receive %d bytes from client: %s \n",ret,buff);
        }
    }

上面的代码我加了比较详细的注释了,大家应该很容易看明白,说白了大概流程其实如下:

  • 首先,创建 socket 套接字,创建完毕后,会获取到此套接字的文件描述符。
  • 然后,bind 到指定的地址进行监听 listen。这样,服务端就在特定的端口启动起来并进行监听了。
  • 之后,利用开启 accept 方法来监听客户端的连接请求。一旦有客户端连接,则将获取到当前客户端连接的 connection 文件描述符。

双方建立连接之后,就可以进行数据互传了。需要注意的是,在循环开始的时候,务必每次都要重新设置当前 connection 的文件描述符,是因为文件描描述符表在内核中被修改过,如果不重置,将会导致异常的情况。

重新设置文件描述符后,就可以利用 select 函数从文件描述符表中,来轮询哪些文件描述符就绪了。

此时系统会将用户态的文件描述符表发送到内核态进行调整,即将准备就绪的文件描述符进行置位,然后再发送给用户态的应用中来。

用户通过 FD_ISSET 方法来轮询文件描述符,如果数据可读,则读取数据即可。

举个例子,假设此时连接上来了 3 个客户端,connection 的文件描述符分别为 4,8,12。

那么其 read_fds 文件描述符表(bitmap 结构)的大致结构为 00010001000100000....0。

由于 read_fds 文件描述符的长度为 1024 位,所以最多允许 1024 个连接。

微信图片_20220908153153.png

而在 select 的时候,涉及到用户态和内核态的转换,所以整体转换方式如下:

微信图片_20220908153220.png

所以,综合起来,select 整体还是比较高效和稳定的,但是呈现出来的问题也不少。

这些问题进一步限制了其性能发挥:

  • 文件描述符表为 bitmap 结构,且有长度为 1024 的限制。
  • fdset 无法做到重用,每次循环必须重新创建。
  • 频繁的用户态和内核态拷贝,性能开销较大。
  • 需要对文件描述符表进行遍历,O(n) 的轮询时间复杂度。

poll 模型

考虑到 select 模型的几个限制,后来进行了改进,这也就是 poll 模型,既然是 select 模型的改进版,那么肯定有其亮眼的地方,一起来看看吧。

当然,这次我们依旧是先翻阅 linux man 二类文档,因为这是官方的文档,对其有着最为精准的定义。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

其实,从运行机制上说来,poll 所做的功能和 select 是基本上一样的,都是等待并检测一组文件描述符就绪,然后在进行后续的 IO 处理工作。

只不过不同的是,select 中,采用的是 bitmap 结构,长度限定在 1024 位的文件描述符表,而 poll 模型则采用的是 pollfd 结构的数组 fds。

也正是由于 poll 模型采用了数组结构,则不会有 1024 长度限制,使其能够承受更高的并发。

pollfd 结构内容如下:

struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 关心的事件 */
    short revents;    /* 实际返回的事件 */
};

从上面的结构可以看出,fd 很明显就是指文件描述符,也就是当客户端连接上来后,fd 会将生成的文件描述符保存到这里。

而 events 则是指用户想关注的事件;revents 则是指实际返回的事件,是由系统内核填充并返回,如果当前的 fd 文件描述符有状态变化,则 revents 的值就会有相应的变化。

events 事件列表如下:

微信图片_20220908153238.png

revents 事件列表如下:

微信图片_20220908153304.png

从列表中可以看出,revents 是包含 events 的。接下来结合示例来看一下:

//创建server端套接字,获取文件描述符
    int listenfd = socket(PF_INET,SOCK_STREAM,0);
    if(listenfd < 0) return -1;
    //绑定服务器
    bind(listenfd,(struct sockaddr*)&address,sizeof(address));
    //监听服务器
    listen(listenfd,5); 
    struct pollfd pollfds[1];
    socklen_t addr_len = sizeof(client);
    //接收客户端连接
    int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
    //放入fd数组
    pollfds[0].fd = connfd;
    pollfds[0].events = POLLIN;
    //读缓冲区
    char buff[1024]; 
    //读文件操作符
    fd_set read_fds;  
    while(1)
    {
        memset(buff,0,sizeof(buff));
        /**
         ** SELECT模型专用
         ** 注意:每次调用select之前都要重新设置文件描述符connfd,因为文件描述符表会在内核中被修改
         ** FD_ZERO(&read_fds);
         ** FD_SET(connfd,&read_fds);
        ** 注意:select会将用户态中的文件描述符表放到内核中进行修改,内核修改完毕后再返回给用户态,开销较大
        ** ret = select(connfd+1,&read_fds,NULL,NULL,NULL);
        **/
        ret = poll(pollfds, 1, 1000);
        if(ret < 0)
        {
            printf("Fail to poll!\n");
            return -1;
        }
        /**
         ** SELECT模型专用
         ** 检测文件描述符表中相关请求是否可读
         ** if(FD_ISSET(connfd, &read_fds))
         ** {
         **   ret = recv(connfd,buff,sizeof(buff)-1,0);
         **   printf("receive %d bytes from client: %s \n",ret,buff);
         ** }
         **/
        //检测文件描述符数组中相关请求
        if(pollfds[0].revents & POLLIN){
            pollfds[0].revents = 0;
            ret = recv(connfd,buff,sizeof(buff)-1,0);
            printf("receive %d bytes from client: %s \n",ret,buff);
        }
    }

由于源码中,我做了比较详细的注释,同时将和 select 模型不一样的地方都列了出来,这里就不再详细解释了。

总体说来,poll 模型比 select 模型要好用一些,去掉了一些限制,但是仍然避免不了如下的问题:

  • 用户态和内核态仍需要频繁切换,因为 revents 的赋值是在内核态进行的,然后再推送到用户态,和 select 类似,整体开销较大。
  • 仍需要遍历数组,时间复杂度为 O(N)。

epoll 模型

如果说 select 模型和 poll 模型是早期的产物,在性能上有诸多不尽人意之处,那么自 Linux 2.6 之后新增的 epoll 模型,则彻底解决了性能问题,一举使得单机承受百万并发的课题变得极为容易。

现在可以这么说,只需要一些简单的设置更改,然后配合上 epoll 的性能,实现单机百万并发轻而易举。

同时,由于 epoll 整体的优化,使得之前的几个比较耗费性能的问题不再成为羁绊,所以也成为了 Linux 平台上进行网络通讯的首选模型。

讲解之前,还是 linux man 文档镇楼:linux man epoll 4 类文档 linux man epoll 7 类文档,俩文档结合着读,会对 epoll 有个大概的了解。

和之前提到的 select 和 poll 不同的是,此二者皆属于系统调用函数,但是 epoll 则不然,他是存在于内核中的数据结构。

可以通过 epoll_create,epoll_ctl 及 epoll_wait 三个函数结合来对此数据结构进行操控。

说到 epoll_create 函数,其作用是在内核中创建一个 epoll 数据结构实例,然后将返回此实例在系统中的文件描述符。

此 epoll 数据结构的组成其实是一个链表结构,我们称之为 interest list,里面会注册连接上来的 client 的文件描述符。

其简化工作机制如下:

微信图片_20220908153358.png

说道 epoll_wait 函数,其作用就是扫描 ready list,处理准备就绪的 client IO,其返回结果即为准备好进行 IO 的 client 的个数。通过遍历这些准备好的 client,就可以轻松进行 IO 处理了。

上面这三个函数是 epoll 操作的基本函数,但是,想要彻底理解 epoll,则需要先了解这三块内容,即:inode,链表,红黑树。

在 Linux 内核中,针对当前打开的文件,有一个 open file table,里面记录的是所有打开的文件描述符信息;同时也有一个 inode table,里面则记录的是底层的文件描述符信息。

这里假如文件描述符 B fork 了文件描述符 A,虽然在 open file table 中,我们看新增了一个文件描述符 B,但是实际上,在 inode table 中,A 和 B 的底层是一模一样的。

这里,将 inode table 中的内容理解为 Windows 中的文件属性,会更加贴切和易懂。

这样存储的好处就是,无论上层文件描述符怎么变化,由于 epoll 监控的数据永远是 inode table 的底层数据,那么我就可以一直能够监控到文件的各种变化信息,这也是 epoll 高效的基础。

简化流程如下:

微信图片_20220908153415.jpg

数据存储这块解决了,那么针对连接上来的客户端 socket,该用什么数据结构保存进来呢?

这里用到了红黑树,由于客户端 socket 会有频繁的新增和删除操作,而红黑树这块时间复杂度仅仅为 O(logN),还是挺高效的。

有人会问为啥不用哈希表呢?当大量的连接频繁的进行接入或者断开的时候,扩容或者其他行为将会产生不少的 rehash 操作,而且还要考虑哈希冲突的情况。

虽然查询速度的确可以达到 o(1),但是 rehash 或者哈希冲突是不可控的,所以基于这些考量,我认为红黑树占优一些。

客户端 socket 怎么管理这块解决了,接下来,当有 socket 有数据需要进行读写事件处理的时候,系统会将已经就绪的 socket 添加到双向链表中,然后通过 epoll_wait 方法检测的时候。

其实检查的就是这个双向链表,由于链表中都是就绪的数据,所以避免了针对整个客户端 socket 列表进行遍历的情况,使得整体效率大大提升。

整体的操作流程为:

  • 首先,利用 epoll_create 在内核中创建一个 epoll 对象。其实这个 epoll 对象,就是一个可以存储客户端连接的数据结构。
  • 然后,客户端 socket 连接上来,会通过 epoll_ctl 操作将结果添加到 epoll 对象的红黑树数据结构中。
  • 然后,一旦有 socket 有事件发生,则会通过回调函数将其添加到 ready list 双向链表中。
  • 最后,epoll_wait 会遍历链表来处理已经准备好的 socket,然后通过预先设置的水平触发或者边缘触发来进行数据的感知操作。

从上面的细节可以看出,由于 epoll 内部监控的是底层的文件描述符信息,可以将变更的描述符直接加入到 ready list,无需用户将所有的描述符再进行传入。

同时由于 epoll_wait 扫描的是已经就绪的文件描述符,避免了很多无效的遍历查询,使得 epoll 的整体性能大大提升,可以说现在只要谈论 Linux 平台的 IO 多路复用,epoll 已经成为了不二之选。

相关文章
|
3月前
|
前端开发 网络协议
Netty实战巅峰:从零构建高性能IM即时通讯系统,解锁并发通信新境界
【8月更文挑战第3天】Netty是一款高性能、异步事件驱动的网络框架,适用于开发高并发网络应用,如即时通讯(IM)系统。本文将指导你利用Netty从零构建高性能IM程序,介绍Netty基础及服务器/客户端设计。服务器端使用`ServerBootstrap`启动,客户端通过`Bootstrap`连接服务器。示例展示了简单的服务器启动过程。通过深入学习,可进一步实现用户认证等功能,打造出更完善的IM系统。
141 1
|
存储 缓存 编解码
一文搞定Netty,打造单机百万连接测试!3
一文搞定Netty,打造单机百万连接测试!
|
编解码 负载均衡 网络协议
Netty实战三-如何让单机下Netty支持百万长连接?
Netty实战三-如何让单机下Netty支持百万长连接?
1244 0
|
设计模式 缓存 前端开发
一文搞定Netty,打造单机百万连接测试!2
一文搞定Netty,打造单机百万连接测试!
|
缓存 监控 网络协议
一文搞定Netty,打造单机百万连接测试!1
一文搞定Netty,打造单机百万连接测试!
|
监控 搜索推荐 Linux
Netty如何做到单机百万并发?(三)
Netty如何做到单机百万并发?(三)
Netty如何做到单机百万并发?(三)
|
消息中间件 编解码 JavaScript
Netty如何做到单机百万并发?(一)
Netty如何做到单机百万并发?(一)
Netty如何做到单机百万并发?(一)
|
前端开发 Java
从源码上理解Netty并发工具-Promise
最近一直在看Netty相关的内容,也在编写一个轻量级的RPC框架来练手,途中发现了Netty的源码有很多亮点,某些实现甚至可以用「苛刻」来形容。另外,Netty提供的工具类也是相当优秀,可以开箱即用。这里分析一下个人比较喜欢的领域,并发方面的一个Netty工具模块 - Promise。
256 0
从源码上理解Netty并发工具-Promise
|
网络协议 Java 物联网
基于Netty的百万级推送服务设计要点
1. 背景 1.1. 话题来源 最近很多从事移动互联网和物联网开发的同学给我发邮件或者微博私信我,咨询推送服务相关的问题。
1674 0
|
存储 缓存 NoSQL
跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)
本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。
13497 1