大綱
- 前言
- 沒有阻塞的代價
- 阻塞的代價
- 多執行緒模式-緩解IO處理能力方式之一
- 基于IO通知的多路復用 - Polling 原理
- 提升Polling的效率-epoll原理
前言
socket-io 是服務端高性能通信的基石,只有徹底弄清楚socket-io原理,才能真正理解一些高性能框架如rocketmq、netty、以及web容器的底層到底做了什么,
整個
socket的知識體系很大,包括計算機網路協議、計算機組成原理(網卡、DMA)、作業系統的IO機制等,沒辦法一次性的全部展開,本文的切入點是解釋清楚socket場景下,作業系統對io的處理程序,為了降低理解成本,將穿插一個現實場景下的例子闡述,辦好小板凳,開始了~
注:本文只是介紹宏觀的基本概念,具體技術細節將通過另外博客闡述,敬請關注后續內容,公眾號: louluan_note(亦山札記)
本主要介紹socket-io的基本原理,如果想了解具體底層實作邏輯,請看我的另外一個博文 《socket-io的底層實作設計原理》
沒有阻塞的代價
大齡程式員
亦山因年齡過大,體力不行沒機會繼續修福報,被公司想社會輸出人才,就這樣失業了,但是總歸要養家呀,于是乎就找了個檔口,開了一家小餐館,名為:“程式員再就業餐廳”,
由于資金有限,只能請得起 一名服務員Amy 和一個后廚 Tony,就這樣,"程式員再就業餐廳"正式營業了,
老板亦山 規定了 服務員Amy的作業內容:
- 去前臺接待顧客;
- 如果接待到顧客,則安排顧客就坐;
- 去每個就餐的餐桌上,看是否需要服務,服務內容有兩項:
3.1. 查看顧客是否已經下好單;如果已下單,則交給后廚tony準備;
3.2. 如果有菜已經準備好,并且餐桌還放得下,則給顧客上菜;- 重復1、2、3
(請注意: 這里描述的步驟轉換成程式描述的語言,每個步驟都是非阻塞的,)
服務員Amy 的作業內容用流程圖表示如下圖:

再來看下餐廳里的情況:

將服務員Amy 的作業轉換成代碼,大致是這樣的:
package org.luanlouis.socket.noblocking;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
/**
* 服務員抽象
*/
public abstract class AbstractWaiter {
/**
* 服務的餐桌串列
*/
private List<Table> servedTableList = new ArrayList<>();
/**
* 前臺接待顧客
* 如果回傳為空,表示 沒有接待到
* 此方法不阻塞
* @return 顧客
*/
public abstract Customer accept();
/**
* 取某個餐桌上的下單串列
* 如果用戶沒有點好,或者沒有加菜,則回傳為空,
* 此方法不阻塞
*
* @param table 餐桌
* @return 下單串列
*/
public abstract OrderList fetchOrderList(Table table);
/**
* 上菜
* 此方法不阻塞,如果餐桌上有空余位置,則上菜,否則不上菜
*
* @param table 餐桌
*/
public abstract Boolean serveDishes(Table table);
/**
* 服務入口
* 主要作業項:
> 1. 去前臺接待顧客;
> 2. 如果接待到顧客,則安排顧客就坐;
> 3. 去每個就餐的餐桌上,看是否需要服務,服務內容有兩項:
> 3.1. 查看顧客是否已經下好單;如果已下單,則交給后廚`tony` 準備;
> 3.2. 如果有菜已經準備好,并且餐桌還放得下,則給顧客上菜;
> 4. 重復1、2、3
*/
public void serve(){
while(true){
//前臺接待顧客
Customer customer = accept();
if(null != customer){
Table table = assignAvailableTable(customer);
servedTableList.add(table);
System.out.println("前臺 接到顧客: " + customer.customerNo + ",分配到餐桌: "+table.getNumber());
}else{
System.out.println("前臺尚未接待到顧客...");
}
//依次遍歷服務的餐桌
servedTableList.forEach(table-> {
// 查看是否餐桌是否點好餐
OrderList orderList = fetchOrderList(table);
if(null == orderList || orderList.isEmpty()){
System.out.println("餐桌 " + table.getNumber() +" 顧客還沒點好餐... ");
}else{
System.out.println("餐桌 "+ table.getNumber() +" 已經點好餐:" + orderList +",交給后廚... ");
}
// 上菜
serveDishes(table);
});
}
}
/**
* 給用戶分配餐桌
* @param customer
* @return
*/
private Table assignAvailableTable(Customer customer) {
String number = "A"+System.currentTimeMillis();
return new Table(number,customer);
}
}
呼叫方式:
package org.luanlouis.socket.noblocking;
/**
* 服務入口
*/
public class Boostrap {
public static void main(String[] args) {
AbstractWaiter waiter = new Waiter();
waiter.serve();
}
}
就這樣,服務員Amy 干了半天(6個小時),就哭著跑到老板亦山那里吵著說不干了,問其原因,她說:
第一:我一直在前臺和各個餐桌上來回穿梭,一刻都沒有停歇過啊;
第二:雖然我沒歇著,感覺做的很多無用功啊!這半天里,總共就來了20個顧客,每個顧客平均從接待、點餐、到上菜花的時間是3分鐘,占上午的(20*3)/(6*60) = 1/6; 其余5/6的時間都浪費在前臺和各個餐桌上來回穿梭 上
我又不是神奇女俠,干不了了,給我結錢走人!
服務員Amy作業模式的問題,轉換成計算機表達的問題是:
一段代碼在沒有阻塞的情況下,持續讓
CPU在執行回圈,而絕大部分的時間都是花在了驗證執行條件是否滿足上,這會導致CPU的利用率一直是100%,這本身是非常大的機器損耗;如果是在多執行緒作業模式下,還會影響到其他執行緒的執行時間(作業系統的執行緒時間片分配),
聽著 Amy 的哭訴,老板 亦山 覺得很有道理,這叫誰誰都扛不住,趕忙安慰安慰,這樣下去肯定不是辦法,老板亦山思考了片刻之后,說我給你換個作業方式吧,
阻塞的代價
老板 亦山 調整了服務員Amy 的作業方式:
- 在前臺必須接待到顧客,如果沒接到顧客,就一直等待,直到接到顧客;
- 依次去每一個顧客的餐桌上服務,服務內容不變,但是策略調整了:
2.1 等待顧客下單,如果沒有下單,就一直等待;
2.2 給顧客上菜,如果餐桌沒有沒有空余空間,就一直等待,直到顧客吃完其他的菜騰出空間來,
Amy 聽后,非常高興,只要條件不滿足,她就一直等待,不用做沒有意義的空跑了,作業模式如下圖:

就這樣作業了沒多會兒,顧客就開始投訴了,說服務員的服務效率慢,老板 亦山一看,確實慢!來看看服務員Amy 慢在什么地方:
場景一:前臺阻塞, 服務員Amy 在前臺等待顧客,但是顧客遲遲沒有來;而之前接待好的入座的顧客,有的顧客點好菜下單了,但是遲遲沒有交給后廚Tony 去做;還有的顧客是 后廚Tony的菜已經準備好了,但是遲遲沒有服務員上菜;

場景二: 顧客點菜時阻塞 , 服務員Amy 在等 A1餐桌的顧客點菜下單,但是顧客考慮了很久都沒點好;而這時前臺已經排了很長的隊伍等待接待;另外A2,A3桌的顧客還在當著服務(點菜下單或上菜)

場景三: 上菜時阻塞 , 服務員Amy 在給A2餐桌上菜,但是A2桌 之前已經上滿了,沒有多余空間了,所以Amy 在等待;而這時前臺已經排了很長的隊伍等待接待;另外A1,A3桌的顧客還在當著服務(點菜下單或上菜)

根據上述的幾個阻塞場景可以看出,任何一個環節阻塞,將會導致其他顧客都得不到任何服務!
還有一個致命的問題是:任何一個阻塞場景是沒有阻塞時間限制,有無限等待阻塞的可能:如前臺等待,可能一直等不到任何顧客;等待顧客點餐,但是顧客可能就是遲遲不能下決定,甚至顧客還在等人;顧客吃的非常慢,桌子上一直沒有空余出來空間能夠上新的菜…
老板亦山 這么一分析,這可不行,這樣的話,我這一天接待不了多少顧客,要血虧的… 轉念一想,既然前臺、和每個接待的餐桌都可能阻塞,我多招些服務員,每個人負責一個餐桌不就可以緩解了嗎?
多執行緒模式-緩解IO處理能力方式之一
基于上述的慘痛的代價,老板亦山 決定多雇點員工,保證前臺和每個餐桌上都有一個服務員可接待,大概的作業模式如下圖所示:

這里對服務員做了簡單的區分:
- 前臺接待服務員:作業內容是在前臺接待顧客(沒有顧客上門時阻塞),安排到一個座位上,然后指定服務員負責接待
- 餐桌服務員:作業內容是 接待顧客,等顧客點餐(未拿到點餐串列時阻塞),拿到訂餐串列交給后廚;給顧客上菜(餐桌已滿沒地方放時阻塞)
在代碼層面職能描述如下:
/**
* 前臺服務員
*/
public class Receptionist extends AbstractWaiter {
ExecutorService tableWaiterPool = Executors.newFixedThreadPool(20);
/**
* 前臺接待顧客
* 如果回傳為空,表示 沒有接待到
* 此方法不阻塞
* @return 顧客
*/
public Customer accept(){
//模擬沒有接待到的場景
if( new Random(10).nextInt(3) ==2){
return null;
}
return new Customer("Customer_"+System.currentTimeMillis());
}
@Override
public Table assignAvailableTable(Customer customer) {
return null;
}
/**
* 服務內容:
* 1. 前臺接待
* 2. 給接待的顧客安排座位并且指定服務員
*/
@Override
public void serve() {
while(true){
//前臺接待顧客,可能阻塞
Customer customer = accept();
if(null != customer){
final Table table = assignAvailableTable(customer);
servedTableList.add(table);
System.out.println("前臺 接到顧客: " + customer.customerNo + ",分配到餐桌: "+table.getNumber());
//分配服務員
tableWaiterPool.submit(new Runnable() {
public void run() {
new TableWaiter(table).serve();
}
});
}else{
System.out.println("前臺尚未接待到顧客...");
}
}
}
}
而餐桌服務員的作業內容如下:
/**
* 餐桌服務員
*/
public class TableWaiter extends Waiter {
public Table table;
public TableWaiter(Table table) {
this.table = table;
}
/**
* 主要做兩件事:
* 1. 等待顧客下單(如果顧客遲遲沒有下單,在一直等待)
* 2. 上菜,如果餐桌已滿,則可能阻塞等待
*/
@Override
public void serve() {
while(true){
// 查看是否餐桌是否點好餐,可能阻塞
OrderList orderList = fetchOrderList(table);
if(null == orderList || orderList.isEmpty()){
System.out.println("餐桌 " + table.getNumber() +" 顧客還沒點好餐... ");
}else{
System.out.println("餐桌 "+ table.getNumber() +" 已經點好餐:" + orderList +",交給后廚... ");
}
// 上菜,可能阻塞
serveDishes(table);
}
}
}
每個餐桌一個服務員的模式,乍一看是緩解了一個只有一個服務員處理處理不過來接待和服務的問題,但是老板亦山 月底做結算的時候發現:
- 不得了,店面的收入遠遠不及員工工資的!
- 另外雖然每個餐桌配備了服務員,實際上服務員真正作業的時間(下單和上菜)非常短,其他時間都在做無謂的等待
這個代價太大了!這種模式轉換成計算機語言表述是:
多執行緒模式下的
socket-io能夠有效地緩解了 當系統中io過多中,io因阻塞問題來不及處理的吞吐問題;但是引入了多執行緒模式, 會導致執行緒數量會隨著請求數直線膨脹;作業系統內執行緒數過多會導致CPU時間片分配會被嚴重打散,每個執行緒的處理請求所需要的時間會被大幅延長,另外由于執行緒過多,導致作業系統要分配大量的記憶體來維護執行緒背景關系,也增加了空間成本,
所以可以得出一個結論:靠分配獨立執行緒的模式無法解決多IO操作的問題,
最終,迫于無奈,老板亦山 還是辭退和其他的服務員,就只留下了 服務員Amy 和 廚師Tony,
基于IO通知的多路復用 - Polling 原理
老板亦山 再回頭思考了三種阻塞場景:
- 前臺接待顧客(如果沒有等到,就一直阻塞)
- 餐桌服務,獲取顧客下單串列(如果沒下單,就一直阻塞)
- 餐桌服務,給顧客上菜(如果餐桌沒有空余位置,就一直阻塞)
如果讓服務員服務,每個環節都有可能阻塞,實際上阻塞的原因是因為顧客的需求還沒準備好,那能不能換個思路呢?如果是顧客需要服務的時候,通知 服務員Amy 一下,然后Amy在到前臺或者各個餐桌上,(如前臺顧客已到達、餐桌上顧客選單點好、餐桌上已經空出空余空間) 那基本上就不存在等待阻塞的情況了!
于是乎,老板
亦山對店面做了升級,并對服務員Amy的服務方式做了調整:
- 在前臺和每個餐桌上安排了一個鬧鈴,如果需要服務,顧客就按下鬧鈴,鬧鈴通知接收器會發出聲響;
服務員Amy身上安裝一個鬧鈴通知接收器,如果接收到了通知,則表示需要服務了,否則就一直等待,但是這個接收器功能有個限制:不知道是具體前臺或者哪個餐桌具體發出的,需要到前臺和餐桌一個個遍歷一遍去查看到底是哪些滿足條件(詢問的程序是非阻塞的),找到后再提供服務,

