C++基礎:多線程編程

參考:C++高級編程. (美) Marc Gregoire著, 張永強譯. 清華大學出版社. 2015.


CPU核心、進程與線程、併發與並行

查看CPU

在這裏插入圖片描述
圖示計算機擁有1塊CPU,包含4個獨立的物理內核,8個邏輯處理器,即所謂的 單CPU 4核心 8線程。其中邏輯處理器的個數對應的就是線程數,是一個邏輯概念,表示一種能力。由於單個物理核同一時間點只能處理一個線程,所以通常線程數等於核心數。但是可以通過 “超線程技術” Hyper-Threading,使用每個CPU核心沒有達到滿負荷運載的剩餘用量,用一個物理核模擬兩個虛擬核,實現每個核處理兩個線程。然而這兩個虛擬核肯定是比不上真正的物理核的,這也是有些媒體聲稱 “六核六線程優於四核八線程” 的原因。

進程與線程

對比 進程 Process 線程 Thread
定義 進程是程序運行的一個實體的運行過程,是系統進行資源分配和調配的一個獨立單位 線程是進程運行和執行的最小調度單位
系統開銷 創建撤銷切換開銷大,資源要重新分配和收回 僅保存少量寄存器的內容,開銷小,在進程的地址空間執行代碼
擁有資產 資源擁有的基本單位 基本上不佔資源,僅有不可少的資源(程序計數器,一組寄存器和棧)
調度 資源分配的基本單位 獨立調度分配的單位
安全性 進程間相互獨立,互不影響 線程共享一個進程下面的資源,可以互相通信和影響
地址空間 系統賦予的獨立的內存地址空間 由相關堆棧寄存器和和線程控制表TCB組成,寄存器可被用來存儲線程內的局部變量

簡單來說,
進程 Process 是在系統中正在運行的應用程序;某軟件一旦運行就是一個進程,如瀏覽器;進程是資源分配的最小單位,是線程的容器。一個進程可以包含多個線程,但肯定存在 主線程 MainThread

線程 Thread 是系統分配處理器時間資源(調度)的基本單元,包含在進程中,是進程中實際運作的最小單元。一個進程中的多個線程可以相互通信、內存共享,每條線程並行執行不同的任務。

併發與並行

併發 Concurrency:由於同一時刻只能有一條指令執行,在單CPU中多個進程指令被快速地輪換執行,使得在宏觀上具有多個進程同時執行的效果,但在微觀上並不是同時執行的,只是把時間分成若干段,使多個進程被快速地交替執行;或者在單核心中多個線程被快速地交替執行。

一條指令和另一條指令交錯執行,操作系統實現這種交錯執行的機制稱爲:上下文切換 Context Switch。上下文是指操作系統保持跟蹤進程或線程運行所需的所有狀態信息,如寄存器文件的當前值、主存內容等

在這裏插入圖片描述
並行 Parallel:指在同一時刻有多條指令被同時執行,可以是多進程分配到多CPU上同時執行,也可以是多線程分配到多核心上。所以無論從微觀還是從宏觀來看,二者都是一起執行的。
在這裏插入圖片描述
總結來說,
無論是併發還是並行,都只是一種執行順序的描述,使用者看到的則是多進程、多線程。進程 處理的任務多,每個進程都有獨立的內存單元,佔用CPU資源相對較少,但進程間切換開銷大、不能通信;線程 處理任務相對較少,多個線程共享內存單元,佔用資源少,可以相互通信(同步機制),就像同一程序(進程)的兩個函數都能訪問全局變量。


C++11標準線程庫

C++11實現了標準的線程庫,支持跨平臺編程,定義在 <thread> 頭文件中。下面介紹線程的創建、取消和異常處理。

namespace std
{
    class thread
    {
    public:
    	// 構造,可接受任意個數的參數
    	template< class Function, class... Args >
		explicit thread( Function&& f, Args&&... args );
        // 複製構造
        thread(const thread&) = delete;
        thread(thread&&) noexcept;
        thread& operator=(const thread&) = delete;
        thread& operator=(thread&&) noexcept;

        class id;
        id get_id();                    // 返回線程id

        void swap(thread&) noexcept;
        bool joinable() const noexcept; // 檢查線程是否可合併
        void join();                    // 阻塞父線程,等待其執行完成
        void detach();                  // 子線程獨立執行
    };

