天天看點

Linux多線程實踐(10) --使用 C++11 編寫 Linux 多線程程式

在這個多核時代,如何充分利用每個 CPU 核心是一個繞不開的話題,從需要為成千上萬的使用者同時提供服務的服務端應用程式,到需要同時打開十幾個頁面,每個頁面都有幾十上百個連結的 web 浏覽器應用程式,從保持着幾 t 甚或幾 p 的資料的資料庫系統,到手機上的一個有良好使用者響應能力的 app,為了充分利用每個 CPU 核心,都會想到是否可以使用多線程技術。這裡所說的“充分利用”包含了兩個層面的意思,一個是使用到所有的核心,再一個是核心不空閑,不讓某個核心長時間處于空閑狀态。在 C++98 的時代,C++标準并沒有包含多線程的支援,人們隻能直接調用作業系統提供的 SDK API 來編寫多線程程式,不同的作業系統提供的 SDK API 以及線程控制能力不盡相同,到了 C++11,終于在标準之中加入了正式的多線程的支援,進而我們可以使用标準形式的類來建立與執行線程,也使得我們可以使用标準形式的鎖、原子操作、線程本地存儲 (TLS) 等來進行複雜的各種模式的多線程程式設計,而且,C++11 還提供了一些進階概念,比如 promise/future,packaged_task,async 等以簡化某些模式的多線程程式設計。

多線程可以讓我們的應用程式擁有更加出色的性能,同時,如果沒有用好,多線程又是比較容易出錯的且難以查找錯誤所在,甚至可以讓人們覺得自己陷進了泥潭,希望本文能夠幫助您更好地使用 C++11 來進行 Linux 下的多線程程式設計。

認識多線程

首先我們應該正确地認識線程。維基百科對線程的定義是:線程是一個編排好的指令序列,這個指令序列(線程)可以和其它的指令序列(線程)并行執行,作業系統排程器将線程作為最小的 CPU 排程單元。在進行架構設計時,我們應該多從作業系統線程排程的角度去考慮應用程式的線程安排,而不僅僅是代碼。

當隻有一個 CPU 核心可供排程時,多個線程的運作示意如下:

Linux多線程實踐(10) --使用 C++11 編寫 Linux 多線程程式

圖 1、單個 CPU 核心上的多個線程運作示意圖

我們可以看到,這時的多線程本質上是單個 CPU 的時間分片,一個時間片運作一個線程的代碼,它可以支援并發處理,但是不能說是真正的并行計算。

當有多個 CPU 或者多個核心可供排程時,可以做到真正的并行計算,多個線程的運作示意如下:

Linux多線程實踐(10) --使用 C++11 編寫 Linux 多線程程式

圖 2、雙核 CPU 上的多個線程運作示意圖

從上述兩圖,我們可以直接得到使用多線程的一些常見場景:

    程序中的某個線程執行了一個阻塞操作時,其它線程可以依然運作,比如,等待使用者輸入或者等待網絡資料包的時候處理啟動背景線程處理業務,或者在一個遊戲引擎中,一個線程等待使用者的互動動作輸入,另外一個線程在背景合成下一幀要畫的圖像或者播放背景音樂等。

    将某個任務分解為小的可以并行進行的子任務,讓這些子任務在不同的 CPU 或者核心上同時進行計算,然後彙總結果,比如歸并排序,或者分段查找,這樣子來提高任務的執行速度。

需要注意一點,因為單個 CPU 核心下多個線程并不是真正的并行,有些問題,比如 CPU 緩存不一緻問題,不一定能表現出來,一旦這些代碼被放到了多核或者多 CPU 的環境運作,就很可能會出現“在開發測試環境一切沒有問題,到了實施現場就莫名其妙”的情況,是以,在進行多線程開發時,開發與測試環境應該是多核或者多 CPU 的,以避免出現這類情況。

C++11 的線程類 std::thread

C++11 的标準類 std::thread 對線程進行了封裝,它的聲明放在頭檔案 thread 中,其中聲明了線程類 thread, 線程辨別符 id,以及名字空間 this_thread,按照 C++11 規範,這個頭檔案至少應該相容如下内容:

清單 1.例子 thread 頭檔案主要内容

