執行緒同步 3:任務、執行緒、行程、與程式

在解釋明白了作業系統實現多工的原理後,本篇要來整理一些與執行緒相關的常用名詞解釋, 特別是那些一般可能容易混淆的名詞與含義。

指令與任務

首先來解釋「指令」(Instruction),或者有些地方叫作「機器碼」(Maching Code)。 指令是 CPU 真正能夠讀懂並且知道要去做什麼事的命令碼, 是電腦 CPU 硬體實際上真正一個接一個不斷執行的命令, 也是我們的程式最晚到了執行之前要變成的東西。 而「任務」(Task),就是一群為了共同的目標而生,服務於共同目的的多個指令的集合。 只不過光有任務是遠遠不夠支持一個程式的執行的, 因為程式的執行並不只依靠命令口令,還需要許多使它能夠工作的相關資源,因而才有了執行緒; 不過關於執行緒我們先暫時放著,先來看看行程。

行程與執行緒

「行程」(Process)是電腦作業系統裡一個真正完整的具有執行狀態的程式。 行程裡面當然內含了上面所說的任務,可能還不只一個(只不過通常不會以裸裝的任務型態存在)。 然而行程所擁有的並不只是一大堆的指令。一個行程裡面還包含了程式執行所需要的各種資源, 例如記憶體空間、程式計數器與各種暫存器空間、堆疊空間、資料空間、系統與檔案資源等等。 由於行程包含了一個獨立程式執行所需要的完整資源, 因此行程也被稱呼為一個程式在作業系統內存在的完整實體。

如同前面說過,任務作為指令的集合其本身並不完備,因為還缺少了真正能夠「執行」起來所必要的一些資源, 於是通常除非在討論排程器這樣的話題裡,否則通常不容易看到任務這個名詞被提起。 因此在作業系統內真正能夠被安排與調動的最小單位其實是「執行緒」(Thread)。 執行緒不像行程那樣包山包海的完整,而僅只有一些能夠支持它正常執行運作所需的最小資源集合, 因此執行緒並不能夠獨立的作為一個執行實體存在於系統內,而必須依附於行程; 或者可以說,執行緒其實就是任務加上所必須的執行資源後所成的包裝, 因此它的完整性介於任務與行程中間。

於是這裡總結一下,行程就是在系統內完整獨立的單位; 而一個行程內部會擁有至少一個執行緒,作為能夠執行與被排程的單元。 單獨的執行緒本身並不足夠完整以致於能夠獨立的存在和運作, 執行緒需要依附於一個行程以使用其之資源,並且屬於同一個行程內的所有執行緒皆共享行程內的資源, 而這也是為什麼我們前篇範例內的執行緒可以共同存取記憶體變數等資源的原因。 然而行程並不只是擁有完整資源的獨立存在而已,它還被作業系統給設法限制與保護住, 因此不同行程之間彼此是獨立的,行程之間(正常情況下)沒辦法存取、利用、與窺探另一個行程的內部資源。 這也是為什麼通常來說我們電腦上的一個軟體當掉之後並不會影響其它軟體與系統, 並且我們還能夠單獨殺掉那個異常的行程的緣故了!

不過在古時候作業系統對行程的保護與隔離還沒那麼完善的時候就不是這樣了。 那個時候的電腦如果有一個程式寫不好,違規存取它不該取用的資源,或者它自己異常死掉了, 是可能會造成其它原本正常的程式也發生異常,甚至能把系統也搞到異常當掉的! 當然好處也是有的,在那個時代不同的程式之間要傳遞消息互相溝通就想當方便。 反正我對你的內部能看能拿,並且你對我也是,那麼什麼資料我就可以隨意塞給你,你也可以隨意塞給我。 而到了現代的行程之間被隔離獨立, 還要再能互相溝通的話就只能依靠「行程間通訊」(Inter-Process Communication (IPC))的手段了。

執行緒的獨立與共用資源

