深入了解IO模型

Tomcat的IO

在我们的开发中最常用的就是Tomcat ,Tomcat 中经常被提到的一个调优就是修改线程的 I/O 模型。Tomcat 8.5 版本之前,默认情况下使用的是 BIO 线程模型,如果在高负载、高并发的场景下,可以通过设置 NIO 线程模型,来提高系统的网络通信性能。

我们可以通过一个性能对比测试来看看在高负载或高并发的情况下,BIO 和 NIO 通信性能(这里用页面请求模拟多 I/O 读写操作的请求):

在这里插入图片描述

在这里插入图片描述
测试结果:Tomcat 在 I/O 读写操作比较多的情况下,使用 NIO 线程模型有明显的优势。Tomcat 中看似一个简单的配置,其中却包含了大量的优化升级知识点。

网络 I/O 模型

网络通信中,最底层的就是内核中的网络 I/O 模型了。随着技术的发展,操作系统内核的网络模型衍生出了五种 I/O 模型,《UNIX 网络编程》一书将这五种 I/O 模型分为阻塞式 I/O、非阻塞式 I/O、I/O 复用、信号驱动式 I/O 和异步 I/O。

每一种 I/O 模型的出现,都是基于前一种 I/O 模型的优化升级。最开始的阻塞式 I/O,它在每一个连接创建时,都需要一个用户线程来处理,并且在 I/O 操作没有就绪或结束时,线程会被挂起,进入阻塞等待状态,阻塞式 I/O 就成为了导致性能瓶颈的根本原因。

在《Unix 网络编程》中,套接字通信可以分为流式套接字(TCP)和数据报套接字(UDP)。其中 TCP 连接是我们最常用的,一起来了解下 TCP 服务端的工作流程(由于 TCP 的数据传输比较复杂,存在拆包和装包的可能,这里我只假设一次最简单的 TCP 数据传输

在这里插入图片描述

  1. 应用程序创建一个线程,之后通过系统调用socket来创建一个套接字,它是分配给应用程序的一个文件描述符
  2. 其次应用程序调用bind,来绑定地址和端口号,给套接字一个名称
  3. 然后系统会调用listen创建一个队列用于存放客户端放进来的连接
  4. 最后应用服务通过系统调用accept来监听客户端的连接请求

当有一个客户端连接到服务端之后,服务端就会调用fork创建一个子进程,通过系统调用read监听客户端发过来的消息,在通过write想客户端返回信息

1. 阻塞式IO

在整个socket通信工程中,socket的通信默认状态是阻塞的;就是说当一个不能立即完成的套接字调用时,其进程将被阻塞,被系统挂起,进入睡眠状态,一直等待相应的操作响应。

这种阻塞 也是跟TCP的三次握手协议 有关,在在TCP/IP协议中,TCP协议通过三次握手建立一个可靠的连接

第一次握手:客户端尝试连接服务器,向服务器发送syn包(同步序列编号Synchronize Sequence Numbers),syn=j,客户端进入SYN_SEND状态等待服务器确认

第二次握手:服务器接收客户端syn包并确认(ack=j+1),同时向客户端发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态

第三次握手:第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手

socket中也是这么样
在这里插入图片描述
所以通过以上我们可以分析得出 一般阻塞会有这几种情况

  • connect 阻塞

当客户端发起 TCP 连接请求,通过系统调用 connect 函数,TCP 连接的建立需要完成三次握手过程,客户端需要等待服务端发送回来的 ACK 以及 SYN 信号,同样服务端也需要阻塞等待客户端确认连接的 ACK 信号,这就意味着 TCP 的每个 connect 都会阻塞等待,直到确认连接。
在这里插入图片描述

  • accept 阻塞

一个阻塞的 socket 通信的服务端接收外来连接,会调用 accept 函数,如果没有新的连接到达,调用进程将被挂起,进入阻塞状态。
在这里插入图片描述

  • read、write 阻塞

当一个 socket 连接创建成功之后,服务端用 fork 函数创建一个子进程, 调用 read 函数等待客户端的数据写入,如果没有数据写入,调用子进程将被挂起,进入阻塞状态。
在这里插入图片描述

2. 非阻塞式IO

使用 fcntl 可以把以上三种操作都设置为非阻塞操作。如果没有数据返回,就会直接返回一个 EWOULDBLOCK 或 EAGAIN 错误,此时进程就不会一直被阻塞。

当我们把以上操作设置为了非阻塞状态,我们需要设置一个线程对该操作进行轮询检查,这也是最传统的非阻塞 I/O 模型
在这里插入图片描述

3. I/O 复用

多路复用IO模型是目前使用得比较多的模型。Java NIO实际上就是多路复用IO。
在多路复用IO模型中,会有一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。(而一个线程在连接后,关注点在与操作IO的读写 ,而不再是连接情况)

在Java NIO中,是通过selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况

select()函数

在linux中,提供了IO复用函数,来实现上述的操作,不关注连接的情况,而关注读写情况,在超时时间内,监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。Linux 操作系统的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd)。

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