    namespace this_thread
    {
        std::thread::id get_id() noexcept;
        void yield() noexcept;

        template <class Clock, class Duration>
        void sleep_until(const chrono::time_point<Clock, Duration>& abs_time);

        template <class Rep, class Period>
        void sleep_for(const chrono::duration<Rep, Period>& rel_time);
    }
}

線程創建

函數指針

函數名即爲函數指針,也是函數地址。

void task(&param1, &param2, ...);

std::thread t{ task, param1, std::ref(params), ... };
t.join();   // 實際應用中需避免使用,會阻塞父線程

需要說明的是:

  • 不同線程中訪問 std::cout線程安全 的,沒有任何競爭風險
  • 參數需要引用傳遞時,必須使用std::refstd::cref,這是因爲C++11的設計者認爲函數模板 bindthread 默認應該對參數進行按值傳遞,禁止了一般的&引用傳遞。理由是函數模板在創建函數時已經把參數傳入,而該函數不確定什麼時候執行,如果使用一般引用的話,函數執行前引用參數發生改變,則最終無法得到創建函數時的預想結果。因此需要區別設計,以示提醒。

函數對象

只要實現了operator()的類或者結構體,都可以稱爲函數對象。相比函數指針,在面向對象編程中,這種實現更有優勢。

class Counter
{
public:
    Counter(int _id, int _iterations) : id(_id), iterations(_iterations) {};
    void operator()() const
    {
        for (int i=0; i<=iterations; ++i)
        {
            std::cout << "Counter " << id << " has value:";
            std::cout << i << std::endl;
        }
    }

private:
    int id;
    int iterations;
};

std::thread t1{ Task{0, 5} };   // 統一初始化語法
std::thread t2{ Task{1, 5} };   
std::thread t3{ Task{2, 5} };   
t1.join();   // 實際應用中需避免使用,會阻塞父線程
t2.join(); 
t3.join(); 

需要說明的是:

  • 爲避免編譯錯誤,應使用統一初始化語法
  • 函數對象總是複製到線程的某個內部存儲中
  • 函數對象的特定實例如需引用傳遞,也是必須使用std::refstd::cref
  • 所示代碼的cout輸出存在 “交錯” 現象,需要互斥以實現每次只有一個線程讀寫流對象。
    在這裏插入圖片描述

lambda

lambda 表達式用來生成簡單的匿名函數。

[函數對象參數] (操作符重載函數參數) mutable 或 exception 聲明 -> 返回值類型 {函數體}

- 函數對象參數不能省略
- 操作符重載參數可省
- 返回值類型可省,編譯器根據 return 語句自動推斷
- 函數體不能省
std::thread t( [param1, param2] {
	函數體
} );
t.join();   // 實際應用中需避免使用,會阻塞父線程

類的成員函數

通過這種方式創建線程,可在不同線程中執行某對象的成員函數。如何和其他線程訪問同一個對象,需要保證訪問是線程安全的,以避免競爭。

class MyClass
{
public:
	MyClass();
	task(_param1, _param2);
private:
	param1;
	param2;
};

MyClass object;
std::thread t{ &MyClass::task, &object };   // &類成員函數 + &實例
t.join();   // 實際應用中需避免使用,會阻塞父線程

讀取線程的處理結果

  • 傳入結果變量的引用 std::ref()
  • 將結果存儲在類的實例化對象的數據成員中
  • 使用下文介紹的 std::future::get()

線程取消

不存在線程取消機制,即無法在一個線程中取消另一個線程。但可以通過線程通信,最簡單就是設置共享變量flag,線程自身通過查詢flag來決定是否應該終止,注意競爭。

線程本地存儲

通過關鍵字 thread_local 可將變量定義爲 線程本地數據 ,即每個線程都有這個變量的獨立副本,不會出現競爭。

thread_local int var1;	// 每個線程都有獨立的 n 副本
int var2;				// 每個線程共享 a 數據,應避免競爭

void task(&param1, &param2, ...);

int main()
{
	std::thread t{ task, param1, param2 };  
	t.join();   // 實際應用中需避免使用,會阻塞父線程
}

如果 thread_local 聲明位於函數作用域內,則其定義的變量相當於 static,同時在每個線程中都有自己的獨立副本,且無論函數調用多少次,該變量在每個線程內只初始化一次。

異常處理

