[譯] 多執行緒簡介:一步一步來接近多執行緒的世界

掘金翻譯計劃發表於2019-04-02

現代計算機已經具備了在同一時間執行多個操作的能力。在更先進的硬體和更智慧的作業系統支援下,這個特徵可以讓你程式的執行和響應速度變得更快。

編寫能夠利用這種特性的軟體會很有意思,但也很棘手:這需要你理解計算機背後所發生的事情。在第一節中,我將會試著簡單覆蓋關於執行緒的知識,它是由作業系統提供能實現這種魔術的工具之一。讓我們開始吧!

程式和執行緒:用正確的方式來命名事物

現代作業系統可以在同一時間執行多個程式。這就是為什麼你可以在瀏覽器(一個程式)閱讀這篇文章的同時還可以在播放器(另一個程式)上收聽音樂。這裡的每個程式被認為是一個正在執行的程式。作業系統知道很多軟體層面的技巧來使一個程式和其他程式一起執行,也可以利用底層硬體來實現這個目的。無論哪種方式,最終的結果就是你會感覺所有程式都正在同時執行。

在作業系統中執行程式並不是同時執行多個操作唯一的方式。每個程式其內部還可以同時執行多個子任務,這些子任務叫做執行緒。你可以把執行緒理解為程式本身的一部分。每個程式在啟動時至少會觸發一個執行緒,被稱為主執行緒。然後,根據程式/開發者的需要,可以在程式內啟動和終止額外的執行緒。多執行緒就是指在同一個程式中執行多個執行緒的技術。

比如說,你的播放器就可能執行了多個執行緒:一個執行緒用來渲染介面 —— 這個執行緒通常是主執行緒,另一個用於播放音樂等等。

你可以把作業系統理解為一個包含多個程式的容器,其中的每個程式都是一個包含多個執行緒的容器。在本文中,我將只關注執行緒,但是這整個主題都很吸引人,所以值得在將來做更深入的分析。

程式 vs 執行緒

圖1:作業系統可以被看作一個包含程式的盒子,程式又可以被看作包含一個或多個執行緒的盒子。

程式和執行緒之間的區別

每個程式都有屬於它自己的記憶體塊,由作業系統負責進行分配。在預設情況下,程式之間不能共享彼此的記憶體塊:瀏覽器程式無法訪問分配給播放器的記憶體,反之亦然。就算你執行了相同的程式例項(比如你啟動了瀏覽器兩次),它們之間也不會共享記憶體。作業系統將每個例項視為一個新的程式,並分配其各自獨立的記憶體。所以,在一般情況下,多個程式相互之間無法共享資料,除非它們使用一些高階的技巧 —— 所謂的程式間通訊

和程式不一樣,執行緒共享由作業系統分配給其父程式的同一塊記憶體:這樣播放器的音訊引擎可以很簡單的讀取到主介面的資料,反之亦然。因此相較於程式,執行緒之間相互通訊更加容易。除此之外,執行緒通常比程式更輕:它們佔用的資源更少,建立的速度更快,這就是為什麼它們也被稱為輕量級程式的原因。

要讓你的程式在同一時間執行多個操作,執行緒是一種簡單的方式。如果沒有執行緒,你就需要為每個任務寫一個程式,把它們作為程式執行並通過作業系統對這些程式進行同步。相較之下,這不僅會變得更難(程式間通訊比較棘手)而且速度更慢(程式比執行緒更重)。

綠色執行緒,纖程

到目前為止提到的執行緒都是作業系統層面的概念:一個程式想要啟動一個新執行緒必須通過作業系統。然而並非每個平臺都原生支援執行緒。綠色執行緒,也被稱為纖程是對執行緒的一種模擬,使多執行緒程式可以在不提供執行緒能力的環境下工作。比如說,在虛擬機器的底層作業系統並沒有對執行緒原生支援的情況下,它還是可以實現綠色執行緒。

綠色執行緒可以更快的建立和管理,因為對其的操作完全繞過了作業系統,但是這也有缺點。我將在下一節中談到這個話題。

“綠色執行緒”的名字來自於 Sun Microsystem 的綠色團隊,他們在 90 年代設計了 Java 最初 的執行緒庫。現在,Java 不再使用綠色執行緒:它們在 2000 年的時候被切換成了原生執行緒。其它一些像 Go,Haskell 或者 Ruby 等程式語言 —— 它們採用了和綠色執行緒相同的實現而沒有用原生執行緒。

執行緒是用來幹嘛的

為什麼一個程式應該使用多個執行緒?就像我之前提到的,並行處理可以極大加快速度。假設你要在電影編輯器中渲染一部電影。這個編輯器足夠智慧的話,它可以將渲染操作分散到多個執行緒中,每個執行緒負責處理電影的一部分。這樣的話如果用一個執行緒處理該任務要一個小時,那麼使用兩個執行緒則需要 30 分鐘;使用 4 個執行緒要 15 分鐘,以此類推。

