摘要
本文研究并比较了基于TCP的高性能服务器的不同设计模式。除了现有方法之外,它还提出了一个可扩展的单代码库,多平台解决方案(带有代码示例),并描述了它在不同平台上的微调。它还比较了提议和现有解决方案的Java,C#和C ++实现的性能。
系统I / O可以被阻断,或非阻塞同步的,或异步非阻塞[ 1,2 ]。阻止I / O意味着在操作完成之前,调用系统不会将控制权返回给调用者。结果,呼叫者被阻止,并且在此期间无法执行其他活动。最重要的是,在等待I / O完成时,调用程序线程不能重用于其他请求处理,并且在此期间成为浪费的资源。例如,read()
如果套接字缓冲区为空,则在阻塞模式下对套接字的操作将不会返回控制,直到某些数据可用为止。
相反,非阻塞同步调用立即将控制权返回给调用者。调用者不会等待,并且被调用的系统立即返回两个响应之一:如果执行了调用并且结果准备就绪,则告知调用者。或者,被调用的系统可以告诉调用者系统没有资源(套接字中没有数据)来执行请求的操作。在这种情况下,呼叫者可以重复呼叫,直到成功为止。例如,read()
对非套接字模式的套接字上的操作可能会返回读取字节数或特殊返回码-1,并将errno设置为EWOULBLOCK/EAGAIN
,意味着“未就绪;请稍后再试”。
在非阻塞异步调用中,调用函数立即将控制权返回给调用者,报告所请求的操作已启动。调用系统将使用其他系统资源/线程执行调用者的请求,并在结果准备好处理时通知调用者(例如,通过回调)。例如,Windows ReadFile()
或POSIX aio_read()
API立即返回并启动内部系统读取操作。在这三种方法中,这种非阻塞异步方法提供了最佳的可扩展性和性能。
本文研究了不同的非阻塞I / O复用机制,并提出了单一的多平台设计模式/解决方案。我们希望本文能够帮助基于TCP的高性能服务器的开发人员选择最佳的设计解决方案。我们还比较了提议和现有解决方案的Java,C#和C ++实现的性能。我们将阻止阻塞方法进一步讨论和比较,因为它是可扩展性和性能最不有效的方法。
Reactor和Proactor:两种I / O多路复用方法
通常,I / O复用机制依赖于一个事件多路分解器[ 1,3 ],即调度从有限数目的源到相应的读/写事件处理程序的I / O事件的对象。开发人员注册对特定事件的兴趣,并提供事件处理程序或回调。事件解复用器将所请求的事件传递给事件处理程序。
涉及事件多路分解器的两种模式称为Reactor和Proactor [ 1 ]。Reactor模式涉及同步I / O,而Proactor模式涉及异步I / O. 在Reactor中,事件解复用器等待指示文件描述符或套接字何时准备好进行读取或写入操作的事件。解复用器将此事件传递给适当的处理程序,该处理程序负责执行实际的读取或写入。
相反,在Proactor模式中,代表处理程序的处理程序或事件多路分解器启动异步读写操作。I / O操作本身由操作系统(OS)执行。传递给OS的参数包括用户定义的数据缓冲区的地址,OS从中获取要写入的数据,或者操作系统将数据读取到的地址。事件解复用器等待指示I / O操作完成的事件,并将这些事件转发给适当的处理程序。例如,在Windows上,处理程序可以启动异步I / O(在Microsoft术语中重叠)操作,并且事件解复用器可以等待IOCompletion事件[ 1]。这种经典异步模式的实现基于异步操作系统级API,我们将此实现称为“系统级”或“真实”异步,因为应用程序完全依赖操作系统来执行实际I / O.
一个例子将帮助您理解Reactor和Proactor之间的区别。我们将重点关注这里的读操作,因为写实现类似。这是Reactor的读物:
- 事件处理程序声明对I / O事件感兴趣,这些事件指示在特定套接字上读取的准备情况
- 事件解复用器等待事件
- 一个事件进入并唤醒解复用器,解复用器调用适当的处理程序
- 事件处理程序执行实际的读取操作,处理读取的数据,声明对I / O事件的重新关注,并将控制权返回给调度程序
相比之下,这是Proactor中的读操作(真正的异步):
- 处理程序启动异步读取操作(注意:操作系统必须支持异步I / O)。在这种情况下,处理程序不关心I / O就绪事件,而是注册接收完成事件的兴趣。
- 事件解复用器等待操作完成
- 当事件多路分解器等待时,OS在并行内核线程中执行读操作,将数据放入用户定义的缓冲区,并通知事件多路分解器读取完成
- 事件解复用器调用适当的处理程序;
- 事件处理程序处理来自用户定义缓冲区的数据,启动新的异步操作,并将控制返回给事件多路分解器。
目前的做法
开源C ++开发框架ACE [ 1,3 ]由Douglas施密特开发。,等人,提供了一个宽范围的独立于平台的,低级并发支持类(线程,互斥,等)。在顶层,它提供了两组独立的类:ACE Reactor和ACE Proactor的实现。虽然它们都基于与平台无关的原语,但这些工具提供了不同的接口。
的ACE摄给出在MS-Windows更好的性能和鲁棒性,作为Windows提供了一个非常有效的异步API,基于操作系统级支持[ 4,5 ]。
遗憾的是,并非所有操作系统都提供完全强大的异步操作系统级支持。例如,许多Unix系统没有。因此,ACE Reactor是UNIX中的首选解决方案(目前UNIX没有强大的套接字异步功能)。因此,为了在每个系统上实现最佳性能,联网应用程序的开发人员需要维护两个独立的代码库:基于ACE Proactor的Windows解决方案和基于ACE Reactor的基于Unix的系统解决方案。
正如我们所提到的,真正的异步Proactor模式需要操作系统级支持。由于事件处理程序和操作系统交互的不同性质,很难为Reactor和Proactor模式创建通用的统一外部接口。反过来,这使得创建完全可移植的开发框架并封装接口和OS相关的差异变得很困难。
提出的解决方案
在本节中,我们将提出解决设计Proactor和Reactor I / O模式的可移植框架的挑战。为了演示此解决方案,我们将通过从解复用器内部的事件处理程序移动读/写操作,将Reactor解复用器I / O解决方案转换为模拟的异步I / O(这是“模拟异步”方法)。以下示例说明了读取操作的转换:
- 事件处理程序声明对I / O事件(读取准备)的兴趣,并为解复用器提供诸如数据缓冲区的地址或要读取的字节数之类的信息。
- Dispatcher等待事件(例如,打开
select()
);- 当一个事件到来时,它唤醒了调度员。调度程序执行非阻塞读取操作(它具有执行此操作所需的所有信息),并在完成时调用适当的处理程序。
- 事件处理程序处理来自用户定义缓冲区的数据,声明了新兴趣,以及有关数据缓冲区放置位置的信息以及I / O事件中要读取的字节数。然后,事件处理程序将控制权返回给调度程序。
我们可以看到,通过向解复用器I / O模式添加功能,我们能够将Reactor模式转换为Proactor模式。就工作量而言,这种方法与Reactor模式完全相同。我们只是在不同的角色之间转移责任 没有性能下降,因为执行的工作量仍然相同。这项工作只是由不同的演员执行。以下步骤列表表明每种方法执行的工作量相等:
标准/经典反应器:
- 步骤1)等待事件(反应堆工作)
- 步骤2)将“Ready-to-Read”事件发送给用户处理程序(Reactor job)
- 步骤3)读取数据(用户处理程序作业)
- 步骤4)处理数据(用户处理程序作业)
拟议模拟的前提:
- 步骤1)等待事件(前驱工作)
- 步骤2)读取数据(现在是Proactor作业)
- 步骤3)将“Read-Completed”事件分派给用户处理程序(Proactor job)
- 步骤4)处理数据(用户处理程序作业)
对于不提供异步I / O API的操作系统,此方法允许我们隐藏可用套接字API的反应性质并公开完全主动的异步接口。这使我们能够创建一个完全可移植的平台无关解决方案,并具有通用的外部接口。
TProactor
提出的解决方案(TProactor)是在Terabit P / L [ 6 ] 开发和实施的。该解决方案有两种替代实现,一种是C ++,另一种是Java。C ++版本是使用ACE跨平台低级原语构建的,并且在所有平台上都有一个通用的统一异步主动接口。
主要的TProactor组件是Engine和WaitStrategy接口。引擎管理异步操作生命周期。WaitStrategy管理并发策略。WaitStrategy依赖于Engine,两者总是成对工作。Engine和WaitStrategy之间的接口是强定义的。
引擎和等待策略是作为可插入的类驱动程序实现的(有关所有已实现的引擎和相应的WaitStrategies的完整列表,请参阅附录1)。TProactor是一种高度可配置的解决方案。它在内部实现了三个引擎(POSIX AIO,SUN AIO和Emulated AIO)并隐藏了六种不同的等待策略,基于异步内核API(对于POSIX-由于内部POSIX AIO API问题,这现在效率不高)和同步Unix select()
,poll()
,/ dev / poll(Solaris 5.8+),port_get
(Solaris 5.10),RealTime(RT)信号(Linux 2.4+),epoll(Linux 2.6),k-queue(FreeBSD)API。TProactor符合标准的ACE Proactor实现界面。这使得可以开发具有通用(ACE Proactor)接口的单个跨平台解决方案(POSIX / MS-WINDOWS)。
通过一组可互换的“乐高式”引擎和WaitStrategies,开发人员可以通过设置适当的配置参数在运行时选择适当的内部机制(引擎和等待策略)。可以根据特定要求指定这些设置,例如连接数,可伸缩性和目标OS。如果操作系统支持异步API,则开发人员可以使用真正的异步方法,否则用户可以选择基于不同同步等待策略构建的模拟异步解决方案。所有这些策略都隐藏在模拟的异步外观背后。
例如,对于在Sun Solaris上运行的HTTP服务器,/ dev / poll或 port_get()
-based引擎是最合适的选择,能够提供大量连接,但是对于另一个连接数量有限但吞吐量要求高的UNIX解决方案,select()
基于a 的引擎可能是更好的方法。由于不同等待策略的固有算法问题,使用标准ACE Reactor / Proactor无法实现这种灵活性(见附录2)。
在性能方面,我们的测试表明,从被动模式到主动模拟不会产生任何开销 - 它可以更快,但不会更慢。根据我们的测试结果,与各种UNIX / Linux平台上的标准ACE Reactor实现中的反应模型相比,TProactor平均提供高达10-35%的性能(以吞吐量和响应时间衡量)。在Windows上,它提供与标准ACE Proactor相同的性能。
性能比较(JAVA与C ++与C#)。
除了C ++之外,我们还在Java中实现了TProactor。至于JDK的版本1.4,Java提供仅基于同步的办法,是在逻辑上类似于C select()
[ 7,8 ]。Java TProactor基于Java的非阻塞工具(java.nio包),逻辑上类似于C ++ TProactor,具有基于等待策略select()
。
图1和图2显示了以位/秒为单位的传输速率与连接数的关系。这些图表表示在标准ACE Reactor上构建的简单echo服务器的比较结果,在Microsoft的Windows和RedHat Linux9.0上使用RedHat Linux 9.0,TProactor C ++和Java(IBM 1.4JVM),以及在Windows上运行的C#echo服务器操作系统。本机AIO API的性能由“Async”标记的曲线表示; 通过模拟AIO(TProactor)-AsyncE曲线; 并通过TP_Reactor-Synch曲线。所有实现都被同一客户端应用程序轰炸 - 通过N个连接连续传输任意固定大小的消息流。
全套测试在同一硬件上执行。对不同机器的测试证明相对结果是一致的。
图1. Windows XP / P4 2.6GHz超线程/ 512 MB RAM。
图2. Linux RedHat 2.4.20-smp / P4 2.6GHz超线程/ 512 MB RAM。
用户代码示例
以下是基于TProactor的简单Java echo-server的框架。简而言之,开发人员只需要实现两个接口:OpRead
使用缓冲区,其中TProactor放置其读取结果,以及OpWrite
缓冲区,TProactor从中获取数据。显影剂还需要通过提供回调来实现特定于协议的逻辑onReadCompleted()
和 onWriteCompleted()
在AsynchHandler
接口实现。这些回调将在完成读/写操作时由TProactor异步调用,并在TProactor提供的线程池空间上执行(开发人员不需要编写自己的池)。
class EchoServerProtocol implements AsynchHandler
{
AsynchChannel achannel = null;
EchoServerProtocol( Demultiplexor m, SelectableChannel channel ) throws Exception
{
this.achannel = new AsynchChannel( m, this, channel );
}
public void start() throws Exception
{
// called after construction
System.out.println( Thread.currentThread().getName() + ": EchoServer protocol started" );
achannel.read( buffer);
}
public void onReadCompleted( OpRead opRead ) throws Exception
{
if ( opRead.getError() != null )
{
// handle error, do clean-up if needed
System.out.println( "EchoServer::readCompleted: " + opRead.getError().toString());
achannel.close();
return;
}
if ( opRead.getBytesCompleted () <= 0)
{
System.out.println( "EchoServer::readCompleted: Peer closed " + opRead.getBytesCompleted();
achannel.close();
return;
}
ByteBuffer buffer = opRead.getBuffer();
achannel.write(buffer);
}
public void onWriteCompleted(OpWrite opWrite) throws Exception
{
// logically similar to onReadCompleted
...
}
}
IOHandler
是一个TProactor基类。AsynchHandler
除了其他方面,Multiplexor在内部执行开发人员选择的等待策略。
结论
TProactor为多平台高性能通信开发提供了通用,灵活且可配置的解决方案。附录2中提到的所有问题和复杂性都隐藏在开发人员面前。
从图表中可以清楚地看出,C ++仍然是高性能通信解决方案的首选方法,但Linux上的Java非常接近。但是,整体Java性能因Windows上的糟糕结果而减弱。其中一个原因可能是Java 1.4 nio包基于select()
-style API。�这是真的,爪哇NIO包是基于一种反应堆模式的select()
样式的API(见[ 7,8 ])。Java NIO允许编写自己的select()
风格的提供者(相当于TProactor等待策略)。看看Windows的Java NIO实现(为了检查jdk1.5.0 \ jre \ bin \ nio.dll中的导入符号,我们可以得出结论,Java NIO 1.4.2和1.5.0 for Windows基于WSAEventSelect()API。那比做得好select()
,但是对于大量连接,比IOCompletionPortï¿更慢。。如果1.5版本的Java的nio基于IOCompletionPorts,那么这应该可以提高性能。如果Java NIO会使用IOCompletionPorts,那么应该在nio.dll中将Proactor模式转换为Reactor模式。虽然这种转换比Reactor-> Proactor转换更复杂,但它可以在Java NIO接口的框架中实现。(这是下一个主题的主题,但我们可以提供算法)。目前,还没有对JDK 1.5进行过TProactor性能测试。
注意。所有Java测试都在“原始”缓冲区(java.nio.ByteBuffer)上执行,无需数据处理。
考虑到在Linux上开发强大的AIO的最新活动[ 9 ],我们可以得出结论,与POSIX标准相比,Linux内核API(io_xxxx系统调用集)应该更具可伸缩性,但仍然不可移植。在这种情况下,基于本机LINUX AIO的新引擎/等待策略对的TProactor可以轻松实现,以克服可移植性问题,并使用标准ACE Proactor接口覆盖Linux本机AIO。
附录一
TProactor中实施的引擎和等待策略
Engine Type | Wait Strategies | Operating System |
---|---|---|
POSIX_AIO (true async)aio_read() /aio_write() | aio_suspend() | POSIX complained UNIX (not robust) POSIX (not robust) SGI IRIX, LINUX (not robust) |
SUN_AIO (true async)aio_read() /aio_write() | aio_wait() | SUN (not robust) |
Emulated Async Non-blocking read() /write() | select() poll() /dev/poll Linux RT signals Kqueue | generic POSIX Mostly all POSIX implementations SUN Linux FreeBSD |
附录二
所有同步等待策略可分为两组:
- 边缘触发(例如Linux RT信号) - 仅当套接字准备好(改变状态)时才有信号准备;
- 级别触发(例如
select()
,poll()
/ dev / poll) - 随时可读。
让我们描述一下这些群体的一些常见逻辑问题:
- 边沿触发组:执行I / O操作后,解复用循环可能会丢失套接字就绪状态。示例:“read”处理程序未读取整个数据块,因此套接字仍然可以读取。但解复用器循环不会接收下一个通知。
- level-triggered group:当解复用器循环检测到就绪时,它启动写/读用户定义的处理程序。但在开始之前,它应该从受监视的描述符集中删除套接字描述符。否则,可以分派两次相同的事件。
- 显然,解决这些问题会给开发带来额外的复杂性。所有这些问题都在TProactor内部解决,开发人员不应该担心这些细节,而在同步方法中,需要额外的努力来解决它们。
点评:个人在翻译之后理解nio和aio区别主要是否是由于程序本身的线程在执行读操作,nio是由程序本身的子线程进行读操作,开始还需要询问是否准备好i/o操作;对于aio,主线程将读操作交给操作系统,由于不再是程序本身在执行i/o操作,而且因为操作系统比程序更为底层,所以速度会更快。