网络IO概述

现在计算机的发展离不开网络,特别是分布式应用(本质上就是计算机+网络),本文重点关注常见的网络IO模型以及经典的IO多路复用几种实现方式(select、poll、epoll)的比较,方便后续网络 IO 编程。
需要说明的是,本文讨论的背景是类Unix环境(以Linux为例)下的网络 IO。本文提到的网络 IO 模型是指 Richard Stevens 等人编著的《UNIX 网络编程 卷1: 套接字联网API》一书中的网络 IO 模型。

背景

要编写通过计算机网络通信的程序,首先要确定这些程序相互通信使用的网络协议。根据场景的不同,涉及的网络协议很多,本文重点关注TCP/IP协议。举例来说,Web客户与服务器之间使用TCP(Transmission Control Protocol, 传输控制协议)进行通信,TCP又使用IP(Internet Protocol, 网际协议)通信,IP再通过某种形式的数据链路层通信。如果Web客户与服务器处于同一个以太网,则其数据交互如下图所示:
网络IO概述_第1张图片
注意,客户与服务器之间使用某种应用协议通信(如HTTP协议),传输层则使用TCP协议通信。另外,客户和服务器通常是用户进程,而TCP协议和IP协议通常是内核中协议栈的一部分。需要说明的是,传输层协议不仅仅限制于TCP,其他的还有UDP(User Datagram Protocol, 用户数据报协议)、SCTP(Stream Control Transmission Protocol, 流控制传输协议)等。而IP协议则是一个统称。正式名称有IPv4和IPv6协议等。
编写网络通信程序,就是实现应用层和传输层的交互编码。为了简化和标准化应用层和传输层的交互编码,Socket API应运而生。Socket API 是应用层和传输层之间的抽象层,它把传输层的复杂操作抽象成一些简单的接口,供应用层调用。Socket API层次结构如下:
网络IO概述_第2张图片
为什么在应用层和传输层之间构建 Socket API,主要有两方面的考虑:(1) Unix等现在操作系统提供分隔用户进程和内核的机制。对TCP/IP协议来说,应用层是用户进程,传输层及以下层级是作为内核的一部分提供。在用户进程和内核之间是构建API的自然位置;(2) 应用层处理具体网络应用(HTTP、FTP、SSH等)的细节,而传输层及以下层级则处理通信细节:发送数据,等待确认,对数据排序,计算并验证校验和,等等。
网络IO概述_第3张图片
这里以TCP为例,介绍下如何基于TCP实现一个简单的Socket编程。服务器首先启动,稍后某个客户启动,它试图连接到服务器。客户给服务器发送一个请求,服务器处理该请求,并给客户发送一个响应。这个过程一直持续,直到客户关闭连接的客户端,从而给服务器发送一个EOF(End Of File,文件结束)的通知为止。服务器接着也关闭连接的服务器端,然后结束运行或等待新的客户连接。执行流程如下图所示:
网络IO概述_第4张图片
在上图的Socket编程实例中,客户和服务器的读写操作均涉及IO操作,接下来将具体介绍使用到的网络IO类型。

五种网络IO模型

基于网络IO进行数据读写,根据同步调用或异步调用、是否阻塞调用等维度,可将网络IO模型细分为5种:(1) 阻塞IO(Blocking IO)模型、非阻塞IO(Nonblocking IO)模型、I/O多路复用(IO Multiplexing)模型、信号驱动IO(Signal Driven IO)模型、异步IO(Asynchronous IO)模型。接下来将以UDP的数据报接收为例,介绍下五种网络IO模型。之所以选择UDP而不是TCP,是因为TCP会复杂化网络IO模型的介绍。此外,将recvfrom函数看成系统调用。这里屏蔽掉不同操作系统的实现细节。

阻塞IO(Blocking IO)模型

对于阻塞IO模型来说,应用进程会阻塞在recvfrom的调用。具体执行流程如下图所示:
网络IO概述_第5张图片
应用进程在从调用recvfrom开始到该函数返回的这段时间内是被阻塞的。recvfrom成功返回后,应用进程才开始处理数据报。

