基于nio架构的可高扩展的服务器(Architecture of a highly scalable NIO-based server)

1 篇文章 0 订阅

如果你需要使用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), 使得事件的监听和事件的处理相分离。如果一个读就绪事件发生,一个事件处理函数会被通知去处理该事件,当然这个处理函数会跑在一个分配好的工作线程上。

图 1: A NIO-based Reactor Pattern implementation

  • 为了接入事件架构,连接的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。
图2 Major Components of a connection-oriented server

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() &gt; 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,对此本文没有讨论。

原文链接
https://today.java.net/pub/a/today/2007/02/13/architecture-of-highly-scalable-nio-server.html#application-level-eventhandler

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值