(一)C++11 原生標準多線程:認識多線程

之所以稱之爲C++11原生標準多線程,因爲在C++中C++11版本才加入的多線程。所謂原生也就是C++語言自帶的,區別於其他庫實現,比如POSIX,Boost,winAPI,QT【藐視很少有人提到QT的多線程,不過我就是因爲要使用QT的多線程庫,才認真學習C++11原生標準多線程的。】等的多線程實現。關於系統學習C++原生多線程編程的書,我推薦由Anthony Williams所著的《Cpp Concurrency In Action》,中文版由陳曉偉所譯。Anthony Williams是C++11多線程標準起草着之一。

《C++ Concurrency in Action》中文版在線閱讀可以訪問陳曉偉的gitbook,他是免費開源的。

關於併發

《C++ Concurrency in Action》一書中對併發通俗的解釋:最簡單和最基本的併發,是指兩個或更多獨立的活動同時發生。
併發在生活中隨處可見,我們可以一邊走路一邊說話,也可以兩隻手同時作不同的動作,還有我們每個人都過着相互獨立的生活——當我在游泳的時候,你可以看球賽,等等。

爲什麼使用併發:《C++ Concurrency in Action》中介紹的有點抽象,有點深奧。通俗的講,就是我們希望在相同的時間內做更多的事情:把關注點(SOS)【我們要做的事情、任務】分離。還有就是我們希望通過併發提高一下性能。併發的目的始終會在這兩種情況下發生。

 

C++中的併發和多線程

在C++11標準出來之前,使用C++開發併發程序只有依賴第三方庫實現,或是系統API環境支持。這些都無法完美支持不依賴平臺擴展的多線程開發。C++11帶來了讓人驚喜的變化,原生提供了標準化的多線程支持。有關C++多線程歷史,推薦閱讀《C++ Concurrency in Action》1.3.1節內容。

我使用的是QT開發,QT5系列版本的多線程支持依賴於C++11原生多線程標準。即將到來的QT6系列應該會全面支持C++17標準。所以有必要系統瞭解一下C++11的原生多線程技術。這對於瞭解QT多線程開發實現會有比較深刻的認識。當然,在QT環境,我們可以無視C++的原生多線程開發。因爲即便是C++原生多線程和QT封裝好的開發接口,亦或是其他類庫,其大致不會有太大變化。而瞭解標準,也是敲開其他類庫多線程開發的最好方法。

 

進程

進程(Process)是計算機中的程序關於某數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,是操作系統結構的基礎。在早期面向進程設計的計算機結構中,進程是程序的基本執行實體;在當代面向線程設計的計算機結構中,進程是線程的容器。程序是指令、數據及其組織形式的描述,進程是程序的實體。

通俗一點講就是我們在電腦或是手機中運行的每一個程序,進程就是每一個程序的實體。既然進程是每個正在運行程序的實體,那每個程序至少就有一個獨立的進程(當然某些程序也可能創建多個進程,比如下圖的百度網盤),而進程之間是相互獨立存在的。就像我們運行着:酷狗音樂、百度網盤、WPS等等,當我們聽着歌,同時也在書寫辦公文件,這些同時進行的工作是獨立進行的。而每一項正在進行的工作都是由單獨的進程在執行。進程是由操作系統維護管理的,所以進程的資源開銷相對於線程會大一點。這也是應用程序會更傾向於線程開發的一個原因。

如果我們打開windows的任務管理器我們將會看到:

線程

線程(英語:thread)是操作系統能夠進行運算調度的最小單位。它被包含在進程之中,是進程中的實際運作單位。一條線程指的是進程中一個單一順序的控制流,一個進程中可以併發多個線程,每條線程並行執行不同的任務。在Unix System VSunOS 中也被稱爲輕量進程(lightweight processes),但輕量進程更多指內核線程(kernel thread),而把用戶線程(user thread)稱爲線程。

線程是獨立調度和分派的基本單位。線程可以爲操作系統內核調度的內核線程,如Win32線程;由用戶進程自行調度的用戶線程,如Linux平臺的POSIX Thread;或者由內核與用戶進程進行混合調度,如Windows 7的線程。