非阻塞IO(Nonblocking IO)模型

对于非阻塞IO模型来说,应用进程会循环调用recvfrom并马上返回(轮询)。应用进程持续轮询内核,以查看某个操作是否就绪。具体执行流程如下图所示:
网络IO概述_第6张图片

对于非阻塞IO模型来说,轮询操作会耗费大量CPU时间,使用场景较少。

I/O多路复用(IO Multiplexing)模型

对于I/O多路复用模型来说,应用进程阻塞在select/poll调用,等待数据报变为可读。当select/poll返回套接字可读这一条件时,应用进程调用recvfrom把所读数据报复制到应用进程缓冲区。具体执行流程如下图所示:
网络IO概述_第7张图片
相比阻塞IO模型,I/O多路复用模型并没有明显优势,事实上由于使用增加了select系统调用,I/O多路复用模型要稍显劣势(多了一次系统调用)。I/O多路复用模型的优势主要体现在可以等待多个数据报就绪。
与I/O多路复用模型密切相关的另一种模型是在多线程中使用阻塞IO模型。这种模型与I/O多路复用模型的区别是,相对于I/O多路复用模型使用select/poll等系统函数阻塞在多个数据上,而是使用多线程,这样每个线程都可自由调用recvfrom。

信号驱动IO(Signal Driven IO)模型

对于信号驱动IO模型来说,应用进程首先通过sigaction系统调用并注册一个信号处理函数,然后立即返回。当数据报准备好后,内核就为该进程产生一个SIGIO信号。然后信号处理函数就被执行。而信号处理函数中会调用recvfrom读取数据报。具体执行流程如下图所示:
网络IO概述_第8张图片

异步IO(Asynchronous IO)模型

对于异步IO模型来说,应用进程首先调用aio_read系统调用,告诉内核当整个操作完成时如何通知应用进程,然后立即返回。内核自行将数据从内核复制到应用进程的缓存区,并在完成后通知应用进程。具体执行流程如下图所示:
网络IO概述_第9张图片
异步IO模型和信号驱动IO模型类似,区别在于信号驱动IO模型是由内核通知应用进程何时可以启动一个IO操作,而异步IO模型是由内核通知IO操作何时完成。异步IO模型的信号直到数据已复制到应用进程缓冲区才产生,而信号驱动IO模型的信号是在数据报准备好就产生。

IO模型比较

对于以上五种IO模型,阻塞 IO 模型、非阻塞 IO 模型、IO 多路复用模型 和 信号驱动 IO 模型都属于同步IO模型,因为应用进程都会阻塞在数据从内核复制到应用进程的缓冲区。相反,异步IO模型委托内核将数据复制到缓冲区。五种IO模型的详细比较,如下图所示:
网络IO概述_第10张图片
这里补充下POSIX对同步IO(Synchronous IO)和异步IO(Asynchronous IO)的定义:
(1) 同步 IO 会导致请求进程阻塞,直到IO操作完成。
(2) 异步 IO 不导致请求进程阻塞。
同步 IO 和 异步IO 的区别就是:同步 IO 将数据从内核复制到用户进程的操作是由应用进程完成,而异步 IO 则是注册回调函数并告知内核用户进程缓冲区存放地址,数据复制操作由内核完成。

select、poll、epoll比较

在众多的网络IO模型中,异步IO的表现要优于同步IO(应用进程无需阻塞等待)。但是,因为不同操作系统对异步IO的支持程度不同,所以异步IO的应用范围并不是很广泛。在同步IO模型中,IO多路复用模型因其高性能,已经成为业内的主流实现。多路复用主要有三种技术:select,poll,epoll。接下来将详细介绍。

select

select系统调用的功能是对多个文件描述符进行监视,当有文件描述符的文件读写操作完成,发生异常或者超时,该调用会返回这些文件描述符。

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds,struct timeval *timeout);

