如何理解高性能服務器的高性能、高併發?之一_風聞
蓝海大脑GPU服务器-水冷服务器、大数据一体机、图数据一体机01-12 18:32

線程 | 同步 | 異步 | 異構
協程 | 進程 | 同構 | 線程池
當前,隨着“東數西算”政策的落地,算力時代正在全面開啓。隨着機器學習、深度學習的快速發展,人們對高性能服務器這一概念不再陌生。伴隨着數據分析、數據挖掘數目的不斷增大,傳統的風冷散熱方式已經不足以滿足散熱需要,這就需要新興的液冷散熱技術以此滿足節能減排、靜音高效的需求。
作為國內品牌服務器廠商,藍海大腦液冷GPU服務器擁有大規模並行處理能力和無與倫比的靈活性。它主要用於為計算密集型應用程序提供足夠的處理能力。GPU的優勢在於可以由CPU運行應用程序代碼,同時圖形處理單元(GPU)可以處理大規模並行架構的計算密集型任務。GPU服務器是遙感測繪、醫藥研發、生命科學和高性能計算的理想選擇。
本文將為大家全面介紹高性能GPU服務器所涉及技術以及如何搭建。

線程與線程池
下面將從CPU開始路來到常用的線程池,從底層到上層、從硬件到軟件。一、CPU
對此大家可能會有疑問,講多線程為什麼要從CPU開始?實際上CPU並沒有線程、進程之類的概念。CPU所作的就是從內存中取出指令——執行指令,然後回到1。
1、CPU從哪裏取出指令
就是我們熟知的程序計數器,在這裏大家不要把寄存器想的太神秘,可以簡單的將寄存器理解為內存,只不過存取速度更快而已。
2、PC寄存器中存放的是什麼?
指令(CPU將要執行的下一條指令)在內存中的地址

3、誰來改變PC寄存器中的指令地址?
由於大部分情況下CPU都是一條接一條順序執行,所以之前PC寄存器中的地址默認是自動加1。但當遇到if、else時,這種順序執行就被打破了,為了正確的跳轉到需要執行的指令,CPU在執行這類指令時會根據計算結果來動態改變PC寄存器中的值。**4、PC中的初始值是怎麼被設置的?**CPU執行的指令來自內存,內存中的指令來自於磁盤中保存的可執行程序加載,磁盤中可執行程序是由編譯器生成的,編譯器從定義的函數生成的機器指令。

二、從CPU到操作系統
從上面我們明白了CPU的工作原理,如果想讓CPU執行某個函數,只需把函數對應的第一條機器執行裝入PC寄存器就可以了,這樣即使沒有操作系統也可以讓CPU執行程序,雖然可行但這是一個非常繁瑣的過程(1、在內存中找到一塊大小合適的區域裝入程序;2、找到函數入口,設置好PC寄存器讓CPU開始執行程序)。機器指令由於需加載到內存中執行所以需要記錄下內存的起始地址和長度;同時要找到函數的入口地址並寫到PC寄存器中。數據結構大致如下:1234567struct *** { void* start_addr; int len; void* start_point; …};三、從單核到多核,如何充分利用多核
如果一個程序需要充分利用多核就會遇到以下問題:
1、進程是需要佔用內存空間的(從上一節到這一節),如果多個進程基於同一個可執行程序,那麼這些進程其內存區域中的內容幾乎完全相同,顯然會造成內存浪費;
2、當計算機處理的任務比較複雜時就會涉及到進程間通信,但是由於各個進程處於不同的內存地址空間,而進程間通信需要藉助操作系統,在增大編程難度的同時也增加了系統開銷。
四、從進程到線程
進程到線程即內存中的一段區域,該區域保存了CPU執行的機器指令以及函數運行時的堆棧信息。要想讓進程運行,就把main函數的第一條機器指令地址寫入PC寄存器。
進程的缺點在於只有一個入口函數(main函數),進程中的機器指令只能被一個CPU執行,那麼有沒有辦法讓多個CPU來執行同一個進程中的機器指令呢?可以將main函數的第一條指令地址寫入PC寄存器。main函數和其它函數沒什麼區別,其特殊之處無非在於是CPU執行的第一個函數。當把PC寄存器指向非main函數時,線程就誕生了。
至此一個進程內可以有多個入口函數,也就是説屬於同一個進程中的機器指令可以被多個CPU同時執行。
多個CPU可以在同一個屋檐下(進程佔用的內存區域)同時執行屬於該進程的多個入口函數。操作系統為每個進程維護一堆信息,用來記錄進程所處的內存空間等,這堆信息記為數據集A。同樣的,操作系統也為線程維護一堆信息,用來記錄線程的入口函數或者棧信息等,這堆數據記為數據集B。顯然數據集B要比數據A的量要少,由於線程是運行在所處進程的地址空間在程序啓動時已經創建完畢,同時線程是程序在運行期間創建的(進程啓動後),所以當線程開始運行的時候這塊地址空間就已經存在了,線程可以直接使用。值得一提的是,有了線程這個概念後,只需要進程開啓後創建多個線程就可以讓所有CPU都忙起來,這就是所謂高性能、高併發的根本所在。
**另外值得注意的一點是:**由於各個線程共享進程的內存地址空間,所以線程之間的通信無需藉助操作系統,這給工作人員帶來了便利同時也有不足之處。多線程遇到的多數問題都出自於線程間通信太方便以至於非常容易出錯。出錯的根源在於CPU執行指令時沒有線程的概念,多線程編程面臨的互斥與同步問題需要解決。
**最後需要注意的是:**雖然前面關於線程講解使用的圖中用了多個CPU,但並不一定要有多核才能使用多線程,在單核的情況下一樣可以創建出多個線程,主要是由於線程是操作系統層面的實現,和有多少個核心是沒有關係的,CPU在執行機器指令時也意識不到執行的機器指令屬於哪個線程。即使在只有一個CPU的情況下,操作系統也可以通過線程調度讓各個線程“同時”向前推進,即將CPU的時間片在各個線程之間來回分配,這樣多個線程看起來就是“同時”運行了,但實際上任意時刻還是隻有一個線程在運行。
五、線程與內存前面介紹了線程和CPU的關係,也就是把CPU的PC寄存器指向線程的入口函數,這樣線程就可以運行起來了。無論使用任何編程語言,創建一個線程大體相同:12345// 設置線程入口函數DoSomethingthread = CreateThread(DoSomething); // 讓線程運行起來thread.Run();函數在被執行的時產生的數據包括:函數參數、局部變量、返回地址等信息。這些信息保存在棧中,線程這個概念還沒有出現時進程中只有一個執行流,因此只有一個棧,這個棧的棧底就是進程的入口函數,也就是main函數。假設main函數調用了funA,funcA又調用了funcB,如圖所示:

有了線程以後一個進程中就存在多個執行入口,即同時存在多個執行流,只有一個執行流的進程需要一個棧來保存運行時信息,顯然有多個執行流時就需要有多個棧來保存各個執行流的信息,也就是説操作系統要為每個線程在進程的地址空間中分配一個棧,即每個線程都有獨屬於自己的棧,能意識到這一點是極其關鍵的。同時創建線程是要消耗進程內存空間的。
六、線程的使用從生命週期的角度講,線程要處理的任務有兩類:長任務和短任務。**1、長任務(long-lived tasks)**顧名思義,就是任務存活的時間長。以常用的word為例,在word中編輯的文字需要保存在磁盤上,往磁盤上寫數據就是一個任務,這時一個比較好的方法就是專門創建一個寫磁盤的線程,該線程的生命週期和word進程是一樣的,只要打開word就要創建出該線程,當用户關閉word時該線程才會被銷燬,這就是長任務。長任務非常適合創建專用的線程來處理某些特定任務。**2、短任務(short-lived tasks)**即任務的處理時間短,如一次網絡請求、一次數據庫查詢等。這種任務可以在短時間內快速處理完成。因此短任務多見於各種Server,像web server、database server、file server、mail server等。該場景有任務處理所需時間短和任務數量巨大的兩個特點。
這種工作方法可對長任務來説很好,但是對於大量的短任務來説雖然實現簡單但卻有其缺點:1)線程是操作系統中的概念,因此創建線程需要藉助操作系統來完成,操作系統創建和銷燬線程是需要消耗時間的;
2)每個線程需要有自己獨立的棧,因此當創建大量線程時會消耗過多的內存等系統資源。
這就好比一個工廠老闆手裏有很多訂單,每來一批訂單就要招一批工人,生產的產品非常簡單,工人們很快就能處理完,處理完這批訂單後就把這些工人辭掉,當有新的訂單時再招一遍工人,幹活兒5分鐘招人10小時,如果你不是勵志要讓企業倒閉的話大概是不會這麼做到的。因此一個更好的策略就是招一批人後就地養着,有訂單時處理訂單,沒有訂單時大家可以待着。這就是線程池的由來。
七、從多線程到線程池
線程池的無非就是創建一批線程之後就不再釋放,有任務就提交給線程處理,因此無需頻繁的創建、銷燬線程,同時由於線程池中的線程個數通常是固定的,也不會消耗過多的內存。
**八、線程池是如何工作的?**一般來説提交給線程池的任務包含需要被處理的數據和處理數據的函數兩部分。
偽碼描述一下:1234struct task { void* data; // 任務所攜帶的數據 handler handle; // 處理數據的方法}線程池中的線程會阻塞在隊列上,當工作人員向隊列中寫入數據後,線程池中的某個線程會被喚醒,該線程從隊列中取出上述結構體(或者對象),以結構體(或者對象)中的數據為參數並調用處理函數。
偽碼如下:1234while(true) { struct task = GetFromQueue(); // 從隊列中取出數據 task->handle(task->data); // 處理數據}八、線程池中線程的數量眾所周知線程池的線程過少就不能充分利用CPU,線程創建的過多反而會造成系統性能下降,內存佔用過多,線程切換造成的消耗等等。因此線程的數量既不能太多也不能太少,到底該是多少呢?從處理任務所需要的資源角度看有CPU密集型和I/O密集型兩種類型。1、CPU密集型所謂CPU密集型是指説理任務不需要依賴外部I/O,比如科學計算、矩陣運算等。在這種情況下只要線程的數量和核數基本相同就可以充分利用CPU資源。
2、I/O密集型這一類任務可能計算部分所佔用時間不多,大部分時間都用在磁盤I/O、網絡I/O等方面。
工作人員需要利用性能測試工具評估出用在I/O等待上的時間,這裏記為WT(wait time),以及CPU計算所需要的時間,這裏記為CT(computing time),那麼對於一個N核的系統,合適的線程數大概是 N * (1 + WT/CT) ,假設I/O等待時間和計算時間相同,那麼大概需要2N個線程才能充分利用CPU資源,注意這只是一個理論值,具體設置多少需要根據真實的業務場景進行測試。當然充分利用CPU不是唯一需要考慮的點,隨着線程數量的增多,內存佔用、系統調度、打開的文件數量、打開的socker數量以及打開的數據庫鏈接等等是都需要考慮的。所以沒有萬能公式,要具體情況具體分析。
九、使用線程前需要考慮的因素1、充分理解任務是長任務還是短任務、是CPU密集型還是I/O密集型,如果兩種都有,那麼一種可能更好的辦法是把這兩類任務放到不同的線程池。
2、如果線程池中的任務有I/O操作,那麼務必對此任務設置超時,否則處理該任務的線程可能會一直阻塞下去;
4、線程池中的任務不要同步等待其它任務的結果。
I/O與零拷貝技術
一、什麼是I/O?
I/O就是簡單的數據Copy,如果數據從外部設備copy到內存中就是Input。如果數據是內存copy到外部設備則是Output。內存與外部設備之間不嫌麻煩的來回copy數據就是Input and Output,簡稱I/O(Input/Output)。

二、I/O與CPU
簡單來説:CPU執行機器指令的速度是納秒級別的,而通常的I/O比如磁盤操作,一次磁盤seek大概在毫秒級別,因此如果我們把CPU的速度比作戰鬥機的話,那麼I/O操作的速度就是肯德雞。
也就是説當程序跑起來時(CPU執行機器指令),其速度是要遠遠快於I/O速度。那麼接下來的問題就是二者速度相差這麼大,該如何設計、更加合理的高效利用系統資源呢?既然有速度差異,進程在執行完I/O操作前不能繼續向前推進,那就只有等待(wait)。三、執行I/O時底層都發生了什麼在支持線程的操作系統中,實際上被調度的是線程而不是進程,為了更加清晰的理解I/O過程,暫時假設操作系統只有進程這樣的概念,先不去考慮線程。如下圖所示,現在內存中有兩個進程,進程A和進程B,當前進程A正在運行。如下圖所示:
進程A中有一段讀取文件的代碼,不管在什麼語言中通常定義一個用來裝數據的buff,然後調用read之類的函數。1read(buff);注意:與CPU執行指令的速度相比,I/O操作操作是非常慢的,因此操作系統是不可能把寶貴的CPU計算資源浪費在無謂的等待上的。由於外部設備執行I/O操作是相當慢的,所以在I/O操作完成之前進程是無法繼續向前推進的,這就是所謂的阻塞,即block。
只需記錄下當前進程的運行狀態並把CPU的PC寄存器指向其它進程的指令就操作系統檢測到進程向I/O設備發起請求後就暫停進程的運行。進程有暫停就會有繼續執行,因此操作系統必須保存被暫停的進程以備後續繼續執行,顯然我們可以用隊列來保存被暫停執行的進程。
如上圖所示,操作系統已經向磁盤發送I/O請求,因此磁盤driver開始將磁盤中的數據copy到進程A的buff中。雖然這時進程A已經被暫停執行了,但這並不妨礙磁盤向內存中copy數據。過程如下圖所示:

操作系統中除了有阻塞隊列之外也有就緒隊列,所謂就緒隊列是指隊列裏的進程準備就緒可以被CPU執行了。在即使只有1個核的機器上也可以創建出成千上萬個進程,CPU不可能同時執行這麼多的進程,因此必然存在這樣的進程,即使其一切準備就緒也不能被分配到計算資源,這樣的進程就被放到了就緒隊列。
由於就緒隊列中還有嗷嗷待哺的進程B,所以當進程A被暫停執行後CPU是不可以閒下來的。這時操作系統開始在就緒隊列中找下一個可以執行的進程,也就是這裏的進程B。此時操作系統將進程B從就緒隊列中取出,找出進程B被暫停時執行到的機器指令的位置,然後將CPU的PC寄存器指向該位置,這樣進程B就開始運行啦。

如上圖所示,進程B在被CPU執行,磁盤在向進程A的內存空間中copy數據,數據copy和指令執行在同時進行,在操作系統的調度下,CPU、磁盤都得到了充分的利用。此後磁盤將全部數據都copy到了進程A的內存中,操作系統接收到磁盤中斷後發現數據copy完畢,進程A重新獲得繼續運行的資格,操作系統把進程A從阻塞隊列放到了就緒隊列當中。

此後進程B繼續執行,進程A繼續等待,進程B執行了一會兒後操作系統認為進程B執行的時間夠長了,因此把進程B放到就緒隊列,把進程A取出並繼續執行。操作系統把進程B放到的是就緒隊列,因此進程B被暫停運行僅僅是因為時間片到了而不是因為發起I/O請求被阻塞。
**四、零拷貝(Zero-copy)**值得注意的一點是:上面的講解中直接把磁盤數據copy到了進程空間中,但實際上一般情況下I/O數據是要首先copy到操作系統內部,然後操作系統再copy到進程空間中。性能要求很高的場景其實也是可以繞過操作系統直接進行數據copy,這種繞過操作系統直接進行數據copy的技術被稱為零拷貝(Zero-copy)。I/O多路複用
本文我們詳細講解什麼是I/O多路複用以及使用方法,這其中以epoll為代表的I/O多路複用(基於事件驅動)技術使用非常廣泛,實際上你會發現但凡涉及到高併發、高性能的場景基本上都能見到事件驅動的編程方法。
**一、什麼是文件?**在Linux世界中文件是一個很簡單的概念,只需要將其理解為一個N byte的序列就可以了:b1, b2, b3, b4, ……. bN
實際上所有的I/O設備都被抽象了,一切皆文件(Everything is File),磁盤、網絡數據、終端,甚至進程間通信工具管道pipe等都被當做文件對待。
常用的I/O操作接口一般有以下幾類:1、打開文件,open;
2、改變讀寫位置,seek;
3、文件讀寫,read、write;
4、關閉文件,close。
**二、什麼是文件描述符?**在上文中我們講到:要想進行I/O讀操作,像磁盤數據,需要指定一個buff用來裝入數據。在Linux世界要想使用文件,需要藉助一個號碼,根據“弄不懂原則”,這個號碼就被稱為了文件描述符(file descriptors),在Linux世界中鼎鼎大名,其道理和上面那個排隊號碼一樣。文件描述僅僅就是一個數字而已,但是通過這個數字我們可以操作一個打開的文件。
有了文件描述符,進程可以對文件一無所知,比如文件在磁盤的什麼位置、加載到內存中又是怎樣管理的等等,這些信息統統交由操作系統打理,進程無需關心,操作系統只需要給進程一個文件描述符就足夠了。三、文件描述符太多了怎麼辦?從上文中我們知道,所有I/O操作都可以通過文件樣的概念來進行,這當然包括網絡通信。如果你有一個IM服務器,當三次握手建議長連接成功以後,我們會調用accept來獲取一個鏈接,調用該函數我們同樣會得到一個文件描述符,通過這個文件描述符就可以處理客户端發送的聊天消息並且把消息轉發給接收者。也就是説,通過這個描述符就可以和客户端進行通信了:// 通過accept獲取客户端的文件描述符int conn_fd = accept(…);
**Server端的處理邏輯通常是接收客户端消息數據,然後執行轉發(給接收者)邏輯:**if(read(conn_fd, msg_buff) > 0) { do_transfer(msg_buff);}
既然主題是高併發,那麼Server端就不可能只和一個客户端通信,而是可能會同時和成千上萬個客户端進行通信。這時需要處理不再是一個描述符這麼簡單,而是有可能要處理成千上萬個描述符。為了不讓問題一上來就過於複雜先簡單化,假設只同時處理兩個客户端的請求。**有的同學可能會説,這還不簡單,這樣寫不就行了:**if(read(socket_fd1, buff) > 0) { // 處理第一個 do_transfer();}if(read(socket_fd2, buff) > 0) { // 處理第二個 do_transfer();
如果此時沒有數據可讀那麼進程會被阻塞而暫停運行。這時我們就無法處理第二個請求了,即使第二個請求的數據已經就位,這也就意味着處理某一個客户端時由於進程被阻塞導致剩下的所有其它客户端必須等待,在同時處理幾萬客户端的server上。這顯然是不能容忍的。聰明的你一定會想到使用多線程:為每個客户端請求開啓一個線程,這樣一個客户端被阻塞就不會影響到處理其它客户端的線程了。注意:既然是高併發,那麼我們要為成千上萬個請求開啓成千上萬個線程嗎,大量創建銷燬線程會嚴重影響系統性能。那麼這個問題該怎麼解決呢?這裏的關鍵點在於:我們事先並不知道一個文件描述對應的I/O設備是否是可讀的、是否是可寫的,在外設的不可讀或不可寫的狀態下進行I/O只會導致進程阻塞被暫停運行。**三、I/O多路複用(I/O multiplexing)**multiplexing一詞多用於通信領域,為了充分利用通信線路,希望在一個信道中傳輸多路信號,要想在一個信道中傳輸多路信號就需要把這多路信號結合為一路,將多路信號組合成一個信號的設備被稱為Multiplexer(多路複用器),顯然接收方接收到這一路組合後的信號後要恢復原先的多路信號,這個設備被稱為Demultiplexer(多路分用器)。如下圖所示:
所謂I/O多路複用指的是這樣一個過程:1、拿到一堆文件描述符(不管是網絡相關的、還是磁盤文件相關等等,任何文件描述符都可以);
2、通過調用某個函數告訴內核:“這個函數你先不要返回,你替我監視着這些描述符,當這堆文件描述符中有可以進行I/O讀寫操作的時候你再返回”;
3、當調用的這個函數返回後就能知道哪些文件描述符可以進行I/O操作了。
三、I/O多路複用三劍客由於調用這些I/O多路複用函數時如果任何一個需要監視的文件描述符都不可讀或者可寫那麼進程會被阻塞暫停執行,直到有文件描述符可讀或者可寫才繼續運行。所以Linux上的select、poll、epoll都是阻塞式I/O,也就是同步I/O。1、select:初出茅廬在select I/O多路複用機制下,需要把想監控的文件描述集合通過函數參數的形式告訴select,然後select將這些文件描述符集合拷貝到內核中。為了減少這種數據拷貝帶來的性能損耗,Linux內核對集合的大小做了限制,並規定用户監控的文件描述集合不能超過1024個,同時當select返回後,僅僅能知道有些文件描述符可以讀寫了。select的特點1、能照看的文件描述符數量有限,不能超過1024個;
2、用户給文件描述符需要拷貝的內核中;
3、只能告訴有文件描述符滿足要求但不知道是哪個。
2、poll:小有所成poll和select是非常相似,相對於select的優化僅僅在於解決文件描述符不能超過1024個的限制,select和poll都會隨着監控的文件描述數量增加而性能下降,因此不適合高併發場景。3、epoll:獨步天下在select面臨的三個問題中,文件描述數量限制已經在poll中解決了,剩下的兩個問題呢?針對拷貝問題epoll使用的策略是各個擊破與共享內存。文件描述符集合的變化頻率比較低,select和poll頻繁的拷貝整個集合,epoll通過引入epoll_ctl很體貼的做到了只操作那些有變化的文件描述符。同時epoll和內核還成為了好朋友,共享了同一塊內存,這塊內存中保存的就是那些已經可讀或者可寫的的文件描述符集合,這樣就減少了內核和程序的拷貝開銷。針對需要遍歷文件描述符才能知道哪個可讀可寫的問題,epoll使用的策略是在select和poll機制下:進程要親自下場去各個文件描述符上等待,任何一個文件描述可讀或者可寫就喚醒進程,但是進程被喚醒後也是一臉懵逼併不知道到底是哪個文件描述符可讀或可寫,還要再從頭到尾檢查一遍。在epoll機制下進程不需要親自下場了,進程只要等待在epoll上,epoll代替進程去各個文件描述符上等待,當哪個文件描述符可讀或者可寫的時候就告訴epoll,由epoll記錄。
在epoll這種機制下,實際上利用的就是“不要打電話給我,有需要我會打給你”這種策略,進程不需要一遍一遍麻煩的問各個文件描述符,而是翻身做主人了——“你們這些文件描述符有哪個可讀或者可寫了主動報上來”。同步與異步
一、同步與異步場景:打電話與發郵件****1、同步
通常打電話時都是一個人在説另一個人聽,一個人在説的時候另一個人等待,等另一個人説完後再接着説,因此在這個場景中你可以看到,“依賴”、“關聯”、“等待”這些關鍵詞出現了,因此打電話這種溝通方式就是所謂的同步。
2、異步另一種常用的溝通方式是郵件,因為沒有人傻等着你寫郵件什麼都不做,因此你可以慢慢悠悠的寫,當你在寫郵件時收件人可以去做一些像摸摸魚啊、上個廁所、和同時抱怨一下為什麼十一假期不放兩週之類有意義的事情。同時當你寫完郵件發出去後也不需要乾巴巴的等着對方回覆什麼都不做,你也可以做一些像摸魚之類這樣有意義的事情。
在這裏,你寫郵件別人摸魚,這兩件事又在同時進行,收件人和發件人都不需要相互等待,發件人寫完郵件的時候簡單的點個發送就可以了,收件人收到後就可以閲讀啦,收件人和發件人不需要相互依賴、不需要相互等待。因此郵件這種溝通方式就是異步的。**二、編程中的同步調用****一般的函數調用都是同步的,就像這樣:**123456funcA() { // 等待函數funcB執行完成 funcB(); // 繼續接下來的流程}funcA調用funcB,那麼在funcB執行完前,funcA中的後續代碼都不會被執行,也就是説funcA必須等待funcB執行完成,如下圖所示。
從上圖中可以看出,在funcB運行期間funcA什麼都做不了,這就是典型的同步。一般來説,像這種同步調用,funcA和funcB是運行在同一個線程中的,但值得注意的是即使運行在兩個不能線程中的函數也可以進行同步調用,像我們進行IO操作時實際上底層是通過系統調用的方式向操作系統發出請求。
如上圖所示,只有當read函數返回後程序才可以被繼續執行。和上面的同步調用不同的是,函數和被調函數運行在不同的線程中。由此我們可以得出結論,同步調用和函數與被調函數是否運行在同一個線程是沒有關係的。在這裏需要再次強調同步方式下函數和被調函數無法同時進行。
三、編程中的異步調用有同步調用就有異步調用。一般來説異步調用總是和I/O操作等耗時較高的任務如影隨形,像磁盤文件讀寫、網絡數據的收發、數據庫操作等。在這裏以磁盤文件讀取為例,在read函數的同步調用方式下,文件讀取完之前調用方是無法繼續向前推進的,但如果read函數可以異步調用情況就不一樣了。假如read函數可以異步調用的話,即使文件還沒有讀取完成,read函數也可以立即返回。

如上圖所示,在異步調用方式下,調用方不會被阻塞,函數調用完成後可以立即執行接下來的程序。這時異步的重點在於調用方接下來的程序執行可以和文件讀取同時進行。值得注意的是異步調用對於程序員來説在理解上是一種負擔,代碼編寫上更是一種負擔,總的來説,上帝在為你打開一扇門的時候會適當的關上一扇窗户。有的同學可能會問,在同步調用下,調用方不再繼續執行而是暫停等待,被調函數執行完後很自然的就是調用方繼續執行,那麼異步調用下調用方怎知道被調函數是否執行完成呢?這就分為調用方根本就不關心執行結果和調用方需要知道執行結果兩種情況。
第一種情況比較簡單,無需討論。
第二種情況下就比較有趣了,通常有兩種實現方式:1、通知機制
當任務執行完成後發送信號來通知調用方任務完成(這裏的信號有很多實現方式:Linux中的signal,或使用信號量等機制都可實現);
**2、回調機制:**也就是常説的callback。
四、具體的編程例子中理解同步和異步以常見Web服務為例來説明這個問題。一般來説Web Server接收到用户請求後會有一些典型的處理邏輯,最常見的就是數據庫查詢(當然,你也可以把這裏的數據庫查詢換成其它I/O操作,比如磁盤讀取、網絡通信等),在這裏假定處理一次用户請求需要經過步驟A、B、C,然後讀取數據庫,數據庫讀取完成後需要經過步驟D、E、F。其中步驟A、B、C和D、E、F不需要任何I/O,也就是説這六個步驟不需要讀取文件、網絡通信等,涉及到I/O操作的只有數據庫查詢這一步。一般來説Web Server有主線程和數據庫處理線程兩個典型的線程。首先我們來看下最簡單的實現方式,也就是同步。**這種方式最為自然也最為容易理解:**0102030405060708091011121314151617// 主線程main_thread() { A; B; C; 發送數據庫查詢請求; D; E; F;}// 數據庫線程DataBase_thread() { while(1) { 處理數據庫讀取請求; 返回結果; }}主線程在發出數據庫查詢請求後就會被阻塞而暫停運行,直到數據庫查詢完畢後面的D、E、F才可以繼續運行,這就是最為典型的同步方法。
如上圖所示,主線程中會有“空隙”,這個空隙就是主線程的“休閒時光”,主線程在這段休閒時光中需要等待數據庫查詢完成才能繼續後續處理流程。在這裏主線程就好比監工的老闆,數據庫線程就好比苦逼搬磚的程序員,在搬完磚前老闆什麼都不做只是緊緊的盯着你,等你搬完磚後才去忙其它事情。
1、異步情況:主線程不關心數據庫操作結果如下圖所示,主線程根本就不關心數據庫是否查詢完畢,數據庫查詢完畢後自行處理接下來的D、E、F三個步驟。
一個請求通常需要經過七個步驟,其中前三個是在主線程中完成的,後四個是在數據庫線程中完成的,數據庫線程通過回調函數查完數據庫後處理D、E、F幾個步驟。偽碼如下:12345void handle_DEF_after_DB_query () { D; E; F;}主線程處理請求和數據庫處理查詢請求可以同時進行,從系統性能上看能更加充分的利用系統資源,更加快速的處理請求;從用户的角度看,系統的響應也會更加迅速。這就是異步的高效之處。但可以看出,異步編程並不如同步來的容易理解,系統可維護性上也不如同步模式。
2、異步情況:主線程關心數據庫操作結果如下圖所示,數據庫線程需要將查詢結果利用通知機制發送給主線程,主線程在接收到消息後繼續處理上一個請求的後半部分。
由此我們可以看到:ABCDEF幾個步驟全部在主線中處理,同時主線程同樣也沒有了“休閒時光”,只不過在這種情況下數據庫線程是比較清閒的,從這裏並沒有上一種方法高效,但是依然要比同步模式下要高效。但是要注意的是並不是所有的情況下異步都一定比同步高效,還需要結合具體業務以及IO的複雜度具體情況具體分析。高併發中的協程
協程是高性能高併發編程中不可或缺的技術,包括即時通訊(IM系統)在內的互聯網產品應用產品中應用廣泛,比如號稱支撐微信海量用户的後台框架就是基於協程打造的。而且越來越多的現代編程語言都將協程視為最重要的語言技術特徵,已知的包括:Go、Python、Kotlin等。
一、從普通函數到協程1234567void func() { print(“a”) 暫停並返回 print(“b”) 暫停並返回 print(“c”)}普通函數下,只有當執行完print(“c”)這句話後函數才會返回,但是在協程下當執行完print(“a”)後func就會因“暫停並返回”這段代碼返回到調用函數。**我寫一個return也能返回,就像這樣:**1234567void func() { print(“a”) return print(“b”) 暫停並返回 print(“c”)}直接寫一個return語句確實也能返回,但這樣寫的話return後面的代碼都不會被執行到了。協程之所以神奇就神奇在當我們從協程返回後還能繼續調用該協程,並且是從該協程的上一個返回點後繼續執行。**就好比孫悟空説一聲“定”,函數就被暫停了:**1234567void func() { print(“a”) 定 print(“b”) 定 print(“c”)}這時我們就可以返回到調用函數,當調用函數什麼時候想起該協程後可以再次調用該協程,該協程會從上一個返回點繼續執行。值得注意的是當普通函數返回後,進程的地址空間中不會再保存該函數運行時的任何信息,而協程返回後,函數的運行時信息是需要保存下來的。**二、“Talk is cheap,show me the code”**在python語言中,這個“定”字同樣使用關鍵詞yield。**這樣我們的func函數就變成了:**1234567void func() { print(“a”) yield print(“b”) yield print(“c”)}這時我們的func就不再是簡簡單單的函數了,而是升級成為了協程,那麼我們該怎麼使用呢?很簡單:
12345def A(): co = func() # 得到該協程 next(co) # 調用協程 print(“in function A”) # do something next(co) # 再次調用該協程雖然func函數沒有return語句,也就是説雖然沒有返回任何值,但是我們依然可以寫co = func()這樣的代碼,意思是説co就是拿到的協程了。接下來調用該協程,使用next(co),運行函數A看看執行到第3行的結果是什麼:1a顯然,和預期一樣協程func在print(“a”)後因執行yield而暫停並返回函數A。接下來是第4行,這個毫無疑問,A函數在做一些自己的事情,因此會打印:
12ain function A接下來是重點的一行,當執行第5行再次調用協程時該打印什麼呢?如果func是普通函數,那麼會執行func的第一行代碼,也就是打印a。但func不是普通函數,而是協程,我們之前説過,協程會在上一個返回點繼續運行,因此這裏應該執行的是func函數第一個yield之後的代碼,也就是 print(“b”)。
123ain function Ab三、圖形化解釋為了更加徹底的理解協程,我們使用圖形化的方式再看一遍。首先是普通的函數調用:
在該圖中方框內表示該函數的指令序列,如果該函數不調用任何其它函數,那麼應該從上到下依次執行,但函數中可以調用其它函數,因此其執行並不是簡單的從上到下,箭頭線表示執行流的方向。從上圖中可以看到:首先來到funcA函數,執行一段時間後發現調用了另一個函數funcB,這時控制轉移到該函數,執行完成後回到main函數的調用點繼續執行。這是普通的函數調用。接下來是協程:
在這裏依然首先在funcA函數中執行,運行一段時間後調用協程,協程開始執行,直到第一個掛起點,此後就像普通函數一樣返回funcA函數,funcA函數執行一些代碼後再次調用該協程。三、函數只是協程的一種特例和普通函數不同的是,協程能知道自己上一次執行到了哪裏。協程會在函數被暫停運行時保存函數的運行狀態,並可以從保存的狀態中恢復並繼續運行。四、協程的歷史協程這種概念早在1958年就已經提出來了,要知道這時線程的概念都還沒有提出來。到了1972年,終於有編程語言實現了這個概念,這兩門編程語言就是Simula 67 以及Scheme。但協程這個概念始終沒有流行起來,甚至在1993年還有人考古一樣專門寫論文挖出協程這種古老的技術。因為這一時期還沒有線程,如果你想在操作系統寫出併發程序那麼你將不得不使用類似協程這樣的技術,後來線程開始出現,操作系統終於開始原生支持程序的併發執行,就這樣,協程逐漸淡出了程序員的視線。直到近些年,隨着互聯網的發展,尤其是移動互聯網時代的到來,服務端對高併發的要求越來越高,協程再一次重回技術主流,各大編程語言都已經支持或計劃開始支持協程。
**五、協程到底如何實現?**讓我們從問題的本質出發來思考這個問題協程的本質是什麼呢?協程之所以可以被暫停也可以繼續,那麼一定要記錄下被暫停時的狀態,也就是上下文,當繼續運行的時候要恢復其上下文(狀態)函數運行時所有的狀態信息都位於函數運行時棧中。如下圖所示,函數運行時棧就是需要保存的狀態,也就是所謂的上下文。
從上圖中可以看出,該進程中只有一個線程,棧區中有四個棧幀,main函數調用A函數,A函數調用B函數,B函數調用C函數,當C函數在運行時整個進程的狀態就如圖所示。再仔細想一想,為什麼我們要這麼麻煩的來回copy數據呢?我們需要做的是直接把協程的運行需要的棧幀空間直接開闢在堆區中,這樣都不用來回copy數據了,如下圖所示。
從上圖中可以看到該程序中開啓了兩個協程,這兩個協程的棧區都是在堆上分配的,這樣我們就可以隨時中斷或者恢復協程的執行了。進程地址空間最上層的棧區現在的作用是用來保存函數棧幀的,只不過這些函數並不是運行在協程而是普通線程中的。在上圖中實際上共有一個普通線程和兩個協程3個執行流。雖然有3個執行流但我們創建了幾個線程呢?答案是:一個線程。使用協程理論上我們可以開啓無數併發執行流,只要堆區空間足夠,同時還沒有創建線程的開銷,所有協程的調度、切換都發生在用户態,這就是為什麼協程也被稱作用户態線程的原因所在。所以即使創建了N多協程,但在操作系統看來依然只有一個線程,也就是説協程對操作系統來説是不可見的。
這也許是為什麼協程這個概念比線程提出的要早的原因,可能是寫普通應用的程序員比寫操作系統的程序員最先遇到需要多個並行流的需求,那時可能都還沒有操作系統的概念,或者操作系統沒有並行這種需求,所以非操作系統程序員只能自己動手實現執行流,也就是協程。六、協程技術概念小結****1、協程是比線程更小的執行單元協程是比線程更小的一種執行單元可以認為是輕量級的線程。之所以説輕的其中一方面的原因是協程所持有的棧比線程要小很多,java當中會為每個線程分配1M左右的棧空間,而協程可能只有幾十或者幾百K,棧主要用來保存函數參數、局部變量和返回地址等信息。我們知道而線程的調度是在操作系統中進行的,而協程調度則是在用户空間進行的,是開發人員通過調用系統底層的執行上下文相關api來完成的。有些語言,比如nodejs、go在語言層面支持了協程,而有些語言,比如C,需要使用第三方庫才可以擁有協程的能力。由於線程是操作系統的最小執行單元,因此也可以得出,協程是基於線程實現的,協程的創建、切換、銷燬都是在某個線程中來進行的。使用協程是因為線程的切換成本比較高,而協程在這方面很有優勢。**2、協程的切換到底為什麼很廉價?**關於這個問題,回顧一下線程切換的過程:1)線程在進行切換的時候,需要將CPU中的寄存器的信息存儲起來,然後讀入另外一個線程的數據,這個會花費一些時間;
2)CPU的高速緩存中的數據,也可能失效,需要重新加載;
3)線程的切換會涉及到用户模式到內核模式的切換,據説每次模式切換都需要執行上千條指令,很耗時。
實際上協程的切換之所以快的原因主要是:1)在切換的時候,寄存器需要保存和加載的數據量比較小;
2)高速緩存可以有效利用;
3)沒有用户模式到內核模式的切換操作;
4)更有效率的調度,因為協程是非搶佔式的,前一個協程執行完畢或者堵塞,才會讓出CPU,而線程則一般使用了時間片的算法,會進行很多沒有必要的切換。