一篇文章幫你徹底搞清楚“I/O多路複用”和“非同步I/O”的前世今生

過客啊發表於2019-06-18

曾經的VIP服務

在網路的初期,網民很少,伺服器完全無壓力,那時的技術也沒有現在先進,通常用一個執行緒來全程跟蹤處理一個請求。因為這樣最簡單。

其實程式碼實現大家都知道,就是伺服器上有個ServerSocket在某個埠監聽,接收到客戶端的連線後,會建立一個Socket,並把它交給一個執行緒進行後續處理。

執行緒主要從Socket讀取客戶端傳過來的資料,然後進行業務處理,並把結果再寫入Socket傳回客戶端。

由於網路的原因,Socket建立後並不一定能立刻從它上面讀取資料,可能需要等一段時間,此時執行緒也必須一直阻塞著。在向Socket寫入資料時,也可能會使執行緒阻塞。

這裡準備了一個示例,主要邏輯如下:

客戶端:建立20個Socket並連線到伺服器上,再建立20個執行緒,每個執行緒負責一個Socket。

伺服器端:接收到這20個連線,建立20個Socket,接著建立20個執行緒,每個執行緒負責一個Socket。

為了模擬伺服器端的Socket在建立後不能立馬讀取資料,讓客戶端的20個執行緒分別休眠5-10之間的一個隨機秒數。

客戶端的20個執行緒會在第5秒到第10秒這段時間內陸陸續續的向伺服器端傳送資料,伺服器端的20個執行緒也會陸陸續續接收到資料。

/**
 * @author lixinjie
 * @since 2019-05-07
 */
public class BioServer {