和有些語言中定義的線程不同,C++11 所定義的線程是和操作系的線程是一一對應的,也就是說我們生成的線程都是直接接受作業系統的排程的,通過作業系統的相關指令(比如 ps -M 指令)是可以看到的,一個程序所能建立的線程數目以及一個作業系統所能建立的總的線程數目等都由運作時作業系統限定。

native_handle_type 是連接配接 thread 類和作業系統 SDK API 之間的橋梁,在 g++(libstdc++) for Linux 裡面,native_handle_type 其實就是 pthread 裡面的 pthread_t 類型,當 thread 類的功能不能滿足我們的要求的時候(比如改變某個線程的優先級),可以通過 thread 類執行個體的 native_handle() 傳回值作為參數來調用相關的 pthread 函數達到目的。thread::id 定義了在運作時作業系統内唯一能夠辨別該線程的辨別符,同時其值還能訓示所辨別的線程的狀态,其預設值 (thread::id()) 表示不存在可控的正在執行的線程(即空線程,比如,調用 thread() 生成的沒有指定入口函數的線程類執行個體),當一個線程類執行個體的 get_id() 等于預設值的時候,即 get_id() == thread::id(),表示這個線程類執行個體處于下述狀态之一:

    尚未指定運作的任務

    線程運作完畢

    線程已經被轉移 (move) 到另外一個線程類執行個體

    線程已經被分離 (detached)

空線程 id 字元串表示形式依具體實作而定,有些編譯器為 0X0,有些為一句語義解釋。

有時候我們需要線上程執行代碼裡面對目前調用者線程進行操作,針對這種情況,C++11 裡面專門定義了一個名字空間 this_thread,其中包括 get_id() 函數可用來擷取目前調用者線程的 id,yield() 函數可以用來将調用者線程跳出運作狀态,重新交給作業系統進行排程,sleep_until 和 sleep_for 函數則可以讓調用者線程休眠若幹時間。get_id() 函數實際上是通過調用 pthread_self() 函數獲得調用者線程的辨別符,而 yield() 函數則是通過調用作業系統 API sched_yield() 進行排程切換。

如何建立和結束一個線程

和 pthread_create 不同,使用 thread 類建立線程可以使用一個函數作為入口,也可以是其它的 Callable 對象,而且,可以給入口傳入任意個數任意類型的參數:

清單 2.例子 thread_run_func_var_args.cc

我們也可以傳入一個 Lambda 表達式作為入口,比如:

清單 3.例子 thread_run_lambda.cc

一個類的成員函數也可以作為線程入口:

清單 4.例子 thread_run_member_func.cc

雖然 thread 類的初始化可以提供這麼豐富和友善的形式,其實作的底層依然是建立一個 pthread 線程并運作之,有些實作甚至是直接調用 pthread_create 來建立。

建立一個線程之後,我們還需要考慮一個問題:該如何處理這個線程的結束?一種方式是等待這個線程結束,在一個合适的地方調用 thread 執行個體的 join() 方法,調用者線程将會一直等待着目标線程的結束,當目标線程結束之後調用者線程繼續運作;另一個方式是将這個線程分離,由其自己結束,通過調用 thread 執行個體的 detach() 方法将目标線程置于分離模式。一個線程的 join() 方法與 detach() 方法隻能調用一次,不能在調用了 join() 之後又調用 detach(),也不能在調用 detach() 之後又調用 join(),在調用了 join() 或者 detach() 之後,該線程的 id 即被置為預設值(空線程),表示不能繼續再對該線程作修改變化。如果沒有調用 join() 或者 detach(),那麼,在析構的時候,該線程執行個體将會調用 std::terminate(),這會導緻整個程序退出,是以,如果沒有特别需要,一般都建議在生成子線程後調用其 join() 方法等待其退出,這樣子最起碼知道這些子線程在什麼時候已經確定結束。

在 C++11 裡面沒有提供 kill 掉某個線程的能力,隻能被動地等待某個線程的自然結束,如果我們要主動停止某個線程的話,可以通過調用 Linux 作業系統提供的 pthread_kill 函數給目标線程發送信号來實作,示例如下:

清單 5.例子 thread_kill.cc

上述例子還可以用來給某個線程發送其它信号,具體的 pthread_exit 函數調用的約定依賴于具體的作業系統的實作,是以,這個方法是依賴于具體的作業系統的,而且,因為在 C++11 裡面沒有這方面的具體約定,用這種方式也是依賴于 C++編譯器的具體實作的。

