IO多路复用
前言
随着互联网的繁荣,使用网络的人越来越多,对一个服务而言就是调用他的人越来越多,让一个服务器能够接受越来越多的客户端,尽量压榨服务器的资源成了后续程序员的研究方向。也就是有了著名的C10K 问题,C10M问题。本文主要探讨下IO在网络请求的演进。
BIO
上图是经典的服务处理网络模型。客户端请求,然后服务端每个请求做处理。这里面涉及到的问题就是如何处理。
第一个模型就是最简单的模型,使用队列的方式,先进先出,依次处理每个请求。这种情况就好比开个饭馆,但是呢,你每次只招待一个客户,招待员将客户招待进门,厨师为他做菜,然后等他吃完,招待员将他送走。无论是开哪一种餐厅,这种方式都无法接受。因为招待员在工作的时候,厨师没事情做,厨师做完菜也需要等着下次订单才开始继续做菜。我们的客人统统在外面等着。这种方式在计算机中就是进程级别的等待IO
第二种就是,每次进来都有一个服务员为他做事情,厨师会稍微忙点,因为这个时候点菜的人多起来了。当然为了成本,不能来多少客人就招多少服务员,突然哪天生意太好饭店的服务员可能比客人都多。所以可以只招特定的服务员。
第三种方式就是将服务员进行不同职能的区分,比如门口专职一位,控制当前饭店的客人进出,内部的服务员负责代理客户到桌子,还有人端盘子,洗菜等等。他们之间通过对讲机进行沟通。
上面的例子个人觉得和现在的应用服务端很类似,首先连接创建就是客人到了店门口,客人进入就餐,点菜过程就是传送需要的参数,此时服务器是处于读客户端请求的节点,菜单点完,厨师做菜,端盘子上桌客人开始吃饭,此时服务端进入写的状态。等到客人吃完饭,客户端拿到了所有的数据就可以返回了。
也就是服务端就是accept–>read –>write。客户端就是connect–>write–> read 。上一个事件的结束也就意味着下一次事件的开始,也就是事件驱动模式的运行。后面演化出来了reactor涉及模式,该模式主要是操作系统对外提供了事件通知的select poll 和epoll
其中select是一个数组默认大小是1024,服务端可以接受到accept时间后,将该连接的channel绑定到select中,并且标识出下次唤醒是因为该channel上发送了read事件。
关于文件描述符和channel
这里需要介绍下这个channel,在**UNIX System Calls**中介绍到:
A channel is a connection between a process and a file that appears to the process as an unformatted stream of bytes.
因为在linux中everything is file ,所以可以理解成当前运行进程和其他进程、IO等交换数据的时候就是打开了一个channel,比如socket,打开了文件,pipe等。
在linux中,服务端一般绑定在一个端口上,客户端连接该服务端的时候会通过IP:port的方式进行connect,此时客户端也会使用一个端口创建,该端口会在/proc/{pid}/fd下新建文件描述符,然后当有链接创建的时候会在/proc/net/tcp6 下创建对应的文件描述符。也就是说,如果创建了某个链接,在tcp目录下会创建一个文件,该文件标识出具体的某次请求,上面会有端口号等信息
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 0100007F:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 500 0 14759 1 ffff8801396d5b00 3000 0 0 2 -1
相应的,在fd目录下也会有个对应的文件描述符,标识出当前打开了某个socket。当网络数据达到的时候,会放在在套接字接受缓冲区,发送会放在发送缓冲区,也就是fd目录下的socket描述符对应的文件缓冲区中。
NIO
not-blocking IO。上文提到了阻塞IO和使用线程的方式,但是都发现了部分缺点。而且也提到数据的发送和接受都是会放在socket的缓冲区的,那么是否可以等他在缓冲区数据放好以后,通知下系统,然后系统在决定是否拉数据或者发送呢?
当我们收到accept事件后,将当前的文件描述符(fd文件)放入到epoll中,然后将他的唤醒事件变成读,因为此时数据是由操作系统将网卡上的数据读入到数据缓冲区的,而且tcp是流式的数据。等到有数据到达读缓冲区的时候会产生数据回调,触发读事件,然后下次epoll 处理中就可以收到读就绪的文件描述符,此时有两种方式,即来一个数据触发一次或者有数据的时候就触发一次。即:
epoll
可以使用两种触发模式:边缘触发(Edge-Triggered)和水平触发(Level-Triggered)。它们是 epoll
提供的不同 I/O 事件触发方式。
- 边缘触发(Edge-Triggered):
- 在边缘触发模式下,
epoll
仅在状态改变时通知应用程序。也就是说,只有当 I/O 事件从未就绪状态变为就绪状态时,epoll
才会通知应用程序。 - 一旦应用程序收到就绪通知,它需要尽可能地处理所有可用的数据,直到再次调用
epoll_wait
获取下一次就绪通知。 - 边缘触发可以确保应用程序不会错过状态改变的事件,但也要求应用程序在接收到通知后立即处理数据,否则会出现数据丢失的情况。
- 在边缘触发模式下,
- 水平触发(Level-Triggered):
- 在水平触发模式下,
epoll
只要 I/O 事件处于就绪状态,就会一直通知应用程序。也就是说,只要有数据可读或可写,epoll
就会通知应用程序。 - 如果应用程序没有处理完所有的可用数据,
epoll
将持续不断地通知应用程序,直到数据被处理完为止。 - 水平触发不要求应用程序立即处理所有数据,应用程序可以在任何时候处理数据,但需要确保在数据可读或可写时持续处理,否则可能会出现数据丢失。
- 在水平触发模式下,
好坏很显然就是,如果边缘触发没有处理完数据,可能会造成数据丢失。
在Netty中,NioChannel体系是水平触发,EpollChannel体系是边缘触发。
回到以前提到过的零拷贝来,现在知晓的情况就是,数据被写入到socket读写缓冲区,读取这部分的数据是需要系统调用的。如果传入的buffer 就是最终我们使用的buffer,则不需要将数据从缓冲区复制到内核态后复制给用户态,减少了一次数据拷贝。
有了epoll 后,可以进一步将操作分离,比如使用一个epoll 专门处理accept,然后将读写事件放入到其他的epoll中。也就是reactor设计模式:
Reactor 设计模式
首先是使用一个epoll:
在主线程中,负责接受,处理和发送。
然后就是使用线程池,将业务处理逻辑和接受分开:
更进一步,将reactor分为两个,一个专职接收,一个专职处理
Select poll 和Epoll
select
select的设计比较暴力,可以浅显的认为创建了一个定长(1024)的数组,应用通过传入文件描述符,并且设置文件描述符的触发的事件,然后会发送到内核态。内核态会轮询这个数组中的事件,如果事件被触发,则会select,并且将对传入的的描述符修改,用于指示描述符是准备好了的,所以如果实在循环中使用的话,文件描述符需要重新生成。最后会发回到应用层。
poll
poll 相较于select的性能没有提升,但是去掉了1024的限制,不在将文件描述符放在数组中,而是放在内核的链表中,在链表中轮询数据。这样减少了大小的限制,但是仍然需要内核上下文切换和数据的拷贝(每次都需要传送一个polldfs的对象),而且都是轮询的方式,性能和开销相对较大。
Epoll
epoll在上面的两个基础上做了优化,首先就是不在一次性遍历所有的数据,而是将准备好的事件的node 信息先存放在链表中,每次只需要遍历和返回准备好的数据就行了。然后就是上文是根据文件描述符进行判断是否有事件发生并且修改文件描述符里面的信息,epoll 减少了这一步的操作,因为他记录的是inode table中的数据,这样无论上层如何变化,只要该inode发生变化,就直接触发事件。而且用户每次只需要传入对应的事件到内核态,不在需要将整个数据传入。
后记
IO 多路复用里面的复用是指的复用了一个线程或者进程去监听多个IO事件。实现就是通过事件通知,减少不必要的等待。未来如果某天可以直接去读取IO的写缓冲数据或者读缓冲数据不知道回事什么样子的。也就是直接绑定到了网卡上,然后指定各种硬件,算不算是回到了曾经。