同一進程中的多條線程將共享該進程中的全部系統資源,如虛擬地址空間,文件描述符和信號處理等等。但同一進程中的多個線程有各自的調用棧(call stack),自己的寄存器環境(register context),自己的線程本地存儲(thread-local storage)。

一個程序可以有一個或多個進程,一個進程可以有很多線程。因爲進程與進程是相互獨立的,所以進程之間的通訊與信息共享會很繁瑣而且難以實現。所以大多應用程序更多選擇的是多線程。每條線程並行執行不同的任務。即便如此多線程開發也不是一件簡單的事情,因爲他關係到很多概念和陷阱。比如當年單例模式中發生的前期Bug,在使用了很久以後才發現。

在多核或多CPU,或支持Hyper-threadingCPU上使用多線程程序設計的好處是顯而易見,即提高了程序的執行吞吐率。在單CPU單核的計算機上,使用多線程技術,也可以把進程中負責I/O處理、人機交互而常被阻塞的部分與密集計算的部分分開來執行,編寫專門的workhorse線程執行密集計算,從而提高了程序的執行效率。

我對線程的理解就是:一個線程就是一段序列指令,爲了完成某項單一的任務。所以,一個線程就是串行的執行任務。

 多線程

多線程(英語:multithreading),是指從軟件或者硬件上實現多個線程併發執行的技術。具有多線程能力的計算機因有硬件支持而能夠在同一時間執行多於一個線程,進而提升整體處理性能。具有這種能力的系統包括對稱多處理機、多核心處理器以及芯片級多處理(Chip-level multithreading)或同時多線程(Simultaneous multithreading)處理器。在一個程序中,這些獨立運行的程序片段叫作“線程”(Thread),利用它編程的概念就叫作“多線程處理(Multithreading)”。

我的理解:多線程就是多個線程並行執行任務。更通俗一點就是多個串行的任務,並行執行,同一時間,同時執行多個串行任務。

我的理解:併發不一定是同時進行的。在單核CPU時代,更多的是一種假象。把所有的進程統一起來,按一定的算法順序分片一個一個串行執行。併發更像是一種概念,指在同一時間開始的任務,但不一定是並行。而在現在多核心時代,併發往往伴隨並行,但不一定完全是。我的理解是這樣的:計算機上運行的程序(進程)往往比計算機的核心數量多,顯而易見進程裏包涵的線程數量會更多。這樣一來,CPU不可能給我們提供這麼多的並行計算。還是得回到單核時代的執行算法。這樣一來,所謂的並行有時候也成爲一種理想的概念。其宗旨和目的是不變得。

當然還有一種可能,在某些系統上,進程在某一個時間得到了CPU所有的核心執行權限,這時候如果線程還是比CPU核心多,那很可能依舊會有一定的串行執行。

有的資料說,多線程就是絕對的並行。從分析來看這是錯誤的。還要補充一點是,線程是被操作系統統一分配的執行權限。所以,編程人員頂多也就只能控制程序內部的線程控制。而系統對線程的安排,是程序員無能爲力的。

線程安全:在實現線程之前需要考慮的問題

在單線程的執行環境中,無論線程是在運行還是因爲某些信號中斷而暫停,無論這個線程怎樣去訪問它所擁有的變量、資源,都會按照我們寫的代碼去安全的執行(線程安全的)。

如果在多線程中,我們可以想象,兩個或兩個以上的線程同時訪問一個方法,而這個方法不會影響外部數據,只是影響函數內部的局部變量。它應該依舊是安全的(除非我們關心這幾個進程調用的這個方法返回的數據之間所產生的數據有關聯性)。問題是,如果我們訪問的方法會影響全局數據,或是放在數據庫裏的持久數據。安全問題也就顯而易見。我們稱線程安全的對象或是方法是無態的。反之,多線程中的對象或是方法是有態的。【我對無態和有態的理解是:無態不會影響外部的數據,只會影響自己產生的局部變量數據。有態會影響外部的信息數據,與外部的對象或是屬性有關聯。】

