徹底理解什麼是同步和異步!

相信很多同學遇到同步異步這兩個詞的時候大腦瞬間就像紅綠燈失靈的十字路口一樣陷入一片懵逼的狀態:

是的,這兩個看上去很像實際上也很像的詞彙給博主造成過很大的困擾,這兩個詞背後所代表的含義到底是什麼呢?

我們先從工作場景講起。

苦逼程序員

假設現在老闆分配給了你一個很緊急並且很重要的任務,讓你下班前必須完成(萬惡的資本主義)。爲了督促進度,老闆搬了個椅子坐在一邊盯着你寫代碼。

你心裏肯定已經罵上了,“WTF,你有這麼閒嗎?盯着老子,你就不能去幹點其他事情嗎?”

老闆彷彿接收到了你的腦電波一樣:“我就在這等着,你寫完前我哪也不去,廁所也不去。”

這個例子中老闆交給你任務後就一直等待,什麼都不做直到你寫完,這個場景就是所謂的同步。

第二天,老闆又交給了你一項任務。

不過這次就沒那麼着急啦,這次老闆輕描淡寫,“小夥子可以啊,不錯不錯,你再努力幹一年,明年我就財務自由了,今天的這個任務不着急,你寫完告訴我一聲就行”。

這次老闆沒有盯着你寫代碼,而是轉身刷視頻去了,你寫完後簡單的和老闆報告一聲 “我寫完了”。

在這個例子中老闆交代完任務後不再一直等着什麼都不做而是就去忙其它事情,你完成任務後簡單的告訴老闆任務完成,這就是所謂的異步。

值得注意的是,在異步這種場景下重點是在你寫代碼的同時老闆在刷劇,這兩件事在同時進行而不是一方等待另一方因此這就是爲什麼一般來說異步比同步高效的本質所在,不管同步異步應用在什麼場景下。

我們可以看到同步這個詞往往和任務的 “依賴”、“關聯”、“等待” 等關鍵詞相關,而異步往往和任務的 “不依賴”,“無關聯”,“無需等待”,“同時發生” 等關鍵詞相關。

By the way,如果遇到一個在身後盯着你寫代碼的老闆,三十六計走爲上策。

打電話與發郵件

作爲一名苦逼的程序員是不能只顧埋頭搬磚的,平時工作中的溝通免除不了,其中一種高效的溝通方式是吵架。。。啊不,是電話。

通常打電話時都是一個人在說另一個人聽,一個人在說的時候另一個人等待,等另一個人說完後再接着說,因此在這個場景中你可以看到,“依賴”、“關聯”、“等待” 這些關鍵詞出現了,因此打電話這種溝通方式就是所謂的同步。

另一種碼農常用的溝通方式是郵件。

郵件是另一種必不可少溝通方式,因爲沒有人傻等着你寫郵件什麼都不做,因此你可以慢慢悠悠的寫,當你在寫郵件時收件人可以去做一些像摸摸魚啊、上個廁所、和同時抱怨一下爲什麼十一假期不放兩週之類有意義的事情。

同時當你寫完郵件發出去後也不需要乾巴巴的等着對方回覆什麼都不做,你也可以做一些像摸魚之類這樣有意義的事情

在這裏,你寫郵件別人摸魚,這兩件事又在同時進行,收件人和發件人都不需要相互等待,發件人寫完郵件的時候簡單的點個發送就可以了,收件人收到後就可以閱讀啦,收件人和發件人不需要相互依賴、不需要相互等待。

你看,在這個場景下 “不依賴”,“無關聯”,“無需等待” 這些關鍵詞就出現了,因此郵件這種溝通方式就是異步的。

同步調用

現在終於回到編程的主題啦。

既然現在我們已經理解了同步與異步在各種場景下的意義 (I hope so),那麼對於程序員來說該怎樣理解同步與異步呢?

我們先說同步調用,這是程序員最熟悉的場景。

一般的函數調用都是同步的,就像這樣:

funcA() {
// 等待函數funcB執行完成
    funcB();
// 繼續接下來的流程
}

funcA 調用 funcB,那麼在 funcB 執行完前,funcA 中的後續代碼都不會被執行,也就是說 funcA 必須等待 funcB 執行完成,就像這樣:

從上圖中我們可以看到,在 funcB 運行期間 funcA 什麼都做不了,這就是典型的同步。

注意,一般來說,像這種同步調用,funcA 和 funcB 是運行在同一個線程中的,這是最爲常見的情況。

但值得注意的是,即使運行在兩個不能線程中的函數也可以進行同步調用,像我們進行 IO 操作時實際上底層是通過系統調用(關於系統調用請參考《程序員應如何理解系統調用》)的方式向操作系統發出請求的,比如磁盤文件讀取:

read(file, buf);

這就是我們在《讀取文件時,程序經歷了什麼》中描述的阻塞式 I/O,在 read 函數返回前程序是無法繼續向前推進的

