I/O 模型大致分为 5 类:同步阻塞 I/O,同步非阻塞 I/O,异步 I/O,I/O 复用,信号驱动。
- 阻塞 I/O:如 recv、read,一直等,直到接收缓冲区有数据。
- 非阻塞 I/O:设置描述符为非阻塞后,若接收缓冲区有数据则读,无数据立即返回,但进程需要一直检查是否可读。
- I/O 复用:它可以同时处理多个连接,原来单路的是一个处理完才能接入新的连接。人们常说的 select/poll/epoll 仅仅是查询多路复用 I/O 状态的方式。
- 信号驱动:采用信号等待机制,不用监视描述符了,也不用阻塞着等待数据到来。等待信号通知,调用相应的信号处理函数,这个信号告知应用程序的是“现在可以进行 I/O 了”。
- 异步 I/O:应用程序发送 I/O 请求后不用等也不用主动发送获取结果的请求,收到通知时,系统已经把数据读好了(是的,这个通知也不是告诉你数据到了去取吧,而是直接告知你 I/O 请求过程已经结束了),应用程序可以直接处理数据了。
1. 阻塞 I/O
几乎所有的程序员第一次接触到的网络编程都是从 listen()、send()、recv() 开始的,这些接口都是阻塞型的(接口的使用介绍见《socket编程在TCP中的应用》)。下面是一个简单的“一问一答”型服务器:
当用户进程调用了 recv() 这个系统调用,kernel 就开始了 I/O 的第一个阶段:准备数据。对于网络 I/O 来说,很多时候数据在一开始还没能完整到达,这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。kernel 一直等到数据准备好了,就将数据从 kernel 中拷贝到用户内存,然后返回结果,用户进程才解除阻塞状态,重新运行起来。
稍微改进一下 Figure 1-1 中的 CS 模型,将服务器改为多线程的,可以同时为许多客户端提供“一问一答”型服务:
这样任何一个连接的阻塞都不会影响其他的连接。主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。
TCP 服务器不是一对一的吗?这样多次调用
accept()
没关系吗?
- 一个端口肯定只能绑定一个 socket,但这个 socket 可能会产生很多“socket 连接”。要是服务器性能好一个端口就可以绑定无数个“socket连接”(其实也不是无数个,假设使用 IPv4 的话就是 IPv4 的地址空间 * 端口号个数 = 248 个,假设服务器内存够存)。
- 五元组(本地 IP,本地端口,远程 IP,远程端口,协议)唯一标识服务器的一个 socket,其中协议通过
socket()
方法指定,然后本地 IP 和端口通过bind()
方法绑定(想要多次绑定同一对 IP 端口一定会报错),最后accept()
方法绑定发来连接请求(connect()
)的客户端 IP 和端口,想连多少连多少。- TCP 的一对一指的是
accept()
方法产生的连接套接字只能对应一个客户端,但监听套接字下可能已经对应了许多客户端。
上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但若要同时响应成百上千路的连接请求,无论多线程还是多进程都会严重占据系统资源,降低响应效率。很多程序员可能会考虑使用线程池或连接池。这两种技术都可以很好的降低系统开销。
- 线程池旨在减少创建和销毁线程的频率,维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。
- 连接池维持 socket 连接,尽量重用已有的连接,减少创建和关闭连接的频率。
但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用 I/O 接口带来的资源占用问题。而且“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有“池”的时候好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。
2. 非阻塞接口
在 Linux 中,默认情况下所有的 socket 都是阻塞的,要设置成非阻塞的可以参考如下程序:
void setnonblocking(int sock) {
int opts;
opts = fcntl(sock, F_GETFL); // 读取文件状态标识
if (opts < 0) {
perror("fcntl(sock, GETFL)");
exit(1);
}
opts = opts|O_NONBLOCK;
if (fcntl(sock,F_SETFL,opts)<0) { // 设置文件描述符状态
perror("fcntl(sock,SETFL,opts)");
exit(1);
}
}
非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。当用户进程发出 read()
操作时,如果 kernel 中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以先做点别的,过一会再进行 read()
操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,在非阻塞式 I/O 中,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有。
在非阻塞状态下,recv()
或 read()
接口在被调用后立即返回,返回值代表了不同的含义:
- 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;
- 返回 0,表示连接已经正常断开,可以 close 掉这个连接套接字了;
- 返回 -1,且 errno 等于 EAGAIN,表示读取操作未完成,接收缓冲区暂时无数据可读;
- 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误,错误值记录在 errno 中。
I/O 复用
这种 I/O 方式也称为事件驱动 I/O。它的好处就在于单个进程就可以同时处理多个网络连接的 I/O。它的基本原理就是 select/poll/epoll 这个方法会不断的轮询所负责的所有 socket 描述符,当某个 socket 有数据到达了,就通知用户进程,用户再调用 recv()/read()
,就不会阻塞在 I/O 上了。
这部分的介绍见之前的总结《并发编程与 IO 复用》。
I/O 复用其实也是阻塞的,只不过不是阻塞在 I/O 读取上,而是阻塞在 select/poll/epoll 调用上。反正最终都会导致用户的进程阻塞,那么阻塞在哪到底有什么意义呢?
复用最大的好处还是能处理更多的套接字,进程阻塞等待所有套接字中的人一个变为可读即解除阻塞。也不需要像同步非阻塞 I/O 一样你自己处理轮询的逻辑。如果场景中不会有大量连接,那么复用当然也就没意义。
信号驱动
进程预先告知内核,使得当某个 socket fd 有事件发生时,内核使用信号通知相关进程。这个信号与一个信号处理函数绑定,在信号处理函数内执行 I/O 操作。
信号驱动的作用在于在等待 I/O 可用的过程中可以执行其它的指令。这种方法从理论上看是不错,由于进程已经休眠,就不会再占用 CPU,仅当 I/O 可用时它才恢复执行。但是这种方法的问题在于信号处理的开销有点大。若只是少数的请求还没有问题,若是每分钟收到 100 个请求,那就几乎一直都在捕获信号。每秒钟捕获上百个信号的开销是相当大的,不单是进程,对于内核发送信号的开销而言也是一样的。
因此,在高性能的服务器编程中,用异步 I/O 来处理多个 I/O 更为高效。当然,也可以用 I/O 复用模型来实现(epoll 是一个相当高效的方法),但是对于 Regular File 来说,是不能使用 epoll 的,因为不能设置非阻塞模式(O_NOBLOCK 方式对于传统文件句柄是无效的,但 epoll 的边缘触发模式必须设置为非阻塞)。
异步 I/O
用户进程发起读请求之后,立刻就可以开始去做其它的事。
从 kernel 的角度,当它收到一个异步读请求之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。然后,kernel 会等待数据准备完成,将数据拷贝到用户内存。当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它读操作完成了。
信号驱动 I/O 和异步 I/O 比较容易混淆,信号驱动虽然也是异步的但它不同于我们所说的异步 I/O(意思是说信号驱动的 I/O 过程仍然不是异步的),这两者的过程其实看操作系统到底替应用程序做了哪些事就能分辨出来:
模型 | 内核 | 进程 |
---|---|---|
信号驱动 I/O 模型 | 发送信号:I/O 能用了 | 接收到 I/O 能用的信号并执行接下来的操作 |
异步 I/O 模型 | 等待这个 I/O 有消息了,接受到数据 | 从缓存中得到数据直接处理 |