天天看點

C++ 線程的使用

C++11 之前,C++ 語言沒有對并發程式設計提供語言級别的支援,這使得我們在編寫可移植的并發程式時,存在諸多的不便。現在 C++11 中增加了線程以及線程相關的類,很友善地支援了并發程式設計,使得編寫的多線程程式的可移植性得到了很大的提高。

C++11 中提供的線程類叫做 ​

​std::thread​

​,基于這個類建立一個新的線程非常的簡單,隻需要提供線程函數或者函數對象即可,并且可以同時指定線程函數的參數。我們首先來了解一下這個類提供的一些常用 API:

1. 構造函數

// ①
thread() noexcept;
// ②
thread( thread&& other ) noexcept;
// ③
template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );
// ④
thread( const thread& ) = delete;      

構造函數①:預設構造函,構造一個線程對象,在這個線程中不執行任何處理動作

構造函數②:移動構造函數,将 other 的線程所有權轉移給新的 thread 對象。之後 other 不再表示執行線程。

構造函數③:建立線程對象,并在該線程中執行函數 f 中的業務邏輯,args 是要傳遞給函數 f 的參數

任務函數 f 的可選類型有很多,具體如下:

  • 普通函數,類成員函數,匿名函數,仿函數(這些都是可調用對象類型)
  • 可以是可調用對象包裝器類型,也可以是使用綁定器綁定之後得到的類型(仿函數)

構造函數④:使用 =delete 顯示删除拷貝構造,不允許線程對象之間的拷貝

2. 公共成員函數

2.1 get_id()

應用程式啟動之後預設隻有一個線程,這個線程一般稱之為主線程或父線程,通過線程類建立出的線程一般稱之為子線程,每個被建立出的線程執行個體都對應一個線程 ID,這個 ID 是唯一的,可以通過這個 ID 來區分和識别各個已經存在的線程執行個體,這個擷取線程 ID 的函數叫做 ​

​get_id()​

​,函數原型如下:

std::thread::id get_id() const noexcept;      

示例程式如下:

#include <iostream>
#include <thread>
#include <chrono>
using namespace std;

void func(int num, string str)
{
    for (int i = 0; i < 10; ++i)
    {
        cout << "子線程: i = " << i << "num: " 
             << num << ", str: " << str << endl;
    }
}

void func1()
{
    for (int i = 0; i < 10; ++i)
    {
        cout << "子線程: i = " << i << endl;
    }
}

int main()
{
    cout << "主線程的線程ID: " << this_thread::get_id() << endl;
    thread t(func, 520, "i love you");
    thread t1(func1);
    cout << "線程t 的線程ID: " << t.get_id() << endl;
    cout << "線程t1的線程ID: " << t1.get_id() << endl;
}      
  1. ​thread t(func, 520, "i love you")​

    ​;:建立了子線程對象 t,func() 函數會在這個子線程中運作
  • ​func()​

    ​ 是一個回調函數,線程啟動之後就會執行這個任務函數,程式猿隻需要實作即可
  • ​func()​

    ​​ 的參數是通過 thread 的參數進行傳遞的,​

    ​520,i love you​

    ​ 都是調用 ​

    ​func()​

    ​ 需要的實參
  • 線程類的構造函數③ 是一個變參函數,是以無需擔心線程任務函數的參數個數問題
  • 任務函數​

    ​func()​

    ​ 一般傳回值指定為 ​

    ​void​

    ​,因為子線程在調用這個函數的時候不會處理其傳回值
  1. ​thread t1(func1)​

    ​​;:子線程對象 t1 中的任務函數​

    ​func1()​

    ​,沒有參數,是以線上程構造函數中就無需指定了 通過線程對象調用 ​

    ​get_id()​

    ​ 就可以知道這個子線程的線程 ID 了,​

    ​t.get_id()​

    ​,​

    ​t1.get_id()​

    ​。
  2. 基于命名空間​

    ​this_thread​

    ​ 得到目前線程的線程 ID

在上面的示例程式中有一個 bug,在主線程中依次建立出兩個子線程,列印兩個子線程的線程 ID,最後主線程執行完畢就退出了(主線程就是執行 ​

​main ()​

​ 函數的那個線程)。預設情況下,主線程銷毀時會将與其關聯的兩個子線程也一并銷毀,但是這時有可能子線程中的任務還沒有執行完畢,最後也就得不到我們想要的結果了。

當啟動了一個線程(建立了一個 thread 對象)之後,在這個線程結束的時候(​

​std::terminate ()​

​),我們如何去回收線程所使用的資源呢?thread 庫給我們兩種選擇:

  • 加入式(join())
  • 分離式(detach())

另外,我們必須要線上程對象銷毀之前在二者之間作出選擇,否則程式運作期間就會有 bug 産生。