用户进程调用select系统调用时,select会将需要监控的readfds集合拷贝到内核空间(以可读socket为例),然后遍历自己监控的socket,以检查该socket是否可读。如果没有一个socket可读,那么select会调用schedule_timeout进入schedule循环,使用户进程进入睡眠。如果在timeout时间内存在Socket可读,或者timeout时间达到,则用户进程会被唤醒,然后select将可读的socket返回给用户进程。
分析select系统调用实现,存在三个问题:
(1) 一个进程所能打开的最大连接数有限。单个进程所能打开的最大连接数由FD_SETSIZE宏定义。当然可以对宏FD_SETSIZE进行修改,然后重新编译内核,但是性能可能会受到影响。一般情况下,32位机默认1024个,64位默认2048。
(2) 需要额外的用户进程和内核的数据拷贝操作。select调用需要将监控的socket集合从用户进程拷贝到内核,然后在执行完毕时,将可读/可写的socket集合从内核拷贝到用户进程。
(3) 随着连接数增加,带来的性能问题。因为每次调用时都会对连接进行线性遍历,所以随着连接数的增加,会造成遍历速度的"线性下降"问题。

poll

poll的实现和select相似,只是描述文件描述符的方式不同。poll使用pollfd结构,而不是select的fd_set结构。poll使用链表存储文件描述符,解决了select中固定大小的问题。但poll和select一样,需要将文件描述符的数组整体复制于用户进程和内核之间,且其性能开销随着文件描述符的增加而线性增大。

int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
struct pollfd {
  int fd;           /*文件描述符*/
  short events;     /*监控的事件*/
  short revents;    /*监控事件中满足条件返回的事件*/
};
int poll(struct pollfd *fds, nfds_tnfds, int timeout);

epoll

epoll与select/poll不同的是,它是由一组系统调用组成:

// 创建一个epoll句柄
int epoll_create(int size);
// 向 epoll 对象中添加/修改/删除要管理的连接
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 等待epoll管理的连接上的 IO 事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

调用 epoll_create 系统调用,可以创建一个epoll的句柄,其中size用来告诉内核这个监听的数目一共有多大。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
调用 epoll_ctl 系统调用,注册要监听的事件类型。
调用 epoll_wait 系统调用,等待事件的产生,收集在 epoll 监控的事件中已经发送的事件,作用类似于 select() 调用。

select、poll、epoll选型

select,poll,epoll都是IO多路复用的实现,所以select,poll,epoll等本质上都是同步I/O,因为其读写内核中数据都是阻塞的。
select 单个进程所能打开的最大连接数由FD_SETSIZE宏定义。而 poll 则没有最大连接数的限制,因为它是基于链表来存储文件描述符。对epoll而言,虽然连接数有上限,但1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。
select 和 poll 需要额外的用户进程和内核的数据拷贝操作。即将监控的socket集合从用户进程拷贝到内核,然后在执行完毕时,将可读/可写的socket集合从内核拷贝到用户进程。且随着连接数增加,会造成遍历速度的"线性下降"问题。对epoll而言,监控的socket集合首次调用epoll_ctl拷贝,后续调用epoll_wait则不需要拷贝。
在选择select,poll,epoll时,要根据具体的使用场合以及这三种方式的自身特点。表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好。而且,基于epoll实现网络编程,需要使用到三个系统调用,其实现复杂度要高于select和poll。

参考

UNIX 网络编程 卷1: 套接字联网API(第 3 版), W. Richard Stevens, Bill Fenner, Andrew M. Rudoff 著
https://zhuanlan.zhihu.com/p/54580385 5种网络IO模型
https://zhuanlan.zhihu.com/p/260450151 一文搞懂,网络IO模型
https://zhuanlan.zhihu.com/p/367591714 深入浅出理解select、poll、epoll的实现
http://arganzheng.life/select-poll-and-epoll.html select、poll和epoll简介
https://haicoder.net/note/linux-interview/linux-interview-linux-io-mode.html Linux网络IO模型

你可能感兴趣的:(分布式应用,软件架构,计算机网络,分布式,软件架构,计算机网络)