真的有那麼簡單嗎?這裡有三點需要考慮:

  1. 並不是每個程式都需要多執行緒。如果你的應用執行的是順序操作或者等待使用者做一些事情,多執行緒可能並沒有那麼好;
  2. 你不能只是簡單在應用中增加更多的執行緒,來讓它執行更快:每個子任務都必須經過仔細的思考和設計從而實現並行操作;
  3. 並不能百分百保證執行緒將真正並行的執行操作(即同時執行):它實際上取決於程式執行的底層硬體。

最後至關重要的一點:如果你的計算機不支援在同一時間執行多個操作,作業系統就會偽裝成它們是那樣執行的。我們之後將會馬上看到這個。目前,讓我們把併發理解成我們看起來任務在同時執行,而真正的並行就是像字面上理解的那樣,任務在同一時間執行。

併發 vs 並行

圖 2:並行是併發的子集。

是什麼使併發和並行成為可能

計算機的中央處理單元(CPU)負責執行程式的繁重工作。它由幾部分組成,其中主要的部分叫做核心:這就是實際執行計算的地方。一個核心在同一時間只能執行一個操作。

無疑,這是核心一個主要的缺點。因此,作業系統層面提供了先進的技術使使用者能夠同時執行多個程式(或執行緒),特別是在圖形環境中,甚至在單核機器上。其中最重要的方式叫做搶佔式多工處理,這裡面的搶佔式是指可以控制中斷正在執行的任務,切換到另一個任務,一段時間後再恢復執行之前執行任務的能力。

因此如果你的 CPU 只有一個核心,那麼作業系統的一部分工作就是把這個單核的計算能力分配到多個程式或執行緒中,這些程式或執行緒會一個接一個地迴圈執行。這種操作會給你一種多個程式在並行執行的錯覺,如果是使用了多執行緒,就會覺得這個程式在同時做很多事。這滿足了併發性,但是並不是真的並行 —— 即同時執行程式的能力仍然是缺失的。

目前現代 CPU 都會有多個核心,其中每個核心同一時間執行一次獨立的操作。這意味著在多核的情況下真正的並行是可以實現的。比如說,我的 Intel Core i7 處理器有 4 個核心:它可以同時執行 4 個不同的程式和執行緒。

作業系統可以檢測 CPU 內部核心的數量併為其中的每一個都分配程式或者執行緒。只要作業系統喜歡,執行緒可以被分配到其中的任何一個核心,並且這種排程對於執行的程式來講是完全透明的。另外如果所有核心都在忙的話,搶佔式多工就會參與其中進行排程。這就可以讓你能夠執行比計算機實際可用核心數量更多的程式和執行緒。

多執行緒應用跑在一個單獨的核心:這有意義嗎?

在單核機器上是不可能實現真正意義上的並行的。然而,如果你的應用可以從多執行緒中獲益,那在單核機器上跑多執行緒應用還是有意義的。這種情況下當一個程式使用多執行緒的時候,即使其中的一個執行緒在執行比較慢或者阻塞的任務,搶佔式多工機制還是可以讓應用保持執行。

比如說你正在開發一個桌面應用,它會從一個很慢的磁碟讀取一些資料。如果你只是寫了個單執行緒程式,整個應用在讀取資料的時候就會失去響應一直到讀取完成:分配給這個唯一執行緒的 CPU 算力在等待磁碟喚醒的過程中被浪費。當然,作業系統還執行了除此之外的其它很多程式,但是你這個特定應用的執行將不會有任何進展。

讓我們重新用多執行緒的方式思考你的應用。程式的執行緒 A 負責磁碟訪問,執行緒 B 負責主介面。如果執行緒 A 由於裝置讀取慢而卡住,執行緒 B 仍執行著主介面,從而讓你的應用保持響應。這是有可能的,因為有了兩個執行緒,作業系統就可以在它們之間切換分配 CPU 資源,而不會讓這個程式因為較慢的執行緒而卡住。

執行緒越多,問題越多

如我們所知,執行緒共享它們父程式的同一塊記憶體。這使得在同一個應用的執行緒間交換資料非常容易。比如:一個電影編輯器可能有一大部分的共享記憶體用於包含視訊時間線。這樣的共享記憶體被數個用於渲染電影到檔案中的工作執行緒讀取。它們只需要一個指向該記憶體區域的控制程式碼(例如指標),就可以從中讀取資料並將渲染幀輸出到磁碟。

只要多個執行緒是從同一個記憶體位置讀取資料那這事情還算順利。如果它們之中的一個或多個資料到共享記憶體中而有其他執行緒正從中讀取資料的時候,麻煩就開始了。這個時候會出現兩個問題:

  • 資料競爭 —— 當寫執行緒修改記憶體的時候,讀執行緒可能這在讀這個記憶體。如果寫執行緒還沒有完成寫操作,讀執行緒將會得到損壞的資料;

  • 競爭條件 —— 讀執行緒應該在寫執行緒寫完之後才能讀記憶體。如果事情發生的順序正好相反呢?比資料競爭更微妙在於,競爭條件是指多個執行緒以不可預知的順序執行它們的工作,而實際上,我們想要這些操作按照正確的順序執行。即使對資料競爭做了保護,你的程式可能還是會觸發競爭條件。

