java epoll网络编程

从通信开始

  • 人类社会的发展离不开相互协作,一起围猎、抵御野兽,一起扛起锄头夯地、夯人,再到你与好兄弟之间征战峡谷。在这一切互相协作的背后,都离不开信息的传递,也就是通信。一群人聚在一起,想搞点事情,少不得统一个想法,不然,你往左,他往右,还未开始,便已结束。又或者你抬手想打个招呼,人家还以为你想扇他,率先一个巴掌甩过来,大业未成身先卒,呜呼!
  • 彼此之间的想法要达成一致,得有个媒介,能够基于此将心中所想传达出去,并准确无误地到达对方,而对方亦能通过你传过来的这段信息,准确的理解你所要表达的意思(这就是通信协议,有了协议,他才能知道你抬手是在示好,而不是要打他)。
  • 最开始,人类纯粹以声音和肢体动作作为通信的媒介。往后,部落发展壮大,物资逐渐丰富,多到你记不住,等到酋长过来问你还有多少头猪的时候,你脑门一拍:“哎呀,忘了”,赶紧过去数一遍,数到一半,发出疑问:“前面是几头猪来着”。为了克服记忆不准确,易忘这个缺点,伟大的你在绳子上打个结,一个结代表一头猪,这下就可以信心满满的给酋长汇报了。之后,人类在结绳的基础上,逐步演进,形成了一门门独特的语言与文字。
  • 近现代,人类的足迹已经遍布全球,以往的通信方式由于传播距离、速度有限,已不能满足人类日益狂野的内心,一场变革势在必行。19世纪,摩尔斯发明了第一台电报机,将人类语言转化成摩斯码,通过电流传送到接受方,接受方收到摩斯码后便可根据事先约定好的协议将其解码为能够理解的人类语言,如此,一场跨越整山海的爱情便可以萌芽了。20世纪,随着计算机的兴起,其数量不断攀升,本着团结就是力量的原则,这些计算机之间也得建立一套通信方式,这样才方便集中大量搞事情。本文研究的内容便是彼此独立的计算机是怎么高效的搞事情。

计算机之间的通信

  • 如今,计算机已经普遍存在于我们的日常生活中,我们可以通过安装在计算机上的各类软件与其他人交换信息,十分便捷。在这便捷的背后,不禁思考,计算机咋就这么牛呢,隔着大老远,还能将你的土味情话传出去。
  • 人与人之间交流少不了三种基本媒介
  • 空气——作为声音传播介质,将你声带的振动传播出去。
  • 耳朵——作为声音的采集装置,捕捉声波,并将声波转化为生物电传输到神经。
  • 大脑——作为声音的处理装置,接收到生物电后,进行分析处理和存储。
  • 同样,计算机之间的交流也需要三种媒介:
  • 网线——作为电信号或光信号的传输介质。
  • 网卡——作为信号的采集和发射装置,完成信号的采集和转换。
  • 主机——作为信号的处理装置,接收到来自网卡的信号后,进行存储。
  • 我们知道,计算机主机内的cpu控制着数据的处理,一旦开机后,就一直处于运转状态,十足的大忙人。那么,问题来了,当网卡采集数据完成后,如此繁忙的cpu怎么知道数据准备好了,并启动处理程序呢?难不成整天不干旁事,就盯着网卡,看有没有数据到来,要是这样,我保证你立马会把你的计算机给砸了,毕竟,不能让撩妹进程阻塞了工作进程。为了弄清楚计算机是如何在协调这两个进程的,就需要讲下计算机体系当中的一个名词——中断,也只有了解了这个概念,才能更深入的理解epoll原理。

中断

  • 目前,大部分的个人计算机都是交互式多任务处理系统,有很多程序跑在这上面,在使用者看来,仿佛这些程序是同时运行的,工作、摸鱼,丝毫不耽误。但实际上,cpu并不能施展孙猴子的分身术,一个cpu在某个瞬间只能处理某一个进程。那么,计算机是怎么提供这个“并行”假象的呢?(只考虑单核CPU计算机)
  • 计算机cpu的运算速度极快,利用这一特性,可以将所有可运行的进程放在一个工作队列中,同时给每个进程分配一个时间片,待进程的时间片耗费完,cpu就会通过进程调度算法从工作队列中取出另一个进程,继续运行。于是,在我们使用者看来,这些程序仿佛是在同时运行着。
  • 每当进程的时间片用完,cpu是如何知晓的呢?这就轮到中断上场了,计算机内的定时器每隔一个周期(一个周期的大小等于一个时间片),就会触发一个时钟中断,cpu接收到该信号后,就会进入相应的中断处理程序,完成进程切换,如右图所示。

