如果你需要使用java编写一个高伸缩的 server,你很快会想到使用nio包。为了让你的server能最终跑起来,你可能会花大量的时间阅读各种博客和指南来理解NIO Selector中需要解决的线程同步问题,或者踩平其他常见的坑。这篇文章为你描述如何架构一个面向连接的基于nio的server,文章介绍了一个更优的线程模型,并讨论该server的基本组件。
线程架构
实现一个多线程的server,第一个最容易想到的线程架构就是thread-per-connection模式。这是java 1.4 之前最传统的模式,因为那时候java还不支持非阻塞式IO(non-blocking I/O)。thread-per-connection模式为每个connection分配一个专一的线程。在一个处理循环里,一个工作线程等待新的数据到达,处理请求,然后返回结果,最后调用socket的read方法, 并一直阻塞直到新的数据到达。
/*
* server based on thread-per-connection pattern
*/
public class Server {
private ExecutorService executors = Executors.newFixedThreadPool(10);
private boolean isRunning = true;
public static void main(String... args) throws ... {
new Server().launch(Integer.parseInt(args[0]));
}
public void launch(int port) throws ... {
ServerSocket sso = new ServerSocket(port);
while (isRunning) {
Socket s = sso.accept();
executors.execute(new Worker(s));
}
}
private class Worker implements Runnable {
private LineNumberReader in = null;
...
Worker(Socket s) throws ... {
in = new LineNumberReader(new InputStreamReader(...));
out = ...
}
public void run() {
while (isRunning) {
try {
// blocking read of a request (line)
String request = in.readLine();
// processing the request
...
String response = ...
// return the response
out.write(resonse);
out.flush();
} catch (Exception e ) {
...
}
}
in.close();
...
}
}
}
在这种模式下,所有同时发生的connection和所有的并发工作线程总是一一对应的。正因为每个connection都对应服务器端的一个线程,单个已经连接的connection的相应会很迅速。但是高负载下就需要更多并发的工作线程,这限制了可扩展性。并且如果存在大量的long-living connection,例如HTTP长连接,将会导致大量的并发线程,而每个线程的利用率很低,因为有大量时间浪费在等待新的数据/求到达,同样大量并发的线程也浪费内存空间,每个线程默认的栈空间大小为512KB。
如果服务器需要处理大量的并发请求,同时需要容忍相应慢的客户端,这就需要新的线程架构。一种更有效率的方式为thread-on-event。工作线程独立于connection而只被用来处理特定的事件。例如,如果新的数据到达,一个工作线程会被调用去处理应用相关的解码和服务任务(或者只是开始这些任务)。一旦任务完成,这个工作线程会返回线程池。这种处理方式需要非阻塞的执行socket的I/O操作,即socket上的read和write可以非阻塞式调用,并且需要一个额外的event system,用来通知事件的发生。总之这种方式去除了线程和connection之间的1:1对应。这种event-driven的I/O系统称为Reactor模式。
Reactor 模式
Reactor模式(如图1), 使得事件的监听和事件的处理相分离。如果一个读就绪事件发生,一个事件处理函数会被通知去处理该事件,当然这个处理函数会跑在一个分配好的工作线程上。
- 为了接入事件架构,连接的Channel需要注册到Selector上,这一步通过调用SelectorChannel.register()方法,该方法的Selector参数会反过来注册SelectorChannel到自身内部。
SocketChannel channel = serverChannel.accept();
//configure it as non blocking way, required!
channel.configureBlocking(false);
// register the connection
SelectionKey sk = channel.register(selector, SelectionKey.OP_READ);
为了检测新的事件,
Selector
类提供了·select()·方法,收集已经注册的Channel上所有就绪的事件,该方法阻塞直到出现就绪事件。上面的例子里,select()方法会返回所有I/O就绪的connection数目,所有被选择的connection可以通过·selector.selectedKeys()· 得到SelectionKey的集合。每个SelecttionKey上有当前connection上事件的状态和对应的Channel引用。Selector引用被Dispatcher持有,后者负责分发事件到EventHandler中。Dispatcher的分发逻辑中不断的调用Selector.select()方法等待新的事件,一旦有事件发生,分发方法会调用对应的处理函数。例如对于读就绪事件或者写就绪事件,EventHandler会被调用来处理数据,调用服务,并编码响应数据。
//Diapatcher's diapatching logic
...
while (isRunning) {
// blocking call, to wait for new readiness events
int eventCount = selector.select();
// get the events
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
// readable event?
if (key.isValid() && key.isReadable()) {
eventHandler.onReadableEvent(key.channel());
}
// writable event?
if (key.isValid() && key.isWritable()) {
key.interestOps(SelectionKey.OP_READ); // reset to read only
eventHandler.onWriteableEvent(key.channel());
}
...
}
...
}
在整个架构中,因为工作线程并没有被迫浪费时间等待连接上新的请求,这种架构的吞吐量和可扩展性仅仅取决于系统自身的资源例如CPU或者内存。但是由于线程切换和同步,单个连接的处理速度没有thread-per-connection快。因此这种event-driven的架构真实的挑战来自如何优化线程管理和降低线程同步的消耗,从而尽量减小这种开销。
组件架构
大部分的高伸缩Java服务器都基于Reactor模式。但在原有Reactor模式的类的基础上需要增加诸如connection管理,buffer管理,负载均衡管理等。Server的入口一般称作Acceptor类。如图2。
Acceptor
所有客户端连接都会被单个Acceptor接受,这个Acceptor绑定到特定端口,并且跑在一个单独的线程上。因为它所要处理的任务就是接受客户端请求,操作时间很短,因此采用阻塞式I/O便足够。Acceptor调用ServerSocketChannel上的Accept()方法接受新的请求,得到请求对应的connection的SocketChannel,然后注册该SocketChannel到Selector上,这个SocketChannel就进入到事件监听和处理循环中。
因为单个Dispatcher扩展性有限,这种限制常常来自操作系统上Selector的具体实现,大部分主流的OS会将SocketChannel映射到文件句柄(File handle)上,因此不同的系统上单个Selector最多的文件句柄数目有所不同。基于该原因,Server常常会维护一个Dispatcher Pool。
class Acceptor implements Runnable {
...
void init() {
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(true);
serverChannel.socket().bind(new InetSocketAddress(serverPort));
}
public void run() {
while (isRunning) {
try {
SocketChannel channel = serverChannel.accept();
/*
* a Connection object holds the SocketChannel and an Application-Level event handler
*/
Connection con = new Connection(channel, appHandler);
dispatcherPool.nextDispatcher().register(con);
} catch (...) {
...
}
}
}
}
Dispatcher
Dispacther.Register()会调用内部的Selector来注册该SocketChannel。此处有一问题, Selector内部通过key sets
管理已注册的Channels, 注册管道时,需要创建一个SelectionKey并添加到key sets
中,而在并发操作下,同一个Dispatcher同样会通过Selector.select()方法读取key sets
。因为key sets
本身不是thread-safe,因此需要实现额外的线程同步。
class Dispatcher implements Runnable {
private Object guard = new Object();
...
void register(Connection con) {
// retrieve the guard lock and wake up the dispatcher thread
// to register the connection's channel
synchronized (guard) {
selector.wakeup();
con.getChannel().register(selector, SelectionKey.OP_READ, con);
}
// notify the application EventHandler about the new connection
...
}
void announceWriteNeed(Connection con) {
SelectionKey key = con.getChannel().keyFor(selector);
synchronized (guard) {
selector.wakeup();
key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}
}
public void run() {
while (isRunning) {
synchronized (guard) {
// suspend the dispatcher thead if guard is locked
}
int eventCount = selector.select();
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
// read event?
if (key.isValid() && key.isReadable()) {
Connection con = (Connection) key.attachment();
disptacherEventHandler.onReadableEvent(con);
}
// write event?
...
}
}
}
}
一旦connection注册完成,Selector会监听connection上的就绪事件,事件发生后,Dispatcher上对应的处理函数会调用,同时向处理函数传入对应的connection。
Dispatcher-Level EventHandler
在处理connection上的读就绪事件时,首先会调用对应的SocketChannel上的read()方法,不同于流式I/O,这个read()方法需要一块分配好的缓冲区。通常在此处使用直接缓冲区(Direct Buffer)。直接缓冲区位于本地内存中,绕过了Java的堆内存。因此通过使用直接缓冲区,Socket I/O 操作不需要创建内部的中间缓冲区。
通常情况下socket上read()方法会很快,在操作系统上的实现为将数据从kernel memory space转移到user-controlled memory space上read buffer中。读好的数据会最终放到connection的read queue中,这是一个线程安全的队列。接下来Application Level上的处理函数会处理具体的逻辑,这通常会跑在一个线程池中的某个工作线程上。
class DispatcherEventHandler {
...
void onReadableEvent(final Connection con) {
// get the received data
ByteBuffer readBuffer = allocateMemory();
con.getChannel().read(readBuffer);
ByteBuffer data = extractReadAndRecycleRenaming(readBuffer);
// append it to read queue
con.getReadQueue().add(data);
...
// perform further operations (encode, process, decode)
// by a worker thread
if (con.getReadQueue().getSize() > 0) {
workerPool.execute(new Runnable() {
public void run() {
synchronized (con) {
con.getAppHandler().onData(con);
}
}
});
}
}
void onWriteableEvent(Connection con) {
ByteBuffer[] data = con.getWriteQueue().drain();
con.getChannel().write(data); // write the data
...
if (con.getWriteQueue().isEmpty()) {
if (con.isClosed()) {
dispatcher.deregister(con);
}
} else {
// there is remaining data to write
dispatcher.announceWriteNeed(con);
}
}
}
Application-Level EventHandler
于Dispatchers上的EventHandler对比,Application-Level上的处理函数处理的是抽象好的面向连接的事件,如connection established data received和connection disconnected. 不同的框架如 SEDA, MINA, emberIO设计了不同的EventHandler.这些框架通常会设计多层次架构,这样可以使用事件处理链,例如添加SSLHandler或者DelayedWriteHandler用来拦截request/resonse处理过程。下面是基于xSocket框架一个例子,xSocket框架支持不同的处理器接口,可以用来实现application特定的处理逻辑。
class POP3ProtocolHandler implements IConnectHandler, IDataHandler, ... {
private static final String DELIMITER = ...
private Mailbox mailbox = ...
public static void main(String... args) throws ... {
new MultithreadedServer(110, new POP3ProtocolHandler()).run();
}
public boolean onConnect(INonBlockingConnection con) throws ... {
if (gatekeeper.isSuspiciousAddress(con.getRemoteAddress())) {
con.setWriteTransferRate(5); // reduce transfer: 5byte/sec
}
con.write("+OK My POP3-Server" + DELIMITER);
return true;
}
public boolean onData(INonBlockingConnection con) throws ... {
String request = con.readStringByDelimiter(DELIMITER);
if (request.startsWith("QUIT")) {
mailbox.close();
con.write("+OK POP3 server signing off" + DELIMITER);
con.close();
} else if (request.startsWith("USER")) {
this.user = request.substring(4, request.length());
con.write("+OK enter password" + DELIMITER);
} else if (request.startsWith("PASS")) {
String pwd = request.substring(4, request.length());
boolean isAuthenticated = authenticator.check(user, pwd);
if (isAuthenticated) {
mailbox = MailBox.openAndLock(user);
con.write("+OK mailbox locked and ready" + DELIMITER);
} else {
...
}
} else if (...) {
...
}
return true;
}
}
为了方便读取底层的read queue和write queue,Connection对象提供了一些便利的方法,包括基于流和基于通道的操作。
在Connection内部,当调用close()方法时,底层实现用了writeable event round-trip去清空write queue,在数据完全写完后,连接关闭。同样对于意外关闭,如硬件错误导致TCP连接断开,这些问题可以通过调用read或者write操作或者空闲超时(idle timeout)检测。大部分的NIO框架都提供了针对意外断开的内部处理。
总结
事件驱动非阻塞式架构是实现高效率,高扩展高可靠服务器的基础。挑战在于减小线程同步开销,优化连接/缓冲区管理。这是最难的部分。
但是重复发明轮子没有必要,服务器框架如xSocket, emberIO, SEDA 或者MINA均对底层事件处理和线程管理进行了抽象,使得构建服务器变得容易。大部分框架也实现了SSL或者UDP,对此本文没有讨论。