回到執行緒身上。 同一個行程內部的所有執行緒原則上共享所有的資源,然而有些資源還是不能夠共用的,必須執行緒自己擁有一份。 作為一個能夠獨立被 CPU 排程器調派並執行運作的單位,不同的執行緒之間除了各自擁有各自的指令之外, 顯然那些與執行有關的上下文狀態就沒有辦法共用,因為不同的執行緒彼此的執行狀態並不相同。 具體來說,這些執行緒必須自己擁有一份的資源有像是: 程式計數器(Program Counter)的備份、各 CPU 暫存器(Registers)的備份、以及堆疊空間(Stack)等; 至於像堆積空間(Heap)、全域空間(Global)、檔案與各種系統資源等,則所有執行緒共用同一個行程裡面的這些資源。 因此在前篇的範例中數個執行緒才能夠直接取用共同的全域變數。 不過同一個行程裡面的多個執行緒僅僅只是擁有一份屬於自己的資源而已,並不像不同行程之間會受到作業系統的隔離保護, 因此執行緒之間是可以存取彼此的資源如堆疊空間上的變數等等,只要你知道那些東西的位址在哪裡就可以了。

上面解釋的方式比較中規中矩,但是怕可能許多並沒有那麼多資訊工程背景的程序員們可能會像在看天書, 於是這裡用更加白話而貼近程序員日常的語言再解釋一遍。 濃縮總結最重要的部份其實主要在於記憶體空間的分配上。 全域空間(Global),也就是宣告為全域變數、或者是函式、類別內的 static 型態變數,都是整個行程裡面只有一份的, 任何一個執行緒改寫了變數的值,則所有參用到這個值的其它執行緒都會受到影響。 堆積空間(Heap),也就是我們在程式裡面透過 new、malloc 等操作所動態建立的空間,也是所有執行緒都可以直接使用的, 並且一個執行緒建立的空間由另一個執行緒進行釋放也是可以的(至於合適不合適這麼做,就要看程式的設計結構了); 並且一個執行緒釋放了一個堆積上的空間後,其它執行緒再嘗試存取就屬於違規存取了! 最後是堆疊空間(Stack),就是函式內的區域變數,讓函式可以層層呼叫甚至嵌套呼叫的基礎, 於是這個堆疊空間便是每一個執行緒獨立擁有一份的。 意思就是同一個函式內的區域變數在不同的執行緒執行之下,彼此是獨立且互不相干的, 這個執行緒怎樣操作區域變數並不會影響另一個執行緒; 但是執行緒各自擁一份區域變數空間並不表示彼此被完全相互隔離, 一個執行緒仍然可以將它堆疊空間內的變數位址傳遞給另一個執行緒去做參用或修改, 並且雙方都能看到被修改更新後的內容。

不過這裡再補充一點知識, 以上的定義與區分是屬於邏輯概念上的東西,但是個別作業系統的實現方式與細節可能仍有所不同。 比方說 Windows 的實現方式就與前面的解釋內容一樣,行程裡面包含執行緒,因此每一個行程至少都會擁有一條執行緒。 而 Linux 就沒有獨立的執行緒實現,而是設計無論執行緒還是行程都是一樣的東西。 在 Linux 裡只有行程,一條邏輯上的執行緒其背後其實是一個行程; 一個行程裡面也沒有再包含一個執行緒,因為它本身自己就是。 對於 Linux 來說,一切都是行程, 只不過是有些行程之間是共用部份資源的,有些行程之間是存在父子關係的,僅此而已。 然而不論個別作業系統對這些功能的實際實現方式如何,在外顯的運作邏輯與特性上都符合本篇所講述的結構, 因此我們站在應用程式的視角其實可以把這些細節看作為實作方式的不同就行了。

上下文切換

我們知道當代的分時多工作業系統依靠一個排程器在多個執行緒之間不斷來回切換執行, 營造出好似大家同時執行的效果 (當然在多核或多 CPU 情況下還真的會有那麼幾個執行緒能真正意義上的「同時」執行), 但是這種執行緒之間的來回切換並不是沒有代價的!

