多執行緒--執行緒管理

aFakeProgramer發表於2018-07-31

說到多執行緒程式設計,那麼就不得不提並行併發,多執行緒是實現併發(並行)的一種手段。並行是指兩個或多個獨立的操作同時進行。注意這裡是同時進行,區別於併發,在一個時間段內執行多個操作。在單核時代,多個執行緒是併發的,在一個時間段內輪流執行;在多核時代,多個執行緒可以實現真正的並行,在多核上真正獨立的並行執行。例如現在常見的4核4執行緒可以並行4個執行緒;4核8執行緒則使用了超執行緒技術,把一個物理核模擬為2個邏輯核心,可以並行8個執行緒。

併發程式設計的方法

通常,要實現併發有兩種方法:多程式和多執行緒。

多程式併發

使用多程式併發是將一個應用程式劃分為多個獨立的程式(每個程式只有一個執行緒),這些獨立的程式間可以互相通訊,共同完成任務。由於作業系統對程式提供了大量的保護機制,以避免一個程式修改了另一個程式的資料,使用多程式比多執行緒更容易寫出安全的程式碼。但這也造就了多程式併發的兩個缺點:

  • 在程式件的通訊,無論是使用訊號、套接字,還是檔案、管道等方式,其使用要麼比較複雜,要麼就是速度較慢或者兩者兼而有之。
  • 執行多個執行緒的開銷很大,作業系統要分配很多的資源來對這些程式進行管理。

由於多個程式併發完成同一個任務時,不可避免的是:操作同一個資料和程式間的相互通訊,上述的兩個缺點也就決定了多程式的併發不是一個好的選擇。

多執行緒併發

多執行緒併發指的是在同一個程式中執行多個執行緒。有作業系統相關知識的應該知道,執行緒是輕量級的程式,每個執行緒可以獨立的執行不同的指令序列,但是執行緒不獨立的擁有資源,依賴於建立它的程式而存在。也就是說,同一程式中的多個執行緒共享相同的地址空間,可以訪問程式中的大部分資料,指標和引用可以線上程間進行傳遞。這樣,同一程式內的多個執行緒能夠很方便的進行資料共享以及通訊,也就比程式更適用於併發操作。由於缺少作業系統提供的保護機制,在多執行緒共享資料及通訊時,就需要程式設計師做更多的工作以保證對共享資料段的操作是以預想的操作順序進行的,並且要極力的避免死鎖(deadlock)

C++ 11的多執行緒初體驗

C++11的標準庫中提供了多執行緒庫,使用時需要#include <thread>標頭檔案,該標頭檔案主要包含了對執行緒的管理類std::thread以及其他管理執行緒相關的類。下面是使用C++多執行緒庫的一個簡單示例:

#include <iostream>
#include <thread>

using namespace std;

void output(int i)
{
    cout << i << endl;
}

int main()
{
    
    for (uint8_t i = 0; i < 4; i++)
    {
        thread t(output, i);
        t.detach(); 
    }
        
    getchar();
    return 0;
}

在一個for迴圈內,建立4個執行緒分別輸出數字0、1、2、3,並且在每個數字的末尾輸出換行符。語句thread t(output, i)建立一個執行緒t,該執行緒執行output,第二個引數i是傳遞給output的引數。t在建立完成後自動啟動,t.detach表示該執行緒在後臺允許,無需等待該執行緒完成,繼續執行後面的語句。這段程式碼的功能是很簡單的,如果是順序執行的話,其結果很容易預測得到

0 \n 1 \n 2 \n 3 \n 

但是在並行多執行緒下,其執行的結果就多種多樣了,下圖是程式碼一次執行的結果:

可以看出,首先輸出了01,並沒有輸出換行符;緊接著卻連續輸出了2個換行符。不是說好的並行麼,同時執行,怎麼還有先後的順序?這就涉及到多執行緒程式設計最核心的問題了資源競爭。CPU有4核,可以同時執行4個執行緒這是沒有問題了,但是控制檯卻只有一個,同時只能有一個執行緒擁有這個唯一的控制檯,將數字輸出。將上面程式碼建立的四個執行緒進行編號:t0,t1,t2,t3,分別輸出的數字:0,1,2,3。參照上圖的執行結果,控制檯的擁有權的轉移如下:

  • t0擁有控制檯,輸出了數字0,但是其沒有來的及輸出換行符,控制的擁有權卻轉移到了t1;(0)
  • t1完成自己的輸出,t1執行緒完成 (1\n)
  • 控制檯擁有權轉移給t0,輸出換行符 (\n)
  • t2擁有控制檯,完成輸出 (2\n)
  • t3擁有控制檯,完成輸出 (3\n)

