Reactor模式理解(二)

weixin_34054866發表於2017-11-30

本文轉發,總結的很不錯,特別是Reactor模式部分結合程式碼實踐。

本文轉發自:http://www.jasongj.com/java/nio_reactor/

Java I/O模型

同步 vs. 非同步

同步I/O 每個請求必須逐個地被處理,一個請求的處理會導致整個流程的暫時等待,這些事件無法併發地執行。使用者執行緒發起I/O請求後需要等待或者輪詢核心I/O操作完成後才能繼續執行。

非同步I/O 多個請求可以併發地執行,一個請求或者任務的執行不會導致整個流程的暫時等待。使用者執行緒發起I/O請求後仍然繼續執行,當核心I/O操作完成後會通知使用者執行緒,或者呼叫使用者執行緒註冊的回撥函式。

阻塞 vs. 非阻塞

阻塞 某個請求發出後,由於該請求操作需要的條件不滿足,請求操作一直阻塞,不會返回,直到條件滿足。

非阻塞 請求發出後,若該請求需要的條件不滿足,則立即返回一個標誌資訊告知條件不滿足,而不會一直等待。一般需要通過迴圈判斷請求條件是否滿足來獲取請求結果。

需要注意的是,阻塞並不等價於同步,而非阻塞並非等價於非同步。事實上這兩組概念描述的是I/O模型中的兩個不同維度。

同步和非同步著重點在於多個任務執行過程中,後發起的任務是否必須等先發起的任務完成之後再進行。而不管先發起的任務請求是阻塞等待完成,還是立即返回通過迴圈等待請求成功。

而阻塞和非阻塞重點在於請求的方法是否立即返回(或者說是否在條件不滿足時被阻塞)。

Unix下五種I/O模型

Unix 下共有五種 I/O 模型:

  • 阻塞 I/O
  • 非阻塞 I/O
  • I/O 多路複用(select和poll)
  • 訊號驅動 I/O(SIGIO)
  • 非同步 I/O(Posix.1的aio_系列函式)

阻塞I/O

如上文所述,阻塞I/O下請求無法立即完成則保持阻塞。阻塞I/O分為如下兩個階段。

  • 階段1:等待資料就緒。網路 I/O 的情況就是等待遠端資料陸續抵達;磁碟I/O的情況就是等待磁碟資料從磁碟上讀取到核心態記憶體中。
  • 階段2:資料拷貝。出於系統安全,使用者態的程式沒有許可權直接讀取核心態記憶體,因此核心負責把核心態記憶體中的資料拷貝一份到使用者態記憶體中。

非阻塞I/O

非阻塞I/O請求包含如下三個階段

  • socket設定為 NONBLOCK(非阻塞)就是告訴核心,當所請求的I/O操作無法完成時,不要將執行緒睡眠,而是返回一個錯誤碼(EWOULDBLOCK) ,這樣請求就不會阻塞。
  • I/O操作函式將不斷的測試資料是否已經準備好,如果沒有準備好,繼續測試,直到資料準備好為止。整個I/O 請求的過程中,雖然使用者執行緒每次發起I/O請求後可以立即返回,但是為了等到資料,仍需要不斷地輪詢、重複請求,消耗了大量的 CPU 的資源。
  • 資料準備好了,從核心拷貝到使用者空間。

一般很少直接使用這種模型,而是在其他I/O模型中使用非阻塞I/O 這一特性。這種方式對單個I/O 請求意義不大,但給I/O多路複用提供了條件。

I/O多路複用(非同步阻塞 I/O)

I/O多路複用會用到select或者poll函式,這兩個函式也會使執行緒阻塞,但是和阻塞I/O所不同的是,這兩個函式可以同時阻塞多個I/O操作。而且可以同時對多個讀操作,多個寫操作的I/O函式進行檢測,直到有資料可讀或可寫時,才真正呼叫I/O操作函式。

