1. 程式人生 > >C++併發程式設計1

C++併發程式設計1

C++11中引入了多執行緒程式設計,一般教科書中都沒有涉及到這個概念,但是在工作中多執行緒卻又是必不可少的。本文會從最簡單的hello world入手,細述如何建立管理執行緒。

Hello World

經典的Hello World式開端。

#include <iostream>
#include <thread>

void hello()
{
    std::cout << "Hello world" << std::endl;
}

int main()
{
    std::thread t(hello);
    t.join(); // 沒有這句話,會Debug Error的
return 0; }

這段程式碼很簡單,如果用過boost多執行緒程式設計,那麼應該對這個瞭如指掌了。首先包含執行緒庫標頭檔案<thread>,然後定義一個執行緒物件t,執行緒物件負責管理以hello()函式作為初始函式的執行緒,join()等待執行緒函式執行完成——這兒是阻塞的。

建立執行緒

上文中的經典hello world例子使用了最基本的執行緒建立方法,也是我們最常用的方法。std::thread物件的構造引數需要為Callable Object,可以是函式、函式物件、類的成員函式或者是Lambda表示式。接下來我們會給出這四種建立執行緒的方法。

以函式作為引數

上文中的Hello C++ Concurrency程式,就是最好的以函式為引數構造std::thread的例子,這裡不再贅述。

以函式物件作為引數

函式物件利用了C++類的呼叫過載運算子,實現了該過載運算子的類物件可以當成函式一樣進行呼叫。如下例:

#include <iostream>
#include <thread>

class hello
{
public:
    hello(){ }
    void operator()()const
    {
        std::cout << "Hello world" << std
::endl; } }; int main() { hello h; std::thread t1(h); t1.join(); return 0; }

這裡需要注意一點:如果需要直接傳遞臨時的函式物件,C++編譯器會將std::thread物件構造解析為函式宣告:

std::thread t2(hello()); // error, compile as std::thread t2(hello(*)()); 
std::thread t3((hello())); // ok
std::thread t4{ hello() }; // ok
t2.join();   // compile error: expression must have class type
t3.join();   // ok
t4.join();   // ok

以類的成員函式作為引數

為了作為std::thread的構造引數,類的成員函式名必須唯一,在下例中,如果world1()和world2()函式名都是world,則編譯出錯,這是因為名字解析發生在引數匹配之前。

#include <iostream>
#include <thread>
#include <string>

class hello
{
public:
    hello(){ }
    void world1()
    {
        std::cout << "Hello world" << std::endl;
    }
    void world2(std::string text)
    {
        std::cout << "Hello world, " << text << std::endl;
    }
};

int main()
{
    hello h;
    std::thread t1(&hello::world1, &h);
    std::thread t2(&hello::world2, &h, "lee");
    t1.join();
    t2.join();
    return 0;
}

以lambda物件作為引數

#include <iostream>
#include <thread>
#include <string>

int main()
{
    std::thread t([](std::string text){
        std::cout << "hello world, " << text << std::endl;
    }, "lee");
    t.join();
    return 0;
}

建立執行緒物件時需要切記,使用一個能訪問區域性變數的函式去建立執行緒是一個糟糕的注意。

等待執行緒

join()等待執行緒完成,只能對一個執行緒物件呼叫一次join(),因為呼叫join()的行為,負責清理執行緒相關內容,如果再次呼叫,會出現Runtime Error

std::thread t([](){ 
               std::cout << "hello world" << std::endl;
});
t.join(); // ok
t.join(); // runtime error
if(t.joinable()) 
{
               t.join(); // ok
}

對join()的呼叫,需要選擇合適的呼叫時機。如果執行緒執行之後父執行緒產生異常,在join()呼叫之前丟擲,就意味著這次呼叫會被跳過。解決辦法是,在無異常的情況下使用join()——在異常處理過程中呼叫join()

#include <iostream>
#include <thread>
#include <string>