每一個 CPU 核心一次只能執行一條任務,每一條執行緒都有自己獨立的執行狀態, 而每一個執行中的任務其實都有許多正在臨時使用中的執行資源。 比方說當我們在辦公室埋首處理各種文件資料與公文時,或者當我們在書桌前認真研究什麼東西時, 我們通常不會乖乖的在需要參考使用資料時才站起來去到書櫃或檔案櫃那裡拿取, 而是會預估並取出某些可能會需要即時參考的資料到書桌上,一邊工作一邊參考, 工作做完了或告一段落了才會把東西放回去。 甚至於我們可能會臨時隨筆寫下一些幫助短期記憶, 比方說現在讀到哪裡,剛剛參考的是哪本書第幾頁,臨時想到什麼怕等會忘了於是寫下來的便簽等等。 這些東西在電腦、CPU、與作業系統中同樣都存在,比方說像是程式計數器和各種 CPU 暫存器等等, 甚至於某方面來說 CPU 快取也能算在其中之內,而這些就統稱為執行條件的「上下文」。

當我們的作業系統要切換執行不同的執行緒時, 就會需要先把當前執行的這個執行緒的相關資料、狀態數值等先保存在執行緒的相關備份區域內, 然後再把要切換過去執行的那個執行緒的相關狀態從它的備份區域讀出來並取代當前的執行上下文, 然後才真正開始執行那個要切換過來的執行緒的任務。 因此這樣的行為就被稱為「上下文切換」(Context Switch), 而閱讀過前面的描述之後,想也知道這樣的上下文切換需要花費一點時間。 雖然上下文切換的速度相當快,但如果過於頻繁切換的話也會導致效能有所降低。 因此作業系統排程的時間片(Timeslice)如果設計的過短也會導致過於頻繁的進行上下文切換,從而導致效能受損; 同樣也是當我們對程式的效能有些刁鑽的時候,有些時候檢討並儘量減少上下文切換的機會, 在某些時候也是能夠提升程式計算效能的其中一處入手點。 (不過雖然說是這麼說,但這裡還是建議程式小白先不要想那麼多,看過就算了。 因為很多時候當了解掌握還不夠熟悉的情況下,很容易弄巧成拙! 只需要先當了解一個冷知識那樣知道有這麼件事存在就可以,等你的火候足夠的時候自然會了解領會過來。)

程式

最後再來題一個東西叫作「程式」(Program)。 程式這東西有點意義不那麼明確,因為口語上其實許多應該被叫作行程的東西會被我們稱呼為程式, 比方「電腦後臺管理器看到有個程式在跑」之類,然而這裡我要說的是嚴格意義上的「程式」(Program)。

一個程式其實就是一堆死的資料,通常以一個執行檔檔案的型態存在, 也就是我們撰寫的程式碼編譯(或稱建置可能更合適)後所成為的東西,那個叫作程式。 那麼當我們要執行一個程式的時候,作業系統就會去讀取這個檔案, 按照檔案內所記載的內容將指令資料讀入記憶體、規劃記憶體空間、配置需要的系統資源, 並初始化各項資源後成為一個系統內的行程,然後從第一個指令開始執行。 也就是說,系統內真正能夠執行運作的實體仍是行程, 而程式則是告訴系統如何建立產生一個用來執行特定任務的行程的規格書。

這就是為什麼明明只有一個「小畫家」程式,我們卻可以重複啟動好多次, 讓桌面上同時開啟好幾個小畫家程式(行程),且每一個程式(行程)所載入編輯的圖檔都不一樣, 彼此之間完全獨立不互相干擾。 這時桌面上存在的好幾個小畫家的畫面,其實就是一個一個各自獨立的行程實體, 而這些行程全部都來自於同一個「程式」。

上一篇:「執行緒同步 2:多工原理」
下一篇:「執行緒同步 4:為什麼出錯?」