原文出处http://zhangsr.cn/i/1281(对作者表示感谢)
服务器端编程经常需要构造高性能的 IO 模型,常见的 IO 模型有四种:
(1) 同步阻塞 IO(Blocking IO):即传统的 IO 模型。
(2) 同步非阻塞 IO(Non-blocking IO):默认创建的 socket 都是阻塞的,非阻塞 IO 要求 socket 被设置为 NONBLOCK。注意这里所说的 NIO 并非 Java 的 NIO(New IO)库。
(3) IO 多路复用(IO Multiplexing):即经典的 Reactor 设计模式,有时也称为异步阻塞 IO,Java 中的 Selector 和 Linux 中的 epoll 都是这种模型。
(4) 异步 IO(Asynchronous IO):即经典的 Proactor 设计模式,也称为异步非阻塞 IO。
同步(synchronous)和异步(asynchronous)的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起 IO 请求后需要等待或者轮询内核 IO 操作完成后才能继续执行;而异步是指用户线程发起 IO 请求后仍继续执行,当内核 IO 操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞(blocking)和非阻塞(non-blocking)的概念描述的是用户线程调用内核 IO 的操作方式:阻塞是指 IO 操作需要彻底完成后才返回到用户空间;而非阻塞是指 IO 操作被调用后立即返回给用户一个状态值,无需等到 IO 操作彻底完成。
再说一下 IO 发生时涉及的对象和步骤。对于一个 network IO(这里我们以 read 举例),它会涉及到两个系统对象,一个是调用这个 IO 的 process(or thread),另一个就是系统内核(kernel)。当一个 read 操作发生时,它会经历两个阶段:
(1) 等待数据准备(Waiting for the data to be ready)
(2) 将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
记住这两点很重要,因为这些 IO 模型的区别就是在两个阶段上各有不同的情况。
1. 同步阻塞 IO
在 linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程大概是这样:
当用户进程调用了 recvfrom 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于 network io 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的 UDP 包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。
所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被 block 了。
几乎所有的程序员第一次接触到的网络编程都是从 listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器 / 客户机的模型。下面是一个简单地"一问一答"的服务器。
我们注意到,大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。
实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send() 的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。
一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的cpu资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create() 创建新线程,fork() 创建新进程。
我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。
在上述的线程 / 时间图例中,主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。
很多初学者可能不明白为何一个 socket 可以 accept 多次。实际上 socket 的设计者可能特意为多客户机的情况留下了伏笔,让 accept() 能够返回一个新的 socket。下面是 accept 接口的原型:
1
|
int
accept(
s,struct sockaddr *addr,socklen_t *addrlen);
|
输入参数 s 是从 socket(),bind() 和 listen() 中沿用下来的 socket 句柄值。执行完 bind() 和 listen() 后,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请求,则将该连接请求加入请求队列。调用 accept() 接口正是从 socket s 的请求队列抽取第一个连接信息,创建一个与 s 同类的新的 socket 返回句柄。新的 socket 句柄即是后续 read() 和 recv() 的输入参数。如果请求队列当前没有请求,则 accept() 将进入阻塞状态直到有请求进入队列。
上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。
很多程序员可能会考虑使用"线程池"或"连接池"。"线程池"旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。"连接池"维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。但是,"线程池"和"连接池"技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓"池"始终有其上限,当请求大大超过上限时,"池"构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用"池"必须考虑其面临的响应规模,并根据响应规模调整"池"的大小。
对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,"线程池"或"连接池"或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。
2. 同步非阻塞 IO
Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:
从图中可以看出,当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有。
非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下的函数可以将某句柄 fd 设为非阻塞状态。
下面将给出只用一个线程,但能够同时从多个连接中检测数据是否送达,并且接受数据的模型。
在非阻塞状态下,recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在本例中,
* recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;
* recv() 返回 0,表示连接已经正常断开;
* recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成;
* recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno。
可以看到服务器线程可以通过循环调用 recv() 接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 recv() 将大幅度推高 cpu 占用率;此外,在这个方案中 recv() 更多的是起到检测"操作是否完成"的作用,实际操作系统提供了更为高效的检测"操作是否完成"作用的接口,例如 select() 多路复用模式,可以一次检测多个连接是否活跃。
3. IO 多路复用
同步阻塞 IO 在等待数据就绪上花去太多时间,而传统的同步非阻塞 IO 虽然不会阻塞进程,但是结合轮询来判断数据是否就绪仍然会耗费大量的 cpu 时间。
多路 IO 复用提供了对大量文件描述符进行就绪检查的高性能方案。
select
select 诞生于4.2 BSD,在几乎所有平台上都支持,其良好的跨平台支持是它的主要的也是为数不多的优点之一。
select 的缺点:
(1)单个进程能够监视的文件描述符的数量存在最大限制
(2)select 需要复制大量的句柄数据结构,产生巨大的开销
(3)select 返回的是含有整个句柄的列表,应用程序需要遍历整个列表才能发现哪些句柄发生了事件
(4)select 的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行 IO 操作,那么之后每次 select 调用还是会将这些文件描述符通知进程。相对应方式的是边缘触发。
poll
poll 诞生于 UNIX System V Release 3,那时 AT&T 已经停止了 UNIX 的源代码授权,所以显然也不会直接使用 BSD 的 select,所以 AT&T 自己实现了一个和 select 没有多大差别的 poll。
poll 和 select 是名字不同的孪生兄弟,除了没有监视文件数量的限制,select 后面3条缺点同样适用于 poll。
面对 select 和 poll 的缺陷,不同的 OS 做出了不同的解决方案,可谓百花齐放。不过他们至少完成了下面两点超越,一是内核长期维护一个事件关注列表,我们只需要修改这个列表,而不需要将句柄数据结构复制到内核中;二是直接返回事件列表,而不是所有句柄列表。
/dev/poll
Sun 在 Solaris 中提出了新的实现方案,它使用了虚拟的 /dev/poll 设备,开发者可以将要监视的文件描述符加入这个设备,然后通过 ioctl() 来等待事件通知。
/dev/epoll
名为 /dev/epoll 的设备以补丁的方式出现在 Linux2.4 中,它提供了类似 /dev/poll 的功能,并且在一定程度上使用 mmap 提高了性能。
kqueue
FreeBSD 实现了 kqueue,可以支持水平触发和边缘触发,性能和下面要提到的epoll非常接近。
epoll
IO multiplexing 这个词可能有点陌生,但是如果我说 select / epoll,大概就都能明白了。有些地方也称这种 IO 方式为事件驱动 IO(event driven IO)。我们都知道,select / epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select / epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:
当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 会"监视"所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。
这个图和 blocking IO 的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select 和 recvfrom),而 blocking IO 只调用了一个系统调用(recvfrom)。但是,用 select 的优势在于它可以同时处理多个 connection。(多说一句:所以,如果处理的连接数不是很高的话,使用 select / epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select / epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。因此 select() 与非阻塞 IO 类似。
{
@H_274_301@
select(socket);
while
(
1
) {
sockets = select();
for
(socket in sockets) {
if
(can_read(socket)) {
read(socket,buffer);
process(buffer);
}
}
}
}
|
其中 while 循环前将 socket 添加到 select 监视中,然后在 while 内一直调用 select 获取被激活的 socket,一旦 socket 可读,便调用 read 函数将 socket 中的数据读取出来。
IO 多路复用模型使用了 Reactor 设计模式实现了这一机制。
如图所示,EventHandler 抽象类表示 IO 事件处理器,它拥有 IO 文件句柄 Handle(通过 get_handle 获取),以及对 Handle 的操作 handle_event(读/写等)。继承于 EventHandler 的子类可以对事件处理器的行为进行定制。Reactor 类用于管理 EventHandler(注册、删除等),并使用 handle_events 实现事件循环,不断调用同步事件多路分离器(一般是内核)的多路分离函数 select,只要某个文件句柄被激活(可读/写等),select 就返回(阻塞),handle_events 就会调用与文件句柄关联的事件处理器的 handle_event 进行相关操作。
如图所示,通过 Reactor的方式,可以将用户线程轮询 IO 操作状态的工作统一交给 handle_events 事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而 Reactor 线程负责调用内核的 select 函数检查 socket 状态。当有 socket 被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行 handle_event 进行数据读取、处理的工作。由于 select 函数是阻塞的,因此多路 IO 复用模型也被称为异步阻塞 IO 模型。注意,这里的所说的阻塞是指 select 函数执行时线程被阻塞,而不是指 socket。一般在使用 IO 多路复用模型时,socket 都是设置为 NONBLOCK 的,不过这并不会产生影响,因为用户发起 IO 请求时,数据已经到达了,用户线程一定不会被阻塞。
void
UserEventHandler::handle_event() {
@H_274_301@
(can_read(socket)) {
}
}
{
Reactor.register(
new
UserEventHandler(socket));
用户需要重写 EventHandler 的 handle_event 函数进行读取数据、处理数据的工作,用户线程只需要将自己的 EventHandler 注册到 Reactor 即可。Reactor 中 handle_events 事件循环的伪代码大致如下。
|