從流程上來看,使用select函式進行I/O請求和同步阻塞模型沒有太大的區別,甚至還多了新增監視Channel,以及呼叫select函式的額外操作,增加了額外工作。但是,使用 select以後最大的優勢是使用者可以在一個執行緒內同時處理多個Channel的I/O請求。使用者可以註冊多個Channel,然後不斷地呼叫select讀取被啟用的Channel,即可達到在同一個執行緒內同時處理多個I/O請求的目的。而在同步阻塞模型中,必須通過多執行緒的方式才能達到這個目的。

呼叫select/poll該方法由一個使用者態執行緒負責輪詢多個Channel,直到某個階段1的資料就緒,再通知實際的使用者執行緒執行階段2的拷貝。 通過一個專職的使用者態執行緒執行非阻塞I/O輪詢,模擬實現了階段一的非同步化。

訊號驅動I/O(SIGIO)

首先我們允許socket進行訊號驅動I/O,並安裝一個訊號處理函式,執行緒繼續執行並不阻塞。當資料準備好時,執行緒會收到一個SIGIO 訊號,可以在訊號處理函式中呼叫I/O操作函式處理資料。

非同步I/O

呼叫aio_read 函式,告訴核心描述字,緩衝區指標,緩衝區大小,檔案偏移以及通知的方式,然後立即返回。當核心將資料拷貝到緩衝區後,再通知應用程式。所以非同步I/O模式下,階段1和階段2全部由核心完成,完成不需要使用者執行緒的參與。

幾種I/O模型對比

除非同步I/O外,其它四種模型的階段2基本相同,都是從核心態拷貝資料到使用者態。區別在於階段1不同。前四種都屬於同步I/O。

Java中四種I/O模型

上一章所述Unix中的五種I/O模型,除訊號驅動I/O外,Java對其它四種I/O模型都有所支援。其中Java最早提供的blocking I/O即是阻塞I/O,而NIO即是非阻塞I/O,同時通過NIO實現的Reactor模式即是I/O複用模型的實現,通過AIO實現的Proactor模式即是非同步I/O模型的實現。

從IO到NIO

面向流 vs. 面向緩衝

Java IO是面向流的,每次從流(InputStream/OutputStream)中讀一個或多個位元組,直到讀取完所有位元組,它們沒有被快取在任何地方。另外,它不能前後移動流中的資料,如需前後移動處理,需要先將其快取至一個緩衝區。

Java NIO面向緩衝,資料會被讀取到一個緩衝區,需要時可以在緩衝區中前後移動處理,這增加了處理過程的靈活性。但與此同時在處理緩衝區前需要檢查該緩衝區中是否包含有所需要處理的資料,並需要確保更多資料讀入緩衝區時,不會覆蓋緩衝區內尚未處理的資料。

阻塞 vs. 非阻塞

Java IO的各種流是阻塞的。當某個執行緒呼叫read()或write()方法時,該執行緒被阻塞,直到有資料被讀取到或者資料完全寫入。阻塞期間該執行緒無法處理任何其它事情。

Java NIO為非阻塞模式。讀寫請求並不會阻塞當前執行緒,在資料可讀/寫前當前執行緒可以繼續做其它事情,所以一個單獨的執行緒可以管理多個輸入和輸出通道。

選擇器(Selector)

Java NIO的選擇器允許一個單獨的執行緒同時監視多個通道,可以註冊多個通道到同一個選擇器上,然後使用一個單獨的執行緒來“選擇”已經就緒的通道。這種“選擇”機制為一個單獨執行緒管理多個通道提供了可能。

零拷貝

Java NIO中提供的FileChannel擁有transferTo和transferFrom兩個方法,可直接把FileChannel中的資料拷貝到另外一個Channel,或者直接把另外一個Channel中的資料拷貝到FileChannel。該介面常被用於高效的網路/檔案的資料傳輸和大檔案拷貝。在作業系統支援的情況下,通過該方法傳輸資料並不需要將源資料從核心態拷貝到使用者態,再從使用者態拷貝到目標通道的核心態,同時也避免了兩次使用者態和核心態間的上下文切換,也即使用了“零拷貝”,所以其效能一般高於Java IO中提供的方法。

