【C++】多線程與異步編程【四】

【C++】多線程與異步編程【四】

0.三問

同步,異步,多線程之間是什麼關係?異步比同步高效在哪?多線程比單線程高效在哪? 捋一下, 想一下怎麼回答。

1.什麼是異步編程?

1.1同步與異步

前面談到併發互斥鎖條件變量, 前面提到的線程同步主要是爲了解決對共享數據的競爭訪問問題,所以線程同步主要是對共享數據的訪問同步化(按照既定的先後次序,一個訪問需要阻塞等待前一個訪問完成後才能開始)。這篇文章談到的異步編程主要是針對任務或線程的執行順序,也即一個任務不需要阻塞等待上一個任務執行完成後再開始執行,程序的執行順序與任務的排列順序是不一致的。下面從任務執行順序的角度解釋下同步與異步的區別:

  • 同步:就是在發出一個調用時,在沒有得到結果之前,該調用就不返回。但是一旦調用返回,就得到返回值了。換句話說,就是由調用者主動等待這個調用的結果。
  • 異步:調用在發出之後,這個調用就直接返回了,所以沒有返回結果。換句話說,當一個異步過程調用發出後,調用者不會立刻得到結果。 而是在調用發出之後,被調用者通過“狀態”、“通知”、“回調”三種途徑通知調用者。

可以使用哪一種途徑依賴於被帶調用者的實現,除非被調用者提供多種選擇,否則不受調用者控制。如果被調用者用狀態來通知,那麼調用者就需要每隔一定時間檢查一次,效率就很低。如果使用通知和回調的方式,效率則很高。因爲被調用者幾乎不需要做額外的操作。

在這裏插入圖片描述

舉個栗子:
你打電話問書店老闆有沒有《程序員的自我修養》這本書,如果是同步通信機制,書店老闆會說,你稍等,”我查一下",然後開始查啊查,等查好了(可能是5秒,也可能是一天)告訴你結果(返回結果)。而異步通信機制,書店老闆直接告訴你我查一下啊,查好了打電話給你,然後直接掛電話了(不返回結果)。然後查好了,他會主動打電話給你。在這裏老闆通過“回電”這種方式來回調。

1.2 阻塞與非阻塞

阻塞和非阻塞關注的是程序在等待調用結果(消息,返回值)時的狀態。

阻塞調用是指調用結果返回之前,當前線程會被掛起。調用線程只有在得到結果之後纔會返回。

非阻塞調用指在不能立刻得到結果之前,該調用不會阻塞當前線程。

舉個栗子:

愛喝茶的老張,有兩把水壺(普通水壺,簡稱水壺;會響的水壺,簡稱響水壺)。

  1. 老張把水壺放到火上,立等水開,然後泡茶。(同步阻塞)老張覺得自己有點傻。
  2. 老張把水壺放到火上,去客廳看電視,時不時去廚房看看水開沒有,然後泡茶。(同步非阻塞)。
  3. 老張還是覺得自己有點傻,於是變高端了,買了把會響笛的那種水壺。水開後,能大聲發出嘀~~~的噪音。
  4. 老張把響水壺放到火上,立等水開泡茶。(異步阻塞)。
  5. 老張覺得這樣傻等意義不大,老張把響水壺放到火上,去客廳看電視,水壺響後泡茶。(異步非阻塞)。
  • 這裏所謂同步異步,只是對於事件燒水和事件看電視。普通水壺,同步,指水壺燒水(線程)和來張(主線程)同時開始;響水壺,異步。雖然都能幹活,但響水壺可以在自己完工之後,燒水事件,提示老張(主線程)水開了。這是普通水壺所不能及的。同步只能讓調用者去輪詢自己(情況2中),造成老張效率的低下。
  • 所謂阻塞非阻塞,僅僅對於老張的狀態(主線程)而言。立等的老張,阻塞,主線程不能進行其他工作;看電視的老張,非阻塞,主線程可以進行其他工作。
    情況1和情況3中老張就是阻塞的,主線程(老張的狀態)不能去做其他任何事情,媳婦喊他都不知道。雖然4中響水壺是異步的,可對於立等的老張沒有太大的意義。所以一般異步是配合非阻塞使用的,這樣才能發揮異步的效用。

這裏針對多核的CPU在阻塞的情況下,計算量比較大的情況下,可以採用異步的方法,在將第i次的燒水和泡茶採用異步實現,使得喝茶這個目標的實現可以更加的高效。

2、如何使用異步編程

在線程庫< thread >中並沒有獲得線程執行結果的方法,通常情況下,線程調用者需要獲得線程的執行結果或執行狀態,以便後續任務的執行。那麼,通過什麼方式獲得被調用者的執行結果或狀態呢?

2.1 使用全局變量與條件變量傳遞結果

