Tomcat NIO 模型的實現

Tomcat 對 BIO 和 NIO 兩種模型都進行了實現,其中 BIO 的實現理解起來比較簡單,而 NIO 的實現就比較複雜了,並且它跟常用的 Reactor 模型也略有不同,具體設計如下:

Tomcat NIO 模型

可以看出多了一個 BlockPoller 的設計,這是因爲在 Servlet 規範中 ServletInputStream 和 ServletOutputStream 是阻塞的,所以請求體和響應體的讀取和發送需要阻塞處理。請求行讀取SSL 握手使用非阻塞的 Poller 處理。一次連接基本的處理流程是:

  • Acceptor 接收 TCP 連接,並將其註冊到 Poller 上
  • Poller 發現通道有就緒的 I/O 事件,將事件分配給線程池中的線程處理
  • 線程池線程首先在 Poller 上非阻塞完成請求行和 SSL 握手的處理,然後通過容器調用 Servlet,生成響應,最後如果需要讀取請求體或者發送響應,那就會將通道註冊到 BlockPoller 上模擬阻塞完成

接下來分析核心代碼的實現,源碼來自 Tomcat 6.0.53 版本,之所以使用這個版本是因爲看起來簡單直觀沒有太多的抽象,也不影響來理解核心的處理邏輯。首先看下連接處理的方法調用情況,可右鍵直接打開圖片查看大圖:

Tomcat NIO 方法調用

相關類或接口的功能如下:

  • Acceptor: 阻塞監聽和接收通道連接
  • Poller: 事件多路複用器,通知 I/O 事件的發生並分配合適的處理器
  • PollerEvent: 是對通道、SelectionKey 的附加對象和通道關注事件的封裝
  • SocketProcessor: 線程池調度單元,它處理 SSL 握手,調用 Handler 解析協議
  • Handler: 通道處理接口,用於適配多種協議,如 HTTP、AJP
  • NioEndpoint: 服務啓停初始化的入口
  • NioSelectorPool: 提供一個阻塞讀寫使用的 Selector 池和一個單例 Selector
  • NioBlockingSelector: 提供阻塞讀和寫的方法
  • BlockPoller: 與 NioBlockingSelector 配合完成模擬阻塞
  • NioChannel: 封裝 SocketChannel 和讀寫使用的 ByteBuffer
  • KeyAttachment: Key 的附加對象,它包含通道最後訪問時間和用於模擬阻塞使用的讀寫閉鎖

1. Acceptor 註冊通道到 Poller 上

Acceptor 和 Poller 分屬兩個不同的線程,通常情況下 Poller 阻塞在 select() 方法的調用上,此方法會鎖住內部的 publicKeys 集合,所以 Acceptor 接收到通道連接不能直接註冊到 Poller 上,否則會造成死鎖。Tomcat 使用生產者-消費者模式來進行併發協作,緩衝區使用的是 ConcurrentLinkedQueue 無界隊列。

Acceptor 接收到連接的 SocketChannel 後,將其配置成非阻塞模式,封裝成 NioChannel,最後調用 getPoller0().register(NioChannel) 加入到某個 Poller 的事件隊列中。

public void register(final NioChannel socket) {
  socket.setPoller(this); // 關聯此 Poller
  KeyAttachment key = keyCache.poll();
  final KeyAttachment ka = key!=null?key:new KeyAttachment();
  // 重置或者初始化 KeyAttachment 對象
  ka.reset(this,socket,getSocketProperties().getSoTimeout());
  PollerEvent r = eventCache.poll();
  // 聲明此通道關注的事件
  ka.interestOps(SelectionKey.OP_READ);//this is what OP_REGISTER turns into.
  // 將此通道和 SelectionKey 附件對象封裝成 PollerEvent 對象
  if ( r==null) r = new PollerEvent(socket,ka,OP_REGISTER);
  else r.reset(socket,ka,OP_REGISTER);
  // 加入到 Poller 的 events 隊列中
  addEvent(r);
}
public void addEvent(Runnable event) {
  events.offer(event); // 插入隊列
  if ( wakeupCounter.incrementAndGet() == 0 )
    selector.wakeup(); // 喚醒 Selector
}

