C++ 程式設計/執行緒
多工處理 是一個過程,其中多個任務(也稱為程序)共享公共處理資源,例如CPU。
具有單個 CPU 的計算機一次只能執行一個程序。透過執行,意味著在特定時間點,CPU 正在積極地為該程序執行指令。對於具有單個 CPU 的系統,使用排程可以實現多工處理,透過該排程,處理器的執行時間由多個程序共享,從而允許每個程序推進其計算,看起來像是並行的。一個程序執行一段時間,另一個等待的程序就會獲得執行機會。
將 CPU 從一項任務重新分配到另一項任務的行為稱為上下文切換。當上下文切換頻繁到足以產生並行性的錯覺時,就會實現多工處理。
即使在具有多個 CPU 的計算機(多處理器機器)上,多工處理也允許執行比 CPU 數量更多的任務。
作業系統可以採用許多不同的排程策略,這些策略通常可以歸類為以下幾種:
- 在多道程式設計系統中,正在執行的任務會一直執行,直到執行需要等待外部事件(例如從磁帶讀取資料)的操作,或者直到計算機的排程程式強制將正在執行的任務從 CPU 中切換出去。多道程式設計系統旨在最大限度地利用 CPU。
- 在分時系統中,正在執行的任務需要自願地或透過外部事件(例如硬體中斷)釋放 CPU。分時系統旨在允許多個程式看似同時執行。曾經用來定義這種行為的術語分時已經不再使用,取而代之的是多工處理。
- 在即時系統中,一些等待的任務保證在外部事件發生時獲得 CPU。即時系統旨在控制機械裝置,例如工業機器人,這些裝置需要及時處理。
多工處理已經成功地整合到當前的作業系統中。如今使用的大多數計算機都支援一次執行多個程序。這是使用對稱多處理器 (SMP)進行分散式計算以及使用多核或晶片多處理器 (CMP)進行計算的系統所必需的,在這種系統中,處理器已經從雙核發展到四核,並且核的數量還會繼續增加。每種技術都有其特定的侷限性和適用性,但所有這些技術都共享一個共同的目標,即執行併發處理。
程序 是獨立的執行單元,包含自己的狀態資訊,使用自己的地址空間,並且只通過程序間通訊(IPC)機制相互互動。可以說,一個程序至少包含一個執行執行緒(不要與完整的執行緒結構混淆)。程序由託管作業系統在程序資料結構中管理。可以同時執行的程序的最大數量取決於作業系統以及該系統可用的資源。
子程序(也稱為生成程序)是另一個程序(父程序)建立的程序,它繼承了父程序的大多數屬性,例如開啟的檔案。每個程序都可以建立許多子程序,但最多隻有一個父程序;如果一個程序沒有父程序,通常表明它是直接由核心建立的。
在UNIX中,子程序實際上是作為父程序的副本建立的(使用fork)。然後,子程序可以根據需要使用不同的程式覆蓋自身(使用exec)。第一個程序稱為init,它在啟動時由核心啟動,並且永不終止;其他沒有父程序的程序可以啟動以在使用者空間中執行各種守護程序任務。程序最終沒有父程序的另一種方式是,如果其父程序死亡,留下一個孤兒程序;但在這種情況下,它很快就會被 init 採用。
IPC 通常由作業系統管理。
大多數較新的作業系統都提供某種形式的記憶體保護。在 Unix 系統中,每個程序都會獲得自己的虛擬地址空間,而系統反過來保證任何程序都無法訪問另一個程序的記憶體區域。如果程序發生錯誤,只有該程序記憶體的內容會被破壞。
使用共享記憶體,可以解決在不同程序之間啟用對共享資料的隨機訪問的需要。但是,將給定記憶體區域宣告為可以被多個程序同時訪問會引發控制和同步的需要,因為多個程序可能會嘗試同時修改該記憶體區域。
直到最近,C++ 標準才包含對多執行緒的任何規範或內建支援。因此,執行緒必須使用特殊的執行緒庫來實現,這些庫通常依賴於平臺,作為 C++ 標準的擴充套件。
一些流行的 C++ 執行緒庫包括:
(此列表並非旨在完整。)
- Boost - 此包包含多個庫,其中之一是執行緒(併發程式設計)。Boost 執行緒庫的功能並不十分齊全,但它完整、可移植、健壯且符合 C++ 標準的風格。使用與 BSD 許可證類似的 Boost 許可證。
- 英特爾® 執行緒構建模組 (TBB) 提供了一種在 C++ 程式中表達並行性的豐富方法。該庫可幫助您利用多核處理器效能,而無需成為執行緒專家。執行緒構建模組不僅僅是一個執行緒替換庫。它代表了一種更高層次的任務級並行性,它抽象了平臺細節和執行緒機制,以實現效能和可擴充套件性。它是一個在 GNU 通用公共許可證版本 2 (GPLv2) 下的開源專案,具有執行時例外。
- 英特爾® Cilk™ Plus (英特爾® Cilk™ Plus) 為 C 和 C++ 語言添加了簡單的語言擴充套件來表達任務和資料並行性。這些語言擴充套件功能強大,但易於應用和使用在各種應用程式中。
- 自適應通訊環境 (通常稱為 ACE) - 另一個工具包,其中包含一個可移植的執行緒抽象,以及許多其他功能,所有這些都包含在一個庫中。在非標準但非限制性許可證下發布的開源。
- Zthreads - 一個可移植的執行緒抽象庫。該庫功能豐富,只處理併發,並且在 MIT 許可證下開源。
當然,您可以從 C++ 訪問完整的 POSIX 和 C 語言執行緒介面,以及 Windows 上的 API。那麼為什麼要費心在它之上使用庫呢?
原因是諸如鎖之類的資源是分配的,而 C++ 提供了抽象來簡化這些資源的管理。例如,boost::scoped_lock<> 使用物件構造/析構來確保在離開物件的詞法範圍時解除互斥鎖。這樣的類在防止死鎖、競爭條件和其他特定於執行緒程式的問題方面非常有用。此外,這些庫使您能夠編寫跨平臺的多執行緒程式碼,而使用平臺特定函式則不能。
在任何情況下,當使用執行緒方法時,都規定您必須識別熱點,即執行時間最長的程式碼段。為了確定實現最大效能的最佳機會,可以從自下而上和自上而下兩種方法來確定可以並行執行的程式碼段。
在自下而上方法中,人們只關注程式碼中的熱點。這需要對應用程式的呼叫堆疊進行深入分析,以確定可以並行執行並減少熱點的程式碼段。在採用併發的熱點部分中,仍然需要在呼叫堆疊中更高的地方移動該併發,以增加每個執行緒執行的粒度。
使用自上而下方法,重點關注應用程式的所有部分,以確定哪些計算可以在更高層次的抽象上編碼為並行執行。降低抽象級別,直到整體效能提升足以達到必要的目標,其優點是實現速度快,程式碼可重用。這也是為所有計算實現最佳粒度級別的最佳方法。
- 執行緒與程序
執行緒和程序都是並行化應用程式的方法,其實現方式可能因不同的作業系統而異。程序始終只有一個執行執行緒,也稱為主執行緒。一般來說,執行緒包含在程序內部(在程序的地址空間內),同一個程序的不同執行緒共享一些資源,而不同的程序則不共享。
原子性
[edit | edit source]原子性是指原子操作,這些操作是不可分割的和/或不可中斷的。即使在單核上,也不能假設操作是原子的。在這方面,只有在使用匯編器時才能保證操作的原子性。因此,C++ 標準提供了一些保證,作業系統和外部庫也提供了一些保證。
原子操作也可以看作是任何給定的操作集,可以將它們組合在一起,以便它們在系統的其餘部分看來是一個只有一個結果的單一操作:成功或失敗。這完全取決於抽象級別和底層保證。
所有現代處理器都提供基本原子基元,這些基元隨後用於構建更復雜的原子物件。除了原子讀寫操作之外,大多數平臺還提供一個原子讀寫更新操作,例如測試並設定或比較並交換,或一對操作,例如載入連結/儲存條件,這些操作只有在原子地發生(即,沒有中間衝突更新)時才會生效。這些可用於實現鎖,這是多執行緒程式設計中的一種重要機制,允許在操作組之間強制執行不變性和原子性。
許多處理器,尤其是支援64 位浮點的32 位處理器,提供了一些非原子的讀寫操作:一個執行緒讀取一個 64 位暫存器,而另一個執行緒正在寫入它,可能會看到“之前”和“之後”值的組合,這種組合可能永遠不會實際寫入暫存器。此外,只有單個操作保證是原子的;任意執行一組讀寫操作的執行緒也將觀察到“之前”和“之後”值的混合。顯然,當這些影響可能發生時,不能依賴不變性。
如果沒有處理已知的保證原子操作,則應依賴於抽象級別上的同步原語,即編碼到抽象級別上的同步原語。
- 示例 - 一個程序
例如,假設一個程序在計算機上執行,在給定的記憶體位置中遞增一個值。要在該記憶體位置遞增值,
- 程序讀取記憶體位置中的值;
- 程序將值加 1;
- 程序將新值寫回記憶體位置。
- 示例 - 兩個程序
現在,假設兩個程序正在執行,它們遞增同一個共享記憶體位置
- 第一個程序讀取記憶體位置中的值;
- 第一個程序將值加 1;
但它還沒有將新值寫回記憶體位置,就被掛起了,第二個程序被允許執行
- 第二個程序讀取記憶體位置中的值,與第一個程序讀取的值相同;
- 第二個程序將值加 1;
- 第二個程序將新值寫入記憶體位置。
第二個程序被掛起,第一個程序再次被允許執行
- 第一個程序將現在錯誤的值寫入記憶體位置,沒有意識到另一個程序已經更新了記憶體位置中的值。
這是一個簡單的例子。在真實的系統中,操作可能更加複雜,引入的錯誤也極其微妙。例如,從記憶體中讀取 64 位值實際上可能是對兩個 32 位記憶體位置的兩次順序讀取。如果一個程序只讀取了前 32 位,並且在讀取後 32 位之前記憶體中的值發生了改變,那麼它將既沒有原始值,也沒有新值,而是一個混合的垃圾值。
此外,程序執行的特定順序可能會改變結果,這使得這種錯誤難以檢測和除錯。
- 作業系統和可移植性
不僅要考慮底層硬體,還要考慮處理不同的作業系統 API。跨不同作業系統移植程式碼時,應該考慮提供哪些保證。在處理外部庫時,也需要進行類似的考慮。
競爭條件
[edit | edit source]競爭條件(資料競爭,或簡稱為競爭)發生在從多個執行路徑併發訪問資料時。例如,當多個執行緒對同一個資源(例如檔案或記憶體塊)具有共享訪問權,並且至少有一個訪問是寫入操作時,就會發生這種情況。這會導致它們相互干擾。
執行緒程式設計是圍繞謂詞和共享資料構建的。有必要識別所有可能的執行路徑,並識別真正獨立的計算。為了避免問題,最好在儘可能高的級別實現併發。
大多數競爭條件都是由於對執行緒執行順序的錯誤假設造成的。在處理共享變數時,永遠不要假設執行緒寫入操作將線上程讀取操作之前執行。如果您需要保證,您應該檢視是否有同步原語可用,如果沒有,您應該自己實現它們。
鎖定
[edit | edit source]鎖定 暫時阻止不可共享的資源被同時使用。鎖定可以透過使用同步物件來實現。
執行緒最大的問題之一是鎖定需要對資料和程式碼關係進行分析和理解。這會使軟體開發變得複雜,尤其是在針對多個作業系統時。這使得多執行緒程式設計更像是一種藝術,而不是科學。
鎖的數量(取決於同步物件)可能受作業系統限制。如果始終在同一臨界區訪問,則可以設定一個鎖來保護多個資源。
臨界區
[edit | edit source]臨界區是指在程式碼執行並行化中被定義為至關重要的區域。該術語用於定義需要與程式中其他程式碼隔離執行的程式碼部分。
這是一個常見的基本概念。這些程式碼部分需要透過同步技術來保護,因為它們可能會產生競爭條件。
死鎖
[edit | edit source]當發生鎖定操作導致併發執行緒之間出現無休止的等待迴圈時,就會發生死鎖。
同步
[edit | edit source]除了用於保證平行計算的正確執行外,同步是一種開銷。嘗試透過利用執行緒本地儲存或使用專用記憶體位置來將其降至最低。
計算粒度
[edit | edit source]計算粒度是指在進行任何同步操作之前執行的計算量。同步之間的時間越長,計算的粒度越小。在處理並行需求時,這意味著更容易擴充套件到更多執行緒,並具有更低的開銷成本。高粒度意味著使用執行緒的任何好處都可能因同步需求和一般執行緒開銷而消失。
互斥鎖
[edit | edit source]互斥鎖是互斥的縮寫。它依賴於作業系統(而不是 CPU)提供的同步機制。由於此係統物件在任何給定時間只能由一個執行緒擁有,因此互斥鎖物件可以防止資料競爭,並允許執行緒之間進行執行緒安全的同步。透過呼叫其中一個鎖定函式,執行緒獲得互斥鎖物件的擁有權,然後透過呼叫相應的解鎖函式來放棄擁有權。互斥鎖可以是遞迴的或非遞迴的,並且可以同時授予一個或多個執行緒擁有權。
訊號量
[edit | edit source]訊號量是一種讓出同步物件,可以用於同步多個執行緒。這是最常用的同步方法。
自旋鎖
[edit | edit source]自旋鎖是忙等待同步物件,用作互斥鎖的替代品。它們是使用機器相關的彙編指令(如測試和設定)進行執行緒間鎖定的實現,其中執行緒只需在迴圈中等待(自旋),並重複檢查鎖是否可用(忙等待)。這就是為什麼如果自旋鎖鎖定時間很短,它們會執行得更快。[1]它們永遠不會在單 CPU 機器上使用。
執行緒
[edit | edit source]執行緒本身是程式碼結構,是程式的一部分,使它能夠分叉(或拆分)自身到兩個或多個同時(或偽同時)執行的任務。執行緒使用搶佔式多工。
執行緒是作業系統可以分配不同的處理器時間(排程)以供執行的基本單元(程式碼的最小部分)。這意味著實際上執行緒不是在任何單核系統上同時執行,而是在任何單核系統上按順序執行。執行緒通常依賴於作業系統執行緒排程程式來搶佔繁忙的執行緒並恢復另一個執行緒。
如今,執行緒不僅是大多數(如果不是所有)現代計算機、程式語言和作業系統支援的關鍵併發模型,而且本身也是硬體發展(例如對稱多處理器)的核心,理解執行緒對於所有程式設計師來說都是必需的。
執行緒的執行順序由作業系統的程序排程程式控制;它是非確定性的。程式設計師可用的唯一控制是為執行緒分配優先順序,但永遠不要假設特定的執行順序。
使用者介面執行緒
[edit | edit source]這種型別的區分是為了表明特定的執行緒實現了訊息對映,以響應使用者在與應用程式互動時生成的事件和訊息。這在使用 Windows 平臺(Win32 API)時尤其常見,因為它是實現訊息泵的方式。
工作執行緒
[edit | edit source]這種區分用於指定不直接依賴或不是應用程式圖形使用者介面的一部分的執行緒,並與主執行執行緒併發執行。
執行緒本地儲存 (TLS)
[edit | edit source]執行緒本地變數的駐留位置,一個執行緒專用的全域性記憶體部分。每個執行緒(或纖程)都將收到自己的堆疊空間,位於不同的記憶體位置。這將包括保留的記憶體和最初提交的記憶體。執行緒退出時將被釋放,但如果執行緒透過其他方式終止,則不會被釋放。
由於程序中的所有執行緒共享相同的地址空間,因此靜態或全域性變數中的資料通常位於相同的記憶體位置,當由來自同一程序的執行緒引用時。軟體必須考慮硬體快取一致性。例如,在多處理器環境中,每個處理器都有一個本地快取。如果不同處理器的執行緒修改駐留在同一快取行的變數,這將使該快取行無效,從而強制執行快取更新,從而降低效能。這被稱為錯誤共享。
這種型別的儲存適用於儲存臨時資料或甚至部分結果的變數,因為將部分結果的必要同步壓縮到儘可能少的例項中,並將同步開銷降至最低。
執行緒同步
[edit | edit source]同步可以定義為幾個步驟,第一步是程序鎖定,其中程序由於發現受保護的資源被鎖定而暫停執行,鎖定會產生成本,尤其是如果鎖定持續時間過長。
顯然,如果任何同步機制被大量使用,就會降低效能。由於它們是一個昂貴的操作,在某些情況下,增加 TLS 的使用而不是僅僅依賴於共享資料結構將減少對同步的需求。
- 臨界區
- 掛起和恢復
- 在物件上同步
- 協作式執行緒與搶佔式執行緒
- 執行緒池