由於控制檯是系統資源,這裡控制檯擁有權的管理是作業系統完成的。但是,假如是多個執行緒共享程式空間的資料,這就需要自己寫程式碼控制,每個執行緒何時能夠擁有共享資料進行操作。共享資料的管理以及執行緒間的通訊,是多執行緒程式設計的兩大核心。

執行緒管理

每個應用程式至少有一個程式,而每個程式至少有一個主執行緒,除了主執行緒外,在一個程式中還可以建立多個執行緒。每個執行緒都需要一個入口函式,入口函式返回退出,該執行緒也會退出,主執行緒就是以main函式作為入口函式的執行緒。在C++ 11的執行緒庫中,將執行緒的管理在了類std::thread中,使用std::thread可以建立、啟動一個執行緒,並可以將執行緒掛起、結束等操作。

啟動一個執行緒

C++ 11的執行緒庫啟動一個執行緒是非常簡單的,只需要建立一個std::thread物件,就會啟動一個執行緒,並使用該std::thread物件來管理該執行緒。

do_task();
std::thread(do_task);

這裡建立std::thread傳入的函式,實際上其建構函式需要的是可呼叫(callable)型別,只要是有函式呼叫型別的例項都是可以的。所有除了傳遞函式外,還可以使用:

  • lambda表示式

使用lambda表示式啟動執行緒輸出數字

for (int i = 0; i < 4; i++)
{
    thread t([i]{
        cout << i << endl;
    });
    t.detach();
}
  • 過載了()運算子的類的例項

使用過載了()運算子的類實現多執行緒數字輸出

class Task
{
public:
    void operator()(int i)
    {
        cout << i << endl;
    }
};

int main()
{
    
    for (uint8_t i = 0; i < 4; i++)
    {
        Task task;
        thread t(task, i);
        t.detach(); 
    }
}