Poler 有個 events() 方法,用於遍歷事件隊列進行處理,events() 會在 select 調用超時或者被喚醒且沒有通道發生 I/O 事件時被調用,代碼如下:

public boolean events() {
  boolean result = false;
  Runnable r = null;
  // 遍歷事件隊列
  while ( (r = events.poll()) != null ) {
    result = true;// 有事件待處理
    try {
      r.run(); // 本質調用的是 PollerEvent.run()
      if ( r instanceof PollerEvent ) {
        // 重置並緩存 PollerEvent 對象
        ((PollerEvent)r).reset();
        eventCache.offer((PollerEvent)r); 
      }
    } catch ( Throwable x ) {
      log.error("",x);
    }
  }
  return result;
}

可以看出這裏有個關鍵對象 PollerEvent,它內部有個 interestOps 屬性,表示要處理的事件類型,它有三個可能的值分別是:

  • NioEndpoint.OP_REGISTER: 通道註冊事件
  • SelectionKey.OP_READ: 通道重新聲明在 Poller 上關注讀事件
  • SelectionKey.OP_WRITE: 通道重新聲明在 Poller 上關注寫事件

OP_REGISTER 的處理就是將通道註冊到 Selector 上的最終實現,代碼如下:

if ( interestOps == OP_REGISTER ) {
  try {
    // 將 SocketChannel 註冊到 Poller 的 Selector 上並指定關注的事件和附加對象
    socket.getIOChannel().register(socket.getPoller().getSelector(), SelectionKey.OP_READ, key);
  } catch (Exception x) {
    log.error("", x);
  }
}

至此已完成了通道註冊,接下來看一下 PollerEvent 爲什麼還要處理 OP_READ 和 OP_WRITE 事件。

2. PollerEvent 對 OP_READ 和 OP_WRITE 的處理

PollerEvent(又或者說 Poller)要處理讀寫事件,就是因爲程序需要一次非阻塞的讀或寫操作。一開始通道是在 Poller 上聲明關注的事件,但是在發生 I/O 事件後,Poller 就會把此通道就緒的事件從它關注的事件中移除(原因見下文),所以如果需要非阻塞的讀或寫,只能再次在這個 Poller 上重新聲明。

解析請求行是非阻塞的,解析過程中,由於 TCP 存在粘包/拆包的問題,可能導致數據讀取不完整,需要再次從通道讀取,此時就要在關聯的 Poller 上重新關注讀事件,核心代碼:

// 拿到通道在 Poller 上對應的 SelectionKey
final SelectionKey key = socket.getIOChannel().keyFor(socket.getPoller().getSelector());
try {
  boolean cancel = false;
  if (key != null) {
    ...
    // 將 interestOps 合併到 key 現有關注的事件集合中
    int ops = key.interestOps() | interestOps;
    // 更新 key 和 附加對象關注的操作
    att.interestOps(ops);
    key.interestOps(ops);
    att.setCometOps(ops);
  } else {
    cancel = true;
  }
}catch (CancelledKeyException ckx) {}

3. Poller 對 I/O 事件的處理

Poller 就是 Reactor,主要功能是將就緒的 SelectionKey 分配給處理器處理,此外它還檢查通道是否超時。它在調用 select 方法時會根據條件確定是阻塞還是非阻塞,代碼如下:

if ( !close ) {
  if (wakeupCounter.getAndSet(-1) > 0) {
    // wakeupCounter 大於0,意味着 Acceptor 接收了大量連接,產生大量 PollerEvent 急
    // 需 Poller 消費處理,此時進行一次非阻塞調用
    keyCount = selector.selectNow();// 非阻塞直接返回
  } else {
    // wakeupCounter 等於0,阻塞等待 IO 事件發生或被喚醒
    keyCount = selector.select(selectorTimeout);
  }
  wakeupCounter.set(0);
}