使用FileChannel的零拷貝將本地檔案內容傳輸到網路的示例程式碼如下所示。

public class NIOClient {
  public static void main(String[] args) throws IOException, InterruptedException {
    SocketChannel socketChannel = SocketChannel.open();
    InetSocketAddress address = new InetSocketAddress(1234);
    socketChannel.connect(address);
    RandomAccessFile file = new RandomAccessFile(
        NIOClient.class.getClassLoader().getResource("test.txt").getFile(), "rw");
    FileChannel channel = file.getChannel();
    channel.transferTo(0, channel.size(), socketChannel);
    channel.close();
    file.close();
    socketChannel.close();
  }
}

阻塞I/O下的伺服器實現

單執行緒逐個處理所有請求

使用阻塞I/O的伺服器,一般使用迴圈,逐個接受連線請求並讀取資料,然後處理下一個請求。

public class IOServer {
  private static final Logger LOGGER = LoggerFactory.getLogger(IOServer.class);
  public static void main(String[] args) {
    ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        InputStream inputstream = socket.getInputStream();
        LOGGER.info("Received message {}", IOUtils.toString(inputstream));
        IOUtils.closeQuietly(inputstream);
      }
    } catch(IOException ex) {
      IOUtils.closeQuietly(serverSocket);
      LOGGER.error("Read message failed", ex);
    }
  }
}

為每個請求建立一個執行緒

上例使用單執行緒逐個處理所有請求,同一時間只能處理一個請求,等待I/O的過程浪費大量CPU資源,同時無法充分使用多CPU的優勢。下面是使用多執行緒對阻塞I/O模型的改進。一個連線建立成功後,建立一個單獨的執行緒處理其I/O操作。

3609866-76954eaf0e2f69ff.jpg
image
public class IOServerMultiThread {
  private static final Logger LOGGER = LoggerFactory.getLogger(IOServerMultiThread.class);
  public static void main(String[] args) {
  ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        new Thread( () -> {
          try{
            InputStream inputstream = socket.getInputStream();
            LOGGER.info("Received message {}", IOUtils.toString(inputstream));
            IOUtils.closeQuietly(inputstream);
          } catch (IOException ex) {
            LOGGER.error("Read message failed", ex);
          }
        }).start();
      }
    } catch(IOException ex) {
      IOUtils.closeQuietly(serverSocket);
      LOGGER.error("Accept connection failed", ex);
    }
  }
}

使用執行緒池處理請求

為了防止連線請求過多,導致伺服器建立的執行緒數過多,造成過多執行緒上下文切換的開銷。可以通過執行緒池來限制建立的執行緒數,如下所示。

public class IOServerThreadPool {
  private static final Logger LOGGER = LoggerFactory.getLogger(IOServerThreadPool.class);
  public static void main(String[] args) {
    ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
    ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        executorService.submit(() -> {
          try{
            InputStream inputstream = socket.getInputStream();
            LOGGER.info("Received message {}", IOUtils.toString(new InputStreamReader(inputstream)));
          } catch (IOException ex) {
            LOGGER.error("Read message failed", ex);
          }
        });
      }
    } catch(IOException ex) {
      try {
        serverSocket.close();
      } catch (IOException e) {
      }
      LOGGER.error("Accept connection failed", ex);
    }
  }
}

Reactor模式

精典Reactor模式

精典的Reactor模式示意圖如下所示。


3609866-bd0503b1428b3821.jpg
image

在Reactor模式中,包含如下角色

  • Reactor

    將I/O事件發派給對應的Handler

  • Acceptor

    處理客戶端連線請求

  • Handlers

    執行非阻塞讀/寫

最簡單的Reactor模式實現程式碼如下所示。

