深入理解 Socket, NIO 和 Epoll

之前在内部分享过一次关于NIO相关的知识,感觉通过这次整理,对NIO和Epoll整体上又多了一些认识,虽然没有能力阅读内核源码,但是希望这篇文章可以从整体上帮助各位认识NIO和Epoll。

文章目录

  • 中断
    • 网卡中断
  • 软中断
    • 阻塞的原理
    • 内核接受网络数据的全过程
  • Unix 网络IO分类
    • 阻塞I/O
    • 非阻塞I/O
    • I/O多路复用
    • 信号驱动I/O
    • 异步I/O
    • 总结
  • IO多路复用
    • select
    • epoll
    • 原理浅析:
      • 创建epoll对象
      • 维护监视列表
      • 接收数据
      • pollable
  • 对Socket的理解

中断

计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时(电容可以保存少许电量,供CPU运行很短的一小段时间),它应立即去保存数据,保存数据的程序具有较高的优先级。

一般而言,由硬件产生的信号需要cpu立马做出回应(不然数据可能就丢失),所以它的优先级很高。cpu理应中断掉正在执行的程序,去做出响应;当cpu完成对硬件的响应后,再重新执行用户程序。中断的过程如下图,和函数调用差不多。只不过函数调用是事先定好位置,而中断的位置由“信号”决定。

深入理解 Socket, NIO 和 Epoll_第1张图片

不同的设备对应的中断不同,而每个中断都通过一个唯一的数字标识。这些中断值通常被称为中断请求(IRQ)线。比如,IRQ0是时钟中断,而IRQ1是键盘中断。并不是所有的中断号都这样严格定义,像PCI总线上的设备,中断就是动态分配的。

中断号在中断处理过程中起到很重要的作用,在采用向量中断方式的中断系统中,CPU必须通过它才可以找到中断服务程序的入口地址,实现程序的转移。为了在中断向量表中查找中断服务程序的入口地址,可由中断号(n)×4得到一个指针,指向中断向量(即中断服务程序的入口地址)存放在中断向量表的位置,从中取出这个地址(CS:IP),装入代码段寄存器CS和指令指针寄存器IP,即转移到了中断服务程序。

网卡中断

当网卡把数据写入到内存后,网卡向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据(也就是数据经过DMA已经从磁盘缓冲区到内核了,cpu介入内核态将数据从内核返回到用户进程内存,如果没有DMA的话,可能要CPU多次从磁盘缓冲区拷贝到内核态,然后再从内核态到用户进程内存)。

中断函数是 驱动程序注册到Linux Kernel中的中断子系统注册的中断处理函数。

数据的发送与接收:

  1. 当我们需要发送数据时,最终调用的是网卡驱动提供的函数:net_device->hard_start_xmit();

  2. 当我们接收到数据时,会触发中断,中断处理函数调用会调用内核函数来接收数据(放入缓冲区?),最终由驱动程序调用内核函数netif_receive_skb(),把报文送入协议栈(接下来的代码硬件无关,与具体报文处理协议相关,比如:ARP协议,IPv4协议,IPv6协议等)。

  3. 网卡的中断处理函数在调用内核函数接收数据时又分为非NAPI/NAPI两种方式;

  4. NAPI方式涉及到中断的下半部处理的概念以及软中断。

  5. 报文通过**netif_receive_skb()**送入协议栈之后,首先判断需不需要进行桥接处理;

  6. 如果报文没有被桥接代码处理,再调用协议处理函数来处理;

  7. 中断合并:当数据量很少的时候,每来一个数据包网卡都回产生一个中断,kernel响应这个中断,从网卡缓冲区中读出数据放进协议栈处理,当满足一定条件时,kernel回调用户代码,这里的“回调”一般情况下是指从一个kernel syscall中返回(在此之前用户代码一直处于block状态)。
    当数据量很大时,每个包都产生一个中断就划不来了,此时kernel可以启动interrupt coalescing机制,让网卡做中断合并也就是说来足够多的数据包或者等待一个timeout才会产生一个中断,kernel在响应中断时会把所有数据一起读出来处理,这样可以有效的降低中断次数
    当数据量更大时,网卡缓冲区里几乎总是有未处理的数据,此时kernel干脆会禁掉网卡的中断,切换到轮询处理的模式,说白了就是跑一个忙循环不停地读网卡缓冲区里的数据,这样综合开销更低。