線程類 std::thread 的其它方法和特點

thread 類是一個特殊的類,它不能被拷貝,隻能被轉移或者互換,這是符合線程的語義的,不要忘記這裡所說的線程是直接被作業系統排程的。線程的轉移使用 move 函數,示例如下:

清單 6.例子 thread_move.cc

在這個例子中,如果将 t2.join() 改為 t.join() 将會導緻整個程序被結束,因為忘記了調用 t2 也就是被轉移的線程的 join() 方法,進而導緻整個程序被結束,而 t 則因為已經被轉移,其 id 已被置空。

線程執行個體互換使用 swap 函數,示例如下:

清單 7.例子 thread_swap.cc

互換和轉移很類似,但是互換僅僅進行執行個體(以 id 作辨別)的互換,而轉移則在進行執行個體辨別的互換之前,還進行了轉移目的執行個體(如下例的t2)的清理,如果 t2 是可聚合的(joinable() 方法傳回 true),則調用 std::terminate(),這會導緻整個程序退出,比如下面這個例子:

清單 8.例子 thread_move_term.cc

是以,在進行線程執行個體轉移的時候,要注意判斷目的執行個體的 id 是否為空值(即 id())。

如果我們繼承了 thread 類,則還需要禁止拷貝構造函數、拷貝指派函數以及指派操作符重載函數等,另外,thread 類的析構函數并不是虛析構函數。示例如下:

清單 9.例子 thread_inherit.cc

因為 thread 類的析構函數不是虛析構函數,在上例中,需要避免出現下面這種情況:

這種情況會導緻 MyThread 的析構函數沒有被調用。

線程的排程

我們可以調用 this_thread::yield() 将目前調用者線程切換到重新等待排程(放棄目前所占用的CPU),但是不能對非調用者線程進行排程切換,也不能讓非調用者線程休眠(這是作業系統排程器幹的活)。

清單 10.例子 thread_yield.cc

ta 線程因為需要經常切換去重新等待排程,它運作的時間要比 tb 要多,比如在作者的機器上運作得到如下結果:

without yield elapse 0.050199s

without yield elapse 0.051042s

without yield elapse 0.05139s

without yield elapse 0.048782s

with yield elapse 1.63366s

real    0m1.643s

user    0m1.175s

sys 0m0.611s

ta 線程即使扣除系統調用運作時間 0.611s 之後,它的運作時間也遠大于沒有進行切換的線程。

C++11 沒有提供調整線程的排程政策或者優先級的能力,如果需要,隻能通過調用相關的 pthread 函數來進行,需要的時候,可以通過調用 thread 類執行個體的 native_handle() 方法或者作業系統 API pthread_self() 來獲得 pthread 線程 id,作為 pthread 函數的參數。

線程間的資料互動和資料争用 (Data Racing)

同一個程序内的多個線程之間總是免不了要有資料互相來往的,隊列和共享資料是實作多個線程之間的資料互動的常用方式,封裝好的隊列使用起來相對來說不容易出錯一些,而共享資料則是最基本的也是較容易出錯的,因為它會産生資料争用的情況,即有超過一個線程試圖同時搶占某個資源,比如對某塊記憶體進行讀寫等,如下例所示:

清單 11.例子 thread_data_race.cc