int main()
{
    std::thread t([](std::string text){ 
        std::cout << "hello world, " << text << std::endl;
    }, "lee");
    try
    {
        throw std::exception("test");
    }
    catch (std::exception e)
    {
        std::cout << e.what() << std::endl;
        t.join();
    }
    if (t.joinable())
    {
        t.join();
    }
    return 0;
}

上面並非解決這個問題的根本方法,如果其他問題導致程式提前退出,上面方案無解,最好的方法是所謂的RAII。

#include <iostream>
#include <thread>
#include <string>

class thread_guard
{
public:
    explicit thread_guard(std::thread &_t)
        : t(std::move(_t))
    {
                if(!t.joinable())
                    throw std::logic_error("No Thread");
    }

    ~thread_guard()
    {
        if (t.joinable())
        {
            t.join();
        }
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator=(thread_guard const &) = delete;
private:
    std::thread t;
};

void func()
{
    thread_guard guard(std::thread([](std::string text){
        std::cout << "hello world, " << text << std::endl;
    }, "lee"));
    try
    {
        throw std::exception("test");
    }
    catch (...)
    {
        throw;
    }
}

int main()
{
    try
    {
        func();
    }
    catch (std::exception e)
    {
        std::cout << e.what() << std::endl;
    }
    return 0;
}

分離執行緒

detach()將子執行緒和父執行緒分離。分離執行緒後,可以避免異常安全問題,即使執行緒仍在後臺執行,分離操作也能確保std::terminate在std::thread物件銷燬時被呼叫。

通常稱分離執行緒為守護執行緒(deamon threads),這種執行緒的特點就是長時間執行;執行緒的生命週期可能會從某一個應用起始到結束,可能會在後臺監視檔案系統,還有可能對快取進行清理,亦或對資料結構進行優化。

#include <iostream>
#include <thread>
#include <string>
#include <assert.h>

int main()
{
    std::thread t([](std::string text){
        std::cout << "hello world, " << text << std::endl;
    }, "lee");

    if (t.joinable())
    {
        t.detach();
    }
    assert(!t.joinable());

    return 0;
}

上面的程式碼中使用到了joinable()函式,不能對沒有執行執行緒的std::thread物件使用detach(),必須要使用joinable()函式來判斷是否可以加入或分離。

執行緒傳參

正常的執行緒傳參是很簡單的,但是需要記住下面一點:預設情況下,即使我們執行緒函式的引數是引用型別,引數會先被拷貝到執行緒空間,然後被執行緒執行體訪問。上面的執行緒空間為執行緒能夠訪問的內部記憶體。我們來看下面的例子:

void f(int i,std::string const& s);
std::thread t(f,3,”hello”);

即使f的第二個引數是引用型別,字串字面值"hello"還是被拷貝到執行緒t空間內,然後被轉換為std::string型別。在上面這種情況下不會出錯,但是在下面這種引數為指向自動變數的指標的情況下就很容易出錯。

void f(int i,std::string const& s);
void oops(int some_param)
{
    char buffer[1024]; 
    sprintf(buffer, "%i",some_param); 
    std::thread t(f,3,buffer); 
    t.detach();
}

在這種情況下,指標變數buffer將會被拷貝到執行緒t空間內,這個時候很可能函式oops結束了,buffer還沒有被轉換為std::string,這個時候就會導致未定義行為。解決方案如下:

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)); 
    t.detach();
}

由於上面所說,程序傳參時,引數都會被進行一次拷貝,所以即使我們將程序函式引數設為引用,也只是對這份拷貝的引用。我們對引數的操作並不會改變其傳參之前的值。看下面例子:

void update_data_for_widget(widget_id w,widget_data& data);
void oops_again(widget_id w)
{
    widget_data data;
    std::thread t(update_data_for_widget,w,data);
    display_status();
    t.join();
    process_widget_data(data);
}

執行緒t執行完成之後,data的值並不會有所改變,process_widget_data(data)函式處理的就是一開始的值。我們需要顯示的宣告引用傳參,使用std::ref包裹需要被引用傳遞的引數即可解決上面問題:

void update_data_for_widget(widget_id w,widget_data& data);
void oops_again(widget_id w)
{
    widget_data data;
    std::thread t(update_data_for_widget,w,std::ref(data));
    display_status();
    t.join();
    process_widget_data(data);
}

對於可以移動不可拷貝的引數,譬如std::unqiue_ptr物件,如果源物件是臨時的,移動操作是自動執行的;如果源物件是命名變數,必須顯式呼叫std::move函式

void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object,std::move(p));

轉移執行緒所有權

std::thread是可移動的,不可拷貝。在std::thread物件之間轉移執行緒所有權使用sd::move函式。

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::move轉移執行緒所有權
std::thread t3;                          // 4
t3=std::move(t2);                        // 5
t1=std::move(t3);                        // 6 賦值操作將使程式崩潰
t1.detach();
t1=std::move(t3);                        // 7 ok

這裡需要注意的是臨時物件會隱式呼叫std::move轉移執行緒所有權,所以t1=std::thread(some_other_function);不需要顯示呼叫std::move。如果需要析構thread物件,必須等待join()返回或者是detach(),同樣,如果需要轉移執行緒所有權,必須要等待接受執行緒物件的執行函式完成,不能通過賦一個新值給std::thread物件的方式來"丟棄"一個執行緒。第6點中,t1仍然和some_other_function聯絡再一次,所以不能直接轉交t3的所有權給t1。

std::thread支援移動,就意味著執行緒的所有權可以在函式外進行轉移。

std::thread f()
{
  void some_function();
  return std::thread(some_function);
}

std::thread g()
{
  void some_other_function(int);
  std::thread t(some_other_function,42);
  return t;
}

當所有權可以在函式內部傳遞,就允許std::thread例項可作為引數進行傳遞。

void f(std::thread t);
void g()
{
  void some_function();
  f(std::thread(some_function));
  std::thread t(some_function);
  f(std::move(t));
}

利用這個特性,我們可以實現執行緒物件的RAII封裝。

class thread_guard
{
public:
    explicit thread_guard(std::thread &_t)
        : t(std::move(_t))
    {
        if (!t.joinable())
            throw std::logic_error("No Thread");
    }

    ~thread_guard()
    {
        if (t.joinable())
        {
            t.join();
        }
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator=(thread_guard const &) = delete;
private:
    std::thread t;
};
struct func;
void f() {
    int some_local_state;
    scoped_thread t(std::thread(func(some_local_state)));
    do_something_in_current_thread();
}

利用執行緒可以轉移的特性我們可以用容器來集中管理執行緒,看下面程式碼:

void do_work(unsigned id);
void f() {
    std::vector<std::thread> threads;
    for(unsigned i=0;i<20;++i)
    {
        threads.push_back(std::thread(do_work,i));
    }
    std::for_each(threads.begin(),threads.end(),
                  std::mem_fn(&std::thread::join));
}

執行緒相關

執行緒數量

std::thread::hardware_concurrency()函式返回一個程式中能夠同時併發的執行緒數量,在多核系統中,其一般是核心數量。但是這個函式僅僅是一個提示,當系統資訊無法獲取時,函式會返回0。看下面並行處理的例子:

識別執行緒

執行緒標識型別是std::thread::id,可以通過兩種方式進行檢索。

  • 通過呼叫std::thread物件的成員函式get_id()來直接獲取。
  • 當前執行緒中呼叫std::this_thread::get_id()也可以獲得執行緒標識。

上面的方案和執行緒sleep很相似,使用上面一樣的格式,get_id()函式替換成sleep()函式即可。
std::thread::id物件可以自由的拷貝和對比:

  • 如果兩個物件的std::thread::id相等,那它們就是同一個執行緒,或者都“沒有執行緒”。
  • 如果不等,那麼就代表了兩個不同執行緒,或者一個有執行緒,另一沒有。

std::thread::id例項常用作檢測特定執行緒是否需要進行一些操作,這常常用在某些執行緒需要執行特殊操作的場景,我們必須先要找出這些執行緒。


關注微信公眾號FreeHacker