天天看點

Effective Modern C++ Item 37 使std::thread型别對象在所有路徑皆不可聯結哪些屬于可聯結哪些屬于不可聯結為何要求不可聯結的直接原因舉個栗子說明你要關心的地方來了自己寫一個RAII的std::thread對于自己建構的類注意一下幾點

每個

std::thread

型别對象皆處于兩種狀态之一:可聯結或不可聯結。

哪些屬于可聯結

可聯結的

std::thread

對應底層以異步方式已運作或者可運作的線程。

std::thread

型别對象對應的底層線程若處于阻塞或等待排程,則它可聯結。

std::thread

型别對象對應的底層線程如已運作結束,則也認為其可聯結。

哪些屬于不可聯結

不可聯結的

std::thread

的意思如你所想:

std::thread

不處于以上可聯結的狀态。不可聯結的

std::thread

型别對象包括:

  • 預設構造的std::thread。此類

    std::thread

    沒有可以執行的函數,是以也沒有對應的底層執行線程。
  • 已移動的std::thread。移動操作的結果是,一個

    std::thread

    所對應的底層執行線程(若有)被對應到另外一個

    std::thread

  • 已聯結的std::thread。聯結後,

    std::thread

    型别對象不再對應至已結束運作的底層執行線程。
  • 已分離的std::thread。分離操作會把

    std::thread

    型别對象和它對應的底層執行線程之間的連接配接斷開。

為何要求不可聯結的直接原因

std::thread

可聯結性之是以重要的原因之一是:如果可聯結的線程對象的析構函數被調用,則程式的執行就終止了。

舉個栗子說明

舉個栗子,假設我們有一個函數

doWork

,它接受一個篩選器函數

filter

和一個最大值

maxVal

作為形參。

doWork

會校驗它做計算的條件全部成立,然後會針對篩選器選出的

maxVal

之間的值實施計算。如果篩選是費時的,而條件檢驗也是費時的,那麼并發地做這兩件事就是合理的。

我們會優先選用基于任務的設計(參見Item 35),但是讓我們假定會去設定實施篩選的那個線程的優先級。Item 35解釋過,這要求使用線程的低級句柄,進而隻能通過

std::thread

的API來通路。基于任務的API(如期值)沒有提供這個功能。

是以,我們隻有采用基于線程的方式,基于任務在此不可行。

我們可能會撰寫出這樣的代碼:

constexpr auto tenMillion = 10000000;           //關于constexpr參見Item 15

bool doWork(std::function<bool(int)> filter,    //傳回值代表計算是否執行了
            int maxVal = tenMillion)            //關于function參見Item2
{
    std::vector<int> goodVals;
    std::thread t([&filter, maxVal,&goodVals]
        {
            for (auto i = 0; i <= maxVal; i++)   
            { if (filter(i))  goodVals.push_back(i); }
        });
    auto nh = t.native_handle();                //使用t的低級句柄設定t的優先級
    ...
    if (conditionsAreSatisfied()) {
        t.join();                               //讓t結束執行
        performComputation(goodVals);           
        return true;                            //計算已實施
    }
    return false;                               //計算未實施
}
           

以上的原型代碼有諸多問題,以下一一道來:

1. 更有可讀性的寫法

在C++14中,單引号可以作為數字分隔符:

2. 線程開始之前設定優先級

示例代碼中線上程t開始執行之後才去設定它的優先級,這有點像,烈馬已經脫缰跑走後才關上馬廄的門。更好的設計是以暫停狀态啟動線程t。具體代碼執行個體可以在Item39中進行查閱。這裡為了不偏離主題,隻是提到執行個體中的設計不好。

3. 核心問題的地方

如果

conditionsAreSatisfied()

傳回

true

,則一切都好;但如果它傳回

false

或抛出異常,那麼在

doWork

的末尾調用

std::thread

型别對象t的析構函數是,它會處于可聯結狀态,進而導緻程式執行終止。

你可能會想知道,

std::thread

的析構函數為何會這樣運作。不終止程式難道不行嗎??

講講為什麼析構函數要這麼做

因為别無選擇,其他的選擇比這麼做更加糟糕,分别是:

  • 隐式join。在這種情況下,

    std::thread

    的析構函數會等待底層異步執行線程完成。這聽上去合理,但卻可能導緻難以追蹤的性能異常。例如,如果

    conditionsAreSatisfied()

    早已傳回

    false

    了,

    doWork

    卻還在等待所有值上周遊篩選,這是違反直覺的。
  • 隐式detach。在這種情況下,

    std::thread

    的析構函數會分離

    std::thread

    型别對象與底層執行線程之間的連接配接。而該底層執行線程會繼續執行。這聽起來和

    join

    途徑相比在合理性方面并不遜色,但它導緻的調試問題會更加要命。例如,在

    doWork

    goodVals

    是個通過引用捕獲的局部變量,它會在

    lambda

    式内被修改(通過對

    push_back

    的調用)。然後,假如lambda式以異步方式運作時,

    conditionsAreSatisfied()

    傳回了

    false

    。那種情況下,

    doWork

    會直接傳回,它的局部變量(包括

    goodVals

    )會被銷毀,

    doWork

    的棧幀會被彈出,可是線程卻仍然在

    doWork

    的調用方繼續運作着。