public class NIOServer {
  private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);
  public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.configureBlocking(false);
    serverSocketChannel.bind(new InetSocketAddress(1234));
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    while (selector.select() > 0) {
      Set<SelectionKey> keys = selector.selectedKeys();
      Iterator<SelectionKey> iterator = keys.iterator();
      while (iterator.hasNext()) {
        SelectionKey key = iterator.next();
        iterator.remove();
        if (key.isAcceptable()) {
          ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
          SocketChannel socketChannel = acceptServerSocketChannel.accept();
          socketChannel.configureBlocking(false);
          LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
          socketChannel.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
          SocketChannel socketChannel = (SocketChannel) key.channel();
          ByteBuffer buffer = ByteBuffer.allocate(1024);
          int count = socketChannel.read(buffer);
          if (count <= 0) {
            socketChannel.close();
            key.cancel();
            LOGGER.info("Received invalide data, close the connection");
            continue;
          }
          LOGGER.info("Received message {}", new String(buffer.array()));
        }
        keys.remove(key);
      }
    }
  }
}

為了方便閱讀,上示程式碼將Reactor模式中的所有角色放在了一個類中。

從上示程式碼中可以看到,多個Channel可以註冊到同一個Selector物件上,實現了一個執行緒同時監控多個請求狀態(Channel)。同時註冊時需要指定它所關注的事件,例如上示程式碼中socketServerChannel物件只註冊了OP_ACCEPT事件,而socketChannel物件只註冊了OP_READ事件。

selector.select()是阻塞的,當有至少一個通道可用時該方法返回可用通道個數。同時該方法只捕獲Channel註冊時指定的所關注的事件。

多工作執行緒Reactor模式

經典Reactor模式中,儘管一個執行緒可同時監控多個請求(Channel),但是所有讀/寫請求以及對新連線請求的處理都在同一個執行緒中處理,無法充分利用多CPU的優勢,同時讀/寫操作也會阻塞對新連線請求的處理。因此可以引入多執行緒,並行處理多個讀/寫操作,如下圖所示。

3609866-b17131b2dbe04464.jpg
image

多執行緒Reactor模式示例程式碼如下所示。

public class NIOServer {
  private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);
  public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.configureBlocking(false);
    serverSocketChannel.bind(new InetSocketAddress(1234));
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    while (true) {
      if(selector.selectNow() < 0) {
        continue;
      }
      Set<SelectionKey> keys = selector.selectedKeys();
      Iterator<SelectionKey> iterator = keys.iterator();
      while(iterator.hasNext()) {
        SelectionKey key = iterator.next();
        iterator.remove();
        if (key.isAcceptable()) {
          ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
          SocketChannel socketChannel = acceptServerSocketChannel.accept();
          socketChannel.configureBlocking(false);
          LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
          SelectionKey readKey = socketChannel.register(selector, SelectionKey.OP_READ);
          readKey.attach(new Processor());
        } else if (key.isReadable()) {
          Processor processor = (Processor) key.attachment();
          processor.process(key);
        }
      }
    }
  }
}

從上示程式碼中可以看到,註冊完SocketChannel的OP_READ事件後,可以對相應的SelectionKey attach一個物件(本例中attach了一個Processor物件,該物件處理讀請求),並且在獲取到可讀事件後,可以取出該物件。

注:attach物件及取出該物件是NIO提供的一種操作,但該操作並非Reactor模式的必要操作,本文使用它,只是為了方便演示NIO的介面。

具體的讀請求處理在如下所示的Processor類中。該類中設定了一個靜態的執行緒池處理所有請求。而process方法並不直接處理I/O請求,而是把該I/O操作提交給上述執行緒池去處理,這樣就充分利用了多執行緒的優勢,同時將對新連線的處理和讀/寫操作的處理放在了不同的執行緒中,讀/寫操作不再阻塞對新連線請求的處理。

public class Processor {
  private static final Logger LOGGER = LoggerFactory.getLogger(Processor.class);
  private static final ExecutorService service = Executors.newFixedThreadPool(16);
  public void process(SelectionKey selectionKey) {
    service.submit(() -> {
      ByteBuffer buffer = ByteBuffer.allocate(1024);
      SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
      int count = socketChannel.read(buffer);
      if (count < 0) {
        socketChannel.close();
        selectionKey.cancel();
        LOGGER.info("{}\t Read ended", socketChannel);
        return null;
      } else if(count == 0) {
        return null;
      }
      LOGGER.info("{}\t Read message {}", socketChannel, new String(buffer.array()));
      return null;
    });
  }
}

