(二)C++11 原生標準多線程:開始多線程

就像第一篇中分析的那樣,多線程的概念還是很容易理解的。可進入實踐,一切就沒那麼簡單了。裏面會有很多坑,一不小心就會出現不容易發現的錯誤。就如同第一篇末尾我實踐的時候遇到的幾個問題,及最後《C++ Concurrency in Action》提到的那個案例。第一篇中我遇到的問題是對C++的語法解析和thread庫接口聲明不瞭解。

而回歸到線程本身的主題上,生命週期對於多線程至關重要。如果我們忽略,很容易造成程序訪問已經銷燬的對象、函數、變量。很簡單的例子就是,調用者和被調用者,宿主環境和新啓動的線程,必須時刻注意他們的生命週期。如果在新線程任務中我們不小心訪問了宿主環境已經銷燬的設施,這時候我們很難察覺出錯誤的地方。

第一篇我們初步認識了線程的概念,我們也創建了一個線程。線程在程序中存在兩種狀態,如果我們想控制線程必須瞭解這兩種狀態,以避免錯誤的生命週期調用。爲什麼會有兩種狀態,這兩種狀態是哪兩種:

 

join()和detach()

當我們在程序中創建一個新線程,我們應該瞭解這個線程所執行任務(函數)的生命週期及創建線程的宿主(創建線程的函數或對象成員函數)生命週期。如果我們創建的線程所執行的任務(函數)需要調用宿主環境的變量,線程所執行的任務沒有結束,宿主環境卻提前結束了,這時我們調用的宿主環境變量肯定已經銷燬,將導致程序錯誤。

解決這個問題的辦法就是調用join(),讓宿主環境等待線程結束再銷燬宿主環境變量。《C++ Concurrency in Action》書中所言,直接調用join()是簡單粗暴的。在大部分情況下直接調用join()是可以正常工作的(如果您的程序在創建線程之後,在join()之前運行是正確的。)。問題是在一些情況下我們無法保證在這一段時間內程序不會拋出異常。如果程序拋出異常,最大的可能是停止線程任務,跳過join()。避免應用被拋出的異常所終止,就需要作出一個決定。我們爲了避免異常造成的終止,我們應該把join()放在捕獲異常的處理程序中,從而避免生命週期的問題。

如何尋找join的位置:

《C++ Concurrency in Action》書中給了我們兩種方案,一種是不完美版,但,它一般也能很好的執行:

struct func; // 定義在清單2.1中
void f()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    try
    {
        do_something_in_current_thread();
    }
    catch(...)
    {
        t.join(); // 1
        throw;
    }
    t.join(); // 2
}

這一種方案,假設我們對異常比較清楚,對於輕量級的異常捕獲完全在掌控之中。那麼這種方案完全是可行的。這裏僅僅是使用了C++的異常捕獲來避免線程終止而跳過join()。其實這裏有一點疑問,既然程序創建的線程已經終止我們還有必要join()麼,是的我們有必要。這裏的錯誤捕獲完全服務於宿主環境函數執行流程,也就是說,這完全是爲了避免宿主執行函數因爲異常而損害了join()的調用以至於提前釋放了宿主環境變量。而線程任務依舊還是在運行的。

完美版調用join()

class thread_guard
{
    std::thread& t;
    public:
        explicit thread_guard(std::thread& t_):t(t_)
        {
        }
        ~thread_guard()
        {
            if(t.joinable()) // 1
            {
                t.join(); // 2
            }
        }
        thread_guard(thread_guard const&)=delete; // 3
        thread_guard& operator=(thread_guard const&)=delete;
};

struct func; // 定義在清單2.1中

void f()
{
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    thread_guard g(t);
    do_something_in_current_thread();
} // 4

很顯然,這裏用到了一個局部對象。在宿主環境中創建一個對象,把創建的線程實例傳進對象裏,當宿主環境函數執行完畢即將釋放時,肯定會釋放創建的對象而調用對象的成員析構函數。這樣就給了我們一個機會調用join(),這樣,無論發生怎樣的錯誤異常,都會調用析構函數而觸發join()調用。這樣就很完美的解決了join()的位置,執行序列問題,徹底解決了等待線程結束的承諾。

joinable()是一張join()門票,對於一個線程對象只能使用一次。它是線程join()的狀態標誌,且只能調用一次join()。當新創建的線程還沒有join(),那麼joinable()將返回true,否則返回false。

 

detach()可以做什麼:

detach()和join()好像天生就是一對冤家,做着彼此相反的事情。detach()把新創建的線程明確的分離,這裏的分離指的是與宿主環境(一般就是主線程,或者是創建線程的函數方法)撇開關係,不再讓宿主環境等待新創建的線程執行結束才釋放宿主運行環境(變量,對象等)。