前面談到的條件變量具有“通知–喚醒”功能,可以把執行結果或執行狀態放入一個全局變量中,當被調用者執行完任務後,通過條件變量通知調用者結果或狀態已更新,可以使用了。

實例1:

//future1.cpp 使用全局變量傳遞被調用線程返回結果,使用條件變量通知調用線程已獲得結果

#include <vector>
#include <numeric>
#include <iostream>
#include <chrono>
#include <thread>
#include <mutex>
#include <condition_variable>

int res = 0;						//保存結果的全局變量
std::mutex mu;						//互斥鎖全局變量
std::condition_variable cond;       //全局條件變量
 
void accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last)
{
    int sum = std::accumulate(first, last, 0);      //標準庫求和函數
    std::unique_lock<std::mutex> locker(mu);
    res = sum;
    locker.unlock();
    cond.notify_one();              // 向一個等待線程發出“條件已滿足”的通知
}
 
int main()
{
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    std::thread work_thread(accumulate, numbers.begin(), numbers.end());

    std::unique_lock<std::mutex> locker(mu);
    //如果條件變量被喚醒,檢查結果是否被改變,爲真則直接返回,爲假則繼續等待
    cond.wait(locker, [](){ return res;});   
    std::cout << "result=" << res << '\n';
    locker.unlock();
    work_thread.join();//阻塞等待線程執行完成
 
    getchar();
    return 0;
}
result=21

從上面的代碼可以看出,雖然也實現了獲取異步任務執行結果的功能,但需要的全局變量較多,多線程間的耦合度也較高,編寫複雜程序時容易引入bug。有沒有更好的方式實現異步編程呢?C++ 11新增了一個< future >庫函數爲異步編程提供了很大的便利。

2.2 使用promise與future傳遞結果

< future >頭文件功能允許對特定提供者設置的值進行異步訪問,可能在不同的線程中。
這些提供程序(要麼是promise 對象,要麼是packaged_task對象,或者是對異步的調用async)與future對象共享共享狀態:提供者使共享狀態就緒的點與future對象訪問共享狀態的點同步。< future >頭文件的結構如下:
在這裏插入圖片描述

詳細資料見此

在這裏插入圖片描述

實例2

std::promise< T >構造時,產生一個未就緒的共享狀態(包含存儲的T值和是否就緒的狀態)。可設置T值,並讓狀態變爲ready。也可以通過產生一個future對象獲取到已就緒的共享狀態中的T值。繼續使用上面的程序示例,改爲使用promise傳遞結果,修改後的代碼如下:

實例3

//future2.cpp 使用promise傳遞被調用線程返回結果,通過共享狀態變化通知調用線程已獲得結果

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

void accumulate(std::vector<int>::iterator first,
	std::vector<int>::iterator last,
	std::promise<int> accumulate_promise)
{
	int sum = std::accumulate(first, last, 0);
	accumulate_promise.set_value(sum);  // 將結果存入,並讓共享狀態變爲就緒以提醒future
}

int main()
{
	// 演示用 promise<int> 在線程間傳遞結果。
	std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
	std::promise<int> accumulate_promise;
	std::future<int> accumulate_future = accumulate_promise.get_future();
	std::thread work_thread(accumulate, numbers.begin(), numbers.end(),
		std::move(accumulate_promise));
	accumulate_future.wait();  //等待結果
	std::cout << "result=" << accumulate_future.get() << '\n';
	work_thread.join();  //阻塞等待線程執行完成

	getchar();
	return 0;
}
result=21

std::promise< T >對象的成員函數get_future()產生一個std::future< T >對象,代碼示例中已經展示了future對象的兩個方法:wait()與get(),下面給出更多操作函數供參考:

在這裏插入圖片描述

值得注意的是,std::future< T >在多個線程等待時,只有一個線程能獲取等待結果。當需要多個線程等待相同的事件的結果(即多處訪問同一個共享狀態),需要用std::shared_future< T >來替代std::future < T >,std::future< T >也提供了一個將future轉換爲shared_future的方法f.share(),但轉換後原future狀態失效。這有點類似於智能指針std::unique_ptr< T >與std::shared_ptr< T >的關係,使用時需要留心。

2.3使用packaged_task與future傳遞結果

除了爲一個任務或線程提供一個包含共享狀態的變量,還可以直接把共享狀態包裝進一個任務或線程中。這就需要藉助std::packaged_task< Func >來實現了,其具體用法如下:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-CpRCBIyA-1609675314863)(C:\Users\guoqi\AppData\Roaming\Typora\typora-user-images\1609669883139.png)]