什么是中断

  • 中断可以看作是一种信号,当cpu接收到该信号后,就会停止当前运行的进程,转而去执行预先设置好的中断处理程序。好比在学生时代,当你正专心的神游太虚,突然被老师点名,不得不结束当前的遐想,并立刻站起来接受老师的灵魂拷问。在计算机系统中,中断不仅控制者进程间的轮转,平常我们通过鼠标、键盘与计算机进行交互,也是通过中断机制完成的,包括上文提到的计算机通信,cpu亦是通过中断机制得知网卡接收到了数据。按照中断的产生来源,一般可将中断分为两类——硬中断软中断

硬中断

  • 硬中断是由外部设备产生的中断信号,如上文提到的时钟中断,以及我们常见的网卡、键盘、鼠标等,都会产生中断信号。在CPU的外部引脚上,有两根引脚是用来专门接收外部设备发出的中断信号的:
  • INTR引脚——可屏蔽中断:该中断并不会影响系统正常运行,比如来自外部设备如硬盘,打印机,网卡等。
  • NMI引脚——不可屏蔽中断:该中断一旦发生,就是发生比较严重的问题了,比如电源掉电,硬件线路故障等。
  • 当CPU接收到中断信号后,当前执行的进程会被打断,转而去执行中断处理程序,具体处理流程如下:
  • 1、当外部事件发生时,比如点击鼠标,就会产生一个高电平电信号,经过中断控制器(CPU只有一个IINTR引脚,中断控制器可使多个外部中断源共享该资源)传输到将INTR引脚。
  • 2、CPU在每条指令的最后一个时钟周期会去查询INTR引脚,若查询到中断请求信号有效,并且在开中断的情况下,会终止当前进程,将CPU寄存器的值保存到当前进程的栈空间中(保护上下文)。
  • 3、之后CPU会通过中断控制器获取中断号,再根据中断号查询中断向量表,获取中断处理程序的入口地址,进入中断处理程序。
  • 4、中断处理程序执行完毕,CPU恢复被打断进程的信息到寄存器中(恢复上下文),返回原来进程停止的位置继续执行。
  • 下图以网卡接受数据为例,展示了这一过程。

软中断

  • cpu响应中断,会打断当前正在运行的进程,进入中断处理程序。与此同时,还会禁止中断,如果中断处理程序执行时间过长,会导致其它进程长时间得不到执行,系统实时响应能力差,甚至会导致中断丢失。因此,中断处理程序应该尽可能快的执行。
  • 在Linux系统中,为使硬中断的处理尽可能快。会将中断的处理分为上下两个部分,上半部分完成对硬件的即使响应,只处理一些紧急的事。而将耗费时间较长的事情,例如将网卡接受到的数据后,交给协议栈层层解析,将被推迟到下半部分完成。
  • 软中断便是实现下半部分处理的一种机制,在linux系统初始化时,会创建一组内核线程ksoftriqd,ksoftriqd的数量等于所在机器的cpu核数。ksoftriqd创建好后,会进入自己的循环体内,不断判断当前有没有软中断需要执行。有的话,就会进入对应的软中断处理函数,完成中断下半部分。
  • 我们还是以接收网卡数据为例,看看软中断是怎么工作的
  • 1、Linux初始化网络子系统时,通过open_softirq(NET_RX_SOFTIRQ, net_rx_action)注册接收数据网络包的软中断,同时会创建一个poll_list的数据结构,等待网卡驱动程序将其poll函数注册进来。
  • 2、网卡驱动初始化,往poll_list中添加自身的poll函数。
  • 3、网络数据到来,触发硬中断,在硬中断处理程序中完成一些基本设置后,调用raise_softirq(NET_RX_SOFTIRQ)触发软中断。
  • 4、ksoftriqd线程开始执行软中断号NET_RX_SOFTIRQ对应的软中断处理函数net_rx_action,net_rx_action会遍历poll_list,最终执行到网卡注册的poll函数,完成数据的接收和处理。

系统调用

  • 计算机的各种资源是有限的(内存、cpu等),为了能够管理好这些资源,保证计算机安全高效的运行,计算机系统提供两种不同的运行级别——内核态和用户态。
  • 运行在用户态的进程不能直接访问操作系统内核数据结构和程序,此时,若是进程想要访问内核数据,只能通过系统调用的方式,将操作权限从内核态切换为内核态。系统调用就是操作系统向外暴露的一系列API的集合,应用程序通过调用这些API,就可以间接访问内核资源,如访问网卡接收到的数据或者通过网卡发送数据。
  • 系统调用是通过中断实现的,其处理过程与硬中断相似。不同的是,系统调用并不会通过INTR引脚触发中断,而是通过int指令实现。当用户态进程发起int 0x80指令时,cpu就会切换到内核态,查询中断向量表中128号中断的中断处理程序入口,执行system_call()系统调用。