在這種作業模式下,服務員Amy 的作業模式就變成了:
- 等待鬧鈴(如果沒接收到,一直阻塞);
- 如果鬧鈴響了,則依次遍歷前臺和所有的餐桌,看哪些需要服務(詢問的程序是非阻塞的),然后提供服務
/**
* 基于鬧鈴通知作業模式的服務員
*/
public abstract class MultiWaiter extends AbstractWaiter {
/**
* 如果沒有觸發,則一直等待
* @return true
*/
public abstract boolean alarmTriggered();
/**
* 是否 下好單
* @return
*/
public abstract boolean isOrderListReady(Table table);
/**
* 餐桌上是否有空余空間
* @return
*/
public abstract boolean isTableAvailable(Table table);
/**
* 服務入口
*/
public void serve(){
//等待鬧鈴觸發,如果沒有,則一直等待
while(alarmTriggered()){
servedTableList.forEach(item->{
//判斷是否已經下好單,非阻塞
if(isOrderListReady(item)){
//TODO: 選單串列交給后廚做菜
}
//判斷是否有空余空間,非阻塞
if(isTableAvailable(item)){
//TODO: 上菜
}
});
}
}
}
上述服務員Amy 可以監聽前臺和所有餐桌的鬧鈴通知的模式,在計算機語言中,被稱為多路復用-multiplexor,接收到鬧鈴通知后,然后逐次遍歷前臺和各個餐桌的程序,在計算機語言中,稱為Polling -輪詢模式,
這種Polling-IO 模式 是 Windows 和早期Linux 2.6 之前的主要支持模式,Polling 的主要問題是 如果 socket 連接過多,而基于這種通知模式,需要依次輪詢每個socket 查看狀態,這個勢必造成極大的性能損耗,
這種模式下,老板亦山 發現服務員Amy 的服務顧客數顯著增加,并且也沒有這么疲勞了,開了一段時間之后,發現收入非常可觀,想擴大店面,之前是10個餐桌,現在擴大到100個,
服務員Amy 有不開心了她說:老板,現在100個餐桌+一個前臺,只要有一個按了鬧鈴,我要把所有的餐桌都要遍歷一遍,這個效率太低了啊,我跑了太多的冤枉路,能不能升級下你的鬧鈴,鬧鈴響的時候,顯示下是哪個餐桌或前臺按的?
老板亦山 想了想,說:“好,雖然升級鬧鈴需要額外成本,為了你的健康和效率,必須升級”,
提升Polling的效率-epoll原理
升級后的餐館,變成了這個樣子:

前臺和餐桌的真正觸發了通知,會被記錄在一個訊息佇列中,在服務員Amy 從鬧鈴等待恢復的程序中,遍歷的串列是真正觸發通知的,這樣,Amy 的服務能力又能提升很多倍!
這種模式在計算機語言中,被稱為epoll 模式,在Linux 2.6 及以后的內核得到了支持,
本主要介紹socket-io的基本原理,如果想了解具體底層實作邏輯,請看我的另外一個博文 《socket-io的底層實作設計原理》
注:本文只是介紹宏觀的基本概念,具體技術細節將通過另外博客闡述,敬請關注后續內容,公眾號: louluan_note(亦山札記)
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/271243.html
標籤:AI
下一篇:演算法面試記錄-某集團公司