最典型的案例應該是後臺服務線程,它們無視主線程,無視創建他們的線程環境。只要創建成功一般都會等到整個程序的運行結束,一直提供它力所能及的服務,而不管其他線程是什麼情況,它只保證交給他的任務。

需要了解的一點是,當我們調用detach()後,線程對象和執行的任務已經分離開來,執行的任務就是新創建的線程,它不再提供任何接口供我們使用。joinable()同樣對detach()有效,一旦我們調用detach()使用了這張門票,那這張joinable()門票就永遠作廢,返回false。所以join()也無法使用到這個線程。

需要分清的一點概念:std::thread創建的對象不是線程,而對象調用的任務纔是真正的線程。當我們調用join()後,線程和對象不會分離,這樣我們就有機會控制線程。而detach()調用,會分離線程,這時的對象與運行的線程就沒有任何關係了。這也證明在join()調用後,std::thread對象是新創建線程的引用。

detach()的調用時機與join()調用時機基本是相同的。因爲無需在意宿主環境的生命週期,前提是我們設計的新線程不依賴宿主環境變量。

 

向進程傳遞參數:

當我們瞭解了線程在程序中的兩種狀態後,我們在設計線程任務時應該明確的區分我們所創建線程屬於哪種狀態。而後我們很有可能需要向我們調用的函數或是對象構造函數傳遞參數。完成了傳遞參數任務後,設計好的線程任務應該就可以正常的啓動運行了。

下面是一個簡單的傳參例子:

Connection::Connection(QObject *parent)
{
    std::thread t(&Connection::threadA,this,6);
    t.join();
}

void Connection::threadA(int num)
{
    while (num >= 1)
    {
        --num;
        std::cout<<"My First ThreadA !"<<std::endl;
    }

}

運行結果:

My First ThreadA !
My First ThreadA !
My First ThreadA !
My First ThreadA !
My First ThreadA !
My First ThreadA !

向可調用對象或函數傳遞參數在本質上與向std::thread構造函數傳遞附加參數一樣。但必須記住的是,默認情況下,參數被複制到內部存儲器中,新創建的執行線程可以訪問這些參數,即使函數中的相應參數需要引用。就像上面的例子一樣,如果向可調用對象或函數傳遞參數,我們只要依序傳遞附加參數列表即可。

傳參陷阱:

字符串隱式轉換陷阱:

《C++ Concurrency in Action》指出:指向動態變量的指針作爲參數傳遞給線程的情況,這種情況不僅僅發生在線程創建時,只要是字符常量隱式轉化成string類型時,都會有可能發生。所以這不是線程獨有的。在C++開發的整個過程中,我們都需要注意字符常量隱式轉化可能產生的崩潰現象。解決辦法就是顯示強類型轉換。

實例分析:當buffer指針所指向的字符內存空間接受格式化後的字符串,通過buffer傳遞給thread時,將發生隱式類型轉換。char const *類型轉化爲string類型,在此時將有可能產生轉換失敗。thread構造函數默認的會把參數變量統統拷貝,這時拷貝的將是一個不可知的類型變量,造成指針懸空發生錯誤。

//《C++ Concurrency in Action》有可能發生錯誤的例子:
void f(int i,std::string const& s);
void oops(int some_param)
{
    char buffer[1024]; // 1
    sprintf(buffer, "%i",some_param);
    std::thread t(f,3,buffer); // 2
    t.detach();
}

//《C++ Concurrency in Action》有可能發生錯誤例子的解決方案:
void f(int i,std::string const& s);
void not_oops(int some_param)
{
    char buffer[1024];
    sprintf(buffer,"%i",some_param);
    std::thread t(f,3,std::string(buffer)); // 使用std::string,避免懸垂指針
    t.detach();
}

對象引用陷阱:

還可能遇到相反的情況:期望傳遞一個引用,但整個對象被複制了。當線程更新一個引用傳遞的數據結構時,這種情況就可能發生,比如:

struct strdata
{
        std::string data_a = "Thead";
};

Connection::Connection(QObject *parent)
{
    strdata data;


    //std::ref(data)
    std::thread t(&Connection::threadA,this,1,"I Love Thread!", data);
    t.join();

    std::cout<<"宿主環境變量:"<<data.data_a<<std::endl;
}

void Connection::threadA(int num, std::string const& str, strdata& strs)
{
    while (num >= 1)
    {
        --num;
        std::cout<<"My First ThreadA !"<<std::endl;
        std::cout<<str<<std::endl;
        strs.data_a = "QQQQQQ";
        std::cout<<"線程參數拷貝環境變量:"<<strs.data_a<<std::endl;
    }

}