epoll的演进

  • 有了上面的一些知识储备,我们对计算机之间的通信算是有了一个大致的了解,但上面讲述的都是一些基本原理,它们是怎么应用到实际的应用程序中去的呢?不妨让我们先看下两个应用程序之间的整体通信框架图,对其做个整体了解。
  • 应用程序A想要给应用程序B发送数据,会先将数据发往sokcet缓冲区,然后再经过协议栈的层层封装,最终通过网卡发送出去。另一端的网卡接收到数据后,通过协议栈反向解析,最终到达socket缓冲区,并通知应用程序B的读取。
  • 在计算机系统中,要完成上述过程,便需要借助我们常见的五种IO模型:
  • 阻塞IO
  • 非阻塞IO
  • IO多路复用
  • 信号驱动IO
  • 异步IO
  • 本文主要讲的是epoll,属于IO多路复用模型。在这里,我会从最原始的阻塞IO出发,再深入到epoll的实现,通过两者的对比,希望各位看官能够对epoll的理解更加深入。

阻塞IO

  • 上文讲到应用程序想要获取内核中的数据,只能通过系统调用的方式。在linux内核中,就提供了这样的一组系统调用供应用程序使用。我们以阻塞IO为例,服务端与客户端的交互如下:
  • 1、应用程序通过sokcet()在内核中创建socket文件,并返回该文件描述符,应用程序通过文件描述符就可以操作该socket。sokcet在内核里是一种复杂的数据结构,这里,我们只需了解其中的三种即可——接受缓冲区、发送缓冲区、等待队列。
  • 2、socket创建好后,调用bind()将socket和本机的ip:port绑定起来,之后通过listen()设置socket为监听状态,紧接着调用accept()接收来自客户端的连接。accpet()调用会阻塞当前进程,直到客户端发送连接请求(此处的进程阻塞过程与下文将要提到的recv()相同)
  • 3、当客户端通过connect()发起连接请求,服务端内核在接收到该请求后,便会在内核中创建一个socket,并将其描述符返回给应用程序。内核创建的socket将与ip四元组——服务器IP地址、服务器端口,客户端IP地址、客户端端口绑定,当服务器有数据到达时,内核解析出数据中携带的ip地址,再与socket绑定的ip地址对比,就可以确定该数据要发往哪个socket的接收缓冲区。
  • 4、上述准备工作结束后,服务端即可通过recv()系统调用,接收客户端发来的数据。服务端发起recv()调用时,会陷入内核态,内核程序会首先检查socket的接收缓冲区是否有数据,如果有数据,则直接读取,并返回给应用程序。否则,内核会将当前进程移除【就绪队列】,并创建一个等待队列项,将当前进程的描述符添加到等待队列项中,与此同时,还会在等待队列项中注册回调函数autoremove_wake_function()。如此,CPU在收到时钟中断,执行调度程序时就不会调入该进程,从而阻塞该进程。
  • 5、客户端通过send()将数据发送出去,服务端的网卡接收到数据后,发出中断信号。CPU开始执行中断处理,在中断处理的下部分调用网卡驱动注册的poll()函数,将内核ringBuffer中的数据取出来,并组装为sk_buffer结构体(sk_buffer是linux网络代码中重要的数据结构,负责管理和控制接收或发送数据包的信息)。之后交由协议栈做反向解析,将解析出的数据送到sokcet的接收缓冲区。最后执行在上一步注册在socket等待队列项中的回调函数autoremove_wake_function(),唤醒进程A。
  • 6、进程A从中断处继续执行,进行数据处理。至此,一次完整的通信宣告结束。

多路复用——select、poll、epoll

  • 阻塞IO只适用于少量连接的情况,如果服务端需要处理上百万的客户端连接,阻塞IO的性能将变的极差,主要因为以下几点:
  • recv()会导致一次系统调用,在没有数据到来的时候,进程会被阻塞,导致一次进程切换(进程切换是一件相对耗时的操作)。
  • 当有数据到来时,触发一次中断,唤醒进程,又导致一次进程切换。
  • 一个recv()只能处理一个连接,当需要处理多个连接时,需要开启多个线程,但是每个线程都是需要占用一定的系统资源的。大量线程会使系统资源很快被消耗完,系统性能急剧下降。
  • 为高效处理大量客户端连接,IO多路复用技术便登上历史的舞台。IO多路复用就是利用一个用户态进程或线程管理多个连接,以此来减少频繁的进程切换和线程创建。在Linux中,其实现方式有三种,分别是select、poll、epoll。