doWork

調用方的後續語句中,在某個時刻,會調用其他函數,而至少會有一個函數可能會使用一部分或者全部

doWork

棧幀占用過的記憶體,假設這個函數稱為

f

。當

f

運作時,

doWork

發起的lambda式依然在異步執行。該lambda式在原先的棧上對

goodVals

調用

push_back

,不過那已是在

f

的棧幀中了。這樣的調用會修改過去屬于

goodVals

的記憶體,而那意味着從

f

的視角看,棧幀上的記憶體内容會莫名其妙的被改變!想想看那樣的問題,會多麼酸爽。

綜上所述,标準委員會意識到,銷毀一個可聯結的線程是在太過可怕,是以實際上已經封印了這件事(通過規定可聯結的現成的析構函數導緻程式異常終止)。

你要關心的地方來了

既然标準委員會把抉擇權利給了你,如果你使用了

std::thread

型别對象,就必須確定從它定義的作用域出去的任何路徑,使它成為不可聯結狀态。但是覆寫所有路徑是複雜的,這包括正常走完作用域,還有經由

return

continue

break

goto

或異常跳出作用域。路徑何其多。

任何時候,隻要想在每條出向路徑上都執行某動作,最常用的方法就是在局部對象的析構函數中執行該動作。這樣的對象稱為

RAII

對象,它們來自

RAII

類(

RAII

本身代表

Resource Acquistion Is Initialzation

,資源擷取即初始化,即使該技術的關鍵其實在于析構而非初始化)。

RAII

類在标準庫中很常見,例如STL容器(各個容器的析構函數都會析構容器内容并釋放其記憶體)、标準智能指針(Item 18, Item 19, Item 20解釋過,

std::unique_ptr

的析構函數會對它指涉的對象調用删除容器,而

std::shared_ptr

std::weak_ptr

的析構函數會對引用計數實施自減)、

std::fstream

型别對象(其析構函數會關閉對應的檔案),還有很多。然而,沒有和

std::thread

型别對象對應的标準

RAII

類,可能是因為标準委員會把join或detach用做預設選項的途徑都堵死了,這麼一來也就不知道真有這樣的類的話該如何運作。

自己寫一個

RAII

std::thread

幸運的是,自己寫一個也并不難。例如,下面這個類就允許調用者指定

ThreadRAII

型别對象(它是個

std::thread

對應的

RAII

對象)銷毀時調用

join

還是

detach

class ThreadRAII {
public:
    enum class DtorAction {join, detach};       //關于枚舉類,參見Item 10

    ThreadRAII(std::thread&& t, DtorAction a)   //在析構函數中
    : action(a), t(std::move(t)) {}             //在 t 上采取行動a

    ~ThreadRAII()
    {
        if (t.joinable()) {                     //可聯結性測試見下
            if (action == DtorAction::join) {
                t.join();
            } else {
                t.detach();
            }
        }
    }
    std::thread& get() {return t;}                  //見下

private:
    DtorAction      action;
    std::thread     t;
};
           

對于自己建構的類注意一下幾點

我希望這段代碼基本上不言自明,但指出以下幾點可能會有幫助:

1.

std::thread

是隻移型别

  • 構造函數隻接受右值型别的

    std::thread

    ,因為我們想要把傳入的

    std::thread

    型别對象移入

    ThreadRAII

    對象(提醒一下,

    std::thread

    是不可複制的)。

2. 類中成員的聲明順序有講究

  • 讀/寫哪個線程的

    thread_local

    變量并無影響。或者可以給出保證在

    std::async

    傳回的期值之上調用

    get

    wait

    ,或者可以接受任務可能永不執行。使用

    wait_for

    wait_until

    的代碼會将任務被推遲的可能性納入考量。構造函數的形參順序的設計對于調用者而言是符合直覺的(指定

    std::thread

    作為第一個形參,而銷毀行動作為第二個參數,比相反順序更直覺),但是,成員初始化清單的設計要求它比對成員變量聲明的順序,而後者是把

    std::thread

    的順序放到靠後的。在本類中,順序不會導緻不同,但作為一般讨論,一個成員變量的初始化有可能會依賴另一個成員變量,又因為

    std::thread

    型别對象初始化後可能會馬上用來運作函數,是以把它們聲明在類的最後是個好習慣。這保證了當

    std::thread

    型别對象在構造之時,所有在它之前的成員變量都已經完成了初始化,因而

    std::thread

    成員變量對應的底層異步執行線程可以安全地通路它們了。

3. 提供一個

get