異常的捕獲和處理可以使用下文介紹的future,也可以按以下方法手動捕獲處理。
在這裏插入圖片描述
一個線程的異常不能在另一線程中捕獲。可以通過參數引用傳遞的方式,通過std::exception_ptr類型的異常變量判斷異常std::current_exception()是否發生,若是則重新拋出異常 std::rethrow_exception,再捕獲處理 try...catch。這樣,異常就從子線程轉移到了父線程中。

void task(std::exception_ptr& error)
{
	try
	{
		// do something
		throw std::runtime_error("人爲異常");
	}
	catch(...)  // catch-all處理所有異常
	{
		std::cout << "線程異常..." << std::endl;
		error = std::current_exception();
	}
}

int main()
{
	try
    {
		std::exception_ptr error;
		std::thread t{ task, std::ref(error) };
		t.join();
		if(error)
		{
			std::cout << "收到子線程異常,重新拋出..." << std::endl;	
			std::rethrow_exception(error);
		}
    }
    catch(const std::exception& e)
    {
		std::cout << "捕獲子線程異常:'" << e.what() << "'" << std::endl;	
    }
}

Race Conditions

A race condition or race hazard is the condition of an electronics, software, or other system where the system’s substantive behavior is dependent on the sequence or timing of other uncontrollable events. It becomes a bug when one or more of the possible behaviors is undesirable. —— 維基百科

競爭冒險(race hazard)又名競態條件、競爭條件(race condition),它旨在描述一個系統或者進程的輸出依賴於不受控制事件的出現順序或者出現時機。

舉例來說,如果計算機中的兩個進程 or 線程同時試圖修改一個 共享內存 的內容,在沒有併發控制的情況下,最後的結果依賴於它們的執行順序與時機,這會導致Bug出現。如下代碼所示,10個線程,每個計數10次,預想結果應該是100,但實際輸出87,耗時1.01秒。

#include <iostream>
#include <vector>
#include <thread>
#include <chrono>

using namespace std::chrono_literals; // C++14支持用戶定義的字面量,如 100ms
void task_sleep(int& counter)
{
    for (int i = 0; i < 10; ++i)
    {
        ++counter;
        std::this_thread::sleep_for(100ms);  
    }
}

int main(int argc, char* argv[])
{
    int counter{ 0 };
    std::vector<std::thread> threads(10);
    for (int i = 0; i < threads.size(); ++i)
    {
        threads[i] = std::thread{ task_sleep, std::ref(counter) };
    }
    for (auto& t : threads) t.join();
    std::cout << "result: " << counter << std::endl;   // 82
    return 0;
}

常見於不良設計的電子系統;在軟件中也比較常見,尤其是有采用 多線程技術 的軟件,那麼必須分外注意執行順序。爲了避免這個問題,可以禁止線程間共享內存或者提供 同步機制

同步機制用於保證一次只有一個線程在更改共享內存。在C++11版本中,提供了兩種手段:原子操作和顯式同步。原子操作 常用來同步簡單的標量數據類型和其指針類型,如bool, int, char*;而由 互斥體和鎖 實現的顯式同步則用於複雜數據。

原子操作

原子操作 Atomic Operation,指不會被線程調度機制打斷的操作;這種操作一旦開始,就一直運行到結束,中間不會有任何上下文切換。原子操作的主要特性是 不可分割,可以是一個步驟,也可以是多個步驟的整體,都是一個 最小 操作單元,這正是原子性(atomic)的體現。因此,原子操作具體的內部實現與結構不可被上層操作發現、修改和分割。原子性必須需要硬件的支持,是和CPU架構相關的。

原子操作常用來同步標量數據類型和其指針類型,如bool, int, char*等簡單數據。如下代碼所示,修補上一節的Bug,使用原子操作進行計數,實際輸出100,耗時1.01秒。

#include <iostream>
#include <vector>
#include <thread>
#include <chrono>
#include <atomic>		// 原子操作
#include <ctime>

using namespace std::chrono_literals; // C++14支持用戶定義的字面量,如 10ms

void task_sleep(std::atomic<int>& counter)
{
    int temp = 0;
    for (int i = 0; i < 10; ++i)
    {
        //++counter;  應避免頻繁的原子操作,使用局部變量替代
        ++temp;
        std::this_thread::sleep_for(100ms);  
    }
    counter += temp;
}

