美文网首页
什么是 NIO

什么是 NIO

作者: 放开那个BUG | 来源:发表于2021-09-12 21:00 被阅读0次

    1、前言

    开始知道 NIO 来源于 Java 语言的一些知识,当时以为 NIO 是 Java 的首创,然后查找各种关于 NIO 的知识。但是各种文章都写如何使用 API,并没有讲是如何实现的。有的文章可能深入一点,但是到了底层也不再下去了。导致我学这里的知识很懵逼。

    那么什么是 NIO 呢?我们都知道传统的 BIO 在接受客户端连接并且调用 accept 读取数据时,服务器通信线程会阻塞,直到数据读写完毕才会重新释放。虽然可以使用线程池来实现线程复用,可同时接受的连接也只能是线程池的上限(也就是一个线程服务一个 socket)。

    而 NIO(准确来说是 I/O 多路复用) 在接受连接时并不会阻塞,它同时可接受大量的连接。也就是说它将连接跟线程的强绑定关系可去除了,不在是一个线程服务一个连接(而是可以同时监听多个 socket),即 I/O 多路复用。Linux 实现 I/O 多路复用使用的是 linux 的 epoll 函数,Java 也是基于 epoll 实现 NIO 的功能(select 不说了,每次查询的时候要循环所有注册的 socket)。

    2、原理

    我们程序接受网卡的信息的过程是怎样的呢,以之前的同步阻塞 I/O 为例。当程序执行到 recv 时,操作系统会将进程 A 从工作队列移动到该 socket 的等待队列中,socket 持有进程 A 的引用:


    同步阻塞

    进程在 recv 阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知 cpu 有数据到达,cpu 执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应 socket 的接收缓冲区里面(步骤④),再唤醒进程 A(步骤⑤),重新将进程 A 放入工作队列中。


    收到数据

    从上面我们看出,一个次只能监视一个 socket,效率很低,如何同时监视多个 socket 呢?这就引出了 epoll 关键字(select/poll 直接忽略)。epoll 由三个函数组成,分别是 epoll_create、epoll_ctl、epoll_wait:


    事件模型

    当调用 epoll_create 时,会创建一个 eventpoll 对象,它分别维护了一个红黑树 rbr、一个双向队列 rdllist。


    eventpoll

    创建 epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 socket。以添加 socket 为例,如下图,如果通过 epoll_ctl 添加 sock1、sock2 和 sock3 的监视,内核会将 eventpoll 添加到这三个 socket 的等待队列中。


    添加 socket

    当 socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程(也就是调用 epoll 的进程)。不管从 socket 找 eventpoll 再找 rdlist 还是从 eventpoll 直接找 rdlist 都很方便,我没看实现,不是很清楚。

    当 socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 socket 引用。如下图展示的是 sock2 和 sock3 收到数据后,中断程序让 rdlist 引用这两个 socket。


    添加 rdlist 引用

    假设计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。如下图所示,内核会将进程 A 放入 eventpoll 的等待队列中,阻塞进程。


    放入阻塞队列

    当 socket 接收到数据,中断程序一方面修改 rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下图)。也因为 rdlist 的存在,进程 A 可以知道哪些 socket 发生了变化。


    修改 rdlist

    3、示例 demo

    首先,如何写一个简单的 epoll demo,因为我们下面所有的讲解都是基于示例的,流程如下:

    • 首先,需要调用epoll_create创建epoll;
    • 此后我们就可以进行socket/bind/listen;
    • 然后调用epoll_ctl进行注册;
    • 接下来,就可以通过一个while(1)循环调用epoll_wait来等待事件的发生;
    • 然后循环查看接收到的事件并进行处理;
      1)如果事件是sever的socketfd我们就要进行accept,并且把接收到client的socketfd加入到要监听的事件中;
      2)如果在监听过程中,需要修改操作方式(读/写),可以调用epoll_ctl来重新修改;
      3)如果监听到某一个客户端关闭,那么我就需要再次调用epoll_ctl把它从epoll监听事件中删除。
    #include<stdio.h>
     #include<arpa/inet.h>
     #include<sys/epoll.h>
     #include<unistd.h>
     #include<ctype.h>
     #define MAXLEN 1024
     #define SERV_PORT 8000
     #define MAX_OPEN_FD 1024
     
     int main(int argc,char *argv[])
     {
         int  listenfd,connfd,efd,ret;
         char buf[MAXLEN];
         struct sockaddr_in cliaddr,servaddr;
         socklen_t clilen = sizeof(cliaddr);
         struct epoll_event tep,ep[MAX_OPEN_FD];
     
         listenfd = socket(AF_INET,SOCK_STREAM,);
     
         servaddr.sin_family = AF_INET;
         servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
         servaddr.sin_port = htons(SERV_PORT);
         bind(listenfd,(struct sockaddr*)&servaddr,sizeof(servaddr));
         listen(listenfd,);
         // 创建一个epoll fd
         efd = epoll_create(MAX_OPEN_FD);
         tep.events = EPOLLIN;tep.data.fd = listenfd;
         // 把监听socket 先添加到efd中
         ret = epoll_ctl(efd,EPOLL_CTL_ADD,listenfd,&tep);
         // 循环等待
         for (;;)
         {
             // 返回已就绪的epoll_event,-1表示阻塞,没有就绪的epoll_event,将一直等待
             size_t nready = epoll_wait(efd,ep,MAX_OPEN_FD,-);
             for (int i = ; i < nready; ++i)
             {
                 // 如果是新的连接,需要把新的socket添加到efd中
                 if (ep[i].data.fd == listenfd )
                 {
                     connfd = accept(listenfd,(struct sockaddr*)&cliaddr,&clilen);
                     tep.events = EPOLLIN;
                     tep.data.fd = connfd;
                     ret = epoll_ctl(efd,EPOLL_CTL_ADD,connfd,&tep);
                 }
                 // 否则,读取数据
                 else
                 {
                     connfd = ep[i].data.fd;
                     int bytes = read(connfd,buf,MAXLEN);
                     // 客户端关闭连接
                     if (bytes == 0){
                         ret =epoll_ctl(efd,EPOLL_CTL_DEL,connfd,NULL);
                         close(connfd);
                         printf("client[%d] closed\n", i);
                     }
                     else
                     {
                         for (int j = ; j < bytes; ++j)
                         {
                             buf[j] = toupper(buf[j]);
                         }
                         // 向客户端发送数据
                         write(connfd,buf,bytes);
                     }
                 }
             }
         }
         return ;
     }
    

    当调用 epoll_create 函数时,会创建一个红黑树 rbr、双向队列 rdllist,然后将开始服务器监听的 socket 注册到红黑树 rbr 中。当没有数据进来时,双向队列 rdllist 为空,程序调用 epoll_wait 会阻塞,有数据则直接拿。如果有数据,那么红黑树中有数据的 socket 的引用会放到双向队列 rdllist 中,然后唤醒客户端程序,此时我们针对有数据的 socket 会进行处理。

    如果是 client 的连接(即有数据的 socket 是客户端监听 socket),那么会创建一个新的 socket 注册到红黑树上;如果是之前 client 的 socket 有读写数据,则直接做相应的处理。

    4、后记

    写完这篇文章之前,我一直在思考 sokcet 是什么?之前我以为 socket 是跟线程绑定的,socket 是线程内的属性。后来我错了,socket 和线程没有任何关系,socket 就是资源,跟文件差不多(创建 socket 的时候拿到的是文件描述符)。

    文件描述符是Unix系统标识文件的int,Unix的哲学一切皆文件,所以各自资源(包括常规意义的文件、目录、管道、POSIX IPC、socket)都可以看成文件。文件描述符是内核提供给用户来安全地操作文件的标识,不像指针,拥有了指针后你能瞎JB改。拥有了描述符后,你只能传入描述符给特定的接口,实际操作由内核读取用户输入的参数后来安全地执行。

    5、参考资料

    https://xie.infoq.cn/article/5241a48ffdb62ec3ba0235733
    https://mp.weixin.qq.com/s/_G9KRzIl7B7cPWKiMsZzOA

    相关文章

      网友评论

          本文标题:什么是 NIO

          本文链接:https://www.haomeiwen.com/subject/endtgltx.html