歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
您现在的位置: Linux教程網 >> UnixLinux >  >> Linux編程 >> Linux編程

C++ 11 多線程--線程管理

說到多線程編程,那麼就不得不提並行並發,多線程是實現並發(並行)的一種手段。並行是指兩個或多個獨立的操作同時進行。注意這裡是同時進行,區別於並發,在一個時間段內執行多個操作。在單核時代,多個線程是並發的,在一個時間段內輪流執行;在多核時代,多個線程可以實現真正的並行,在多核上真正獨立的並行執行。例如現在常見的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獲取。

Copyright © Linux教程網 All Rights Reserved