read(file, buf);
// 程序暫停運行,
// 等待文件讀取完成後繼續運行

如圖所示:

只有當 read 函數返回後程序纔可以被繼續執行。

注意,和上面的同步調用不同的是,函數和被調函數運行在不同的線程中。

因此我們可以得出結論,同步調用和函數與被調函數是否運行在同一個線程是沒有關係的

在這裏我們還要再次強調,同步方式下函數和被調函數無法同時進行。

同步編程對程序員來說是最自然最容易理解的。

但容易理解的代價就是在一些場景下,同步並不是高效的,原因很簡單,因爲任務沒有辦法同時進行。

接下來我們看異步調用。

異步調用

有同步調用就有異步調用。

如果你真的理解了本節到目前爲止的內容的話,那麼異步調用對你來說不是問題。

一般來說,異步調用總是和 I/O 操作等耗時較高的任務如影隨形,像磁盤文件讀寫、網絡數據的收發、數據庫操作等。

我們還是以磁盤文件讀取爲例。

在 read 函數的同步調用方式下,文件讀取完之前調用方是無法繼續向前推進的,但如果 read 函數可以異步調用情況就不一樣了。

假如 read 函數可以異步調用的話,即使文件還沒有讀取完成,read 函數也可以立即返回。

read(file, buff);
// read函數立即返回
// 不會阻塞當前程序

就像這樣:

可以看到,在異步這種調用方式下,調用方不會被阻塞,函數調用完成後可以立即執行接下來的程序。

這時異步的重點就在於調用方接下來的程序執行可以和文件讀取同時進行,從上圖中我們也能看出這一點,這就是異步的高效之處。

但是,請注意,異步調用對於程序員來說在理解上是一種負擔,代碼編寫上更是一種負擔,總的來說,上帝在爲你打開一扇門的時候會適當的關上一扇窗戶。

有的同學可能會問,在同步調用下,調用方不再繼續執行而是暫停等待,被調函數執行完後很自然的就是調用方繼續執行,那麼異步調用下調用方怎知道被調函數是否執行完成呢?

這就分爲了兩種情況:

  1. 調用方根本就不關心執行結果

  2. 調用方需要知道執行結果

第一種情況比較簡單,無需討論。

第二種情況下就比較有趣了,通常有兩種實現方式:

一種是通知機制,也就是說當任務執行完成後發送信號來通知調用方任務完成,注意這裏的信號有很多實現方式,Linux 中的 signal,或者使用信號量等機制都可以實現。

另一種是就是回調,也就是我們常說的 callback,關於回調我們將在下一篇文章中重點講解,本篇會有簡短的討論。

接下來我們用一個具體的例子講解一下同步調用與異步調用。

同步 VS 異步

我們以常見的 Web 服務來舉例說明這一問題。

一般來說 Web Server 接收到用戶請求後會有一些典型的處理邏輯,最常見的就是數據庫查詢 (當然,你也可以把這裏的數據庫查詢換成其它 I/O 操作,比如磁盤讀取、網絡通信等),在這裏我們假定處理一次用戶請求需要經過步驟 A、B、C,然後讀取數據庫,數據庫讀取完成後需要經過步驟 D、E、F,就像這樣:

# 處理一次用戶請求需要經過的步驟:
A;
B;
C;
數據庫讀取;
D;
E;
F;

其中步驟 A、B、C 和 D、E、F 不需要任何 I/O,也就是說這六個步驟不需要讀取文件、網絡通信等,涉及到 I/O 操作的只有數據庫查詢這一步。

一般來說這樣的 Web Server 有兩個典型的線程:主線程和數據庫處理線程,注意,這討論的只是典型的場景,具體業務實際上可會有差別,但這並不影響我們用兩個線程來說明問題。

首先我們來看下最簡單的實現方式,也就是同步。

這種方式最爲自然也最爲容易理解:

// 主線程
main_thread() {
    A;
    B;
    C;
    發送數據庫查詢請求;
    D;
    E;
    F;
}
// 數據庫線程
DataBase_thread() {
while(1) {
        處理數據庫讀取請求;
        返回結果;
    }
}

這就是最爲典型的同步方法,主線程在發出數據庫查詢請求後就會被阻塞而暫停運行,直到數據庫查詢完畢後面的 D、E、F 纔可以繼續運行,就像這樣:

從圖中我們可以看到,主線程中會有 “空隙”,這個空隙就是主線程的 “休閒時光”,主線程在這段休閒時光中需要等待數據庫查詢完成才能繼續後續處理流程。

在這裏主線程就好比監工的老闆,數據庫線程就好比苦逼搬磚的程序員,在搬完磚前老闆什麼都不做只是緊緊的盯着你,等你搬完磚後纔去忙其它事情。

顯然,高效的程序員是不能容忍主線程偷懶的。

是時候祭出大殺器了,這就是異步。

在異步這種實現方案下主線程根本不去等待數據庫是否查詢完成,而是發送完數據庫讀寫請求後直接處理下一個請求。