select
  • Linux中,select是最早实现IO多路复用的。其思想也较为简单,就是通过轮训的方式来管理若干个连接。主要流程如下:
  • 1、将需要管理的socket连接全部放到一个文件描述符号集合fds,然后通过调用select()将fds拷贝到内核中。
  • 2、内核遍历fds,检查socket接收缓冲区是否有数据,有的话就将此socket标记为可读,遍历结束后把整个fds拷贝回用户进程,用户进程再遍历fds,找到被标记的fds,然后处理数据;如果所有的socket接收缓冲区都没有数据,就会将进程描述符加入到每个socket中的等待队列项中,并将当前进程从内核的就绪队列中移除,阻塞该进程。
  • 3、当任何一个socket接收缓冲区接收到数据,都会标记自身为可读状态,并唤醒被阻塞的用户进程,返回fds。之后用户进程遍历fds,找到被标记的fds,进行数据处理。
  • 从上述的流程来看,select实现虽然简单,但是却有以下缺点,导致性能不佳:
  • 1、每次select都需要把fds拷贝到内核态,内核态遍历fds。
  • 2、有数据到来时,需要将fds从内核拷贝回用户空间,用户进程再遍历fds。
  • 3、selec只支持同时管理1024个连接,有内核中的FD_SETSIZE宏定义限制。
poll
  • poll 和 select 本质上没有太大区别,只不过poll用的不是数组来存储文件描述符,而是以链表的形式存储,不会受到1024大小的限制。但还是需要在用户态和内核态之间来回拷贝fds,同时遍历文件描述符集合来找到可读 socket,性能损耗较大。
epoll
  • select/poll效率低主要有两个原因:其一是select/poll将维护文件描述符fds和阻塞进程这两件事情都放在来一个系统调用内完成,导致每次系统调用时,都需要将fds拷贝到内核中。其二是当fds中的某个socket有数据到来时,用户程序并不知道是哪个socket,只能通过遍历的方式获取数据。
  • 针对以上两点,epoll分别从两个方面着手,很好的解决了select/poll的问题。
  • 1、考虑到在实际应用中,fds中的socket都比较稳定,不会频繁发生变化。epoll通过epoll_create()在内核中创建一个eventepoll内核对象,eventepoll中有一个红黑树rbr,用来管理所有socket描述符,每个红黑树节点都是一个epitem对象,epitem对象中包含socket描述符。用户进程可以利用epoll_clt()函数实现对socket描述符的增删操作,时间复杂度为O(log n)。如此就不需要像select/poll样,每次调用都需要往内核中拷贝整个文件描述符集合。
  • 2、eventepoll对象中还有一个就绪链表rdlist,当rbr中的某个socket接收到数据时,就会将其对应的epitem加入rdlist中,并将rdlist返回给用户进程。如此就避免了select/poll所必须的遍历操作。
  • 上面只介绍了epoll的一个整体思想,没有设计实现细节。接下来,我们就通过与前面阻塞IO的对比,探讨下epoll具体是怎么实现的。
  • 1、服务端进程通过epoll_create()在内核中创建了一个eventepoll内核对象,该对象中主要有三个数据结构:
  • 等待队列(wa):存放被阻塞进程的进程描述符,数据就绪的时会通过 wq ,找到被阻塞的进程。
  • 红黑树(rbr):通过红黑树管理服务端进程下添加进来的所有 socket 连接。
  • 就绪链表(rdlist):当有socke中接收到数据时,就会把其放到rdlist中,并拷贝给服务端进程。
  • 2、服务端进程收到sokcet连接后,通过epoll_ctl()将该socket转换为红黑树的epitem节点,插入到rbr中。同时会设置socket等待队列中等待项的回调函数为ep_poll_callback(),并使其持有epitem节点的引用。
  • 3、服务端调用epol_wait(),进入内核态,系统调用程序检查rdlist是否为空,不为空则直接拷贝rdlist回服务端进程空间,否则阻塞服务端进程,等待数据到来。
  • 4、客户端发送数据,服务端收到后,开启中断处理,最终会调用第二步注册的ep_poll_callback()函数,将该socket转换为红黑树的epitem节点,添加到rdlist中,然后拷贝rdlist到服务端进程空间。
  • 5、最后并唤醒服务端进程,进行数据处理。
  • 最后,我们以一张图来描述整个epoll的过程,其中灰色部分的处理与阻塞IO相同,彩色部分为epoll特有,通过与阻塞IO的对比,我们能够更好的理解epoll的底层原理。