作者:南理漢子
鏈接:https://zhuanlan.zhihu.com/p/52612180
來源:知乎
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處
本文結構如下:
- 線程概念的起源
- 單核 CPU
- 多核 CPU
- 線程的生命週期
- 訪問數據所面臨的挑戰
- 中斷操作
- 可重入問題
- 線程安全
- Qt 提供的多線程操作及其適用場景
- 線程類
- 解決訪問共享資源遇到的問題
- 不同線程類的適用場景
1. 線程概念的起源
1.1 單核 CPU
在早期的單核 CPU 時代還沒有線程的概念,只有進程。操作系統作爲一個大的“軟件”,協調着各個硬件(如CPU、內存,硬盤、網卡燈)有序的工作着。在雙核 CPU 誕生以前,我們用的 Windows 操作系統依然可以一邊用 word 寫文檔一邊聽着音樂,作爲整個系統唯一可以完成計算任務的 CPU 是如何保證兩個進程“同時進行”的呢?時間片輪轉調度!
注意這個關鍵字「輪轉」。每個進程會被操作系統分配一個時間片,即每次被 CPU 選中來執行當前進程所用的時間。時間一到,無論進程是否運行結束,操作系統都會強制將 CPU 這個資源轉到另一個進程去執行。爲什麼要這樣做呢?因爲只有一個單核 CPU,假如沒有這種輪轉調度機制,那它該去處理寫文檔的進程還是該去處理聽音樂的進程?無論執行哪個進程,另一個進程肯定是不被執行,程序自然就是無運行的狀態。如果 CPU 一會兒處理 word 進程一會兒處理聽音樂的進程,起初看起來好像會覺得兩個進程都很卡,但是 CPU 的執行速度已經快到讓人們感覺不到這種切換的頓挫感,就真的好像兩個進程在“並行運行”。
如上圖所示,每一個小方格就是一個時間片,大約100ms。假設現在我同時開着 Word、QQ、網易雲音樂三個軟件,CPU 首先去處理 Word 進程,100ms時間一到 CPU 就會被強制切換到 QQ 進程,處理100ms後又切換到網易雲音樂進程上,100ms後又去處理 Word 進程,如此往復不斷地切換。我們將其中的 Word 單獨拿出來看,如果時間片足夠小,那麼以人類的反應速度看就好比最後一個處理過程,看上去就會有“CPU 只處理 Word 進程”的幻覺。隨着芯片技術的發展,CPU 的處理速度越來越快,在保證流暢運行的情況下可以同時運行的進程越來越多。
1.2 多核 CPU
隨着運行的進程越來越多,人們發現進程的創建、撤銷與切換存在着較大的時空開銷,因此業界急需一種輕型的進程技術來減少開銷。於是上世紀80年代出現了一種叫 SMP(Symmetrical Multi-Processing)的對稱多處理技術,就是我們所知的線程概念。線程切換的開銷要小很多,這是因爲每個進程都有屬於自己的一個完整虛擬地址空間,而線程隸屬於某一個進程,與進程內的其他線程一起共享這片地址空間,基本上就可以利用進程所擁有的資源而無需調用新的資源,故對它的調度所付出的開銷就會小很多。
以 QQ 聊天軟件爲例,上文我們一直都在說不同進程如何流暢的運行,此刻我們只關注一個進程的運行情況。如果沒有線程技術的出現,當 QQ 這個進程被 CPU “臨幸”時,我是該處理聊天呢還是處理界面刷新呢?如果只處理聊天,那麼界面就不會刷新,看起來就是界面卡死了。有了線程技術後,每次 CPU 執行100ms,其中30ms用於處理聊天,40ms用於處理傳文件,剩餘的30ms用於處理界面刷新,這樣就可以使得各個組件可以“並行”的運行了。於是乎我們可以提煉出兩點關於多線程的適用場景:
- 通過使用多核 CPU 提高處理速度。
- 保證 GUI 界面流暢運行的同時可以執行其他計算任務。
1.3 線程的生命週期
這裏簡單瞭解一下線程從創建到退出的過程。首先是「創建」一個新線程,等待 CPU 來執行;當 CPU 來執行時,如果該線程需要等待另外某個事件被執行完後才能執行,那該線程此時是處於「阻塞」狀態;如果不需要等待其他事件,線程就可以被「運行」了,也可以稱爲正在佔用時間片;時間片用完後,線程會處於「就緒」狀態,等待下一次時間片的到來;所有任務都完成後,線程就會進入「退出」狀態,操作系統就會釋放該線程所分配的資源。
2. 訪問數據所面臨的挑戰
2.1 中斷操作
既然在時間片輪轉的過程中含有中斷的操作,那麼訪問某些數據時就會產生很有意思的現象。爲了更好的理解可重入和線程安全,我們從它們的起源開始講起。
20世紀60年代中期開始,計算機系統進入第三代發展時期,一大批功能完善、集成度高的 CPU 湧入市場。這一時期的計算機除了 CPU 運行速度加快以外,還出現了中斷裝置、輸入輸出通道等。由於這些技術的快速發展,多個進程共享計算機硬件設備就成了操作系統的研究核心。而時間片輪轉調度機制可以很好的解決這個問題,擁有該功能的操作系統稱爲「多任務操作系統」,例如我們熟知的 Windows。除此以外在嵌入式設備中的操作系統也擁有類似的技術,它們共同的特點就是 CPU 一會兒處理 A 進程,一會兒處理 B 進程,在切換任務的過程中都會有中斷操作。
以下代碼可以直觀的感受到中斷操作是個怎樣的過程:
#include <QCoreApplication>
#include <QDebug>
#include <iostream>
#include <csignal>
#include <unistd.h>
using namespace std;
void signalHandler( int signum )
{
qDebug() << "Receiv signal (" << signum << ").";
}
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
signal(SIGINT, signalHandler);
while (1) {
qDebug() << "Go to sleep...";
sleep(1);
}
return a.exec();
}
收到中斷信號的運行結果
上述代碼很簡單,要想讓操作系統中斷某個進程就必須將中斷信號傳給這個進程,而 signal(SIGINT, signalHandle) 函數的作用就是一旦產生中斷(SIGINT,中斷信號的一種),該進程就會執行 signalHandle() 函數。在該進程運行過程中我們手動按下 Ctrl+C 人爲的產生一箇中斷信號,此時就會執行 signalHandle() 函數,即輸出“Receiv signal...”信息。
中斷操作就會引發接下來我們要講的可重入問題。
2.2 可重入問題
可重入問題是在多任務的環境下誕生的,此時還沒有多線程什麼事。在嵌入式系統、實時操作系統下遇到可重入問題的次數會很多,比如某個硬件產品中的 MCU(也叫單片機微控芯片)上掛了很多傳感器,如加速度、光感應、陀螺儀等。這些傳感器屬於公共資源,任何一個進程都有權去訪問它們的數據,這些數據稱爲「全局變量」,即 global_value。
假設該產品的程序員在讀取這些傳感器上的數據是採用定時和中斷的方式進行,寫了兩個程序(A 進程和 B 進程)去修改某個全局變量 global_value 。其中 A 進程要修改 global_value 5次,而 B 進程直接賦值一次。會有這麼一種情況出現,A 進程剛剛修改到第三次時系統出現了中斷信號,CPU 被強制調度去執行 B 進程,B 進程修改了 global_value 值後 CPU 再度返回 A 進程繼續運行代碼來進行第四次修改 global_value 的值。但是,此時 A 進程所面對的 global_value 已經不是它離開前的值了,那麼這段代碼就是不可重入的。這段代碼如果是放在某某函數中,就稱這個函數是不可重入函數。
在單進程中也會出現因訪問全局變量而產生不可預期的結果,如下列代碼所示。在正常運行過程中產生中斷操作與否會導致不同的結果出現。
#include <QCoreApplication>
#include <QDebug>
#include <iostream>
#include <csignal>
#include <unistd.h>
using namespace std;
int global_value = 0;
void signalHandler( int signum )
{
int i = 0;
while (i++ < 5) {
++global_value;
qDebug() << "Global value is " << global_value;
sleep(1);
}
}
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
signal(SIGINT, signalHandler);
signalHandler(2);
qDebug() << "The value is " << global_value;
return a.exec();
}
因此,可重入函數就是那種執行期間被中斷,重新恢復到斷點繼續執行時內部數據在任何情況下都不會發生改變的函數。
2.3 線程安全
計算機的發展來到多線程時代,既然線程是一種輕量級進程,那麼切換線程時產生的中斷也會帶來同樣的問題。
如上文所述,與進程不同的是,多個線程共享同一個進程的地址空間,切換的前後會保存上下文環境。問題來了,既然保存了上下文環境,怎麼還會出現依賴的環境發生了改變的現象呢?這是因爲中斷時保存的上下文環境僅限於返回地址、寄存器等少量的上下文,而函數內部使用的全局變量、static 靜態變量、緩存等並不在保護之列。因此,如果這些值在函數中斷期間發生了改變,那麼當函數返回斷點繼續運行時產生的結果就不可預料了。
因此,可重入的場景多存在於多個進程同時調用,而線程安全是多存在於多個線程同時調用。
面對訪問公共數據所遇到的這些問題,顯然對其訪問必須是序列化的,即 A 線程必須原子級別的執行123步驟後,B 線程才能執行相同的步驟。要做到這一點,通常的做法是加互斥鎖、信號量等同步線程方面的協調操作。
3. Qt 提供的多線程操作及其適用場景
3.1 線程類
首先就是 QThread 類,它是所有線程類的基礎,該類提供了很多低級的 API 對線程進行操作,每一個 QThread 對象都代表一個線程。使用該類開新線程並運行某段代碼的方式一般有兩種:(1)調用 QObject 的 moveToThread() 函數將 QObject 對象移到新開的 QThread 線程對象中,這樣 QObject 對象中所有的耗時操作都會在新線程中被執行;(2)繼承 QThread 並重寫 run() 函數,將耗時操作的代碼放入這個函數裏執行就可以了。除此以外,還有 QThreadStorage 類用於存儲主線程的數據,當然這屬於輔助性的類,是否採用取決於產品的設計思路。詳情參考《Qt 多線程編程之敲開 QThread 類的大門》。
上文我們說過“進程的創建、撤銷與切換存在着較大的時空開銷”,因此出現了線程這種輕量級進程技術。如果還想進一步的降低系統資源開銷,人們想出了一個辦法,就是讓執行完所有任務的線程不被銷燬,讓它處於“待命”的狀態等待新的耗時操作“進駐”進來。Qt 提供了 QThreadPool 和 QRunnable 這兩個類來對線程進行重複的使用。使用的方法一般是將耗時操作放入 QRunnable 類的 run() 函數中,然後整體把 QRunnable 對象扔到 QThreadPool 對象中就可以了。詳情參考《Qt 多線程編程之降低線程的開銷》。
爲了加快寫代碼的速度,我們不可能每個場景都非得用 QThread 這種低級類。如果遇到上文所述“多線程訪問數據會遇到的挑戰”的那樣,每次我都手動加互斥鎖必然會加大我們的工作量。因此,Qt 提供了 QtConcurrent 模塊,該模塊中有很多高級函數用於處理一些常見的並行計算模式,最大的特點就是無需再使用互斥鎖這種很低級的操作,全都封裝好了。除此以外,QFuture、QFutureWatcher、QFutureSynchronizer 類提供了一些輔助性的操作。詳情參考《Qt 多線程編程之高級函數》。
3.2 解決訪問共享資源遇到的問題
到這一步我們已經可以遊刃有餘的開新線程去執行耗時操作,接下來就要解決多線程編程所面臨的核心問題了,主要思路有兩個:(1)既然同時訪問不可行,那我們可以讓線程有序的排隊去處理,稱爲「同步線程」;(2)既然造成這種混亂局面的罪魁禍首是中斷機制,那我們可以不讓它被中斷,稱爲「原子操作」。
同步線程就是讓多個線程有序的去處理同一個變量,不要搶不要擠。有時候 A 線程需要等待 B 線程,強制線程彼此等待的原則稱爲互斥,這是一種保護共享資源的常用技術。QMutex 就是提供互斥操作的基本類,它可以讓 A 線程訪問某個全局變量時加上鎖,那麼在 A 線程沒有執行完的情況下,B 線程是無法訪問這個變量的,直至 A 線程處理完後進行解鎖操作。除此以外,還有 QReadWriteLock、QSemaphore、QWaitCondition 這些輔助類來提高多線程的效率。詳情參考《Qt 多線程編程之同步線程》。
解決“多線程訪問數據”的另一條思路就是原子操作。原子操作是指不會被線程調度機制打斷的操作,這種操作一旦開始就一直運行到結束,中間不會有任何的切換存在。因此,原子操作根本就不需要同步線程了。這種技術很複雜,複雜到 C 語言中根本就沒有這樣的操作,因爲大部分的原子操作都是用匯編語言實現的。強大的 Qt 提供了原子操作的類,如 QAtomicInteger、QAtomicPointer 類。詳情參考《Qt 多線程編程之原子操作》。
3.3 不同線程類的適用場景
雖然 Qt 提供了三種線程類(參考上文“3.1線程類”),但是它們的適用場景不同。在接下來的文章中我會逐個展開來講解,最後會用一篇文章來總結,詳情參考《這些 Qt 多線程類該用在哪裏》。
文中提及的文章就是接下來要寫的。