———————————————————————————————————- 随着所有的在高可用服务器设计上的炒作,以及nodejs背后的风行,我想关注一些IO的设计模式,却一直没有足够的时间。现在正在完成的一些研究,我想最好记下这些资料以备查。让我们跳上IO bus兜风去。 各种各样的I/O根据操作的阻塞或非阻塞类型,以及IO的准备就绪、完成事件通知的同步和异步类型,一共有四种不同方式的IO。
在许多web server上,典型的一个连接一个thread的基础,这种类型是IO操作阻塞着应用程序直到完成。 当阻塞式的read方法或write方法被调用时,将有一次上下文切换至kernel中,IO操作会发生,数据会被复制进kernel的buffer中。然后,kernel buffer会把数据转给用户空间里的应用程序级别的buffer,并且应用程序的thread会被标识为runnable的,此时应用程序会解锁可以从用户空间的buffer中读取数据。 一个连接一个thread的模型想尝试减少强制一个连接给一个thread的阻塞影响,需要掌握剩下的并发连接不再被IO操作在同一个连接上阻塞。当连接些都很短且数据延迟都不是很坏的时候这工作得很好。尽管如此,一旦连接变长且数据连接高延迟,可能性就是,线程些被连接长时间抓住不放,因为新连接的饥饿。如果使用定长的线程池,直到阻塞的线程在阻塞状态中不能被重用以服务新的连接,如果每个新的连接用一个新的线程服务,或者会导致大量的线程会在系统中被产生,这会演变为漂亮的资源争抢,为了完成高并发的负载,而高上下切换消费。
简单的一连接一线程server
这个模型下,设备(网卡)或者连接被设置为非阻塞的,read()和write()操作将不会被阻塞。通常意味着,如果操作不能立即得到结论,将会返回,带一个error code以指出操作会阻塞(POSIX标准是EWOULDBLOCK)或者是设备临时不可用(POSIX标准是EAGAIN)。由应用程序去检测,直到设备准备好了并且所有数据被读到。尽量如此,这也不是非常高效,因为每次调用都会激起一次上下文切换给kernel,并且不会考虑数据有没被读到。
前面的模型的问题在于,应用程序不得不检测,会忙于等待任务完成。当设备准备好被读写时,有更好的办法通知应用程序吗?这的确就是本模型所提供的好处。使用特殊的系统调用(因平台而变-linux下使用select()/poll()/epoll(),BSD使用kqueue(),Solaris使用/dev/poll),应用程序注册感兴趣的点收集IO就绪的信息,从特定的设备(在Linux下使用文件描述符,所有的sockets都被抽象使用了文件描述符),特定的IO操作(读或写)。然后,这个系统调用被调用,至少其中一个被注册的文件描述符变成ready之前,这调用会被阻塞。一旦这个文件描述符准备好做IO操作了,就会被取来当作系统调用的返回,然后系统调用就可以在应用程序的loop中被顺序地调用。 准备好的连接处理逻辑经常包括一个用户提供的事件handler,此handler会一起发起非阻塞的read()/write()调用,目的是从设备取数据给kernel,最终给用户空间的buffer,这会激起上下文切换到kernel。无论如何,通常没有绝对保证,有可能会发生,设备上预期的由操作系统提供的IO,只是一个指示,设备有可能准备好感兴趣的IO操作了,但read()或write()却不行。尽管如此,与标准情况相比这应该算异常了。 所以,总结的办法就是,在异步流中获取就绪事件,注册一些事件处理器,当有类似的事件通知被触发的时候抓住他们。正如你所见,所有的事情都可以在一个单独的线程中完成,即便从多个不同连接过来的多路传输,主要因为select()(这里我选择了典型的系统调用),已经是可以同一时间返回多个sockets准备就绪的类型。同一时间在多个sockets上返回就绪,这只是一部分好处。这种类型就是经常没提供的非阻塞IO模型。 Java已经抽象出来平台特殊性系统调用的不同,实现了NIO API。Socket 文件描述符被用Channels和Selector抽象,封装到selection系统调用中。应用程序感兴趣的收集就绪事件,注册到Channel(通常在ServerSocketChannel上accept()就得到一个SocketChannel),注册的内容是Selector,会得到SelectionKey,这个SelectionKey就是作为一个handle,这个handle的作用是hold住Channel和注册信息。然后阻塞的select()调用被设置在Selector,它会返回一系列的SelectionKey,然后一个接一个地被程序所指定的事件处理器所处理。 简单的非阻塞server
就绪事件只能做到通知你设备\socket准备好做事情的程度。应用程序依然不得不做脏活,为了从设备/socket中读数据(更准确地说是通过系统调用指示操作系统),通过设备的各种思路将数据扔到用户空间的buffer。把任务代理给操作系统在后台运行,一旦完成了让它再通知你,包括从设备到kernel的buffer再最终到应用程序级别的buffer传送所有的数据,这样岂不是很爽?这就是经常被提到的异步IO模型背后的基础想法。所以需要操作系统层支持AIO操作。在Linux下从2.6开始在aio POSIX API中被支持,Windows下用I/O Completion Ports支持。 JAVA NIO2在AsynchronousChannel API中一点点支持此模型。
为了支持就绪和完成事件通知,不同的操作系统提供了各种各样的系统调用。就绪事件 select()和poll()可以在Linux类的系统中使用。尽管如此,更新的epoll()变种更好,因为它比select()和poll()更有效率。当监控的文件描述符增长时,选择时间在线性增长,这一点上导致了select()不行。在复写文件描述符数组这事上已经臭名昭著。所以每次一被调用,描述符数组就需要从一个单独的拷贝上重新构建。无论如何这都不是一个优雅的解决方案。 epoll()变体可以按两种办法被配置,边沿触发和层级触发。在边沿触发情况下,只有在相关的描述符上事件被检测到才会发出通知。说了在一个事件触发通知期间,你的应用程序触发器只会读一半kernel的输入buffer。现在在这个描述符上不会得到通知,甚至到下一个时间周期,除非设备准备好发更多的数据,否则有一点点数据可读的时候也不会有通知,有足够的数据的时候会导致一次文件描述符的事件。层级触发用另一方式配置,每次数据可读了都会触发通知。 相比的系统调用还有BSD口味的kqueue,Solaris由于版本不同有/dev/poll或者”Event Completion”。Windows下等价的是“I/O Completion Ports”。 至少在Linux下AIO模型的情况却大不同。Linux中aio的支持看上去埋头在一些意见困扰中,实际地在kernel层面使用就绪事件,同时在应用程序层面提供异步完成事件的抽象。尽管如此,Windows看上去通过“I/O Completion Ports”支持这个得了第一名。
在软件开发中到处是设计模式。I/O不一样。只有两种I/O模式,NIO和AIO,下面进行介绍。
有许多组件使用这个模式来实现。我会解释一遍先,后面好看懂代码。 Reactor启动器:这是会初始化非阻塞服务器的组件,主要是配置和初始化分配器(dispatcher)。首先,它会bind出服务器的socket,并且通过分接器(demultiplexer)注册,分接器作用是客户端连接接收就绪事件。然后就绪事件(读写接收等)的每种类型的事件处理器实现会被注册到分配器(dispatcher)。下一次分配器事件loop过程会被调起来,以处理事件通知。 dispatcher:为注册、删除定义接口,分发事件处理器起作用,作用是响应连接事件,包括连接被接受、数据输入输出、一组连接上的超时事件。为了服务一个客户端连接,相关的事件处理器(比如接受事件处理器)会被注册给被接受的客户端通道(在client socket其下包装),注册内容是分接器(demultiplexer),就绪事件类的都被会注册,以监听此特定的channel。然后,分配器线程会调出阻塞的就绪选择操作,这些操作在demultiplexer之上,主要为剩下的注册通道。一旦一个或多个被注册的通道准备好IO,分配器会服务给相关的每个准备好的通道一对一的用注册的事件处理器返回“Handle”。很重要的是,这些事件处理器不会hold住分配器线程,但是会延迟分配器服务其他准备好的连接。因为常见的在事件处理器里的逻辑,包括传送数据从/去准备好的连接,这些连接会阻塞,一直到所有的数据在用户空间和内核数据缓存中被送完,一般情况下,这些处理器跑在一个线程池的不同的线程里。 Handle:当一个channel被注册了分接器(demultiplexer)就会返回一个handle,handle概括了连接通道和就绪信息。靠分接器就绪选择操作,一系列的准备好的Handle会被返回。Java NIO里对等的叫SelectionKey。 Demultiplexer:(分接器:54chen专门瞎翻)等待在一个或多个注册的连接通道里的就绪事件。Java NIO里叫Selector。 Event Handler:指接口具有的hook方法,以分配连接事件。这些方法需要被应用程序指定的事件处理器所实现。 Concrete Event Handler:(具体的事件处理器)包括从连接中读写数据的逻辑,并且要做一些必须的过程,或者初始化客户端连接传过的接收协议,这些协议来自通过的Handle。 事件处理器典型地跑在一个线程池的单独的线程中,下面的图片中显示了这一过程。
一个简单的echo server实现,下面的例子显示了这种模式(没有事件处理器线程池)。
此模式基于异步IO模型。主要的组件如下。 Proactive启动器:这是初始化异步操作接收客户端连接的实体。经常是服务器应用程序的主线程。注册一个完成处理器,附着在完成分发器上,以逮到连接接收时的异步事件通知。 Asynchronous Operation Processor:异步操作处理器。其职责是异步地抓出IO操作,提供完成事件通知给应用层的完成处理器。操作系统通常会暴露异步IO接口。 Asynchronous Operation:异步操作的运行在独立的内核线程中,靠异步操作处理器来完成。 Completion Dispatcher:其职责是在异步操作完成时,唤回应用程序的完成处理器。当异步操作处理器完成了一次异步初始化操作,完成分发器会进行应用程序自行维护的回调。通常,委派事件通知处理给相对的事件合适的完成处理器。 Completion Handler:这是被实用程序实现的接口,用于处理异步事件完成events。 让我们来看看如何用新的Java 7里的NIO.2 API来实现这种模式(一个简单的echo server)。 每种类型的事件完成(接受、读、写)都会被一个单独的完成处理器handle,这个处理器实现了CompletionHandler接口(Accept/ Read/ WriteCompletionHandler等)。状态过渡被管理在这些连接处理器中。额外SessionState参数可以被用于hold客户端的session,待定的状态就可以跨这一系列的完成事件了。
如果你在考虑实现一个NIO的HTTP服务器,你有福了。Apache HTTPCore包对使用NIO处理HTTP流量提供了优秀的支持。API在内置的用NIO对付http请求处理层之上提供了高层次的抽象。下面给出一个最小化的非阻塞Http服务器实现,任何的GET访问都会返回一个样本输出。 IOReactor类将基础地包装了分配器定义,靠的是ServerHandler的实现来处理就绪事件。 Apache Synapse(一个开源的ESB)包括了一个好的实现,此实现是个NIO基础的HTTP服务器,在其中NIO被用于扩展每个实例接巨量客户端,但又不会使内存随时间上涨。实现也包括了不错的debug和服务器统计收集算法,还集成了Axis2传输框架。可以在[1]中找到。
(哎呀妈呀,作者话好多,不过很透彻,不得不服—54chen注~~译) IO上有许多的选项可以做,可影响到服务器的扩展性和性能。上面的每种IO都有利有弊,做决定时要考虑扩展性和性能特征,以及利于管理。尽管提建议、改正和评论。所有提及的clients代码都可以从这里下载。
过程中有许多文献一眼就过了,下面是有趣的一些。 [1] http://www.ibm.com/developerworks/java/library/j-nio2-1/index.html [2] http://www.ibm.com/developerworks/linux/library/l-async/ [3] http://lse./io/aionotes.txt [4] http://wknight8111./search/label/AIO [5] http:///dankwiki/index.php/Fast_UNIX_Servers [6] http://today./pub/a/today/2007/02/13/architecture-of-highly-scalable-nio-server.html [7] Java NIO by Ron Hitchens [8] http://www.dre./~schmidt/PDF/reactor-siemens.pdf [9] http://www.cs./~schmidt/PDF/proactor.pdf [*] 读过就想翻是病,得治。翻得匆忙,有错误的地方麻烦指出修正。 转载自五四陈科学院[http://www.]
|
|