Linux网络IO模型分为同步和异步两种,其中同步IO又包括阻塞IO、非阻塞IO、IO多路复用、信号驱动式IO四种。
预备知识
文件
在Unix中,所有东西都是文件,文件可以看作是字节序列。所有的IO设备都可以用文件来描述(文件是对IO设备的抽象):
./dev/sda2
(/usr 磁盘分区)/dev/tty2
(终端)
主要有三种文件类型
- 普通文件:包含任意数据
- 文本文件:由ASCII或者Unicode组成
- 二进制文件:除文本文件外,都是二进制文件(视频,图片,声音)
- 目录文件:一组文件的索引
- Socket文件:用来和其它进程跨网络通信的文件
- 普通文件:包含任意数据
对文件的操作主要有打开文件、读取文件、关闭文件
打开文件
进程通过系统调用(
open()
),内核会将文件名转换为一个文件描述符返回给进程(文件描述符是一个整数,进程可以用文件描述符作为索引对应打开的文件).内核用三个数据结构表示打开的文件,分别是描述符表(descriptor table)、文件列表(file table)、文件列表的描述表(v-node table)。
描述符表
- 每个进程都有独立的描述符表,它的表项是由文件描述符索引的,每个打开的文件描述符表项指向文件列表中的一个表项。(可以把描述符表当成是一个数组,数组元素类型是文件列表表项,数组下标是文件描述符)
文件列表
- 文件列表是所有进程已经打开的文件的集合,由内核维护,所有进程共享。每个表项由当前文件的位置、引用计数(即当前志向该表项的描述符表项数),以及一个指向v-node表的指针。冠以一个文件描述符会减少相应的引用计数。当引用计数为0时内核会删除这个文件列表表项
文件列表描述表
- 文件列表的元数据表,由内核维护,所有进程共享。
Socket
Socket是用来与另一个进程进程进行网络通信的文件类型。进行通信的进程通过系统调用(
socket()
)创建Socket类型文件,通过读写该文件进行通信。通信过程:客户端进程
socket()
创建主动型socket文件(此时的socket还不能使用)connect()
发起请求来和服务器建立连接,此时会阻塞线程,直到连接建立(此时socket才可以读写)或者连接失败返回。
服务器进程
socket()
创建主动型socket文件(此时的socket还不能使用)bind()
绑定到指定的端口listen()
将主动型socket改为监听型socketaccept()
等待客户端发起请求,进而针对该socket请求创建对应的主动型socket(不同于服务器进程创建的socket文件),服务器进程通过对主动型的socket进行读写来和客户端进行通信。
IO操作(IO operation)
- 特指通过系统调用(
recvfrom()
)请求从内核缓冲区拷贝数据到进程内存(copy data from kernel to user), (不包括从设备缓冲区拷贝数据到内核缓冲区) - 整个IO请求过程
- 每个设备都有一个设备控制器(可以类比:每个计算机都有一个CPU,设备控制器是一个微型CPU),设备控制器也有缓存,操作系统会通过每个设备控制器对应的驱动程序管理设备控制器进行硬件操作。
- 当线程发起关于IO操作的系统调用后,线程会阻塞,操作系统通过驱动程序控制设备控制器读取数据到设备控制器的缓存中,然后在将设备控制器缓存中的数据读取到操作系统内核缓冲区中,然后通过硬件中断唤醒被阻塞的线程进行后续操作。
Linux IO模型
阻塞IO(blocking IO)
执行IO操作时,当内核缓冲区还没有准备好数据时,会阻塞当前线程,直到内核缓冲区中的数据拷贝到进程内存中才会使得线程从阻塞变为就绪状态。
非阻塞IO(nonblocking IO)
在进行IO操作期间,当内核缓冲区中还没有准备好数据,不会阻塞当前线程,会直接返回。线程会一直进行系统调用检测内核中数据是否准备好,如果内核准备好数据以后,并且又收到了线程的IO请求,则会进行数据拷贝,拷贝期间会阻塞当前线程(这不是区分阻塞和非阻塞的因素,因为此时内核缓冲区中已经存在数据)。
在Linux下,通过设置socket为non-blocking,从而实现该模型。
非阻塞IO的特点就是不断地询问内核是否已经准备好数据。
IO多路复用(事件驱动IO、IO multiplexing)
通过单个线程管理多个Socket文件描述符,当至少一个Socket文件描述符可用时就会返回可用文件描述符的数量(
select()
、poll()
)或者具体可用的Socket(epoll()
)。然后线程轮询找到可用的Socket文件描述符,通过IO操作读取数据到用户地址空间(这一步常常配合非阻塞IO,也就是将对Socket的操作改为非阻塞)Linux下,IO多路复用的实现通过
select()
、poll()
、epoll()
实现。当调用
select()
、poll()
、epoll()
后线程会阻塞,直到有可用的socket后内核会唤醒线程。然后线程通过轮询(epoll()
除外)找到具体可用的socket,进行IO操作。
信号驱动IO(signal driven IO)
应用进程使用
sigaction
系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达后向应用进程发送SIGIO信号,应用进程收到之后在信号处理程序中执行IO操作(调用recvfrom()
将数据从内核复制到应用进程中)。
异步IO(asynchronous IO)
线程发起
aio_read()
系统调用会立即返回,不会阻塞,而且可以去干其它的事情,内核会等待数据准备好,然后拷贝到进程地址空间,然后以回调的形式通知线程。
几种模型汇总比较
阻塞IO、非阻塞IO、IO多路复用和信号驱动式IO都是同步IO,在IO操作期间都会发生阻塞。主要区别在第一阶段(wait for data),非阻塞IO、IO多路复用、信号驱动式IO在第一阶段不会阻塞。
Q&A
阻塞IO、非阻塞IO区分条件?
- 内核缓冲区中没有数据时,判断此时进行IO操作是否被阻塞
同步IO与异步IO区别?
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;
为什么IO多路复用需要搭配非阻塞IO?
- 比如当内核缓冲区准备好数据时,此时select()会返回,但是后续read()之前:内核检查该数据时,如果出现错误的校验和,就会丢弃该数据,当线程进行IO操作时就会阻塞,从而可能出现安全问题。man select里的一个BUG描述:
- Under Linux, select() may report a socket file descriptor as “ready for reading”, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.
select/poll/epoll区别?
- select监听的文件描述符数量有限制(1024个),并且返回时只是返回的可用的Socket的数量,并不是具体哪个Socket可用,客户端还得需要轮询找出可用的Socket。
- poll解除了文件描述符的数量,但也得轮询找出可用socket。
- 内核会修改select/poll传入的文件描述符集合,所以每次都需要拷贝文件描述符到内核,所以当socket数量比较多时,会影响效率。
- epoll返回时会得到具体可用的Socket描述符,不用去轮询。
- select相对于epoll来说是跨平台的,而epoll是针对于Linux的,Windows是使用IOCP实现,OS X使用kqueue实现的。
- 如果系统存在大量的空闲连接,那每次遍历文件描述符开销会很大,此时可以选用epoll,如果只有很少的连接,可以选用select。
select和epoll工作流程?
select
当客户端调用select以后,会挂起当前线程,此时内核会去检测select所监控的socket,当socket可读或者可写了,就会以中断的形式通知select,select就会返回可用的socket的数量,客户端遍历找到可用的socket。
epoll
epoll维护了一个红黑树用来存放监听的文件描述符,并且注册一个回调函数给内核,当内核发现红黑树上的Socket文件描述符对应的Socket可用时,会将该描述符放到就绪链表中。当内核检测到socket可用时,就会通过注册的回调函数将socket对应的文件描述符放到就绪链表中。epoll就会返回就绪链表。epoll处理文件描述符有两种工作模式,水平触发(level trigger)和边缘触发(edge trigger)
- 水平触发是指当客户端不处理返回的socket时,就绪链表中不会删除这个socket对应的文件描述符,下次调用epoll_wait()时还会返回这个socket对应的文件描述符
- 边缘触发是指如果客户端不处理返回的socket时,就绪链表中不会保存这个socket对应的文件描述符,下次调用epoll_wait()就不会返回这个socket对应的文件描述符,直到有新的事件过来。
总结就是,LT模式下,只要某个文件描述上的事件一次没有处理完,会在以后调用epoll_wait()时次次返回这个文件描述符,而ET模式仅在第一次返回,返回以后,会清除就绪链表
Java NIO?
- Java NIO(New IO)实现了非阻塞IO模型,并且通过Selector选择器实现了IO多路复用模型(Linux2.6之前使用的是select(),2.6之后是epoll(),默认边缘触发)
- NIO可以实现零拷贝,使得数据直接在内核态进行拷贝,不用再进入用户态。
- NIO实现了非阻塞IO模型,并且搭配Selector来实现IO多路复用。
BIO/NIO/AIO区别?
- BIO是面向流的,而NIO是面向缓冲Buffer的。并且Buffer内部维护了三个指针(position、limit、capacity)可以操作,相对于BIO来说比较灵活。BIO中的流是分方向的(输入流、输出流)。而NIO中的Channel类似于BIO中的流,但是Channel没有方向,可以向Channel中读取数据和写入数据。
- AIO是异步IO