執行緒安全的概念

如果一段程式碼由多個執行緒同時執行,且正常工作,即沒有資料競爭或競爭條件,那麼就可以說它是執行緒安全的。你可能已經注意到一些程式庫宣告自己是執行緒安全的:如果你正在編寫一個多執行緒程式,想要確保任何第三方的函式可以跨執行緒使用而不會觸發併發問題,就要注意這些宣告。

資料競爭的根本原因

我們知道一個 CPU 核心在同一時間只能執行一條機器指令。這樣的指令叫做原子操作因為它是不可分割的:它不能被分解成更小的操作。希臘語單詞 “atom”(ἄτομος; atomos)就是指不能被切分了

不可分割的屬性使原子操作本質上就是執行緒安全的。當一個執行緒在共享資料上執行原子寫時,沒有其它執行緒可以讀取被修改了一半的資料。相反,當一個執行緒在共享資料上執行原子讀時,它會讀取在某一時刻出現在記憶體中的整個值。在執行原子操作的時候其它執行緒不可能矇混過關插入進來,因此就不會發生資料競爭。

不幸的是,絕大部分操作都是非原子的。在一些硬體上即使是像 x = 1 這樣簡單的賦值操作也可能是由多個原子機器指令組成的,這就使賦值操作這個整體本身成為一個非原子操作。如果一個執行緒在讀取 x 值的同時另一個執行緒在對其進行賦值就會觸發資料競爭。

競爭條件的根本原因

搶佔式多工機制給予了作業系統對執行緒管理完全的控制權:它可以根據高階排程演算法來開始,停止或者暫停執行緒。作為開發者,你不能控制執行緒執行的時間或者順序。實際上,像下面這樣簡單的程式碼也不能保證按照特定的順序啟動:

writer_thread.start()
reader_thread.start()
複製程式碼

執行這個程式幾次,你就會注意到它每次執行的行為是如何的不同:有時寫執行緒先啟動,有時讀執行緒先啟動。如果你的程式需要在讀之前先寫,那麼肯定會遇到競爭條件。

這種表現被稱為非確定性:執行結果每次都會改變而你無法預測。除錯受競爭條件影響的程式非常煩人,因為你不能總是以一種可控的方式來重現問題。

來教執行緒們相處:併發控制

資料競爭和競爭條件都是現實世界的問題:有些人甚至因之而死。排程多個併發執行緒的藝術叫做併發控制:為了處理這個問題,作業系統和程式語言提供了幾個解決方案。其中最重要的是:

  • 同步 —— 一種確保同一時間資源只會被一個執行緒使用的方式。同步就是把程式碼的特定部分標記為“受保護的”,這樣多個併發執行緒就不會同時執行這段程式碼,避免它們把共享資料搞砸;

  • 原子操作 —— 由於作業系統提供了特殊指令,許多非原子操作(像之前的賦值操作)可以變成原子操作。這樣,無論其它執行緒如何訪問共享資料,共享資料始終保持有效狀態。

  • 不可變資料 —— 共享資料被標記為不可變的,沒有什麼可以改變它:執行緒只能從中讀取,這樣就消除了根本原因。正如我們所知,只要不修改記憶體執行緒就可以安全的從相同的記憶體位置讀取資料。這是函數語言程式設計背後的主要理念。

在這個關於併發的小系列下一節中,我將會討論所有這些引人入勝的主題。敬請期待!

參考

8 bit avenue - Difference between Multiprogramming, Multitasking, Multithreading and Multiprocessing
Wikipedia - Inter-process communication
Wikipedia - Process (computing)
Wikipedia - Concurrency (computer science)
Wikipedia - Parallel computing
Wikipedia - Multithreading (computer architecture)
Stackoverflow - Threads & Processes Vs MultiThreading & Multi-Core/MultiProcessor: How they are mapped?
Stackoverflow - Difference between core and processor?
Wikipedia - Thread (computing)
Wikipedia - Computer multitasking
Ibm.com - Benefits of threads
Haskell.org - Parallelism vs. Concurrency
Stackoverflow - Can multithreading be implemented on a single processor system?
HowToGeek - CPU Basics: Multiple CPUs, Cores, and Hyper-Threading Explained
Oracle.com - 1.2 What is a Data Race?
Jaka's corner - Data race and mutex
Wikipedia - Thread safety
Preshing on Programming - Atomic vs. Non-Atomic Operations
Wikipedia - Green threads
Stackoverflow - Why should I use a thread vs. using a process?

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章