软中断

系统调用就是软中断,也就是用户态切换到内核态

所有软中断的中断号都是0x80,它是上层应用程序与Linux系统内核进行交互通信的唯一接口。其中断处理程序是system_call,当检测到系统调用发生时(int 0x80中断),第一步先保存现场,通过一个宏指令SAVE_ALL实现的,这个指令是把寄存器的状态通过压栈的方式保存起来。

然后会调用sys_call_table,通过eax寄存器的值查找系统调用表,找到几号系统调用,然后调用相应的系统调用。

最终恢复现场,然后应用继续执行。

阻塞的原理

工作队列

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。

下图中的计算机中运行着A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。

深入理解 Socket, NIO 和 Epoll_第2张图片

等待队列

当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。

深入理解 Socket, NIO 和 Epoll_第3张图片

创建socket

当程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源。

ps:操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下。

唤醒进程

当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。

内核接受网络数据的全过程

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

唤醒进程的过程如下图所示。

深入理解 Socket, NIO 和 Epoll_第5张图片

以上是内核接收数据全过程

这里留有两个思考题,大家先想一想。

其一,操作系统如何知道网络数据对应于哪个socket?

其二,如何同时监视多个socket的数据?

第一个问题:因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。

第二个问题则是epoll等内核技术来解决

Unix 网络IO分类

Unix提供了5种不同的I/O模型,分别是

  • 阻塞I/O(blocking I/O)
  • 非阻塞I/O(non-blocking I/O)
  • I/O复用(I/O multiplexing)
  • 信号驱动式I/O(signal-driven I/O)
  • 异步I/O(asynchronous I/O)

一个I/O操作需要从用户态进入内核态运行,通常包括俩阶段

  1. 等待数据
  2. 从内核向进程复制数据

对于socket I/O而言,第一步通常是等待数据从网络中到达,到达之后会复制到内核的某个缓冲区
第二步就是从内核缓冲区复制到应用进程缓冲区

阻塞I/O

深入理解 Socket, NIO 和 Epoll_第6张图片

默认情况下,所有的socket都是阻塞的. 如图所示

  1. 应用发起recvfrom这个系统调用, 应用被阻塞
  2. 内核等待数据准备好
  3. 数据准备好, 内核将数据复制到应用缓冲区
  4. 应用从阻塞里恢复,处理数据

这也是理解和编程起来比较简单的模型,所以计算机早期用的很多,现在在处理超大文件的时候,也依然适用于这种模型。

非阻塞I/O

深入理解 Socket, NIO 和 Epoll_第7张图片

相比于Blocking I/O, Non-Blocking I/O等待数据阶段不会被阻塞,也就是说操作系统不会挂起应用, 应用
不断轮询(polling)内核看是否数据准备好。某次轮询发现准备好了,再直接发系统调用阻塞取数据.

Unix网络编程里对轮询的定义是:
应用进程对非阻塞描述符循环发送系统调用,以查看某个操作是否就绪

I/O多路复用

深入理解 Socket, NIO 和 Epoll_第8张图片
在处理非常多的描述符的时候,I/O多路复用技术显得非常有用。I/O多路复用需要发送2次系统调用:

  1. select或者poll, 获取可读条件, 等待描述符变成可读
  2. 发起recvfrom系统调用,内核复制数据到应用。

在只有1个客户端的时候,I/O多路复用技术甚至不如阻塞I/O.因为多发了一次系统调用。
但有些常用的网络场景,如:

  • 既要处理TCP,又要处理UDP
  • 一个服务器处理多个服务或者多个协议
  • 一个TCP服务器既要监听socket,又要处理已连接socket