2.2 join()

​join()​

​​ 字面意思是連接配接一個線程,意味着主動地等待線程的終止(線程阻塞)。在某個線程中通過子線程對象調用 ​

​join()​

​​ 函數,調用這個函數的線程被阻塞,但是子線程對象中的任務函數會繼續執行,當任務執行完畢之後 ​

​join()​

​ 會清理目前子線程中的相關資源然後傳回,同時,調用該函數的線程解除阻塞繼續向下執行。

再次強調,我們一定要搞清楚這個函數阻塞的是哪一個線程,函數在哪個線程中被執行,那麼函數就阻塞哪個線程。該函數的函數原型如下:

void join();      

有了這樣一個線程阻塞函數之後,就可以解決在上面測試程式中的 bug 了,如果要阻塞主線程的執行,隻需要在主線程中通過子線程對象調用這個方法即可,當調用這個方法的子線程對象中的任務函數執行完畢之後,主線程的阻塞也就随之解除了。修改之後的示例代碼如下:

int main()
{
    cout << "主線程的線程ID: " << this_thread::get_id() << endl;
    thread t(func, 520, "i love you");
    thread t1(func1);
    cout << "線程t 的線程ID: " << t.get_id() << endl;
    cout << "線程t1的線程ID: " << t1.get_id() << endl;
    t.join();
    t1.join();
}      

當主線程運作到第八行 ​

​t.join()​

​​;,根據子線程對象 t 的任務函數 ​

​func()​

​ 的執行情況,主線程會做如下處理:

  • 如果任務函數​

    ​func()​

    ​ 還沒執行完畢,主線程阻塞,直到任務執行完畢,主線程解除阻塞,繼續向下運作
  • 如果任務函數​

    ​func()​

    ​ 已經執行完畢,主線程不會阻塞,繼續向下運作

同樣,第 9 行的代碼亦如此。

為了更好的了解 ​

​join()​

​ 的使用,再來給大家舉一個例子,場景如下:

程式中一共有三個線程,其中兩個子線程負責分段下載下傳同一個檔案,下載下傳完畢之後,由主線程對這個檔案進行下一步處理,那麼示例程式就應該這麼寫:

#include <iostream>
#include <thread>
#include <chrono>
using namespace std;

void download1()
{
    // 模拟下載下傳, 總共耗時500ms,阻塞線程500ms
    this_thread::sleep_for(chrono::milliseconds(500));
    cout << "子線程1: " << this_thread::get_id() << ", 找到曆史正文...." << endl;
}

void download2()
{
    // 模拟下載下傳, 總共耗時300ms,阻塞線程300ms
    this_thread::sleep_for(chrono::milliseconds(300));
    cout << "子線程2: " << this_thread::get_id() << ", 找到曆史正文...." << endl;
}

void doSomething()
{
    cout << "集齊曆史正文, 呼叫羅賓...." << endl;
    cout << "曆史正文解析中...." << endl;
    cout << "起航,前往拉夫德爾...." << endl;
    cout << "找到OnePiece, 成為海賊王, 哈哈哈!!!" << endl;
    cout << "若幹年後,草帽全員卒...." << endl;
    cout << "大海賊時代再次被開啟...." << endl;
}

int main()
{
    thread t1(download1);
    thread t2(download2);
    // 阻塞主線程,等待所有子線程任務執行完畢再繼續向下執行
    t1.join();
    t2.join();
    doSomething();
}      

示例程式輸出的結果:

子線程2: 72540, 找到曆史正文....
子線程1: 79776, 找到曆史正文....
集齊曆史正文, 呼叫羅賓....
曆史正文解析中....
起航,前往拉夫德爾....
找到OnePiece, 成為海賊王, 哈哈哈!!!
若幹年後,草帽全員卒....
大海賊時代再次被開啟....      

在上面示例程式中最核心的處理是在主線程調用 ​

​doSomething()​

​​; 之前在第 35、36行通過子線程對象調用了 ​

​join()​

​ 方法,這樣就能夠保證兩個子線程的任務都執行完畢了,也就是檔案内容已經全部下載下傳完成,主線程再對檔案進行後續處理,如果子線程的檔案沒有下載下傳完畢,主線程就去處理檔案,很顯然從邏輯上講是有問題的。

2.3 detach()

​detach()​

​ 函數的作用是進行線程分離,分離主線程和建立出的子線程。線上程分離之後,主線程退出也會一并銷毀建立出的所有子線程,在主線程退出之前,它可以脫離主線程繼續獨立的運作,任務執行完畢之後,這個子線程會自動釋放自己占用的系統資源。(其實就是孩子翅膀硬了,和家裡斷絕關系,自己外出闖蕩了,如果家裡被誅九族還是會受牽連)。該函數函數原型如下:

void detach();      

線程分離函數沒有參數也沒有傳回值,隻需要線上程成功之後,通過線程對象調用該函數即可,繼續将上面的測試程式修改一下:

int main()
{
    cout << "主線程的線程ID: " << this_thread::get_id() << endl;
    thread t(func, 520, "i love you");
    thread t1(func1);
    cout << "線程t 的線程ID: " << t.get_id() << endl;
    cout << "線程t1的線程ID: " << t1.get_id() << endl;
    t.detach();
    t1.detach();
    // 讓主線程休眠, 等待子線程執行完畢
    this_thread::sleep_for(chrono::seconds(5));
}      
注意事項:線程分離函數 ​

​detach ()​

​​ 不會阻塞線程,子線程和主線程分離之後,在主線程中就不能再對這個子線程做任何控制了,比如:通過 ​

​join ()​

​​ 阻塞主線程等待子線程中的任務執行完畢,或者調用 ​

​get_id ()​

​​ 擷取子線程的線程 ID。有利就有弊,魚和熊掌不可兼得,建議使用 ​

​join ()​

​。

2.5 joinable()

​joinable()​

​ 函數用于判斷主線程和子線程是否處理關聯(連接配接)狀态,一般情況下,二者之間的關系處于關聯狀态,該函數傳回一個布爾類型:

  • 傳回值為 true:主線程和子線程之間有關聯(連接配接)關系
  • 傳回值為 false:主線程和子線程之間沒有關聯(連接配接)關系
bool joinable() const noexcept;      

示例代碼如下:

#include <iostream>
#include <thread>
#include <chrono>
using namespace std;

void foo()
{
    this_thread::sleep_for(std::chrono::seconds(1));
}

int main()
{
    thread t;
    cout << "before starting, joinable: " << t.joinable() << endl;

    t = thread(foo);
    cout << "after starting, joinable: " << t.joinable() << endl;

    t.join();
    cout << "after joining, joinable: " << t.joinable() << endl;

    thread t1(foo);
    cout << "after starting, joinable: " << t1.joinable() << endl;
    t1.detach();
    cout << "after detaching, joinable: " << t1.joinable() << endl;
}      

示例代碼列印的結果如下:

before starting, joinable: 0
after starting, joinable: 1
after joining, joinable: 0
after starting, joinable: 1
after detaching, joinable: 0      

基于示例代碼列印的結果可以得到以下結論:

  • 在建立的子線程對象的時候,如果沒有指定任務函數,那麼子線程不會啟動,主線程和這個子線程也不會進行連接配接
  • 在建立的子線程對象的時候,如果指定了任務函數,子線程啟動并執行任務,主線程和這個子線程自動連接配接成功
  • 子線程調用了​

    ​detach()​

    ​函數之後,父子線程分離,同時二者的連接配接斷開,調用​

    ​joinable()​

    ​傳回false
  • 在子線程調用了​

    ​join()​

    ​函數,子線程中的任務函數繼續執行,直到任務處理完畢,這時​

    ​join()​

    ​會清理(回收)目前子線程的相關資源,是以這個子線程和主線程的連接配接也就斷開了,是以,調用​

    ​join()​

    ​之後再調用​

    ​joinable()​

    ​會傳回false。

2.6 operator=

線程中的資源是不能被複制的,是以通過 = 操作符進行指派操作最終并不會得到兩個完全相同的對象。

// move (1) 
thread& operator= (thread&& other) noexcept;
// copy [deleted] (2) 
thread& operator= (const other&) = delete;      

通過以上 = 操作符的重載聲明可以得知:

  • 如果 other 是一個右值,會進行資源所有權的轉移
  • 如果 other 不是右值,禁止拷貝,該函數被顯示删除(=delete),不可用

3. 靜态函數

thread 線程類還提供了一個靜态方法,用于擷取目前計算機的 CPU 核心數,根據這個結果在程式中建立出數量相等的線程,每個線程獨自占有一個 CPU 核心,這些線程就不用分時複用 CPU 時間片,此時程式的并發效率是最高的。

static unsigned hardware_concurrency() noexcept;      

示例代碼如下:

#include <iostream>
#include <thread>
using namespace std;

int main()
{
    int num = thread::hardware_concurrency();
    cout << "CPU number: " << num << endl;
}      

4. C 線程庫

C 語言提供的線程庫不論在 window 還是 Linux 作業系統中都是可以使用的,看明白了這些 C 語言中的線程函數之後會發現它和上面的 C++ 線程類使用很類似(其實就是基于面向對象的思想進行了封裝),但 C++ 的線程類用起來更簡單一些,連結奉上,感興趣的可以一看。