内容目录
第四章 选择器
在本章中,我们将探索选择器(selectors)。选择器提供选择执行已经就绪的任务的能力,这使得多元 I/O 成为可能。就像在第一章中描述的那样,就绪选择和多元执行使得单线程能够有效率地同时管理多个 I/O 通道(channels)。C/C++代码的工具箱中,许多年前就已经有select()
和poll()
这两个POSIX(可移植性操作系统接口)系统调用可供使用了。许过操作系统也提供相似的功能,但对Java程序员来说,就绪选择功能直到JDK 1.4才成为可行的方案。对于主要的工作经验都是基于Java 环境的开发的程序员来说,之前可能还没有碰到过这种 I/O 模型。
为了更好地说明就绪选择,让我们回到第三章的带传送通道的银行的例子里。想象一下,一个有三个传送通道的银行。在传统的(非选择器)的场景里,想象一下每个银行的传送通道都有一个气动导管,传送到银行里它对应的出纳员的窗口,并且每一个窗口与其他窗口是用墙壁分隔开的。这意味着每个导管(通道)需要一个专门的出纳员(工作线程)。这种方式不易于扩展,而且也是十分浪费的。对于每个新增加的导管(通道),都需要一个新的出纳员,以及其他相关的经费,如表格、椅子、纸张的夹子(内存、CPU 周期、上下文切换)等等。并且当事情变慢下来时,这些资源(以及相关的花费)大多数时候是闲置的。
现在想象一下另一个不同的场景,每一个气动导管(通道)都只与一个出纳员的窗口连接。这个窗口有三个槽可以放置运输过来的物品(数据缓冲区),每个槽都有一个指示器(选择键,selection key),当运输的物品进入时会亮起。同时想象一下出纳员(工作线程)有一个花尽量多的时间阅读《自己动手编写个人档案》一书的癖好。在每一段的最后,出纳员看一眼指示灯(调用select()
函数),来决定人一个通道是否已经就绪(就绪选择)。在传送带闲置时,出纳员(工作线程)可以做其他事情,但需要注意的时候又可以进行及时的处理。
虽然这种分析并不精确,但它描述了快速检查大量资源中的任意一个是否需要关注,而在某些东西没有准备好时又不必被迫等待的通用模式。这种检查并继续的能力是可扩展性的关键,它使得仅仅使用单一的线程就可以通过就绪选择来监控大量的通道。
选择器及相关的类就提供了这种API,使得我们可以在通道上进行就绪选择。
4.1 选择器基础
掌握本章中讨论的主题,在某种程度上,比直接理解缓冲区和通道类更困难一些。这会复杂一些,因为涉及了三个主要的类,它们都会同时参与到整个过程中。如果您发现自己有些困惑,记录下来并先看其他内容。一旦您了解了各个部分是如何相互适应的,以及每个部分扮演的角色,您就会理解这些内容了。
我们会先从总体开始,然后分解为细节。您需要将之前创建的一个或多个可选择的通道注册到选择器对象中。一个表示通道和选择器的键将会被返回。选择键会记住您关心的通道。它们也会追踪对应的通道是否已经就绪。当您调用一个选择器对象的select()
方法时,相关的键建会被更新,用来检查所有被注册到该选择器的通道。您可以获取一个键的集合,从而找到当时已经就绪的通道。通过遍历这些键,您可以选择出每个从上次您调用select()
开始直到现在,已经就绪的通道。
这是在3000英尺高的地方看到的情景。现在,让我们看看在地面上(甚至地下)到底发生了什么。
现在,您可能已经想要跳到例 4-1,并快速地浏览一下代码了。通过在这里和那段代码之间的内容,您将学到这些新类是如何工作的。在掌握了前面的段落里的高层次的信息之后,您需要了解选择器模型是如何在实践中被使用的。
从最基础的层面来看,选择器提供了询问通道是否已经准备好执行每个I/0操作的能力。例如,我们需要了解一个SocketChannel
对象是否还有更多的字节需要读取,或者我们需要知道ServerSocketChannel
是否有需要准备接受的连接。
在与SelectableChannel
联合使用时,选择器提供了这种服务,但这里面有更多的事情需要去了解。就绪选择的真正价值在于潜在的大量的通道可以同时进行就绪状态的检查。调用者可以轻松地决定多个通道中的哪一个准备好要运行。有两种方式可以选择:被激发的线程可以处于休眠状态,直到一个或者多个注册到选择器的通道就绪,或者它也可以周期性地轮询选择器,看看从上次检查之后,是否有通道处于就绪状态。如果您考虑一下需要管理大量并发的连接的网络服务器(web server)的实现,就可以很容易地想到如何善加利用这些能力。
乍一看,好像只要非阻塞模式就可以模拟就绪检查功能,但实际上还不够。非阻塞模式同时还会执行您请求的任务,或指出它无法执行这项任务。这与检查它是否能够执行某种类型的操作是不同的。举个例子,如果您试图执行非阻塞操作,并且也执行成功了,您将不仅仅发现read()
是可以执行的,同时您也已经读入了一些数据。就下来您就需要处理这些数据了。
效率上的要求使得您不能将检查就绪的代码和处理数据的代码分离开来,至少这么做会很复杂。
即使简单地询问每个通道是否已经就绪的方法是可行的,在您的代码或一个类库的包里的某些代码需要遍历每一个候选的通道并按顺序进行检查的时候,仍然是有问题的。这会使得在检查每个通道是否就绪时都至少进行一次系统调用,这种代价是十分昂贵的,但是主要的问题是,这种检查不是原子性的。列表中的一个通道都有可能在它被检查之后就绪,但直到下一次轮询为止,您并不会觉察到这种情况。最糟糕的是,您除了不断地遍历列表之外将别无选择。您无法在某个您感兴趣的通道就绪时得到通知。
这就是为什么传统的监控多个socket的Java解决方案是为每个socket创建一个线程并使得线程可以在read()
调用中阻塞,直到数据可用。这事实上将每个被阻塞的线程当作了socket监控器,并将Java虚拟机的线程调度当作了通知机制。这两者本来都不是为了这种目的而设计的。程序员和Java虚拟机都为管理所有这些线程的复杂性和性能损耗付出了代价,这在线程数量的增长失控时表现得更为突出。
真正的就绪选择必须由操作系统来做。操作系统的一项最重要的功能就是处理I/O请求并通知各个线程它们的数据已经准备好了。选择器类提供了这种抽象,使用Java代码能够以可移植的方式,请求底层的操作系统提供就绪选择服务。
让我们看一下java.nio.channels
包中处理就绪选择的特定的类。
0 条评论
撰写评论