線程安全問題舉例:我們的程序中需要一種特殊的類,這個類在整個程序中只能創建一個對象,如果已經實例化了一個對象,我們不允許再次創建(設計模式之:單例模式)。我們首先會使用if object == nullptr判斷對象是否已經創建,如果已經創建,我們就不需要再次創建對象。這樣看來一切都十分的和諧美好,完全是我們的理想結果。可理想終歸是理想,當其中一個進程(我們稱A進程)判斷還沒有創建對象時,這個類裏的方法就會調用類的構造函數,開始創建對象。可,這時另外一個進程(我們稱作B進程)剛好也需要這個類的對象實例,那麼類的管理函數就會判斷是否已經創建了對象實例,這時假如進程A還在創建的過程中,沒有創建好對象實例。這時進程B就認爲還沒有創建對象實例,也開始創建對象實例。最後的結果是兩個進程都創建了這個特殊類的對象實例,造成了不是我們需要的結果。這時不僅僅只是沒按我們的預訂結果執行,還會出現更無法預料的錯誤結果,還有可能會出現內存泄漏的問題。這就是線程安全的問題。這只是其中一種經典的單例模式多線程安全問題。如果是銀行系統的存款、取款程序,依舊會產生線程安全問題。《C++ Concurrency in Action》一書的附錄有一個完整的ATM多線程例子。

 

如何實現線程:啓動新線程

<thread> 頭文件提供了管理和辨別線程的工具,並且提供函數,可讓當前線程休眠。

std::thread類位於<thread> 頭文件, std::thread 類用來管理線程的執行。其提供創建、啓動或執行,也提供對線程的識別,以及提供其他函數用於管理線程的執行。所以對於C++多線程的管理我們只需要關注<thread> 頭文件所提供的功能。

線程的入口:我們知道線程是應用程序進程的最小執行單位。所以,一個應用程序,至少會有一個進程,而進程也至少會有一個線程存在。一個應用程序通過main()函數啓動資源申請,通過操作系統的入口支持啓動進程。而進程初始化完畢後,真正的實質化操作就交由線程區完成。由main()函數啓動的線程,我們稱之爲主線程原始線程。其他線程(如果我們需要新的線程)就需要我們去實現各自的入口函數(在C++標準中,當然是調用std::thread類)。

創建啓動一個最簡單的線程:其實我們已經非常清楚一件事,線程就是一個任務。當任務工作執行完畢,這個任務(線程)也就執行完畢。也就是說,一個線程(任務)所要執行的工作一但執行完畢,這個線程(任務)也就自動結束。

創建一個新的線程在C++中就是構造std::thread 對象,當我們構造一個空std::thread 對象時,也就相當於我們創建了一個沒有任何實質工作的空任務。當然,我們創建一個空任務,沒有任何意義。所以我們必須告訴std::thread 對象(這個新的進程【任務】)需要實際作點什麼。需要去作的工作有可能是一個函數方法,也可能是一個對象等等。

所以,我們的代碼如下:

//我們提供一個函數交給一個新線程(新的任務)去執行
void do_some_work();
std::thread my_thread(do_some_work);

這是一個最單純的新線程,我們之所以成爲單純。是因爲我們提交給新線程(新任務)的工作是一個單純的函數方法。

可是當我們要給這個新線程(新任務)派發的工作是一個對象的方法時,我們就必須告訴新線程(新任務)這個對象方法是誰的方法。也就是我們必須把對象也給傳進取。

注意:這一點對於剛接觸std::thread 構造方法的朋友會有一點迷惑。很可能你會在一個類裏創建了一個新進程,然後又調用了類裏面的方法,讓新線程去執行。我們可能會這樣寫代碼。

//假如我們在一個Connection類裏
//threadA是Connection類裏的方法

std::thread t(&Connection::threadA);  //很多C++編輯器,不會給你發出警告,直到運行時纔會發生錯誤。
t.join();