  static AtomicInteger counter = new AtomicInteger(0);
  static SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); 
  
  public static void main(String[] args) {
    try {
      ServerSocket ss = new ServerSocket();
      ss.bind(new InetSocketAddress("localhost", 8080));
      while (true) {
        Socket s = ss.accept();
        processWithNewThread(s);
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  
  static void processWithNewThread(Socket s) {
    Runnable run = () -> {
      InetSocketAddress rsa = (InetSocketAddress)s.getRemoteSocketAddress();
      System.out.println(time() + "->" + rsa.getHostName() + ":" + rsa.getPort() + "->" + Thread.currentThread().getId() + ":" + counter.incrementAndGet());
      try {
        String result = readBytes(s.getInputStream());
        System.out.println(time() + "->" + result + "->" + Thread.currentThread().getId() + ":" + counter.getAndDecrement());
        s.close();
      } catch (Exception e) {
        e.printStackTrace();
      }
    };
    new Thread(run).start();
  }
  
  static String readBytes(InputStream is) throws Exception {
    long start = 0;
    int total = 0;
    int count = 0;
    byte[] bytes = new byte[1024];
    //開始讀資料的時間
    long begin = System.currentTimeMillis();
    while ((count = is.read(bytes)) > -1) {
      if (start < 1) {
        //第一次讀到資料的時間
        start = System.currentTimeMillis();
      }
      total += count;
    }
    //讀完資料的時間
    long end = System.currentTimeMillis();
    return "wait=" + (start - begin) + "ms,read=" + (end - start) + "ms,total=" + total + "bs";
  }

  static String time() {
    return sdf.format(new Date());
  }
}
複製程式碼
/**
 * @author lixinjie
 * @since 2019-05-07
 */
public class Client {

  public static void main(String[] args) {
    try {
      for (int i = 0; i < 20; i++) {
        Socket s = new Socket();
        s.connect(new InetSocketAddress("localhost", 8080));
        processWithNewThread(s, i);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

  static void processWithNewThread(Socket s, int i) {
    Runnable run = () -> {
      try {
        //睡眠隨機的5-10秒,模擬資料尚未就緒
        Thread.sleep((new Random().nextInt(6) + 5) * 1000);
        //寫1M資料,為了拉長伺服器端讀資料的過程
        s.getOutputStream().write(prepareBytes());
        //睡眠1秒,讓伺服器端把資料讀完
        Thread.sleep(1000);
        s.close();
      } catch (Exception e) {
        e.printStackTrace();
      }
    };
    new Thread(run).start();
  }
  
  static byte[] prepareBytes() {
    byte[] bytes = new byte[1024*1024*1];
    for (int i = 0; i < bytes.length; i++) {
      bytes[i] = 1;
    }
    return bytes;
  }
}
複製程式碼

執行結果如下:

時間->IP:Port->執行緒Id:當前執行緒數
15:11:52->127.0.0.1:55201->10:1
15:11:52->127.0.0.1:55203->12:2
15:11:52->127.0.0.1:55204->13:3
15:11:52->127.0.0.1:55207->16:4
15:11:52->127.0.0.1:55208->17:5
15:11:52->127.0.0.1:55202->11:6
15:11:52->127.0.0.1:55205->14:7
15:11:52->127.0.0.1:55206->15:8
15:11:52->127.0.0.1:55209->18:9
15:11:52->127.0.0.1:55210->19:10
15:11:52->127.0.0.1:55213->22:11
15:11:52->127.0.0.1:55214->23:12
15:11:52->127.0.0.1:55217->26:13
15:11:52->127.0.0.1:55211->20:14
15:11:52->127.0.0.1:55218->27:15
15:11:52->127.0.0.1:55212->21:16
15:11:52->127.0.0.1:55215->24:17
15:11:52->127.0.0.1:55216->25:18
15:11:52->127.0.0.1:55219->28:19
15:11:52->127.0.0.1:55220->29:20

時間->等待資料的時間,讀取資料的時間,總共讀取的位元組數->執行緒Id:當前執行緒數
15:11:58->wait=5012ms,read=1022ms,total=1048576bs->17:20
15:11:58->wait=5021ms,read=1022ms,total=1048576bs->13:19
15:11:58->wait=5034ms,read=1008ms,total=1048576bs->11:18
15:11:58->wait=5046ms,read=1003ms,total=1048576bs->12:17
15:11:58->wait=5038ms,read=1005ms,total=1048576bs->23:16
15:11:58->wait=5037ms,read=1010ms,total=1048576bs->22:15
15:11:59->wait=6001ms,read=1017ms,total=1048576bs->15:14
15:11:59->wait=6016ms,read=1013ms,total=1048576bs->27:13
15:11:59->wait=6011ms,read=1018ms,total=1048576bs->24:12
15:12:00->wait=7005ms,read=1008ms,total=1048576bs->20:11
15:12:00->wait=6999ms,read=1020ms,total=1048576bs->14:10
15:12:00->wait=7019ms,read=1007ms,total=1048576bs->26:9
15:12:00->wait=7012ms,read=1015ms,total=1048576bs->21:8
15:12:00->wait=7023ms,read=1008ms,total=1048576bs->25:7
15:12:01->wait=7999ms,read=1011ms,total=1048576bs->18:6
15:12:02->wait=9026ms,read=1014ms,total=1048576bs->10:5
15:12:02->wait=9005ms,read=1031ms,total=1048576bs->19:4
15:12:03->wait=10007ms,read=1011ms,total=1048576bs->16:3
15:12:03->wait=10006ms,read=1017ms,total=1048576bs->29:2
15:12:03->wait=10010ms,read=1022ms,total=1048576bs->28:1
複製程式碼

可以看到伺服器端確實為每個連線建立一個執行緒,共建立了20個執行緒。

客戶端進入休眠約5-10秒,模擬連線上資料不就緒,伺服器端執行緒在等待,等待時間約5-10秒。

客戶端陸續結束休眠,往連線上寫入1M資料,伺服器端開始讀取資料,整個讀取過程約1秒。 可以看到,伺服器端的工作執行緒會把時間花在**“等待資料”“讀取資料”**這兩個過程上。

這有兩個不好的地方

  • 一是有很多客戶端同時發起請求的話,伺服器端要建立很多的執行緒,可能會因為超過了上限而造成崩潰。

  • 二是每個執行緒的大部分時光中都是在阻塞著,無事可幹,造成極大的資源浪費。 開頭已經說了那個年代網民很少,所以,不可能會有大量請求同時過來。至於資源浪費就浪費吧,反正閒著也是閒著。

來個簡單的小例子:

飯店共有10張桌子,且配備了10位服務員。只要有客人來了,大堂經理就把客人帶到一張桌子,並安排一位服務員全程陪同。

即使客人暫時不需要服務,服務員也一直在旁邊站著。可能覺著是一種浪費,其實非也,這就是尊貴的VIP服務。 其實,VIP對映的是一對一的模型,主要體現在“專用”上或“私有”上。

真正的多路複用技術 多路複用技術原本指的是,在通訊方面,多種訊號或資料(從巨集觀上看)交織在一起,使用同一條傳輸通道進行傳輸。

這樣做的目的,一方面可以充分利用通道的傳輸能力,另一方面自然是省時省力省錢啦。

其實這個概念非常的“生活化”,隨手就可以舉個例子:

一條小水渠裡水在流,在一端往裡倒入大量乒乓球,在另一端用網進行過濾,把乒乓球和水流分開。

這就是一個比較“土”的多路複用,首先在發射端把多種訊號或資料進行“混合”,接著是在通道上進行傳輸,最後在接收端“分離”出自己需要的訊號或資料。

相信大家都看出來了,這裡的重點其實就是處理好“混合”和“分離”,對於不同的訊號或資料,有不同的處理方法。

比如以前的有線電視是模擬訊號,即電磁波。一家一般只有一根訊號線,但可以同時接多個電視,每個電視任意換臺,互不影響。

這是由於不同頻率的波可以混合和分離。(當然,可能不是十分準確,明白意思就行了。)

再比如城市的高鐵站一般都有數個站臺供高鐵(同時)停靠,但城市間的高鐵軌道單方向只有一條,如何保證那麼多趟高鐵安全執行呢?

很明顯是分時使用,每趟高鐵都有自己的時刻。多趟高鐵按不同的時刻出站相當於混合,按不同的時刻進站相當於分離。

總結一下,多路指的是多種不同的訊號或資料或其它事物,複用指的是共用同一個物理鏈路或通道或載體。 可見,多路複用技術是一種一對多的模型,“多”的這一方複用了“一”的這一方。

其實,一對多的模型主要體現在“公用”上或“共享”上。

您先看著,我一會再過來

一對一服務是典型的有錢任性,雖然響應及時、服務周到,但不是每個人都能享受的,畢竟還是“屌絲”多嘛,那就來個共享服務吧。

所以實際當中更多的情況是,客人坐下後,會給他一個選單,讓他先看著,反正也不可能立馬點餐,服務員就去忙別的了。

可能不時的會有服務員從客人身旁經過,發現客人還沒有點餐,就會主動去詢問現在需要點餐嗎?

如果需要,服務員就給你寫選單,如果不需要,服務員就繼續往前走了。 這種情況飯店整體執行的也很好,但是服務員人數少多了。現在服務10桌客人,4個服務員綽綽有餘。(這節省的可都是純利潤呀。)

因為10桌客人同時需要服務的情況幾乎是不會發生的,絕大部分情況都是錯開的。如果真有的話,那就等會好了,又不是120/119,人命關天的。

回到程式碼裡,情況與之非常相似,完全可以採用相同的理論去處理。

連線建立後,找個地方把它放到那裡,可以暫時先不管它,反正此時也沒有資料可讀。

但是資料早晚會到來的,所以,要不時的去詢問每個連線有資料沒有,有的話就讀取資料,沒有的話就繼續不管它。

其實這個模式在Java裡早就有了,就是Java NIO,這裡的大寫字母“N”是單詞“New”,即“新”的意思,主要是為了和上面的“一對一”進行區分。

先鋪墊一下吧

現在需要把Socket互動的過程再稍微細化一些。客戶端先請求連線,connect,伺服器端然後接受連線,accept,然後客戶端再向連線寫入資料,write,接著伺服器端從連線上讀出資料,read。

和打電話的場景一樣,主叫撥號,connect,被叫接聽,accept,主叫說話,speak,被叫聆聽,listen。主叫給被叫打電話,說明主叫找被叫有事,所以被叫關注的是接通電話,聽對方說。

客戶端主動向伺服器端發起請求,說明客戶端找伺服器端有事,所以伺服器端關注的是接受請求,讀取對方傳來的資料。這裡把接受請求,讀取資料稱為伺服器端感興趣的操作。

在Java NIO中,接受請求的操作,用OP_ACCEPT表示,讀取資料的操作,用OP_READ表示。

我決定先過一遍飯店的場景,讓首次接觸Java NIO的同學不那麼迷茫。就是把常規的場景進行了定向整理,稍微有點刻意,明白意思就行了。

    1. 專門設立一個“跑腿”服務員,工作職責單一,就是問問客人是否需要服務。
    1. 站在門口接待客人,本來是大堂經理的工作,但是他不願意在門口盯著,於是就委託給跑腿服務員,你幫我盯著,有人來了告訴我。

於是跑腿服務員就有了一個任務,替大堂經理盯梢。終於來客人了,跑腿服務員趕緊告訴了大堂經理。

    1. 大堂經理把客人帶到座位上,對跑腿服務員說,客人接下來肯定是要點餐的,但是現在在看選單,不知道什麼時候能看好,所以你不時的過來問問,看需不需要點餐,需要的話就再喊來一個“點餐”服務員給客人寫選單。

於是跑腿服務員就又多了一個任務,就是盯著這桌客人,不時來問問,如果需要服務的話,就叫點餐服務員過來服務。

    1. 跑腿服務員在某次詢問中,客人終於決定點餐了,跑題服務員趕緊找來一個點餐服務員為客人寫選單。
    1. 就這樣,跑腿服務員既要盯著門外新過來的客人,也要盯著門內已經就坐的客人。新客人來了,通知大堂經理去接待。就坐的客人決定點餐了,通知點餐服務員去寫選單。

事情就這樣一直迴圈的持續下去,一切,都挺好。角色明確,職責單一,配合很好。

大堂經理和點餐服務員是需求的提供者或實現者,跑腿服務員是需求的發現者,並識別出需求的種類,需要接待的交給大堂經理,需要點餐的交給點餐服務員。

哈哈,Java NIO來啦

程式碼的寫法非常的固定,可以配合著後面的解說來看,這樣就好理解了,如下:

/**
 * @author lixinjie
 * @since 2019-05-07
 */
public class NioServer {

  static int clientCount = 0;
  static AtomicInteger counter = new AtomicInteger(0);
  static SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); 
  
  public static void main(String[] args) {
    try {
      Selector selector = Selector.open();
      ServerSocketChannel ssc = ServerSocketChannel.open();
      ssc.configureBlocking(false);
      ssc.register(selector, SelectionKey.OP_ACCEPT);
      ssc.bind(new InetSocketAddress("localhost", 8080));
      while (true) {
        selector.select();
        Set<SelectionKey> keys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = keys.iterator();
        while (iterator.hasNext()) {
          SelectionKey key = iterator.next();
          iterator.remove();
          if (key.isAcceptable()) {
            ServerSocketChannel ssc1 = (ServerSocketChannel)key.channel();
            SocketChannel sc = null;
            while ((sc = ssc1.accept()) != null) {
              sc.configureBlocking(false);
              sc.register(selector, SelectionKey.OP_READ);
              InetSocketAddress rsa = (InetSocketAddress)sc.socket().getRemoteSocketAddress();
              System.out.println(time() + "->" + rsa.getHostName() + ":" + rsa.getPort() + "->" + Thread.currentThread().getId() + ":" + (++clientCount));
            }
          } else if (key.isReadable()) {
            //先將“讀”從感興趣操作移出,待把資料從通道中讀完後,再把“讀”新增到感興趣操作中
            //否則,該通道會一直被選出來
            key.interestOps(key.interestOps() & (~ SelectionKey.OP_READ));
            processWithNewThread((SocketChannel)key.channel(), key);
          }
        }
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }

  static void processWithNewThread(SocketChannel sc, SelectionKey key) {
    Runnable run = () -> {
      counter.incrementAndGet();
      try {
        String result = readBytes(sc);
        //把“讀”加進去
        key.interestOps(key.interestOps() | SelectionKey.OP_READ);
        System.out.println(time() + "->" + result + "->" + Thread.currentThread().getId() + ":" + counter.get());
        sc.close();
      } catch (Exception e) {
        e.printStackTrace();
      }
      counter.decrementAndGet();
    };
    new Thread(run).start();
  }
  
  static String readBytes(SocketChannel sc) throws Exception {
    long start = 0;
    int total = 0;
    int count = 0;
    ByteBuffer bb = ByteBuffer.allocate(1024);
    //開始讀資料的時間
    long begin = System.currentTimeMillis();
    while ((count = sc.read(bb)) > -1) {
      if (start < 1) {
        //第一次讀到資料的時間
        start = System.currentTimeMillis();
      }
      total += count;
      bb.clear();
    }
    //讀完資料的時間
    long end = System.currentTimeMillis();
    return "wait=" + (start - begin) + "ms,read=" + (end - start) + "ms,total=" + total + "bs";
  }
  
  static String time() {
    return sdf.format(new Date());
  }
}
複製程式碼

它的大致處理過程如下:

  • 1、定義一個選擇器,Selector。

相當於設立一個跑腿服務員。

  • 2、定義一個伺服器端套接字通道,ServerSocketChannel,並配置為非阻塞的。

相等於聘請了一位大堂經理

  • 3.將套接字通道註冊到選擇器上,並把感興趣的操作設定為OP_ACCEPT。

相當於大堂經理給跑腿服務員說,幫我盯著門外,有客人來了告訴我。

  • 4、進入死迴圈,選擇器不時的進行選擇。

相當於跑腿服務員一遍又一遍的去詢問、去轉悠

  • 5、選擇器終於選擇出了通道,發現通道是需要Acceptable的。

相當於跑腿服務員終於發現門外來客人了,客人是需要接待的

  • 6、於是伺服器端套接字接受了這個通道,開始處理。

相當於跑腿服務員把大堂經理叫來了,大堂經理開始著手接待

  • 7、把新接受的通道配置為非阻塞的,並把它也註冊到了選擇器上,該通道感興趣的操作為OP_READ。

相當於大堂經理把客人帶到座位上,給了客人選單,並又把客人委託給跑腿服務員,說客人接下來肯定是要點餐的,你不時的來問問

  • 8、選擇器繼續不時的進行選擇著。

相當於跑腿服務員繼續不時的詢問著、轉悠著

  • 9、選擇器終於又選擇出了通道,這次發現通道是需要Readable的。

相當於跑腿服務員終於發現了一桌客人有了需求,是需要點餐的

  • 10、把這個通道交給了一個新的工作執行緒去處理。

相當於跑腿服務員叫來了點餐服務員,點餐服務員開始為客人寫選單

  • 11、這個工作執行緒處理完後,就被回收了,可以再去處理其它通道。

相當於點餐服務員寫好選單後,就走了,可以再去為其他客人寫選單

  • 12、選擇器繼續著重複的選擇工作,不知道什麼時候是個頭。

相當於跑腿服務員繼續著重複的詢問、轉悠,不知道未來在何方

相信你已經看出來了,大堂經理相當於伺服器端套接字,跑腿服務員相當於選擇器,點餐服務員相當於Worker執行緒。 啟動伺服器端程式碼,使用同一個客戶端程式碼,按相同的套路發20個請求,結果如下:

時間->IP:Port->主執行緒Id:當前連線數
16:34:39->127.0.0.1:56105->1:1
16:34:39->127.0.0.1:56106->1:2
16:34:39->127.0.0.1:56107->1:3
16:34:39->127.0.0.1:56108->1:4
16:34:39->127.0.0.1:56109->1:5
16:34:39->127.0.0.1:56110->1:6
16:34:39->127.0.0.1:56111->1:7
16:34:39->127.0.0.1:56112->1:8
16:34:39->127.0.0.1:56113->1:9
16:34:39->127.0.0.1:56114->1:10
16:34:39->127.0.0.1:56115->1:11
16:34:39->127.0.0.1:56116->1:12
16:34:39->127.0.0.1:56117->1:13
16:34:39->127.0.0.1:56118->1:14
16:34:39->127.0.0.1:56119->1:15
16:34:39->127.0.0.1:56120->1:16
16:34:39->127.0.0.1:56121->1:17
16:34:39->127.0.0.1:56122->1:18
16:34:39->127.0.0.1:56123->1:19
16:34:39->127.0.0.1:56124->1:20

時間->等待資料的時間,讀取資料的時間,總共讀取的位元組數->執行緒Id:當前執行緒數
16:34:45->wait=1ms,read=1018ms,total=1048576bs->11:5
16:34:45->wait=0ms,read=1054ms,total=1048576bs->10:5
16:34:45->wait=0ms,read=1072ms,total=1048576bs->13:6
16:34:45->wait=0ms,read=1061ms,total=1048576bs->14:5
16:34:45->wait=0ms,read=1140ms,total=1048576bs->12:4
16:34:46->wait=0ms,read=1001ms,total=1048576bs->15:5
16:34:46->wait=0ms,read=1062ms,total=1048576bs->17:6
16:34:46->wait=0ms,read=1059ms,total=1048576bs->16:5
16:34:47->wait=0ms,read=1001ms,total=1048576bs->19:4
16:34:47->wait=0ms,read=1001ms,total=1048576bs->20:4
16:34:47->wait=0ms,read=1015ms,total=1048576bs->18:3
16:34:47->wait=0ms,read=1001ms,total=1048576bs->21:2
16:34:48->wait=0ms,read=1032ms,total=1048576bs->22:4
16:34:49->wait=0ms,read=1002ms,total=1048576bs->23:3
16:34:49->wait=0ms,read=1001ms,total=1048576bs->25:2
16:34:49->wait=0ms,read=1028ms,total=1048576bs->24:4
16:34:50->wait=0ms,read=1008ms,total=1048576bs->28:4
16:34:50->wait=0ms,read=1033ms,total=1048576bs->27:3
16:34:50->wait=1ms,read=1002ms,total=1048576bs->29:2
16:34:50->wait=0ms,read=1001ms,total=1048576bs->26:2
複製程式碼

伺服器端接受20個連線,建立20個通道,並把它們註冊到選擇器上,此時不需要額外執行緒。

當某個通道已經有資料時,才會用一個執行緒來處理它,所以,執行緒“等待資料”的時間是0,“讀取資料”的時間還是約1秒。

因為20個通道是陸陸續續有資料的,所以伺服器端最多時是6個執行緒在同時執行的,換句話說,用包含6個執行緒的執行緒池就可以了。 對比與結論:

  • 處理同樣的20個請求,一個需要用20個執行緒,一個需要用6個執行緒,節省了70%執行緒數。

  • 在本例中,兩種感興趣的操作共用一個選擇器,且選擇器執行在主執行緒裡,Worker執行緒是新的執行緒。

  • 其實對於選擇器的個數、選擇器執行在哪個執行緒裡、是否使用新的執行緒來處理請求都沒有要求,要根據實際情況來定。

  • 比如說redis,和處理請求相關的就一個執行緒,選擇器執行在裡面,處理請求的程式也執行在裡面,所以這個執行緒既是I/O執行緒,也是Worker執行緒。

  • 當然,也可以使用兩個選擇器,一個處理OP_ACCEPT,一個處理OP_READ,讓它們分別執行在兩個單獨的I/O執行緒裡。對於能快速完成的操作可以直接在I/O執行緒裡做了,對於非常耗時的操作一定要使用Worker執行緒池來處理。

這種處理模式就是被稱為的多路複用I/O,多路指的是多個Socket通道,複用指的是隻用一個執行緒來管理它們。

再稍微分析一下

一對一的形式,一個桌子配一個服務員,一個Socket分配一個執行緒,響應速度最快,畢竟是VIP嘛,但是效率很低,服務員大部分時間都是在站著,執行緒大部分時間都是在等待。

多路複用的形式,所有桌子共用一個跑腿服務員,所有Socket共用一個選擇器執行緒,響應速度肯定變慢了,畢竟是一對多嘛。但是效率提高了,點餐服務員在需要點餐時才會過去,工作執行緒在資料就緒時才會開始工作。

從VIP到多路複用,形式上確實有很大的不同,其本質是從一對一到一對多的轉變,其實就是犧牲了響應速度,換來了效率的提升,不過綜合效能還是得到了極大的改進。

就飯店而言,究竟幾張桌子配一個跑腿服務員,幾張桌子配一個點餐服務員,經過一段時間執行,一定會有一個最優解。

就程式而言,究竟需要幾個選擇器執行緒,幾個工作執行緒,經過評估測試後,也會有一個最優解。

一旦達到最優解後,就不可能再提升了,這同樣是由多路複用這種一對多的形式所限制的。就像一對一的形式限制一樣。

人們的追求是無止境的,如何對多路複用繼續提升呢?答案一定是具有顛覆性的,即拋棄多路複用,採用全新的形式。

還以飯店為例,如何在最優解的情況下,既要繼續減少服務員數量,還要使效率提升呢?可能有些朋友已經猜到了,那就是拋棄服務員服務客人這種模式,把飯店改成自助餐廳。

在客人進門時,把餐具給他,並告訴他就餐時長、不準浪費等這些規則,然後就不用管了。客人自己選餐,自己吃完,自己走人,不用再等服務員了,因此也不再需要服務員了。(收拾桌子的除外。)

這種模式對應到程式裡,其實就是AIO,在Java裡也早就有了。

嘻嘻,Java AIO來啦

程式碼的寫法非常的固定,可以配合著後面的解說來看,這樣就好理解了,如下:

/**
 * @author lixinjie
 * @since 2019-05-13
 */
public class AioServer {

  static int clientCount = 0;
  static AtomicInteger counter = new AtomicInteger(0);
  static SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss"); 
  
  public static void main(String[] args) {
    try {
      AsynchronousServerSocketChannel assc = AsynchronousServerSocketChannel.open();
      assc.bind(new InetSocketAddress("localhost", 8080));
      //非阻塞方法,其實就是註冊了個回撥,而且只能接受一個連線
      assc.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {

        @Override
        public void completed(AsynchronousSocketChannel asc, Object attachment) {
          //再次註冊,接受下一個連線
          assc.accept(null, this);
          try {
            InetSocketAddress rsa = (InetSocketAddress)asc.getRemoteAddress();
            System.out.println(time() + "->" + rsa.getHostName() + ":" + rsa.getPort() + "->" + Thread.currentThread().getId() + ":" + (++clientCount));
          } catch (Exception e) {
          }
          readFromChannelAsync(asc);
        }

        @Override
        public void failed(Throwable exc, Object attachment) {
          
        }
      });
      //不讓主執行緒退出
      synchronized (AioServer.class) {
        AioServer.class.wait();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }

  static void readFromChannelAsync(AsynchronousSocketChannel asc) {
    //會把資料讀入到該buffer之後,再觸發工作執行緒來執行回撥
    ByteBuffer bb = ByteBuffer.allocate(1024*1024*1 + 1);
    long begin = System.currentTimeMillis();
    //非阻塞方法,其實就是註冊了個回撥,而且只能接受一次讀取
    asc.read(bb, null, new CompletionHandler<Integer, Object>() {
      //從該連線上一共讀到的位元組數
      int total = 0;
      /**
       * @param count 表示本次讀取到的位元組數,-1表示資料已讀完
       */
      @Override
      public void completed(Integer count, Object attachment) {
        counter.incrementAndGet();
        if (count > -1) {
          total += count;
        }
        int size = bb.position();
        System.out.println(time() + "->count=" + count + ",total=" + total + "bs,buffer=" + size + "bs->" + Thread.currentThread().getId() + ":" + counter.get());
        if (count > -1) {//資料還沒有讀完
          //再次註冊回撥,接受下一次讀取
          asc.read(bb, null, this);
        } else {//資料已讀完
          try {
            asc.close();
          } catch (Exception e) {
            e.printStackTrace();
          }
        }
        counter.decrementAndGet();
      }

      @Override
      public void failed(Throwable exc, Object attachment) {
        
      }
    });
    long end = System.currentTimeMillis();
    System.out.println(time() + "->exe read req,use=" + (end -begin) + "ms" + "->" + Thread.currentThread().getId());
  }
  
  static String time() {
    return sdf.format(new Date());
  }
}
複製程式碼

它的大致處理過程如下:

  • 1、初始化一個AsynchronousServerSocketChannel物件,並開始監聽

  • 2、通過accept方法註冊一個“完成處理器”的接受連線回撥,即CompletionHandler,用於在接受到連線後的相關操作。

  • 3、當客戶端連線過來後,由系統來接受,並建立好AsynchronousSocketChannel物件,然後觸發該回撥,並把該物件傳進該回撥,該回撥會在Worker執行緒中執行。

  • 4、在接受連線回撥裡,再次使用accept方法註冊一次相同的完成處理器物件,用於讓系統接受下一個連線。就是這種註冊只能使用一次,所以要不停的連續註冊,人家就是這樣設計的。

  • 5、在接受連線回撥裡,使用AsynchronousSocketChannel物件的read方法註冊另一個接受資料回撥,用於在接受到資料後的相關操作。

  • 6、當客戶端資料過來後,由系統接受,並放入指定好的ByteBuffer中,然後觸發該回撥,並把本次接受到的資料位元組數傳入該回撥,該回撥會在Worker執行緒中執行。

  • 7、在接受資料回撥裡,如果資料沒有接受完,需要再次使用read方法把同一個物件註冊一次,用於讓系統接受下一次資料。這和上面的套路是一樣的。

  • 8、客戶端的資料可能是分多次傳到伺服器端的,所以接受資料回撥會被執行多次,直到資料接受完為止。多次接受到的資料合起來才是完整的資料,這個一定要處理好。

  • 9、關於ByteBuffer,要麼足夠的大,能夠裝得下完整的客戶端資料,這樣多次接受的資料直接往裡追加即可。要麼每次把ByteBuffer中的資料移到別的地方儲存起來,然後清空ByteBuffer,用於讓系統往裡裝入下一次接受的資料。

注:如果出現ByteBuffer空間不足,則系統不會裝入資料,就會導致客戶端資料總是讀不完,極有可能進入死迴圈。

啟動伺服器端程式碼,使用同一個客戶端程式碼,按相同的套路發20個請求,結果如下:

時間->IP:Port->回撥執行緒Id:當前連線數
17:20:47->127.0.0.1:56454->15:1
時間->發起一個讀請求,耗時->回撥執行緒Id
17:20:47->exe read req,use=3ms->15
17:20:47->127.0.0.1:56455->15:2
17:20:47->exe read req,use=1ms->15
17:20:47->127.0.0.1:56456->15:3
17:20:47->exe read req,use=0ms->15
17:20:47->127.0.0.1:56457->16:4
17:20:47->127.0.0.1:56458->15:5
17:20:47->exe read req,use=1ms->16
17:20:47->exe read req,use=1ms->15
17:20:47->127.0.0.1:56460->15:6
17:20:47->127.0.0.1:56459->17:7
17:20:47->exe read req,use=0ms->15
17:20:47->127.0.0.1:56462->15:8
17:20:47->127.0.0.1:56461->16:9
17:20:47->exe read req,use=1ms->15
17:20:47->exe read req,use=0ms->16
17:20:47->exe read req,use=0ms->17
17:20:47->127.0.0.1:56465->16:10
17:20:47->127.0.0.1:56463->18:11
17:20:47->exe read req,use=0ms->18
17:20:47->127.0.0.1:56466->15:12
17:20:47->exe read req,use=1ms->16
17:20:47->127.0.0.1:56464->17:13
17:20:47->exe read req,use=1ms->15
17:20:47->127.0.0.1:56467->18:14
17:20:47->exe read req,use=2ms->17
17:20:47->exe read req,use=1ms->18
17:20:47->127.0.0.1:56468->15:15
17:20:47->exe read req,use=1ms->15
17:20:47->127.0.0.1:56469->16:16
17:20:47->127.0.0.1:56470->18:17
17:20:47->exe read req,use=1ms->18
17:20:47->exe read req,use=1ms->16
17:20:47->127.0.0.1:56472->15:18
17:20:47->127.0.0.1:56473->19:19
17:20:47->exe read req,use=2ms->15
17:20:47->127.0.0.1:56471->17:20
17:20:47->exe read req,use=1ms->19
17:20:47->exe read req,use=1ms->17

時間->本次接受到的位元組數,截至到目前接受到的位元組總數,buffer中的位元組總數->回撥執行緒Id:當前執行緒數
17:20:52->count=65536,total=65536bs,buffer=65536bs->14:1
17:20:52->count=65536,total=65536bs,buffer=65536bs->14:1
17:20:52->count=65536,total=65536bs,buffer=65536bs->14:1
17:20:52->count=230188,total=295724bs,buffer=295724bs->12:1
17:20:52->count=752852,total=1048576bs,buffer=1048576bs->14:3
17:20:52->count=131072,total=196608bs,buffer=196608bs->17:2

。。。。。。。。。。。。。。。。。。。。。。

17:20:57->count=-1,total=1048576bs,buffer=1048576bs->15:1
17:20:57->count=-1,total=1048576bs,buffer=1048576bs->15:1
17:20:57->count=-1,total=1048576bs,buffer=1048576bs->15:1
17:20:57->count=-1,total=1048576bs,buffer=1048576bs->15:1
17:20:58->count=-1,total=1048576bs,buffer=1048576bs->15:1
17:20:58->count=-1,total=1048576bs,buffer=1048576bs->15:1
17:20:58->count=-1,total=1048576bs,buffer=1048576bs->15:1
複製程式碼

系統接受到連線後,在工作執行緒中執行了回撥。並且在回撥中執行了read方法,耗時是0,因為只是註冊了個接受資料的回撥而已。

系統接受到資料後,把資料放入ByteBuffer,在工作執行緒中執行了回撥。並且回撥中可以直接使用ByteBuffer中的資料。

接受資料的回撥被執行了多次,多次接受到的資料加起來正好等於客戶端傳來的資料。

因為系統是接受到資料後才觸發的回撥,所以伺服器端最多時是3個執行緒在同時執行回撥的,換句話說,執行緒池包含3個執行緒就可以了。

對比與結論: 處理同樣的20個請求,一個需要用20個執行緒,一個需要用6個執行緒,一個需要3個執行緒,又節省了50%執行緒數。

注:不用特別較真這個比較結果,這裡只是為了說明問題而已。哈哈。

三種處理方式的對比
  • 第一種是阻塞IO,阻塞點有兩個,等待資料就緒的過程和讀取資料的過程。

  • 第二種是阻塞IO,阻塞點有一個,讀取資料的過程。

  • 第三種是非阻塞IO,沒有阻塞點,當工作執行緒啟動時,資料已經(被系統)準備好可以直接用了。

可見,這是一個逐步消除阻塞點的過程。 再次來談談各種IO: 只有一個執行緒,接受一個連線,讀取資料,處理業務,寫回結果,再接受下一個連線,這是同步阻塞。這種用法幾乎沒有。

一個執行緒和一個執行緒池,執行緒接受到連線後,把它丟給執行緒池中的執行緒,再接受下一個連線,這是非同步阻塞。對應示例一。

一個執行緒和一個執行緒池,執行緒執行selector,執行select操作,把就緒的連線拿出來丟給執行緒池中的執行緒,再執行下一次的select操作,就是多路複用,這是非同步阻塞。對應示例二。

一個執行緒和一個執行緒池,執行緒註冊一個accept回撥,系統幫我們接受好連線後,才觸發回撥線上程池中執行,執行時再註冊read回撥,系統幫我們接受好資料後,才觸發回撥線上程池中執行,就是AIO,這是非同步非阻塞。對應示例三。

redis也是多路複用,但它只有一個執行緒在執行select操作,處理就緒的連線,整個是序列化的,所以天然不存在併發問題。只能把它歸為同步阻塞了。

BIO是阻塞IO,可以是同步阻塞,也可以是非同步阻塞。AIO是非同步IO,只有非同步非阻塞這一種。因此沒有同步非阻塞這種說法,因為同步一定是阻塞的。

注:以上的說法是站在使用者程式/執行緒的立場上來說的。

建議把程式碼下載下來,自己執行一下,體會體會:

github.com/coding-new-…

感謝閱讀至文末,彩蛋奉上

Java學習、面試;文件、視訊資源免費獲取

部分資料截圖如下

一篇文章幫你徹底搞清楚“I/O多路複用”和“非同步I/O”的前世今生

相關文章