纖程
[edit | edit source]纖程是一種特別輕量級的執行執行緒。與執行緒類似,纖程共享地址空間。但是,纖程使用協作式多工,纖程在執行時會讓出自己以執行另一個纖程。
- 作業系統支援
與執行緒相比,協程需要更少的作業系統支援。它們可以在現代的 Unix 系統中使用 ucontext.h 庫函式中的 getcontext, setcontext 和 swapcontext 來實現,例如在 GNU Portable Threads 中。
在 Microsoft Windows 上,協程是使用 ConvertThreadToFiber 和 CreateFiber 函式建立的;當前掛起的協程可以在任何執行緒中恢復。類似於 執行緒本地儲存,協程本地儲存可以用於建立變數的唯一副本。
Symbian OS 在其 Active Scheduler 中使用了類似於協程的概念。一個 活動物件 (Symbian OS) 包含一個協程,當幾個未完成的非同步呼叫完成時,該協程由 Active Scheduler 執行。多個活動物件可以等待執行(基於優先順序),並且每個活動物件都必須限制自己的執行時間。
利用並行性
[edit | edit source]大多數並行體系結構研究是在 1960 年代和 1970 年代進行的,為今天才逐漸引起人們普遍關注的問題提供瞭解決方案。隨著並行程式設計需求的增長,主要是由於當今硬體的演進,我們作為程式設計師被要求實現程式設計模型,以簡化處理舊執行緒模型的複雜過程,從而透過抽象問題來節省開發時間。
OpenMP
[edit | edit source]