查看以上代码,select() 函数监视的文件描述符分 3 类,分别是 writefds(写文件描述符)、readfds(读文件描述符)以及 exceptfds(异常事件文件描述符)。调用后 select() 函数会阻塞,直到有描述符就绪或者超时,函数返回。当 select 函数返回后,可以通过函数 FD_ISSET 遍历 fdset,来找到就绪的描述符。fd_set 可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:

void FD_ZERO(fd_set *fdset);           //清空集合
void FD_SET(int fd, fd_set *fdset);   //将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset);   //将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset);   // 检查集合中指定的文件描述符是否可以读写 

poll()函数

在每次调用 select() 函数之前,系统需要把一个 fd 从用户态拷贝到内核态,这样就给系统带来了一定的性能开销。再有单个进程监视的 fd 数量默认是 1024,我们可以通过修改宏定义甚至重新编译内核的方式打破这一限制。但由于 fd_set 是基于数组实现的,在新增和删除 fd 时,数量过大会导致效率降低。

poll() 的机制与 select() 类似,二者在本质上差别不大。poll() 管理多个描述符也是通过轮询,根据描述符的状态进行处理,但 poll() 没有最大文件描述符数量的限制。

poll() 和 select() 存在一个相同的缺点,那就是包含大量文件描述符的数组被整体复制到用户态和内核的地址空间之间,而无论这些文件描述符是否就绪,他们的开销都会随着文件描述符数量的增加而线性增大。
在这里插入图片描述

epoll()函数

select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量不宜过大,因此它的使用受到了一些制约。Linux 在 2.6 内核版本中提供了一个 epoll 调用,epoll 使用事件驱动的方式代替轮询扫描 fd。

epoll 事先通过 epoll_ctl() 来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,这个事件表是基于红黑树实现的,所以在大量 I/O 请求的场景下,插入和删除的性能比 select/poll 的数组 fd_set 要好,因此 epoll 的性能更胜一筹,而且不会受到 fd 数量的限制。

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

通过以上代码,我们可以看到:epoll_ctl() 函数中的 epfd 是由 epoll_create() 函数生成的一个 epoll 专用文件描述符。op 代表操作事件类型,fd 表示关联文件描述符,event 表示指定监听的事件类型。一旦某个文件描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知,之后进程将完成相关 I/O 操作。

int epoll_wait(int epfd, struct epoll_event events,int maxevents,int timeout)

在这里插入图片描述

4. 信号驱动式 I/O

信号驱动式 I/O 类似观察者模式,内核就是一个观察者,信号回调则是通知。用户进程发起一个 I/O 请求操作,会通过系统调用 sigaction 函数,给对应的套接字注册一个信号回调,此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个 SIGIO 信号,通过信号回调通知进程进行相关 I/O 操作。这种IO的操作,相当于在钓鱼的时候,在鱼竿上挂个铃铛,之后当有鱼咬钩的时候,铃铛会响,这时候来将将鱼拉上来
在这里插入图片描述
信号驱动式 I/O 相比于前三种 I/O 模式,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以性能更佳。

而由于 TCP 来说,信号驱动式 I/O 几乎没有被使用,这是因为 SIGIO 信号是一种 Unix 信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么。而 TCP socket 生产的信号事件有七种之多,这样应用程序收到 SIGIO,根本无从区分处理。

但信号驱动式 I/O 现在被用在了 UDP 通信上,我们从 10 讲中的 UDP 通信流程图中可以发现,UDP 只有一个数据请求事件,这也就意味着在正常情况下 UDP 进程只要捕获 SIGIO 信号,就调用 recvfrom 读取到达的数据报。如果出现异常,就返回一个异常错误。比如,NTP 服务器就应用了这种模型。

5. 异步 I/O

异步IO模型才是最理想的IO模型,在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何block。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。也就说用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据了。

也就说在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用IO函数进行具体的读写。这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用iO函数进行实际的读写操作。

注意,异步IO是需要操作系统的底层支持,在Java 7中,提供了Asynchronous IO。简称AIO

前面四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值