这些场景下,多发的这次系统调用能带来更高的I/O处理效率,能更均匀的使用服务器时间片, 处理更多连接。
常用的方法是把I/O多路复用和非阻塞I/O结合使用,这样应用进程不需要阻塞,能处理别的业务,同时又能够处理多个I/O请求

这里可以看出,多路复用也是在第一阶段生效,也就是说进程阻塞在多路复用器epoll上,当数据从缓冲区读到内核完毕后,进程被唤醒

但是第二步,进程发起系统调用,将数据从内核buffer read到用户空间,这一步仍然是阻塞的,但是这一步通常都很快(比如一个socketChannel 一端连接的是一个socket,一端是buffer,所以get(buffer)就是将socker 在内核中的数据读到用户空间的buffer)

信号驱动I/O

深入理解 Socket, NIO 和 Epoll_第9张图片
信号模型的步骤是:

  1. 应用进程发起一个信号,告诉内核要什么文件,然后立马返回
  2. 内核准备好数据
  3. 应用进程收到信号发起recvfrom系统调用来阻塞取数据

信号驱动模型类似于你去一点点买奶茶,对方给你一个小票。
什么时候好了对方喊xx号(这就是信号)好了.然后你来取奶茶(recvfrom取数据)

异步I/O

深入理解 Socket, NIO 和 Epoll_第10张图片
步骤:

  1. 应用进程发起aio_read系统调用,告诉内核要什么文件,然后立马返回
  2. 内核准备好数据, copy到应用进程, 然后给应用进程发aio_read指定的信号
  3. 应用进程收到信号,开始处理数据,整个过程应用进程都没有被阻塞

AIO模型是比较高效的, 异步非阻塞,从各个方面来说都是。但是编程难度比较大(和人的思维惯性相左),代码写起来不易维护,可能未来会火吧。目前I/O多路复用已经足够用了。

总结

前四种模型的区别主要在第一阶段, 第二阶段recvfrom将数据从内核复制到应用的缓冲区期间,都会阻塞应用进程。
而异步I/O模型在两个阶段都要处理.

Non-Blocking I/OSignal-Driven I/O在数据准备阶段都不会阻塞,前者要轮询内核数据是否准备好,后者是直接等待内核通知回调
Asynchronous I/O是在真正意义上的POSIX定义的异步io操作, 在数据准备和复制阶段都不会阻塞应用,但是编程难度大
I/O Multiplexing在数据准备阶段也会阻塞,但是可以处理更多I/O请求,也就是说加了层中间人抽象,虽然阻塞了应用进程,但是能知道多个fd可读可写,也是大名鼎鼎的reactor模式的基础(在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。)

IO多路复用

select

使用select, 我们即使应用进程里只有1个线程也能够接受多个连接并且做出处理,因为应用进程不需要阻塞在socket的read,write或者accept系统调用上, 而是内核告诉应用有事件到来了, 应用进程遍历fd_set看是哪个fd就绪了,然后再处理

fd_set称为描述符集,通常是一个整数数组,其中每个整数的每一位对应1个描述符的状态.

select的函数签名是:

#include 
#include 

int select(int maxfdpl, fd_set *readset, fd_set *writeset,
           fd_set *exceptset, const struct timeval *timeout)

其中,返回值表示fd_set里就绪的元素总个数,包括读,写,异常fd_set
第一个参数表示待测试的fd个数,值一般是待测试的fd总数+1
中间仨代表要监听的读set, 写set和异常set
最后一个参数代表select每个fd经历的时间

操作描述符集的方法是4个宏:

  • FD_ZERO 清除fdset里的所有位
  • FD_SET 开启某fd在fdset里对应的位, 一般就置1
  • FD_CLR 清除某fd在fdset里对应的位, 一般就置0
  • FD_ISSET 判断fdset里是否包含某个fd的位

select的fd_size(最大描述符数)只有1024bit, 是在头文件里用宏写死的,
早期bsd内核最多只能开20个进程,在当时看来1024长度的fd_set已经大到用不完了, 放在今天肯定是不够了

