線程安全代碼到底是怎么編寫的?
大家好,我是小風哥。
相信有很多同學在面對多線程代碼時都會望而生畏,認為多線程代碼就像一頭難以馴服的怪獸,你制服不了這頭怪獸它就會反過來吞噬你。
夸張了哈,總之,多線程程序有時就像一潭淤泥,走不進去退不出來。
可這是為什么呢?為什么多線程代碼如此難以正確編寫呢?
從根源上思考
關于這個問題,本質上是有一個詞語你沒有透徹理解,這個詞就是所謂的線程安全,thread safe。
如果你不能理解線程安全,那么給你再多的方案也是無用武之地。
接下來我們了解一下什么是線程安全,怎樣才能做到線程安全。
這些問題解答后,多線程這頭大怪獸自然就會變成溫順的小貓咪。
關你什么屁事
生活中我們口頭上經常說的一句話就是“關你屁事”,大家想一想,為什么我們的屁事不關別人?
原因很簡單,這是我的私事??!我的衣服、我的電腦,我的手機、我的車子、我的別墅以及私人泳池(可以沒有,但不妨礙想象),我想怎么處理就怎么處理,妨礙不到別人,只屬于我一個人的東西以及事情當然不關別人,即使是屁事也不關別人。
我們在自己家里想吃什么吃什么,想去廁所就去廁所!因為這些都是我私有的,只有我自己使用。
那么什么時候會和其它人有交集呢?
答案就是公共場所。
在公共場所下你不能像在自己家里一樣想去哪就去哪,想什么時候去廁所就去廁所,為什么呢?原因很簡單,因為公共場所下的飯館、衛生間不是你家的,這是公共資源,大家都可以使用的公共資源。
如果你想去飯館、去公共衛生間那么就必須遵守規則,這個規則就是排隊,只有前一個人用完公共資源后下一個人才可以使用,而且不能同時使用,想使用就必須排隊等待。
上面這段話道理足夠簡單吧。
如果你能理解這段話,那么馴服多線程這頭小怪獸就不在話下。
維護公共場所秩序
如果把你自己理解為線程的話,那么在你自己家里使用私有資源就是所謂的線程安全,原因很簡單,因為你隨便怎么折騰自己的東西(資源)都不會妨礙到別人;
但到公共場所浪的話就不一樣了,在公共場所使用的是公共資源,這時你就不能像在自己家里一樣想怎么用就怎么用想什么時候用就什么時候用,公共場所必須有相應規則,這里的規則通常是排隊,只有這樣公共場所的秩序才不會被破壞,線程以某種不妨礙到其它線程的秩序使用共享資源就能實現線程安全。
因此我們可以看到,這里有兩種情況:
- 線程私有資源,沒有線程安全問題
- 共享資源,線程間以某種秩序使用共享資源也能實現線程安全。
本文都是圍繞著上述兩個核心點來講解的,現在我們就可以正式的聊聊編程中的線程安全了。
什么是線程安全
我們說一段代碼是線程安全的,當且僅當我們在多個線程中同時且多次調用的這段代碼都能給出正確的結果,這樣的代碼我們才說是線程安全代碼,Thread Safety,否則就不是線程安全代碼,thread-unsafe.。
非線程安全的代碼其運行結果是由擲骰子決定的。
怎么樣,線程安全的定義很簡單吧,也就是說你的代碼不管是在單個線程還是多個線程中被執行都應該能給出正確的運行結果,這樣的代碼是不會出現多線程問題的,就像下面這段代碼:
int func() {
int a = 1;
int b = 1;
return a + b;
}
對于這樣段代碼,無論你用多少線程同時調用、怎么調用、什么時候調用都會返回2,這段代碼就是線程安全的。
那么我們該怎樣寫出線程安全的代碼呢?
要回答這個問題,我們需要知道我們的代碼什么時候呆在自己家里使用私有資源,什么時候去公共場所浪使用公共資源,也就是說你需要識別線程的私有資源和共享資源都有哪些,這是解決線程安全問題的核心所在。
圖片
線程私有資源
線程都有哪些私有資源呢?啊哈,我們在上一篇《線程到底共享了哪些進程資源》中詳細講解了這個問題。
線程運行的本質其實就是函數的執行,函數的執行總會有一個源頭,這個源頭就是所謂的入口函數,CPU從入口函數開始執行從而形成一個執行流,只不過我們人為的給執行流起一個名字,這個名字就叫線程。
既然線程運行的本質就是函數的執行,那么函數運行時信息都保存在哪里呢?
答案就是棧區,每個線程都有一個私有的棧區,因此在棧上分配的局部變量就是線程私有的,無論我們怎樣使用這些局部變量都不管其它線程屁事。
圖片
線程私有的棧區就是線程自己家。
線程間共享數據
除了上一節提到的剩下的區域就是公共場合了,這包括:
- 用于動態分配內存的堆區,我們用C/C++中的malloc或者new就是在堆區上申請的內存
- 全局區,這里存放的就是全局變量
- 文件,我們知道線程是共享進程打開的文件
圖片
有的同學可能說,等等,在上一篇文章不是說還有代碼區和動態鏈接庫嗎?
要知道這兩個區域是不能被修改的,也就是說這兩個區域是只讀的,因此多個線程使用是沒有問題的。
在剛才我們提到的堆區、數據區以及文件,這些就是所有的線程都可以共享的資源,也就是公共場所,線程在這些公共場所就不能隨便浪了。
線程使用這些共享資源必須要遵守秩序,這個秩序的核心就是對共享資源的使用不能妨礙到其它線程,無論你使用各種鎖也好、信號量也罷,其目的都是在維護公共場所的秩序。
知道了哪些是線程私有的,哪些是線程間共享的,接下來就簡單了。
值得注意的是,關于線程安全的一切問題全部圍繞著線程私有數據與線程共享數據來處理,抓住了線程私有資源和共享資源這個主要矛盾也就抓住了解決線程安全問題的核心。
接下來我們看下在各種情況下該怎樣實現線程安全,依然以C/C++代碼為例,但是這里講解的方法適用于任何語言,請放心,這些代碼足夠簡單。
只使用線程私有資源
我們來看這段代碼:
int func() {
int a = 1;
int b = 1;
return a + b;
}
這段代碼在前面提到過,無論你在多少個線程中怎么調用什么時候調用,func函數都會確定的返回2,該函數不依賴任何全局變量,不依賴任何函數參數,且使用的局部變量都是線程私有資源,這樣的代碼也被稱為無狀態函數,stateless,很顯然這樣的代碼是線程安全的。
圖片
這樣的代碼請放心大膽的在多線程中使用,不會有任何問題。
有的同學可能會說,那如果我們還是使用線程私有資源,但是傳入函數參數呢?
線程私有資源+函數參數
這樣的代碼是線程安全的嗎?自己先想一想這個問題。
答案是it depends,也就是要看情況??词裁辞闆r呢?
1,按值傳參
如果你傳入的參數的方式是按值傳入,那么沒有問題,代碼依然是線程安全的:
int func(int num) {
num++;
return num;
}
這這段代碼無論在多少個線程中調用怎么調用什么時候調用都會正確返回參數加1后的值。
原因很簡單,按值傳入的這些參數是線程私有資源。
圖片
2,按引用傳參
但如果是按引用傳入參數,那么情況就不一樣了:
int func(int* num) {
++(*num);
return *num;
}
如果調用該函數的線程傳入的參數是線程私有資源,那么該函數依然是線程安全的,能正確的返回參數加1后的值。
但如果傳入的參數是全局變量,就像這樣:
int global_num = 1;
int func(int* num) {
++(*num);
return *num;
}
// 線程1
void thread1() {
func(&global_num);
}
// 線程2
void thread1() {
func(&global_num);
}
那此時func函數將不再是線程安全代碼,因為傳入的參數指向了全局變量,這個全局變量是所有線程可共享資源,這種情況下如果不改變全局變量的使用方式,那么對該全局變量的加1操作必須施加某種秩序,比如加鎖。
圖片
有的同學可能會說如果我傳入的不是全局變量的指針(引用)是不是就不會有問題了?
答案依然是it depends,要看情況。
即便我們傳入的參數是在堆上(heap)用malloc或new出來的,依然可能會有問題,為什么?
答案很簡單,因為堆上的資源也是所有線程可共享的。
圖片
假如有兩個線程調用func函數時傳入的指針(引用)指向了同一個堆上的變量,那么該變量就變成了這兩個線程的共享資源,在這種情況下func函數依然不是線程安全的。
改進也很簡單,那就是每個線程調用func函數傳入一個獨屬于該線程的資源地址,這樣各個線程就不會妨礙到對方了,因此,寫出線程安全代碼的一大原則就是能用線程私有的資源就用私有資源,線程之間盡最大可能不去使用共享資源。
如果線程不得已要使用全局資源呢?
使用全局資源
使用全局資源就一定不是線程安全代碼嗎?
答案還是。。有的同學可能已經猜到了,答案依然是要看情況。
如果使用的全局資源只在程序運行時初始化一次,此后所有代碼對其使用都是只讀的,那么沒有問題,就像這樣:
int global_num = 100; //初始化一次,此后沒有其它代碼修改其值
int func() {
return global_num;
}
我們看到,即使func函數使用了全局變量,但該全局變量只在運行前初始化一次,此后的代碼都不會對其進行修改,那么func函數依然是線程安全的。
圖片
但,如果我們簡單修改一下func:
int global_num = 100;
int func() {
++global_num;
return global_num;
}
這時,func函數就不再是線程安全的了,對全局變量的修改必須加鎖保護。
線程局部存儲
接下來我們再對上述func函數簡單修改:
__thread int global_num = 100;
int func() {
++global_num;
return global_num;
}
我們看到全局變量global_num前加了關鍵詞__thread修飾,這時,func代碼就是又是線程安全的了。
為什么呢?
其實在上一篇文章中我們講過,被__thread關鍵詞修飾過的變量放在了線程私有存儲中,Thread Local Storage,什么意思呢?
意思是說這個變量是線程私有的全局變量:
- global_num是全局變量
- global_num是線程私有的
圖片
各個線程對global_num的修改不會影響到其它線程,因為是線程私有資源,因此func函數是線程安全的。
說完了局部變量、全局變量、函數參數,那么接下來就到函數返回值了。
函數返回值
這里也有兩種情況,一種是函數返回的是值;另一種返回對變量的引用。
1,返回的是值
我們來看這樣一段代碼:
int func() {
int a = 100;
return a;
}
毫無疑問,這段代碼是線程安全的,無論我們怎樣調用該函數都會返回確定的值100。
2,返回的是引用
我們把上述代碼簡單的改一改:
int* func() {
static int a = 100;
return &a;
}
如果我們在多線程中調用這樣的函數,那么接下來等著你的可能就是難以調試的bug以及漫漫的加班長夜。。
圖片
很顯然,這不是線程安全代碼,產生bug的原因也很簡單,你在使用該變量前其值可能已經被其它線程修改了。因為該函數使用了一個靜態全局變量,只要能拿到該變量的地址那么所有線程都可以修改該變量的值,因為這是線程間的共享資源,不到萬不得已不要寫出上述代碼,除非老板拿刀架在你脖子上。
但是,請注意,有一個特例,這種使用方法可以用來實現設計模式中的單例模式,就像這樣:
class S {
public:
static S& getInstance() {
static S instance;
return instance;
}
private:
S() {}
// 其它省略
}
為什么呢?
因為無論我們調用多少次func函數,static局部變量都只會被初始化一次,這種特性可以很方便的讓我們實現單例模式。
最后讓我們來看下這種情況,那就是如果我們調用一個非線程安全的函數,那么我們的函數是線程安全的嗎?
調用非線程安全代碼
假如一個函數A調用另一個函數B,但B不是線程安全,那么函數A是線程安全的嗎?
答案依然是,要看情況。
我們看下這樣一段代碼,這段代碼在之前講解過:
int global_num = 0;
int func() {
++global_num;
return global_num;
}
我們認為func函數是非線程安全的,因為func函數使用了全局變量并對其進行了修改,但如果我們這樣調用func函數:
mutex l;
int funcA() {
l.lock();
func();
l.unlock();
}
雖然func函數是非線程安全的,但是我們在調用該函數前加了一把鎖進行保護,那么這時funcA函數就是線程安全的了,其本質就是我們用一把鎖間接的保護了全局變量。
再看這樣一段代碼:
int func(int *num) {
++(*num);
return *num;
}
一般我們認為func函數是非線程安全的,因為我們不知道傳入的指針是不是指向了一個全局變量,但如果調用func函數的代碼是這樣的:
void funcA() {
int a = 100;
func(&a);
}
那么這時funcA函數依然是線程安全的,因為傳入的參數是線程私有的局部變量,無論多少線程調用funcA都不會干擾到其它線程。
看了各種情況下的線程安全問題,最后讓我們來總結一下實現線程安全代碼都有哪些措施。
如何實現線程安全
從上面各種情況的分析來看,實現線程安全無外乎圍繞線程私有資源和線程共享資源這兩點,你需要識別出哪些是線程私有,哪些是共享的,這是核心,然后對癥下藥就可以了。
- 不使用任何全局資源,只使用線程私有資源,這種通常被稱為無狀態代碼
- 線程局部存儲,如果要使用全局資源,是否可以聲明為線程局部存儲,因為這種變量雖然是全局的,但每個線程都有一個屬于自己的副本,對其修改不會影響到其它線程
- 只讀,如果必須使用全局資源,那么全局資源是否可以是只讀的,多線程使用只讀的全局資源不會有線程安全問題。
- 原子操作,原子操作是說其在執行過程中是不可能被其它線程打斷的,像C++中的std::atomic修飾過的變量,對這類變量的操作無需傳統的加鎖保護,因為C++會確保在變量的修改過程中不會被打斷。我們常說的各種無鎖數據結構通常是在這類原子操作的基礎上構建的 。
- 同步互斥,到這里也就確定了你必須要以某種形式使用全局資源,那么在這種情況下公共場所的秩序必須得到維護,那么怎么維護呢?通過同步或者互斥的方式,這是一大類問題,我們將在《深入理解操作系統》系列文章中詳細闡述這一問題。
總結
怎么樣,想寫出線程安全的還是不簡單的吧,如果本文你只能記住一句話的話,那么我希望是這句,這也是本文的核心:
實現線程安全無外乎圍繞線程私有資源和線程共享資源來進行,你需要識別出哪些是線程私有,哪些是共享的,然后對癥下藥就可以了。