std::packaged_task< Func >構造時綁定一個函數對象,也產生一個未就緒的共享狀態。通過thread啓動或者仿函數形式啓動該函數對象。但是相比promise,沒有提供set_value()公用接口,而是當執行完綁定的函數對象,其執行結果返回值或所拋異常被存儲於能通過 std::future 對象訪問的共享狀態中。繼續使用上面的程序示例,改爲使用packaged_task傳遞結果,修改後的代碼如下:

實例4

//future3.cpp 使用packaged_task傳遞被調用線程返回結果,通過共享狀態變化通知調用線程已獲得結果

#include <vector>
#include <thread>
#include <future>
#include <numeric>
#include <iostream>
#include <chrono>
 
int accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last)
{
    int sum = std::accumulate(first, last, 0);
    return sum;
}
 
int main()
{
    // 演示用 packaged_task 在線程間傳遞結果。
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    std::packaged_task<int(std::vector<int>::iterator,std::vector<int>::iterator)> accumulate_task(accumulate);
    std::future<int> accumulate_future = accumulate_task.get_future();
    std::thread work_thread(std::move(accumulate_task), numbers.begin(), numbers.end());
    accumulate_future.wait();  //等待結果
    std::cout << "result=" << accumulate_future.get() << '\n';
    work_thread.join();  //阻塞等待線程執行完成
 
    getchar();
    return 0;
}
result=21

一般不同函數間傳遞數據時,主要是藉助全局變量、返回值、函數參數等來實現的。上面第一種方法使用全局變量傳遞數據,會使得不同函數間的耦合度較高,不利於模塊化編程。後面兩種方法分別通過函數參數與返回值來傳遞數據,可以降低函數間的耦合度,使編程和維護更簡單快捷。

2.4 使用async傳遞結果

前面介紹的std::promise< T >與std::packaged_task< Func >已經提供了較豐富的異步編程工具,但在使用時既需要創建提供共享狀態的對象(promise與packaged_task),又需要創建訪問共享狀態的對象(future與shared_future),還是覺得使用起來不夠方便。有沒有更簡單的異步編程工具呢?future頭文件也確實封裝了更高級別的函數std::async,其具體用法如下:

  • std::future std::async(std::launch policy, Func, Args…)

std::async是一個函數而非類模板,其函數執行完後的返回值綁定給使用std::async的std::futrue對象(std::async其實是封裝了thread,packged_task的功能,使異步執行一個任務更爲方便)。Func是要調用的可調用對象(function, member function, function object, lambda),Args是傳遞給Func的參數,std::launch policy是啓動策略,它控制std::async的異步行爲,我們可以用三種不同的啓動策略來創建std::async:

  • std::launch::async參數 保證異步行爲,即傳遞函數將在單獨的線程中執行;
  • std::launch::deferred參數 當其他線程調用get()/wait()來訪問共享狀態時,將調用非異步行爲;
  • std::launch::async | std::launch::deferred參數 是默認行爲(可省略)。有了這個啓動策略,它可以異步運行或不運行,這取決於系統的負載。

繼續使用上面的程序示例,改爲使用std::async傳遞結果,修改後的代碼如下:

實例5

//future4.cpp 使用async傳遞被調用線程返回結果

#include <vector>
#include <thread>
#include <future>
#include <numeric>
#include <iostream>
#include <chrono>
 
int accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last)
{
    int sum = std::accumulate(first, last, 0);
    return sum;
}
 
int main()
{
    // 演示用 async 在線程間傳遞結果。
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    auto accumulate_future = std::async(std::launch::async, accumulate, numbers.begin(), numbers.end());		//auto可以自動推斷變量的類型
    std::cout << "result=" << accumulate_future.get() << '\n';
 
    getchar();
    return 0;
}
result=21

從上面的代碼可以看出使用std::async能在很大程度上簡少編程工作量,使我們不用關注線程創建內部細節,就能方便的獲取異步執行狀態和結果,還可以指定線程創建策略。所以,我們可以使用std::async替代線程的創建,讓它成爲我們做異步操作的首選。

此外,還有什麼機制可以通過底層實現,提高性能,解決鎖機制的問題,下面將會學習基於原子數據類型和對應的原子操作無鎖編程的的思想。

3.小結

  1. 多線程比單線程高效的原因就是利用了CPU的多核計算把一個大的任務分而治之從而加速任務計算。

  2. 異步比同步高效的原因是前者釋放了調用線程,讓調用線程可以做更多的事情而不至於被windows強制休眠浪費線程資源。

  3. 就能方便的獲取異步執行狀態和結果,還可以指定線程創建策略。所以,我們可以使用std::async替代線程的創建,讓它成爲我們做異步操作的首選。

  4. 此外,還有什麼機制可以通過底層實現,提高性能,解決鎖機制的問題,下面將會學習基於原子數據類型和對應的原子操作無鎖編程的的思想。

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