把函式物件傳入std::thread的建構函式時,要注意一個C++的語法解析錯誤(C++'s most vexing parse)。向std::thread的建構函式中傳入的是一個臨時變數,而不是命名變數就會出現語法解析錯誤。如下程式碼:

std::thread t(Task());

這裡相當於宣告瞭一個函式t,其返回型別為thread,而不是啟動了一個新的執行緒。可以使用新的初始化語法避免這種情況

std::thread t{Task()};

當執行緒啟動後,一定要在和執行緒相關聯的thread銷燬前,確定以何種方式等待執行緒執行結束。C++11有兩種方式來等待執行緒結束

  • detach方式,啟動的執行緒自主在後臺執行,當前的程式碼繼續往下執行,不等待新執行緒結束。前面程式碼所使用的就是這種方式。
  • join方式,等待啟動的執行緒完成,才會繼續往下執行。假如前面的程式碼使用這種方式,其輸出就會0,1,2,3,因為每次都是前一個執行緒輸出完成了才會進行下一個迴圈,啟動下一個新執行緒。

無論在何種情形,一定要在thread銷燬前,呼叫t.join或者t.detach,來決定執行緒以何種方式執行。當使用join方式時,會阻塞當前程式碼,等待執行緒完成退出後,才會繼續向下執行;而使用detach方式則不會對當前程式碼造成影響,當前程式碼繼續向下執行,建立的新執行緒同時併發執行,這時候需要特別注意:建立的新執行緒對當前作用域的變數的使用,建立新執行緒的作用域結束後,有可能執行緒仍然在執行,這時區域性變數隨著作用域的完成都已銷燬,如果執行緒繼續使用區域性變數的引用或者指標,會出現意想不到的錯誤,並且這種錯誤很難排查。例如:

auto fn = [](int *a){
    for (int i = 0; i < 10; i++)
    cout << *a << endl; 
};

[]{
    int a = 100;

    thread t(fn, &a);

    t.detach();
}();

在lambda表示式中,使用fn啟動了一個新的執行緒,在裝個新的執行緒中使用了區域性變數a的指標,並且將該執行緒的執行方式設定為detach。這樣,在lamb表示式執行結束後,變數a被銷燬,但是在後臺執行的執行緒仍然在使用已銷燬變數a的指標,其輸出結果如下:

只有第一個輸出是正確的值,後面輸出的值是a已被銷燬後輸出的結果。所以在以detach的方式執行執行緒時,要將執行緒訪問的區域性資料複製到執行緒的空間(使用值傳遞),一定要確保執行緒沒有使用區域性變數的引用或者指標,除非你能肯定該執行緒會在區域性作用域結束前執行結束。當然,使用join方式的話就不會出現這種問題,它會在作用域結束前完成退出。

異常情況下等待執行緒完成

當決定以detach方式讓執行緒在後臺執行時,可以在建立thread的例項後立即呼叫detach,這樣執行緒就會後thread的例項分離,即使出現了異常thread的例項被銷燬,仍然能保證執行緒在後臺執行。但執行緒以join方式執行時,需要在主執行緒的合適位置呼叫join方法,如果呼叫join前出現了異常,thread被銷燬,執行緒就會被異常所終結。為了避免異常將執行緒終結,或者由於某些原因,例如執行緒訪問了區域性變數,就要保證執行緒一定要在函式退出前完成,就要保證要在函式退出前呼叫join

void func() {
    thread t([]{
        cout << "hello C++ 11" << endl;
    });

    try
    {
        do_something_else();
    }
    catch (...)
    {
        t.join();
        throw;
    }
    t.join();
}

上面程式碼能夠保證在正常或者異常的情況下,都會呼叫join方法,這樣執行緒一定會在函式func退出前完成。但是使用這種方法,不但程式碼冗長,而且會出現一些作用域的問題,並不是一個很好的解決方法。

一種比較好的方法是資源獲取即初始化(RAII,Resource Acquisition Is Initialization),該方法提供一個類,在解構函式中呼叫join

class thread_guard
{
    thread &t;
public :
    explicit thread_guard(thread& _t) :
        t(_t){}

    ~thread_guard()
    {
        if (t.joinable())
            t.join();
    }

    thread_guard(const thread_guard&) = delete;
    thread_guard& operator=(const thread_guard&) = delete;
};

void func(){

    thread t([]{
        cout << "Hello thread" <<endl ;
    });

    thread_guard g(t);
}

無論是何種情況,當函式退出時,區域性變數g呼叫其解構函式銷燬,從而能夠保證join一定會被呼叫。

向執行緒傳遞引數

向執行緒呼叫的函式傳遞引數也是很簡單的,只需要在構造thread的例項時,依次傳入即可。例如:

void func(int *a,int n){}

int buffer[10];
thread t(func,buffer,10);
t.join();

需要注意的是,預設的會將傳遞的引數以拷貝的方式複製到執行緒空間,即使引數的型別是引用。例如:

void func(int a,const string& str);
thread t(func,3,"hello");

func的第二個引數是string &,而傳入的是一個字串字面量。該字面量以const char*型別傳入執行緒空間後,在執行緒的空間內轉換為string

如果線上程中使用引用來更新物件時,就需要注意了。預設的是將物件拷貝到執行緒空間,其引用的是拷貝的執行緒空間的物件,而不是初始希望改變的物件。如下:

class _tagNode
{
public:
    int a;
    int b;
};

void func(_tagNode &node)
{
    node.a = 10;
    node.b = 20;
}

void f()
{
    _tagNode node;

    thread t(func, node);
    t.join();

    cout << node.a << endl ;
    cout << node.b << endl ;
}

線上程內,將物件的欄位a和b設定為新的值,但是線上程呼叫結束後,這兩個欄位的值並不會改變。這樣由於引用的實際上是區域性變數node的一個拷貝,而不是node本身。在將物件傳入執行緒的時候,呼叫std::ref,將node的引用傳入執行緒,而不是一個拷貝。thread t(func,std::ref(node));

也可以使用類的成員函式作為執行緒函式,示例如下

class _tagNode{

public:
    void do_some_work(int a);
};
_tagNode node;

thread t(&_tagNode::do_some_work, &node,20);

上面建立的執行緒會呼叫node.do_some_work(20),第三個引數為成員函式的第一個引數,以此類推。

轉移執行緒的所有權

thread是可移動的(movable)的,但不可複製(copyable)。可以通過move來改變執行緒的所有權,靈活的決定執行緒在什麼時候join或者detach。

thread t1(f1);
thread t3(move(t1));

將執行緒從t1轉移給t3,這時候t1就不再擁有執行緒的所有權,呼叫t1.joint1.detach會出現異常,要使用t3來管理執行緒。這也就意味著thread可以作為函式的返回型別,或者作為引數傳遞給函式,能夠更為方便的管理執行緒。

執行緒的標識型別為std::thread::id,有兩種方式獲得到執行緒的id。

  • 通過thread的例項呼叫get_id()直接獲取
  • 在當前執行緒上呼叫this_thread::get_id()獲取

總結

本文主要介紹了C++11引入的標準多執行緒庫的一些基本操作。有以下內容:

  • 執行緒的建立
  • 執行緒的執行方式,join或者detach
  • 向執行緒函式傳遞引數,需要注意的是執行緒預設是以拷貝的方式傳遞引數的,當期望傳入一個引用時,要使用std::ref進行轉換
  • 執行緒是movable的,可以在函式內部或者外部進行傳遞
  • 每個執行緒都一個標識,可以呼叫get_id獲取。
  •  

文章來源:https://www.cnblogs.com/wangguchangqing/p/6134635.html

相關文章