//剛使用的朋友,更可能會這樣寫
std::thread t(this->threadA);  //這時編輯器會給你亮出語法錯誤
t.join();

錯誤解釋:

我們先看一下thread的聲明原型:

//thread的聲明原型
class thread 
{ 
public: 
    thread() noexcept; 
    thread( thread&& other ) noexcept; 
    template< class Function, class... Args > 
    explicit thread( Function&& f, Args&&... args ); 
    thread(const thread&) = delete; 
    ~thread(); 
    thread& operator=( thread&& other ) noexcept; 
    bool joinable() const noexcept; 
    std::thread::id get_id() const noexcept; 
    native_handle_type native_handle(); 
    void join(); 
    void detach(); 
    void swap( thread& other ) noexcept; 
    static unsigned int hardware_concurrency() noexcept; 
}; 

很顯然,如果我們想創建一個新線程。讓我們的任務(函數等)執行起來,而任務(函數)是對象的成員。我們必須實例化一個對象,或者我們要執行的任務(函數)是一個static靜態成員函數。

如果成員函數是一個靜態成員函數,那麼一切都好辦了。我們直接讓新線程去去調用靜態成員函數就可以了,不需要其他條件。這種情況,thread類的thread( thread&& other ) noexcept; 構造函數起作用。

如果我們要調用的是一個普通成員函數,很顯然我們必須讓新線程知道,調用的成員函數是屬於哪個對象的。我們可以看到thread類的構造函數原型裏有一個模板成員函數:template< class Function, class... Args > explicit thread( Function&& f, Args&&... args ); 第一個模板參數依舊是我們需要傳入的類成員函數,第二個模板參數的類型是一個類類型,很顯然是需要我們傳入對象實例。不太瞭解c++11新特性的朋友可能會注意到,這個成員函數有一個explicit聲明。是的,explicit聲明起作用的條件是,函數參數必須是一個,否則無效。而這裏很顯然參數個數已經大於1,這是怎麼回事呢。原來explicit聲明在大於一個參數時,如果在參數列表裏,除了第一個參數以外的參數如果有默認值,那麼explicit聲明依舊有效。

如果熟悉 std::bind ,就應該不會對以上述傳參的形式感到奇怪,因爲 std::thread 構造函數和 std::bind 的操作都在標準庫中定義好了,可以傳遞一個成員函數指針作爲線程函數,並提供一個合適的對象指針作爲第一個參數。——《C++ Concurrency in Action》

另外在《C++ Concurrency in Action》一書的2.1 節:線程管理的基礎中特別提到的一個問題,就是對象的運算符重載問題。這裏假設我們對對象的運算符重載以後,以函數對象的方式傳入任務以啓動一個新線程。這時可能會出現語法錯誤解析。

原書實例:

class background_task
{
    public:
        void operator()() const
        {
            do_something();
            do_something_else();
        }
};

//background_task f;
//std::thread my_thread(f);

std::thread my_thread(background_task());

《C++ Concurrency in Action》原書摘錄:

有件事需要注意,當把函數對象傳入到線程構造函數中時,需要避免“最令人頭痛的語法解析”(C++’s most vexing parse, 中文簡介)。如果你傳遞了一個臨時變量,而不是一個命名的變量;C++編譯器會將其解析爲函數聲明,而不是類型對象的定義。

這裏相當與聲明瞭一個名爲my_thread的函數,這個函數帶有一個參數(函數指針指向沒有參數並返回background_task對象的函數),返回一個 std::thread 對象的函數,而非啓動了一個線程。使用在前面命名函數對象的方式,或使用多組括號①,或使用新統一的初始化語法②,可以避免這個問題。

std::thread my_thread((background_task())); // 1
std::thread my_thread{background_task()}; // 2

使用lambda表達式也能避免這個問題。lambda表達式是C++11的一個新特性,它允許使用一個可以捕獲局部變量的局部函數,可以避免傳遞參數。

這一問題的出現和對象運算符重載有關,當然只限這個案例。問題的根結就像書中所的那樣,我們應該注意傳入的是一個臨時變量。

 

 

 

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