1. 程式人生 > 實用技巧 >深入學習:三分鐘快速教會你編寫執行緒安全程式碼!

深入學習:三分鐘快速教會你編寫執行緒安全程式碼!

相信有很多同學在面對多執行緒程式碼時都會望而生畏,認為多執行緒程式碼就像一頭難以馴服的怪獸,你制服不了這頭怪獸它就會反過來吞噬你。

誇張了哈,總之,多執行緒程式有時就像一潭淤泥,走不進去退不出來。

可這是為什麼呢?為什麼多執行緒程式碼如此難以正確編寫呢

從根源上思考

關於這個問題,本質上是有一個詞語你沒有透徹理解,這個詞就是所謂的執行緒安全,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,也就是要看情況。看什麼情況呢?

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函式:

int funcA() {
  mutex l;
   
  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++會確保在變數的修改過程中不會被打斷。我們常說的各種無鎖資料結構通常是在這類原子操作的基礎上構建的
  • 同步互斥,到這裡也就確定了你必須要以某種形式使用全域性資源,那麼在這種情況下公共場所的秩序必須得到維護,那麼怎麼維護呢?通過同步或者互斥的方式.

總結

怎麼樣,想寫出執行緒安全的還是不簡單的吧,如果本文你只能記住一句話的話,那麼我希望是這句,這也是本文的核心:

實現執行緒安全無外乎圍繞執行緒私有資源和執行緒共享資源來進行,你需要識別出哪些是執行緒私有,哪些是共享的,然後對症下藥就可以了

希望本文對大家編寫多執行緒程式有幫助。

寫在最後

歡迎大家關注我的公眾號【風平浪靜如碼】,海量Java相關文章,學習資料都會在裡面更新,整理的資料也會放在裡面。

覺得寫的還不錯的就點個贊,加個關注唄!點關注,不迷路,持續更新!!!