當有通道 I/O 事件就緒時,Poller 將會創建一個 SocketProcessor 提交線程池處理,具體代碼不再貼出。在這個過程中有一個將當前就緒的事件從 SelectionKey 中移除的操作,這是爲了後續能夠在 BlockPoller 上阻塞讀寫時,防止多個線程的干擾,具體代碼如下:

protected void unreg(SelectionKey sk, KeyAttachment attachment, int readyOps) {
  // 取反再與 - 表示從 sk.interestOps() 中清除 readyOps 所在的位
  reg(sk,attachment,sk.interestOps()& (~readyOps));
}
protected void reg(SelectionKey sk, KeyAttachment attachment, int intops) {
  sk.interestOps(intops);
  attachment.interestOps(intops);
  //attachment.setCometOps(intops);
}

檢查超時的方法是 Poller.timedout(keyCount, hasEvents),它在 Poller 的每次循環上都被調用,但不是每次都處理超時,因爲這會產生過多的負載,而超時可等待幾秒鐘再超時也沒事。Poler 有一個名爲 nextExpiration 的成員變量,它表示檢查超時的最短時間間隔,在這個時間內,如果只是 select() 調用超時(表示負載不大)會執行處理超時。

4. SocketProcessor 的處理

SocketProcessor 處理 SSL 握手和調用 Handler 進行實際的 I/O 操作。Handler 的子類 Http11ConnectionHandler 會創建 一個 Http11NioProcessor 對象最終處理 Socket,這裏不分析具體的協議處理,來看看幾種處理結果:

public SocketState process(NioChannel socket) {
  Http11NioProcessor processor = null;
  try {
    processor = connections.remove(socket);
    ...
    SocketState state = processor.process(socket);
    if (state == SocketState.LONG) {
      // 在處理request和生成response之間,保持socket和此processor的關聯
      connections.put(socket, processor);
      // 通常是收到了不完整的請求行,再次以 OP_READ 註冊到 Poller 上
      socket.getPoller().add(socket);
    } else if (state == SocketState.OPEN) {
      // 長連接,Http 保活,回收 processor
      release(socket, processor);
      // 此時已處理一個完整的請求並響應,再次註冊到 Poller 上,等待處理下個請求
      socket.getPoller().add(socket);
    } else if (state == SocketState.SENDFILE) {
      // 處理文件
      connections.put(socket, processor);
    } else {
      // 連接關閉,回收 processor
      release(socket, processor);
    }
    return state;
  } catch (...) {...}
  release(socket, processor);
  return SocketState.CLOSED;
}

5. 模擬阻塞的實現

模擬阻塞是通過 NioBlockingSelector 和 BlockPoller,以及 KeyAttachment 中的兩個 CountDownLatch 讀寫閉鎖合作完成。這裏分析阻塞讀,阻塞寫的實現類似。一般的,在讀取 POST 請求參數時會使用模擬阻塞完成,來看下 NioBlockingSelector.read() 方法的具體實現:

public int read(ByteBuffer buf, NioChannel socket, long readTimeout) throws IOException {
  // 拿到通道在 Poller 上註冊的 key
  SelectionKey key = socket.getIOChannel().keyFor(socket.getPoller().getSelector());
  if ( key == null ) throw new IOException("Key no longer registered");
  KeyReference reference = new KeyReference();
  // key 的附加對象
  KeyAttachment att = (KeyAttachment) key.attachment();
  int read = 0; // 讀取的字節數
  boolean timedout = false; // 是否超時
  int keycount = 1; //assume we can write 假設通道可讀
  long time = System.currentTimeMillis(); //start the timeout timer
  try {
    while ( (!timedout) && read == 0) {
      if (keycount > 0) { //only read if we were registered for a read
        // 嘗試讀取一次,如果通道無數據可讀則返回 0,若連接斷開則返回 -1
        int cnt = socket.read(buf);
        if (cnt == -1) throw new EOFException();
        read += cnt;
        if (cnt > 0) break;
      }
      try {
        // 初始化讀閉鎖
        if ( att.getReadLatch()==null || att.getReadLatch().getCount()==0) att.startReadLatch(1);
        // 將此通道註冊到 BlockPoller,關注讀取事件
        poller.add(att,SelectionKey.OP_READ, reference);
        // 阻塞等待通道可讀
        att.awaitReadLatch(readTimeout,TimeUnit.MILLISECONDS);
      }catch (InterruptedException ignore) {
        Thread.interrupted();
      }
      if ( att.getReadLatch()!=null && att.getReadLatch().getCount()> 0) {
        // 被打斷了,但是沒有接收到 blockPoller 的提醒
        keycount = 0;
        // 繼續循環等待可讀
      }else {
        //通道可讀,重置讀閉鎖
        keycount = 1;
        att.resetReadLatch();
      }
      if (readTimeout > 0 && (keycount == 0)) // 如果超時了,則不再讀取,拋異常
        timedout = (System.currentTimeMillis() - time) >= readTimeout;
    } //while
    if (timedout)
        throw new SocketTimeoutException();
  } finally {
    poller.remove(att,SelectionKey.OP_READ); // 移除註冊
    if (timedout && reference.key!=null) {
        poller.cancelKey(reference.key); // 超時取消
    }
    reference.key = null;
  }
  return read;
}

BlockPoller 實現邏輯與 Poller 大致相同,不同的地方在於對就緒 key 的處理,核心代碼如下:

Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (run && iterator != null && iterator.hasNext()) {
  SelectionKey sk = iterator.next();
  KeyAttachment attachment = (KeyAttachment)sk.attachment();
  try {
    attachment.access();
    iterator.remove();
    // 移除已就緒的事件
    sk.interestOps(sk.interestOps() & (~sk.readyOps()));
    // 可讀或可寫時減少對應閉鎖的值,此時阻塞在 NioBlockingSelector.read() 上的線程繼續執行讀取
    if ( sk.isReadable() ) {
        countDown(attachment.getReadLatch());
    }
    if (sk.isWritable()) {
        countDown(attachment.getWriteLatch());
    }
  }catch (CancelledKeyException ckx) {
    if (sk!=null) sk.cancel();
    countDown(attachment.getReadLatch());
    countDown(attachment.getWriteLatch());
  }
}//while

6. 小結

至此,本文對連接的接收、分發以及模擬阻塞的核心代碼實現進行了分析,爲了更好的理解內部流程,儘可能的使用簡潔的代碼仿寫了這部分功能。

源碼地址https://github.com/tonwu/rxtomcat 位於 rxtomcat-net 模塊

7. Tomcat 8.5 版本變化

7.1 替換緩存數據結構

Tomcat 對 PollerEvent、NioChannel 和 Processor 對象進行了緩存,目的是減少 GC 提高系統性能,這是一種用空間換時間,被稱爲對象池的優化手段。從版本 8.* 開始,緩存數據結構從 ConcurrentLinkedQueue 換成了自定義的同步棧 SynchronizedStack。SynchronizedStack 的 javadoc 明確說明:

當需要創建一個無需縮小的可重用對象池時,這是 ConcurrentLinkedQueue 無 GC 的主要替代方案。目的是儘可能快地以最少的垃圾提供最少的所需功能。

在這個特殊的情況下,ConcurrentLinkedQueue 有很多功能是不需要的,所以就實現了一個有重點的類,可以專注完成一件事,來提升性能。但它不是 ConcurrentLinkedQueue 的替代品。

7.2 LimitLatch

Acceptor 在接收連接前添加了一個 LimitLatch(類似信號量)來控制總連接數。分析下如果不加有什麼現象,在極端情況下,線程池沒有空閒線程並且它內部的隊列已滿,當有通道發生可讀或可寫事件時,Poller 會關閉此通道,此時系統負載已達到最高,如果 Acceptor 還在繼續接收連接並請求註冊,而不加限制,那麼就會一直重複 PollerEvent 入隊出隊和 Poller 單純關閉通道的操作,浪費系統資源。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章