int main(int argc, char* argv[])
{
    auto startTime = clock();
    std::atomic<int> counter{ 0 };   // 原子操作,或直接定義類型 std::atomic_int16_t
    std::vector<std::thread> threads(10);
    for (int i = 0; i < threads.size(); ++i)
    {
        threads[i] = std::thread{ task_sleep, std::ref(counter) };
    }
    for (auto& t : threads) t.join();
    auto endTime = clock();
    
    int result = counter.load();   // 可以取出原子數據,也可以直接當作源類型參與運算
    printf("輸出%d,耗時%.2fs\n", result, double(endTime - startTime) / 1000.);
    
    return 0;
}

互斥機制

互斥機制用來保證在任一時刻,只能有一個線程讀取數據,線程與線程是互斥的。C++中互斥機制定義在 <mutex> 頭文件中,包括互斥體類、鎖類和call_once函數。關於互斥體類和鎖類,我的理解是:互斥體 是共享內存的大門,線程訪問數據想要實現互斥就要鎖上大門,鎖不上就是該數據正在被其他線程讀寫,因此互斥體需要能夠鎖定lock和解除unlock鎖定。而 類則是管理大門的方式,接管互斥體,用來自動lock和unlock,就不需要互斥體自己動作了。

  • 鎖住的東西越少,執行效率越高
  • 只讀的數據不需要互斥;又讀又寫的數據塊才需要

std::call_once()

是避免競爭的手段,是最簡單的互斥機制,用來確保某個函數或方法只調用一次,不論多少線程嘗試調用,需要配合std::once_flag使用。

#include <iostream>
#include <vector>
#include <thread>
#include <mutex>        // call_once

std::once_flag init_flag;
void init()
{
    std::cout << "執行初始化..." << std::endl;
}

void task_sleep()
{
    using namespace std::chrono_literals;
    std::this_thread::sleep_for(1s);
    std::cout << "線程執行" << std::endl;

    //init();
    std::call_once(init_flag, init);
}

int main(int argc, char* argv[])
{
    std::vector<std::thread> threads(10);
    for (int i = 0; i < threads.size(); ++i)
    {
        threads[i] = std::thread{ task_sleep };
    }
    for (auto& t : threads) t.join();

    return 0;
}

互斥體類

互斥體Mutual Exclusion,或稱“互斥量”,相當於共享數據的大門,能夠鎖定和解除鎖定。類型有

  • std::mutexstd::recursive_mutex
  • std::timed_mutexstd::recursive_timed_mutex
  • std::shared_timed_mutex 共享擁有是隻能被一個線程鎖定去寫入,可被多個線程鎖定去讀取
    在這裏插入圖片描述

共有的成員函數有:

  • lock(): 嘗試鎖定,會阻塞當前線程,直到獲得鎖定成功。
  • try_lock(): 嘗試鎖定一次,返回結果true或false
  • unlock(): 解除鎖定

定時類特有:

  • try_lock_for():在某段時間內嘗試鎖定,超時則返回false
  • try_lock_until():在某個時間點前嘗試鎖定,超時則返回false

鎖類

是管理互斥體的方式,能更方便安全自動地對互斥體進行鎖定lock和解除鎖定unlock,鎖類在作用域結束後析構,同時解鎖關聯的互斥體。

- lock_guard

guard n. 門衛;哨兵

lock_guard:最簡單安全的上鎖和解鎖方式,在 lock_guard 對象構造時自動對互斥量上鎖,析構時自動解鎖,即使程序拋出異常後也能解鎖已被上鎖的 互斥量。來源

在這裏插入圖片描述

- unique_lock

獨佔所有權 的方式(unique owership)自動管理 互斥體mutex 的上鎖和解鎖,也是安全的,允許定時操作,比lock_guard更靈活。

在這裏插入圖片描述

- shared_lock

在這裏插入圖片描述

- std::lock() 和 std::try_lock()

提供了兩個可變參數的模板函數,用來同時鎖定多個鎖,而不會出現死鎖。如 defer_lock_t 方式的unique_lock或shared_lock都需要手動鎖定。

  • std::lock():如果其中某個互斥體+鎖出現異常,則解除所有鎖定。
  • std::try_lock():嘗試鎖定一次所有的互斥體+鎖,全部成功則返回-1;失敗則解除所有鎖定,返回失敗點的位置索引。
    在這裏插入圖片描述

互斥鎖示例