有的同學可能會有疑問,一個請求需要經過 A、B、C、數據庫查詢、D、E、F 這七個步驟,如果主線程在完成 A、B、C、數據庫查詢後直接進行處理接下來的請求,那麼上一個請求中剩下的 D、E、F 幾個步驟怎麼辦呢?

如果大家還沒有忘記上一小節內容的話應該知道,這有兩種情況,我們來分別討論。

1,主線程不關心數據庫操作結果

在這種情況下,主線程根本就不關心數據庫是否查詢完畢,數據庫查詢完畢後自行處理接下來的 D、E、F 三個步驟,就像這樣:

看到了吧,接下來重點來了哦。

我們說過一個請求需要經過七個步驟,其中前三個是在主線程中完成的,後四個是在數據庫線程中完成的,那麼數據庫線程是怎麼知道查完數據庫後要處理 D、E、F 這幾個步驟呢?

這時,我們的另一個主角回調函數就開始登場啦。

沒錯,回調函數就是用來解決這一問題的。

我們可以將處理 D、E、F 這幾個步驟封裝到一個函數中,假定將該函數命名爲 handle_DEF_after_DB_query:

void handle_DEF_after_DB_query () {
    D;
    E;
    F;
}

這樣主線程在發送數據庫查詢請求的同時將該函數一併當做參數傳遞過去

DB_query(request, handle_DEF_after_DB_query);

數據庫線程處理完後直接調用 handle_DEF_after_DB_query 就可以了,這就是回調函數的作用。

也有的同學可能會有疑問,爲什麼這個函數要傳遞給數據庫線程而不是數據庫線程自己定義自己調用呢?

因爲從軟件組織結構上講,這不是數據庫線程該做的工作

數據庫線程需要做的僅僅就是查詢數據庫、然後調用一個處理函數,至於這個處理函數做了些什麼數據庫線程根本就不關心,也不應該關心

你可以傳入各種各樣的回調函數。也就是說數據庫系統可以針對回調函數這一抽象的函數變量來編程,從而更好的應對變化,因爲回調函數的內容改變不會影響到數據庫線程的邏輯,而如果數據庫線程自己定義處理函數那麼這種設計就沒有靈活性可言了。

而從軟件開發的角度看,假設數據庫線程邏輯封裝爲了庫提供給其它團隊,當數據庫團隊在研發時怎麼可能知道數據庫查詢後該做什麼呢?

顯然,只有使用方纔知道查詢完數據庫後該做些什麼,因此使用方在使用時簡單的傳入這個回調函數就可以了。

這樣複雜數據庫的團隊就和使用方團隊實現了所謂的解耦

現在你應該明白回調函數的作用了吧。

如果你覺得有幫到你,請伸出你的小手幫忙分享再看一下,原創不易,你的一個在看是對博主最大的肯定,拜託大家啦。

不容易啊,容我喝口水叉會兒腰歇一歇。

我們繼續。

另外仔細觀察上面兩張圖,你能看出爲什麼異步比同步高效嗎?

原因很簡單,這也是我們在本篇提到過的,異步天然就無需等待,無依賴。

從上一張圖中我們可以看到主線程的 “休閒時光” 不見了,取而代之的是不斷的工作、工作、工作,就像苦逼的 996 程序員一樣,而且數據庫線程也沒有那麼大段大段的空閒了,取而代之的也是工作、工作、工作。

主線程處理請求和數據庫處理查詢請求可以同時****進行,因此從系統性能上看,這樣的設計能更加充分的利用系統資源,更加快速的處理請求;從用戶的角度看,系統的響應也會更加迅速。

這就是異步的高效之處。

但我們應該也可以看出,異步編程並不如同步來的容易理解,系統可維護性上也不如同步模式。

那麼有沒有一種方法既能結合同步模式的容易理解又能結合異步模式的高效呢?答案是肯定的,我們將在後續章節詳細講解這一技術。

接下來我們看第二種情況,那就是主線程需要關心數據庫查詢結果。

2. 主線程關心數據庫操作結果

在這種情況下,數據庫線程需要將查詢結果利用通知機制發送給主線程,主線程在接收到消息後繼續處理上一個請求的後半部分,就像這樣:

從這裏我們可以看到,ABCDEF 幾個步驟全部在主線中處理,同時主線程同樣也沒有了 “休閒時光”,只不過在這種情況下數據庫線程是比較清閒的,從這裏並沒有上一種方法高效,但是依然要比同步模式下要高效。

最後需要注意的是,並不是所有的情況下異步都一定比同步高效,還需要結合具體業務以及 IO 的複雜度具體情況具體分析。

總結

在這篇文章中我們從各種場景分析了同步與異步這兩個概念,但是不管在什麼場景下,同步往往意味着雙方要相互等待、相互依賴,而異步意味着雙方相互獨立、各行其是。希望本篇能對大家理解這兩個重要的概念有所幫助。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/gfF71thKevyrJq2MVGMVAg