多Reactor

Netty中使用的Reactor模式,引入了多Reactor,也即一個主Reactor負責監控所有的連線請求,多個子Reactor負責監控並處理讀/寫請求,減輕了主Reactor的壓力,降低了主Reactor壓力太大而造成的延遲。
並且每個子Reactor分別屬於一個獨立的執行緒,每個成功連線後的Channel的所有操作由同一個執行緒處理。這樣保證了同一請求的所有狀態和上下文在同一個執行緒中,避免了不必要的上下文切換,同時也方便了監控請求響應狀態。

多Reactor模式示意圖如下所示。


3609866-d16089401be235db.jpg
image

多Reactor示例程式碼如下所示。

public class NIOServer {
  private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);
  public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.configureBlocking(false);
    serverSocketChannel.bind(new InetSocketAddress(1234));
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    int coreNum = Runtime.getRuntime().availableProcessors();
    Processor[] processors = new Processor[coreNum];
    for (int i = 0; i < processors.length; i++) {
      processors[i] = new Processor();
    }
    int index = 0;
    while (selector.select() > 0) {
      Set<SelectionKey> keys = selector.selectedKeys();
      for (SelectionKey key : keys) {
        keys.remove(key);
        if (key.isAcceptable()) {
          ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
          SocketChannel socketChannel = acceptServerSocketChannel.accept();
          socketChannel.configureBlocking(false);
          LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
          Processor processor = processors[(int) ((index++) % coreNum)];
          processor.addChannel(socketChannel);
          processor.wakeup();
        }
      }
    }
  }
}

如上程式碼所示,本文設定的子Reactor個數是當前機器可用核數的兩倍(與Netty預設的子Reactor個數一致)。對於每個成功連線的SocketChannel,通過round robin的方式交給不同的子Reactor。

子Reactor對SocketChannel的處理如下所示。

public class Processor {
  private static final Logger LOGGER = LoggerFactory.getLogger(Processor.class);
  private static final ExecutorService service =
      Executors.newFixedThreadPool(2 * Runtime.getRuntime().availableProcessors());
  private Selector selector;
  public Processor() throws IOException {
    this.selector = SelectorProvider.provider().openSelector();
    start();
  }
  public void addChannel(SocketChannel socketChannel) throws ClosedChannelException {
    socketChannel.register(this.selector, SelectionKey.OP_READ);
  }
  public void wakeup() {
    this.selector.wakeup();
  }
  public void start() {
    service.submit(() -> {
      while (true) {
        if (selector.select(500) <= 0) {
          continue;
        }
        Set<SelectionKey> keys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = keys.iterator();
        while (iterator.hasNext()) {
          SelectionKey key = iterator.next();
          iterator.remove();
          if (key.isReadable()) {
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            SocketChannel socketChannel = (SocketChannel) key.channel();
            int count = socketChannel.read(buffer);
            if (count < 0) {
              socketChannel.close();
              key.cancel();
              LOGGER.info("{}\t Read ended", socketChannel);
              continue;
            } else if (count == 0) {
              LOGGER.info("{}\t Message size is 0", socketChannel);
              continue;
            } else {
              LOGGER.info("{}\t Read message {}", socketChannel, new String(buffer.array()));
            }
          }
        }
      }
    });
  }
}

在Processor中,同樣建立了一個靜態的執行緒池,且執行緒池的大小為機器核數的兩倍。每個Processor例項均包含一個Selector例項。同時每次獲取Processor例項時均提交一個任務到該執行緒池,並且該任務正常情況下一直迴圈處理,不會停止。而提交給該Processor的SocketChannel通過在其Selector註冊事件,加入到相應的任務中。由此實現了每個子Reactor包含一個Selector物件,並由一個獨立的執行緒處理。

相關文章