代碼1:使用 lock_guard 同步函數對象的所有實例對cout流輸出的訪問,消除交錯現象。

  • 將互斥體定義爲靜態數據成員
  • 靜態數據成員屬於類而不是某個對象,對所有對象共享
  • 靜態成員變量需要在類內聲明static,類外定義並初始化
#include <iostream>
#include <vector>
#include <thread>
#include <mutex> 

class Counter
{
public:
    Counter(int _id, int _iterations) : id(_id), iterations(_iterations) {};
    void operator()() const
    {
        for (int i=0; i<=iterations; ++i)
        {
            std::lock_guard<std::mutex> lock(mMutex);   // 每次循環結束則自動解除鎖定
            std::cout << "Counter " << id << " has value:";
            std::cout << i << std::endl;
        }
    }

private:
    int id;
    int iterations;
    static std::mutex mMutex;  // 聲明靜態數據成員
};
std::mutex Counter::mMutex;  // 靜態數據成員的定義和初始化

int main(int argc, char* argv[])
{
    std::vector<std::thread> threads(3);
    for (int i = 0; i < threads.size(); ++i)
    {
        threads[i] = std::thread{ Counter{i, 5} };
    }
    for (auto& t : threads) t.join();

    return 0;
}

在這裏插入圖片描述

代碼2:使用 unique_lock 嘗試鎖定cout輸出流,超時則放棄。

#include <iostream>
#include <vector>
#include <thread>
#include <mutex> 

class Counter
{
public:
    Counter(int _id, int _iterations) : id(_id), iterations(_iterations) {};
    void operator()() const
    {
        for (int i=0; i<=iterations; ++i)
        {
            using namespace std::chrono_literals;
            std::unique_lock<std::timed_mutex> lock(mMutex, 50ms);   // 嘗試在50ms內鎖定
            if (lock) 
            {
                std::this_thread::sleep_for(10ms);
                std::cout << "Counter " << id << " has value:";
                std::cout << i << std::endl;
            }
            else
            {
                std::cout << "Counter " << id << " lock failed." << std::endl;
            }
        }
    }

private:
    int id;
    int iterations;
    static std::timed_mutex mMutex;
};
std::timed_mutex Counter::mMutex;

int main(int argc, char* argv[])
{
    std::vector<std::thread> threads(3);
    for (int i = 0; i < threads.size(); ++i)
    {
        threads[i] = std::thread{ Counter{i, 5} };
    }
    for (auto& t : threads) t.join();

    return 0;
}

在這裏插入圖片描述
結果看出,鎖定時間不宜過長,應立即釋放。

線程通信:條件變量

條件變量(condition variable)是利用線程間通信的一種機制,主要包括兩個動作:一個或多個線程等待某個條件爲真,而將自己掛起(阻塞);另一個線程使的條件成立,並通知等待的線程繼續。爲了防止競爭,條件變量的使用總是和一個互斥鎖結合在一起。

有意修改共享變量的線程必須:

  1. 獲得互斥鎖 std::mutex + lock類
  2. 在保有鎖時進行修改
  3. std::condition_variable 上執行 notify_onenotify_all (不需要爲了通知而保有鎖,建議通知前手動解鎖,以避免等待線程剛被喚醒就阻塞)

即使共享變量是原子的,也必須在互斥下修改它,以正確地發佈修改到等待的線程。

任何有意在 std::condition_variable 上等待的線程必須:

  1. 獲得互斥鎖 std::unique_lock<std::mutex>,和鎖定共享變量者的互斥相同
  2. 執行condition_variable :: waitwait_forwait_until ,等待互斥鎖被解除,並掛起(阻塞)該線程
  3. 等待 condition_variable 的線程會被喚醒:① 修改變量的線程調用 notify_onenotify_all,② 等待超時,③ 虛假喚醒 發生。因此,爲了正確起見,有必要在線程完成等待之後驗證條件確實爲真;若喚醒是虛假的,則繼續等待。

std::condition_variable 只可與 std::unique_lock 一同使用;此限制在一些平臺上允許最大效率。 std::condition_variable_any 提供可與任何基本可鎖定 (BasicLockable) 對象,例如 std::shared_lock 一同使用的條件變量。

condition_variable 禁止複製構造,可移動構造。容許所有成員函數的同時調用:

  • notify_one,notify_all
  • wait,wait_for,wait_until

future


線程池

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