這是簡化了的極端情況,我們可以一眼看出來這是兩個線程在同時對&a 這個記憶體位址進行寫操作,但是在實際工作中,在代碼的海洋中發現它并不一定容易。從表面看,兩個線程執行完之後,最後的 a 值應該是 COUNT * 2,但是實際上并非如此,因為簡單如 (*p)++這樣的操作并不是一個原子動作,要解決這個問題,對于簡單的基本類型資料如字元、整型、指針等,C++提供了原子模版類 atomic(#include <atomic>),而對于複雜的對象,則提供了最常用的鎖機制,比如互斥類 mutex,門鎖 lock_guard,唯一鎖 unique_lock,條件變量 condition_variable 等。

現在我們使用原子模版類 atomic 改造上述例子得到預期結果:

清單 12.例子 thread_atomic.cc

我們也可以使用 lock_guard,lock_guard 是一個範圍鎖,本質是 RAII(Resource Acquire Is Initialization),在建構的時候自動加鎖,在析構的時候自動解鎖,這保證了每一次加鎖都會得到解鎖。即使是調用函數發生了異常,在清理棧幀的時候也會調用它的析構函數得到解鎖,進而保證每次加鎖都會解鎖,但是我們不能手工調用加鎖方法或者解鎖方法來進行更加精細的資源占用管理,使用 lock_guard 示例如下:

清單 13.例子 thread_lock_guard.cc

如果要支援手工加鎖,可以考慮使用 unique_lock 或者直接使用 mutex。unique_lock 也支援 RAII,它也可以一次性将多個鎖加鎖;如果使用 mutex(#include <mutex>) 則直接調用 mutex 類的 lock, unlock, trylock 等方法進行更加精細的鎖管理:

清單 14.例子 thread_mutex.cc

在上例中,我們還使用了線程本地存儲 (TLS, 其實作原理類似于線程特定資料, 關于線程特定資料的詳細說明請參考我的前面的一篇部落格) 變量,我們隻需要在變量前面聲明它是 thread_local 即可。TLS 變量線上程棧内配置設定,線程棧隻有線上程建立之後才生效,線上程退出的時候銷毀,需要注意不同系統的線程棧的大小是不同的,如果 TLS 變量占用空間比較大,需要注意這個問題。TLS 變量一般不能跨線程,其初始化在調用線程第一次使用這個變量時進行,預設初始化為 0。

對于線程間的事件通知,C++11 提供了條件變量類 condition_variable(#include <condition_variable>),可視為 pthread_cond_t 的封裝,使用條件變量可以讓一個線程等待其它線程的通知 (wait,wait_for,wait_until),也可以給其它線程發送通知 (notify_one,notify_all),條件變量必須和鎖配合使用(與pthread_cond_t類似),在等待時因為有解鎖和重新加鎖,是以,在等待時必須使用可以手工解鎖和加鎖的鎖,比如 unique_lock,而不能使用 lock_guard,示例如下:

清單 15.例子 thread_cond_var.cc

從上例的運作結果也可以看到,條件變量是不保證次序的,即首先調用 wait 的不一定首先被喚醒。

幾個進階概念

C++11 提供了若幹多線程程式設計的進階概念:promise/future(#include <future>), packaged_task, async,來簡化多線程程式設計,尤其是線程之間的資料互動比較簡單的情況下,讓我們可以将注意力更多地放在業務處理上。

promise/future 可以用來線上程之間進行簡單的資料互動,而不需要考慮鎖的問題,線程 A 将資料儲存在一個 promise 變量中,另外一個線程 B 可以通過這個 promise 變量的 get_future() 擷取其值,當線程 A 尚未在 promise 變量中指派時,線程 B 也可以等待這個 promise 變量的指派:

清單 16.例子 thread_promise_future.cc

一個 future 變量隻能調用一次 get(),如果需要多次調用 get(),可以使用 shared_future,通過 promise/future 還可以線上程之間傳遞異常。

如果将一個 callable 對象和一個 promise 組合,那就是 packaged_task,它可以進一步簡化操作:

清單 17.例子 thread_packaged_task.cc

我們還可以試圖将一個 packaged_task 和一個線程組合,那就是 async() 函數。使用 async() 函數啟動執行代碼,傳回一個 future 對象來儲存代碼傳回值,不需要我們顯式地建立和銷毀線程等,而是由 C++11 庫的實作決定何時建立和銷毀線程,以及建立幾個線程等,示例如下:

清單 18.例子 thread_async.cc

如果是在多核或者多 CPU 的環境上面運作上述例子,仔細觀察輸出結果,可能會發現有些線程 ID 是重複的,這說明重複使用了線程,也就是說,通過使用 async() 還可達到一些線程池的功能。

幾個需要注意的地方

thread 同時也是棉線、毛線、絲線等意思,我想大家都能體會面對一團亂麻不知從何處查找頭緒的感受,不要忘了,線程不是靜态的,它是不斷變化的,請想像一下面對一團會動态變化的亂麻的情景。是以,使用多線程技術的首要準則是我們自己要十厘清楚我們的線程在哪裡?線頭(線程入口和出口)在哪裡?先安排好線程的運作,注意不同線程的交叉點(通路或者修改同一個資源,包括記憶體、I/O 裝置等),盡量減少線程的交叉點,要知道幾條線堆在一起最怕的是互相打結。

當我們的确需要不同線程通路一個共同的資源時,一般都需要進行加鎖保護,否則很可能會出現資料不一緻的情況,進而出現各種時現時不現的莫名其妙的問題,加鎖保護時有幾個問題需要特别注意:一是一個線程内連續多次調用非遞歸鎖 (non-recursive lock) 的加鎖動作,這很可能會導緻異常;二是加鎖的粒度;三是出現死鎖 (deadlock),多個線程互相等待對方釋放鎖導緻這些線程全部處于罷工狀态。

第一個問題隻要根據場景調用合适的鎖即可,當我們可能會在某個線程内重複調用某個鎖的加鎖動作時,我們應該使用遞歸鎖 (recursive lock),在 C++11 中,可以根據需要來使用 recursive_mutex,或者 recursive_timed_mutex。

第二個問題,即鎖的粒度,原則上應該是粒度越小越好,那意味着阻塞的時間越少,效率更高,比如一個資料庫,給一個資料行 (data row) 加鎖當然比給一個表 (table) 加鎖要高效,但是同時複雜度也會越大,越容易出錯,比如死鎖等。

對于第三個問題我們需要先看下出現死鎖的條件:

    資源互斥,某個資源在某一時刻隻能被一個線程持有 (hold);

    請求和保持,持有一個以上的互斥資源的線程在等待被其它程序持有的互斥資源;

    不可搶占,隻有在某互斥資源的持有線程釋放了該資源之後,其它線程才能去持有該資源;

    環形等待,有兩個或者兩個以上的線程各自持有某些互斥資源,并且各自在等待其它線程所持有的互斥資源。

我們隻要不讓上述四個條件中的任意一個不成立即可。在設計的時候,非常有必要先分析一下會否出現滿足四個條件的情況,特别是檢查有無試圖去同時保持兩個或者兩個以上的鎖,當我們發現試圖去同時保持兩個或者兩個以上的鎖的時候,就需要特别警惕了。下面我們來看一個簡化了的死鎖的例子:

清單 19.例子 thread_deadlock.cc

在這個例子中,g_mutex1 和 g_mutex2 都是互斥的資源,任意時刻都隻有一個線程可以持有(加鎖成功),而且隻有持有線程調用 unlock 釋放鎖資源的時候其它線程才能去持有,滿足條件 1 和 3,線程 ta 持有了 g_mutex1 之後,在釋放 g_mutex1 之前試圖去持有 g_mutex2,而線程 tb 持有了 g_mutex2 之後,在釋放 g_mutex2 之前試圖去持有 g_mutex1,滿足條件 2 和 4,這種情況之下,當線程 ta 試圖去持有 g_mutex2 的時候,如果 tb 正持有 g_mutex2 而試圖去持有 g_mutex1 時就發生了死鎖。在有些環境下,可能要多次運作這個例子才出現死鎖,實際工作中這種偶現特性讓查找問題變難。要破除這個死鎖,我們隻要按如下代碼所示破除條件 3 和 4 即可:

清單 20.例子 thread_break_deadlock.cc

在一些複雜的并行程式設計場景,如何避免死鎖是一個很重要的話題,在實踐中,當我們看到有兩個鎖嵌套加鎖的時候就要特别提高警惕,它極有可能滿足了條件 2 或者 4。

結束語

上述例子在 CentOS 6.5,g++ 4.8.1/g++4.9 以及 clang 3.5 下面編譯通過,在編譯的時候,請注意下述幾點:

    設定 -std=c++11;

    連結的時候設定 -pthread;

    使用 g++編譯連結時設定 -Wl,–no-as-needed 傳給連結器,有些版本的 g++需要這個設定;

    設定宏定義 -D_REENTRANT,有些庫函數是依賴于這個宏定義來确定是否使用多線程版本的。

在用 gdb 調試多線程程式的時候,可以輸入指令 info threads 檢視目前的線程清單,通過指令 thread n 切換到第 n 個線程的上下文,這裡的 n 是 info threads 指令輸出的線程索引數字,例如,如果要切換到第 2 個線程的上下文,則輸入指令 thread 2。

聰明地使用多線程,擁抱多線程吧。

繼續閱讀