一旦fd_set里任意fd上有事件发生,内核会立刻返回,将数据从内核空间拷贝到用户空间,然后应用进程需要遍历整个
fd_set去寻找哪个fd有事件了。

所以,select的缺点主要有:

  1. 最大并发限制: 1024个描述符
  2. 内核/用户空间的fd_set数据拷贝
  3. 遍历整个fd_set集合效率低,集合越大浪费的时间越多

epoll

poll只是解决了文件描述符的限制,而没有解决以下问题:

  1. fd数组整体在内核空间和用户空间之间拷贝
  2. 遍历整个fd数组找事件浪费资源

这俩性能问题在Banga在1999年写了篇论文A Scalable and Explicit Event
Delivery Mechanism for UNIX,提出selectpoll都是无状态的,需要用户空间的进程自行遍历查找事件, 一种改进方案是内核内部自己维护事件集合.通过一个类似declare_interest的系统调用,内核能够增量得更新进程感兴趣的事件集合列表, 应用进程通过使用get_next_event调用能派发新事件给内核。

根据论文的研究成果,LINUXFreeBSD各自给出的解决方案:epollkqueue.我们主要讨论epoll, 毕竟日常服务端环境都是LINUX.

在LINUX内核2.6以上,epoll才受到支持。

epoll操作过程有3个函数

#include 

int epoll_create(int size);

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  1. epoll_create创建一个epoll fd和事件表, 在LINUX2.6.8以后是使用红黑树来管理epoll事件表,所以size没有太大作用
  2. epoll_ctl操作上面创建的epoll事件表, 可以加入socket读写事件
  3. epoll_wait类似于以前的selectpoll,得到发生的事件, 如socket可读可写

epoll_ctl的第二个参数使用3个宏来表示动作:

  • EPOLL_CTL_ADD: 注册新的fd到epfd中
  • EPOLL_CTL_MOD: 修改意见注册的fd的监听事件
  • EPOLL_CTL_DEL: 从epfd中删除一个fd

epoll事件和以前poll的事件类型差不多,主要还是这仨:

  • EPOLLIN: 对应的fd可读
  • EPOLLOUT: 对应的fd可写
  • EPOLLERR: 对应的fd发送错误
  1. 新建socket, 用于监听端口
  2. socket的fd绑定端口
  3. socket监听
  4. 创建epoll fd
  5. 发起epoll_wait获取事件,使用epoll_ctl管理事件
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define MAX_FD_NUM 1024
#define MAXLEN 1024

int buf_len = 0;

int main()
{
    // 1. 新建socket, 用于监听端口
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1) printf("创建socket失败, error: %s (errno: %d)\n", strerror(errno), errno);

    // 2. 绑定端口
    unsigned short listenPort = 8090;
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(listenPort);

    int on = 1;
    // 设置socket绑定的端口,再程序关闭之后可以重复使用
    if ((setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(int))) < 0) {
        exit(1);
    }
    int bindRet = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (bindRet == -1) {
        printf("socket绑定地址失败, error: %s (errno: %d)\n", strerror(errno), errno);
        exit(1);
    }

    // 3. 监听端口
    int listenRet = listen(listenfd, 10);
    if (listenRet == -1) printf("socket监听端口失败, error: %s (errno: %d)\n", strerror(errno), errno);
    printf("socket 监听完毕, 地址: 127.0.0.1:%d", listenPort);

    struct sockaddr_in client_addr;
    socklen_t client_addr_len = sizeof(struct sockaddr_in);

    // 4. 创建一个 epfd,并且把 listenfd 注册到这个 epfd上。
    int epfd = epoll_create(1024);
    struct epoll_event ev,events[20];
    ev.data.fd = listenfd;
    ev.events = EPOLLIN;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

    int cur_fd_num = 1;
    char buf[MAXLEN]={0};

    while (1) {
        // 5. 调用epoll_wait获取IO事件
        // nReady 就是 events 数组的长度。
        int nready = epoll_wait(epfd, events, 20, 50);

        int i = 0;
        for (; i < nready; i++) {
            if (events[i].data.fd == listenfd) {
                int client_sockfd = accept(listenfd,(struct sockaddr*)&client_addr,&client_addr_len);

                if(client_sockfd < 0) {
                    perror("accept");
                }
                else {
                    printf("accept client_addr %s\n",inet_ntoa(client_addr.sin_addr));
                    ev.data.fd = client_sockfd;
                    ev.events=EPOLLIN;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, client_sockfd, &ev);
                }
            }
            else if (events[i].events & EPOLLIN) {
                int connfd = events[i].data.fd;
                int n = recv(connfd, buf, MAXLEN, 0);
                if(n <= 0) {
                    if(ECONNRESET == errno) {
                        close(connfd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, 0);
                    }
                    else {
                        perror("recv");
                    }
                }

                printf("receive %s", buf);
                buf_len = n;

                ev.data.fd = connfd;
                ev.events = EPOLLOUT;
                epoll_ctl(epfd, EPOLL_CTL_MOD, connfd, &ev);
            }
            else if (events[i].events & EPOLLOUT) {
                int connfd = events[i].data.fd;
                write(connfd, buf, buf_len);

                ev.data.fd = connfd;
                ev.events = EPOLLIN;
                epoll_ctl(epfd, EPOLL_CTL_MOD, connfd, &ev);
            }
        }
    }

    return 0;
}