這是一個傳參完整的例子,但是我們在第三個參數傳遞的是一個對象的引用。我們期望可以修改宿主環境對象的數據。程序運行卻不像我們預期的那樣執行。thread構造函數直接拷貝了data對象,所以理論上我們已經無法對宿主data對象進行修改。這裏的引用已經是拷貝的數據結構。我在實驗時使用的是Qt環境,編譯器直接提示右值引用不能被調用,直接靜態斷言錯誤。我如果把形參生命爲string const &,編譯器是允許運行的,但是就像我們分析的一樣,我們已經失去了對宿主data對象的引用,而無法修改data對象的數據。

解決這個陷阱的方案是,通過std::ref強制告知thread構造函數,這裏是一個引用,而不是對象拷貝的引用。

解決方案如:

struct strdata
{
        std::string data_a = "Thead";
};

Connection::Connection(QObject *parent)
{
    strdata data;


    //std::ref(data)
    std::thread t(&Connection::threadA,this,1,"I Love Thread!", std::ref(data));
    t.join();

    std::cout<<"宿主環境變量:"<<data.data_a<<std::endl;
}

void Connection::threadA(int num, std::string const& str, strdata& strs)
{
    while (num >= 1)
    {
        --num;
        std::cout<<"My First ThreadA !"<<std::endl;
        std::cout<<str<<std::endl;
        strs.data_a = "QQQQQQ";
        std::cout<<"線程參數拷貝環境變量:"<<strs.data_a<<std::endl;
    }

}

最後再次提示thread構造函數傳遞對象成員函數迷惑的地方:如果你熟悉 std::bind ,就應該不會對以上述傳參的形式感到奇怪,因爲 std::thread 構造函數和 std::bind 的操作都在標準庫中定義好了,可以傳遞一個成員函數指針作爲線程函數,並提供一個合適的對象指針作爲第一個參數。 std::thread 構造函數的第三個參數就是成員函數的第一個參數,以此類推(代碼如下,譯者自加)。

 

引言:參數可以移動,但不能拷貝

智能指針是行爲類似於指針的類對象。屬於標準模板庫的功能。它賦予一個普通指針具有析構函數的能力,當一個函數執行完畢,自動調用析構函數完成垃圾回收,避免內存泄漏。

std::unique_ptr禁止對象拷貝和複製,但能移動。來自智能指針的擴展甜餅。

std::unique_ptrc++11起引入的智能指針,爲什麼必須要在c++11起纔有該特性,主要還是c++11增加了move語義,否則無法對對象的所有權進行傳遞。而智能指針最大的職責就是智能的垃圾回收,爲了提供智能的垃圾回收機制,智能指針就必須避免有多個智能指針指向同一個對象,否則當垃圾回收開始就會多次析構不存在的對象。正是有了這樣的特性才禁止智能指針對象拷貝和複製,但能移動,就是我們瞭解的move語義。ownership(所有權)機制就從此而來。需要注意的一點是,我們提到的對象是std::unique_ptr智能指針對象。

ownership(所有權)機制

std::thread和std::unique_ptr智能指針具有相同的所有權特性,當然他們負責完成的職責不一定是一樣的,但他們的思想基本相同。所以,std::thread也同樣禁止對象拷貝複製,但能移動傳遞所有權。std::thread 支持移動,就意味着線程的所有權可以在函數外進行轉移。就像我們在std::thread detach()調用中瞭解到的,std::thread 實例對象是可以與執行的線程分離的,也就意味着我們有機會通過move語義實現所有權的轉移。std::thread對象在分離執行線程,或是通過move轉移了對象以後,這個分離後的std::thread對象就可以接受新的線程或是轉移過來的線程。

//來自《C++ Concurrency in Action》的實例
void some_function();
void some_other_function();
std::thread t1(some_function); // 1
std::thread t2=std::move(t1); // 2
t1=std::thread(some_other_function); // 3
std::thread t3; // 4
t3=std::move(t2); // 5
t1=std::move(t3); // 6 賦值操作將使程序崩潰

代碼很好理解,其主要思想就是,在同一時間運行的線程只有一個std::thread對象具有它的所有權,std::thread對象也只能指向一個線程。

在實例標記3處,沒有用到顯示的move移動。因爲 td::thread(some_other_function) 是一個臨時對象,沒有具體標識符(名字),所有者是一個臨時對象——移動操作將會隱式的調用move移動所有權。

最後一個移動操作,將some_function線程的所有權轉移⑥給t1。不過,t1已經有了一個關聯的線程(執行some_other_function的線程),所以這裏系統直接調用 std::terminate() 終止程序繼續運行。這樣做(不拋出異常, std::terminate() 是noexcept函數)是爲了保證與 std::thread 的析構函數的行爲一致。不能通過賦一個新值給 std::thread 對象的方式來"丟棄"一個線程。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章