帶你通俗易懂了解進程、線程和協程
作者 | 肖瑋
寫在最前
本故事采用簡潔明了的對話方式,盡洪荒之力讓你在輕松無負擔的氛圍中,稍微深入地理解進程、線程和協程的相關原理知識
如果你覺得自己本來就已經理解得很透徹了,那也不妨瞧一瞧,指不定有意外的收獲呢
在這個 AI 內容生成泛濫的時代,依然有一批人"傻傻"堅持原創,如果您能讀到最后,還請點贊或收藏或關注支持下我唄,感謝 ( ̄︶ ̄)↗
進程
丹尼爾:蛋兄,我對進程、線程、協程這些概念似懂非懂的,要不咱們今天就好好聊聊這些?
蛋先生:當然可以
丹尼爾:先說說進程吧,從字面意思上看,是不是可以理解為正在運(進)行的程序?
蛋先生:正是如此,程序是靜態的,而進程則是動態的
丹尼爾:說得我更糊涂了
蛋先生:好吧,以你電腦上的視頻播放器(就是一個程序)為例。當你不雙擊它時,它就是一個安靜的美男子——哦不,就是一份靜靜躺在硬盤上的代碼
丹尼爾:別逗我了,蛋兄
蛋先生:( ╯▽╰) 但當你雙擊它時,它就通過進程“動”起來了
丹尼爾:進程做了什么讓它“動”起來了?
蛋先生:程序是代碼,比如播放邏輯的代碼。要讓視頻播放,這些代碼必須執行起來對吧
丹尼爾:確實。那進程是怎么執行這些代碼的?
蛋先生:進程會利用操作系統的調度器分配給它的 CPU 時間片,通過 CPU 來執行代碼(注意:現代操作系統都是直接調度線程,不會調度進程哦)
丹尼爾:原來如此,操作系統給進程分配了 CPU 時間片資源。那還有其他的資源嗎?
蛋先生:代碼執行過程,需要存儲一些數據,所以進程還分配有內存空間資源
丹尼爾:都存些什么數據呢?
蛋先生:程序代碼本身就需要先存儲起來。然后代碼執行過程中的變量,參數什么的,也是需要存儲的。給個圖你了解一下吧
丹尼爾:哦,還有其它資源嗎?
蛋先生:程序可能會執行一些 I/O 任務,比如視頻播放器需要加載視頻,這些視頻數據可能從本地文件加載,也可能從網絡上加載,這就需要文件描述符資源。計算,存儲,I/O 涉及的三大資源,就是分配給進程最主要的資源了。而進程就是分配資源的基本單位了
丹尼爾:原來如此,代碼執行,數據存儲,I/O 操作,程序就能運行起來了
蛋先生:正是這樣。有了進程,我們可以同時運行多個程序。比如,你可以一邊播放視頻,一邊編輯文檔,每個程序都有自己的進程,互不干擾。即使它們都是同一份代碼,但各自播放的內容和進度都可以不同
丹尼爾:明白了
蛋先生:既然你有編程基礎,我就簡單總結一下吧。
什么是進程?進程就是程序的實例(就像面向對象編程中的類,類是靜態的,只有實例化后才運行,且同一個類可以有多個實例)
為什么需要進程?為了讓程序運行起來(如果程序不運行,用戶昨看視頻捏)
線程
丹尼爾:這個總結我喜歡,接下來該聊聊線程了
蛋先生:進程(可以看成只有一個線程的進程)同時只能做一件事,所以你的視頻播放器的工作方式就像以下
丹尼爾:那樣的體驗肯定糟糕透了,視頻完全加載并解碼完之前,啥都看不了
蛋先生:沒錯,所以我們期望能夠一邊加載和解碼,一邊播放,這樣就不會浪費時間空等了。為了實現這個目的,一個進程就需要進化成多個線程來同時執行多個任務
丹尼爾:那如果一個進程只能做一件事,我用兩個進程不也可以同時做兩件事嗎?
蛋先生:你說得對,但進程間是完全獨立的,互不干擾。而線程則共享同一個進程的資源,所以線程間交換數據更方便,幾乎沒有通訊損耗。但進程間交換數據就麻煩多了,得通過一些通訊機制,比如管道、消息隊列之類的
想象一下,我和你住在不同的房子,你要寄給我一箱牛奶,就得通過快遞等方式寄給我。但如果我和你住在同一個房子,你買了牛奶只要往冰箱一放,我只要去冰箱一拿,多方便啊
丹尼爾:那線程都共享進程的什么資源呢?
蛋先生:分配給進程的資源,絕大部分都是線程間共享的。比如內存空間的代碼段,數據段,堆,比如文件描述符等。而棧則是每個線程特有的,因為線程是程序執行的最小單位,它需要記錄自己的局部變量等
共享資源覆蓋
丹尼爾:線程之間共享資源,總感覺會有什么問題
蛋先生:大部分情況下線程之間還是可以和平共處的,但有一種情況,就是大家都想對同個資源進行寫操作時,就會發生覆蓋,導致數據不一致等問題
丹尼爾:能具體說一說嗎?
蛋先生:為了更容易理解,我們借助以下代碼來說明。如果兩個線程來運行 main 方法,會有概率出現一些讓你費解的結果
public class Main {
// 定義一個靜態成員變量 a
private static int a = 1;
// 定義一個方法 add 來增加 a 的值
public static void add() {
a += 1;
}
public static void main(String[] args) {
add();
System.out.println("a 的值是: " + a); // 輸出 a 的值
}
}
丹尼爾:怎么說?
蛋先生:a 是個靜態成員變量,它存儲在進程內存空間的數據段,共享于多個線程,所以它屬于線程間共享的資源對吧
丹尼爾:沒錯
蛋先生:我們再看下 add 方法的邏輯 a += 1, 這么簡單的代碼,在底層并非原子操作,而是分為三個步驟
- 步驟一:獲取 a 變量的值
- 步驟二:執行 +1 運算
- 步驟三:將運行結果賦值給 a
丹尼爾:那會有什么問題呢?
蛋先生:如果線程 1 在執行完步驟一和步驟二,還沒執行步驟三時,操作系統進行了 CPU 調度,發生了線程切換,使得線程 2 也開始執行步驟一和步驟二。接下來線程 1 和線程 2 都會各自執行步驟三。因為 add 方法執行了兩次,正確的結果 a 的值應該是 +2。但很遺憾,結果是 +1。這樣的結果有時候會讓你摸不著頭腦,而不穩定的結果也將會導致應用的不穩定
丹尼爾:啊,是這樣啊。那該怎么辦?
蛋先生:解決方法有很多種,比如加鎖方案,比如無鎖方案等,需要根據實際情況選擇。這個話題比較復雜,我們后面再找時間詳細探討吧。現在只要知道多線程會有資源覆蓋的問題就行了
上下文切換
丹尼爾:好的,明白了。剛才提到線程切換,線程切換到底發生了什么呢?
蛋先生:線程切換會進行線程上下文切換。線程在運行時,實際上是在執行代碼,而執行代碼過程中需要存儲一些中間數據,也可能會執行一些 I/O 操作。如果過程中被中斷,是不是得保留現場,以便下次恢復繼續運行?
丹尼爾:嗯,確實需要,但具體都存儲些什么呢?
蛋先生:首先是下一個要執行的代碼,這個存儲在程序計數器中。然后是一些中間數據如局部變量等,會存儲在線程棧中。為了加速計算,中間數據中對當前指令執行至關重要的部分會存儲在寄存器中。所以,程序計數器需要保存,寄存器需要保存,線程棧指針也需要保存
丹尼爾:“中間數據中對當前指令執行至關重要的部分會存儲在寄存器”,能舉個例子嗎?
蛋先生:假設以下代碼,當在執行 add 方法時,x, y, a, b 會壓進線程棧中。而其中 a, b 是和當前運算最相關的,則會存儲在寄存器中,以加速 CPU 的運算
int add(int a, int b) {
return a + b;
}
int main() {
int x = 10;
int y = 20;
int result = add(x, y);
return 0
}
協程
丹尼爾:哦,原來如此。線程已經相當不錯了,那協程又是怎么回事呢?
蛋先生:回想一下,我們之前一個線程負責運行加載和解碼邏輯,另一個線程負責播放邏輯,對吧?
丹尼爾:沒錯,有什么問題嗎?
蛋先生:其實還有優化的空間。線程在執行加載視頻片段時,必須等待結果返回才能執行解碼操作
丹尼爾:確實,加載片段的等待時間似乎又被浪費了
蛋先生:沒錯,我們可以充分利用這段時間。只需讓線程在加載的同時進行解碼,就能大幅減少加載等待的時間。而這正是協程所能發揮的作用
丹尼爾:哇,蛋兄,你可真是個會過日子的人,這么精打細算。但我只要用不同的線程分別處理加載和解碼,不也能達到同樣的效果嗎?
蛋先生:可以是可以,但多線程會帶來一些問題
丹尼爾:啥問題呢?
蛋先生:首先,一個線程用于執行加載操作,這主要是 I/O 操作,幾乎不消耗 CPU 資源,導致該線程長時間處于阻塞狀態,這是很浪費的。當然,你可以讓它休眠以釋放 CPU 時間,但創建線程本身就有開銷,線程切換同樣有開銷。相比之下,協程非常輕量,創建和切換的開銷極小
丹尼爾:為什么協程的創建和切換的開銷極小呢?
蛋先生:主要是因為它并非操作系統層面的東西,就不涉及內核調度。一般是由編程語言來實現(比如 Python 的 asyncio 標準庫),它屬于用戶態的東西
丹尼爾:那協程不會有像多線程那樣的資源覆蓋問題嗎?
蛋先生:線程的執行時機由操作系統調度,程序員無法控制,這正是多線程容易出現資源覆蓋的主要原因。而協程的執行時機由程序自身控制,不受操作系統調度影響,因此可以完全避免這類問題
此外,同一個線程內的多個協程共享同一個線程的 CPU 時間片資源,它們在 CPU 上的執行是有先后順序的,不能并行執行。而線程是可以并行執行的
丹尼爾:那協程是如何實現這一點的呢?
蛋先生:協程(coroutine),其實是一種特殊的子程序(subroutine,比如普通函數)。普通函數一旦執行就會從頭到尾運行,然后返回結果,中間不會暫停。而協程則可以在執行到一半時暫停。利用這一特性,我們可以在遇到 I/O 這類不消耗 CPU 資源的操作時,將其掛起,繼續執行其他計算任務,充分利用 CPU 資源。等 I/O 操作結果返回時,再恢復執行
丹尼爾:感覺很像 NodeJS 的異步 I/O 啊
蛋先生:沒錯,它們的目的都是在一個線程內并發執行多個任務。不過在叫法和實現上會有一些差異
丹尼爾:感覺今天了解得夠多了,謝謝蛋兄
蛋先生:后會有期!