避免複寫接口

  • ThreadRAII

    提供了一個

    get

    函數,用以通路底層的

    std::thread

    型别對象。這和标準智能指針提供的

    get

    函數一樣(後者用以通路底層裸指針)。提供

    get

    可以避免讓

    ThreadRAII

    去重複

    std::thread

    的所有接口,也意味着

    ThreadRAII

    型别對象可以用于需要直接使用

    std::thread

    型别對象的語境。

4. 析構的時候判斷可聯結性很重要

  • ThreadRAII

    的析構函數在調用

    std::thread

    型别對象

    t

    的成員函數之前,會先實施校驗,以確定

    t

    可聯結。這是必要的,因為針對一個不可聯結的線程調用

    join

    或者

    detach

    會産生未定義行為。使用者有可能會建構了一個

    std::thread

    型别對象,然後從它出發建立一個

    ThreadRAII

    型别對象,再使用

    get

    通路

    t

    ,接着針對

    t

    實施移動或是對

    t

    調用

    join

    或者

    detach

    ,而這樣的行為會使

    t

    變的不可聯結。

如果你擔心下面的代碼會有競态風險:

if (t.joinable()){
    if (action == DtorAction::join) {
        t.join();
    } else {
        t.detach();
    }
}
           

理由是,在

t.joinable()

的執行和

join

detach

的調用之間,另一個線程可能讓

t

變得不可聯結。你的直覺可圈可點,但你的擔憂卻是庸人自擾。

一個

std::thread

型别對象隻能通過調用成員函數以從可聯結狀态轉換為不可聯結狀态,例如

join

detach

或者移動操作。當

ThreadRAII

對象的析構函數被調用時,不應該有其他線程調用改對象的成員函數。如果同時發生多個調用,那的确會有競态風險,但這競态風險不是發生在析構函數内,而是發生在試圖同時調用兩個成員函數(一個是析構函數,一個是其他成員函數)的使用者代碼内。一般地,在一個對象之上同時調用多個成員函數,隻有當所有這些函數都是

const

成員函數時才安全(參見Item 16)。

在我們的

doWork

一例中運用

ThreadRAII

,代碼會長成這樣:

bool doWork(std::function<bool(int)> filter,        //同前
            int maxVal = tenMillion)
{
    std::vector<int>  goodVals;

    ThreadRAII t(
    std::thread([&filter, maxVal, &goodVals]
            {
                for (auto i = 0; i <= maxVal; ++i)
                { if (filter(i)) goodVals.push_back(i); }
            }),
            ThreadRAII::DtorAction::join
    );
    auto nh = t.get().native_handle();
    ...
    if (conditionsAreSatisfied()) {
        t.get().join();
        performComputation(goodVals);
        return true;
    }
    return false;
}
           

在該例子中,我們選擇在

ThreadRAII

析構函數中對異步執行線程調用

join

。因為我們之前已經看到了,調用

detach

函數會導緻噩夢般的調試。我們之前也看過

join

會導緻性能異常(實話實說,

join

的調試也絕不令人愉悅),但未定義行為(

detach

導緻的)、程式終止(使用裸

std::thread

産生的)和性能異常之間做出選擇,性能異常也是權衡之下的弊端最小的一個。

可遺憾的是,Item 39會戰士,使用

ThreadRAII

std::thread

析構中實施

join

不是僅僅會導緻性能異常那麼簡單,而是會導緻程式失去響應。這種問題的“合适的”解決方案是和異步執行的lambda式通信,當我們已經不再需要它運作,它應該提前傳回,但C++11中并不支援這種可中斷線程。我們可以手動實作它,但這個話題不展開,可以在《C++ Concurrency in Action》的9.2節中找到答案。

Item 17解釋過,因為

ThreadRAII

聲明了析構函數,是以不會有編譯器生成的 移動操作,但這裡

ThreadRAII

對象沒有理由實作為不可移動的。如果編譯器會生成這些函數,這些函數的行為就是正确的,是以顯示地請求建立它們是适當的:

class ThreadRAII {
public:
    enum class DtorAction {join, detach};       //同前

    ThreadRAII(std::thread&& t, DtorAction a)   //同前
    : action(a), t(std::move(t)) {}             //同前

    ~ThreadRAII()
    {
        if (t.joinable()) {                     //同前
            if (action == DtorAction::join) {
                t.join();
            } else {
                t.detach();
            }
        }
    }
    ThreadRAII(ThreadRAII&& ) = default;                //支援移動構造
    ThreadRAII& operator=(ThreadRAII&&) = default;      //支援移動指派

    std::thread& get() {return t;}                  //同前

private:
    DtorAction      action;
    std::thread     t;
};
           
要點速記
1. 使

std::thread

型别對象在所有路徑皆不可聯結。
2. 在析構時調用

join

可能導緻難以調試的性能異常。
3. 在析構時調用

detach

可能導緻難以調試的未定義行為。
4. 在成員清單的最後聲明

std::thread

型别對象。

繼續閱讀