使用gcc编译,运行

$ gcc -o epoll ./epoll.c
$ ./epoll

然后使用 nc 测试即可

epoll解决了selectpoll时代遗留的2个性能问题,不需要使fd数组整体在内核空间和用户空间之间来回拷贝,同时
不需要应用进程遍历整个fd数组以查找发生的事件。epoll使用mmap加速了内核和用户空间的消息传递(主要是rdlist和 事件树?),避免不必要的内存拷贝。
epoll只会返回活跃的socket fd,所以I/O效率不会随着fd数目增加而显著下降。

原理浅析:

创建epoll对象

如下图所示,当某个进程调用**epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员(*也是一个文件,有fd*),和socket一样,它也会有等待队列。**

深入理解 Socket, NIO 和 Epoll_第11张图片

*内核创建eventpoll对象*

创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。

维护监视列表

深入理解 Socket, NIO 和 Epoll_第12张图片
*添加所要监听的socket*

重要:当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。

Epoll中主要三类事件,可读,可写和发送错误

对于可读来说,socket收到客户端信息变为可读

可写,就是socket缓冲区不满都属于可写

所以编程上一般epoll_wait返回的时候,先判断可读,然后判断可写(因为只要缓冲区不满就一直可以写,或者说写缓冲区的东西被发送走了,就会触发可写事件)

接收数据

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

深入理解 Socket, NIO 和 Epoll_第13张图片

给就绪列表添加引用

eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。

当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。

阻塞和唤醒进程

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

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

深入理解 Socket, NIO 和 Epoll_第15张图片

​ epoll唤醒进程

也就是epoll一直等待在 socket上,而进程 等待在epoll上。

pollable

首先,linux 的 file 有个 pollable 的概念,只有 pollable 的 file 才可以加入到 epoll 和 select 中。一个 file 是 pollable 的当且仅当其定义了 file->f_op->pollfile->f_op->poll 的形式如下

__poll_t poll(struct file *fp, poll_table *wait)

不同类型的 file 实现不同,但做的事情都差不多:

  1. 通过 fp 拿到其对应的 waitqueue
  2. 通过 wait 拿到外部设置的 callback[[1]]
  3. 执行 callback(fp, waitqueue, wait),在 callback 中会将另外一个 callback2[[2]] 注册到 waitqueue[[3]]中,此后 fp 有触发事件就会调用 callback2

waitqueue 是事件驱动的,与驱动程序密切相关,简单来说 poll 函数在 file 的触发队列中注册了个 callback, 有事件发生时就调用callback。

下面来看看Linux内核具体的epoll机制实现思路。

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:

struct eventpoll{
    ....
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
    ....
}

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist 双链表中

在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:

struct epitem{
    struct rb_node  rbn;//红黑树节点
    struct list_head    rdllink;//双向链表节点
    struct epoll_filefd  ffd;  //事件句柄信息
    struct eventpoll *ep;    //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
}

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户

深入理解 Socket, NIO 和 Epoll_第16张图片

因为所有添加到 epoll中的事件都会与 网卡建立回调关系,当事件发生后会调用回调 ep_poll_callback

Epoll的核心原理很简单,使用内核的等待队列机制等待队列中发生事情,向等待队列注册一个回调函数。当等待队列中有事件发生,由*下层调用(socket fd调用)等待队列中的回调函数,这个回调函数的作用就是把发生事件的fd放到ready list中*,然后epoll每次都只是返回ready list中的所有已经发生的事件。**

下层一个fd,例如socket,会在收到数据的时候检查自己的等待队列sk->sk_wq,查看有没有人在等待socket事件的发生(epoll在等待多个fd的多个事件,如果发现有epoll在等待这个事件),如果有,就唤醒这个队列,并且调用epoll的回调函数ep_poll_callback,该函数会把该socket的fd(在epoll中对应epitem结构体)放到epoll的等待队列,从而epoll每次都可以只拿到一个已经全部就绪的列表。这样就没有遍历的过程了。这也是epoll的核心技术点。*但是epoll这样要求所有的fd都要有检查唤醒等待队列的能力,也就是自下而上传导事件的能力。也就是因此,内核中已经建立了完整的自下而上的事件通知链条,所有的fd都支持*

ep_poll_callback函数核心功能是将被目标fd的就绪事件到来时,将fd对应的epitem实例添加到就绪队列。当应用调用epoll_wait()时,内核会将就绪队列中的事件报告给应用。

流程:网卡—>中断程序—>内核协议栈程序—>放入对应socket缓冲区—>调用对应socket waitQueue的callback—>操作eventpoll结构体,将自身加入rdlist—>epoll_wait返回

对Socket的理解

Linux上进行TCP Socket编程的时候,通常流程是

  • 创建一个监听Socket(ServerSocket),系统调用就是int **socket**(int protofamily, int type, int protocol);//返回sockfd
  • 将这个监听Socket bind到某个端口;bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • int listen(int sockfd, int backlog)listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。,这里表示 使用 监听Socket来开始监听,也就是在之前绑定的端口监听
  • int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); 返回建立连接的新的fd–socketfd_new

此时我们需要区分两种套接字,

监听套接字: 监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字(监听套接字)

连接套接字:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接。

一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

自然要问的是:为什么要有两种套接字?原因很简单,如果使用一个描述字的话,那么它的功能太多,使得使用很不直观,同时在内核确实产生了一个这样的新的描述字。

连接套接字socketfd_new 并没有占用新的端口与客户端通信,依然使用的是与监听套接字socketfd一样的端口号

其实新建立的Socket没有监听端口,只是对接了内核缓冲区;所以可以读写数据

首先,一个端口肯定只能绑定一个socket。服务器端的端口在bind的时候已经绑定到了监听套接字socetfd所描述的对象上,accept函数新创建的socket对象其实并没有进行端口的占有,而是复制了监听套接字socetfd的本地IP和端口号,并且记录了连接过来的客户端的IP和端口号

那么,当客户端发送数据过来的时候,究竟是与哪一个socket对象通信呢?

客户端发送过来的数据可以分为2种,一种是连接请求,一种是已经建立好连接后的数据传输。

由于TCP/IP协议栈是维护着一个接收和发送缓冲区的。在接收到来自客户端的数据包后,服务器端的TCP/IP协议栈应该会做如下处理:如果收到的是请求连接的数据包,则传给监听着连接请求端口的socetfd套接字,进行accept处理;如果是已经建立过连接后的客户端数据包,则将数据放入接收缓冲区。这样,当服务器端需要读取指定客户端的数据时,则可以利用socketfd_new套接字通过recv或者read函数到缓冲区里面去取指定的数据(因为socketfd_new代表的socket对象记录了客户端IP和端口,因此可以鉴别)。

你可能感兴趣的:(java,网络,Linux)