第 65 講:多線程(七):一些其它線程同步使用的類型
接下來我們來說一些有關(guān)線程同步里其它同步場景會用到的數(shù)據(jù)類型。這些數(shù)據(jù)類型其實(shí)也可以用在之前我們說過的那些場景,不過今天我們先講了它們之后,你就會對多線程的這些數(shù)據(jù)類型有一個大概的了解。你無需學(xué)習(xí)得非常棒,它們也不必全都馬上記下來。因?yàn)檫@些 API 都是你以后可以通過網(wǎng)絡(luò)查閱查找的,所以大概知道用在什么地方,怎么用基本上就可以了。
Part 1 Mutex
類型
這個 mutex 單詞可能你不認(rèn)識,它實(shí)際上也確實(shí)不是一個單詞。它是兩個單詞的合成,全稱 mutual exclusion。mutual 是“互相的”的意思,而 exclusion 是“排斥”的意思,所以 mutual exclusion 是“互相排斥的”的意思,中文則可以直接簡化翻譯成“互斥的”。這個詞取出了 mutual 的 mut 部分(mut 是詞根,不可拆分),而 exclusion 取出了 ex 部分,湊在一起就成了一個單詞。
Mutex 的意思是“互斥”,這么直接翻譯好像看不太懂。它的作用是為了讓東西有一個綁定,使得這個對象只能在這個時候一直被占用,而別的任何東西都不可以再次使用它。和 lock
有點(diǎn)像?我來舉個例子,介紹一下使用場景。
考慮一種情況。假設(shè)你現(xiàn)在運(yùn)行某個程序。這個程序你期望用戶最多只能打開一個,而不能一口氣開兩個三個甚至更多相同的這個程序。我們常常使用 Mutex
類型來完成這個任務(wù)。
程序在打開之前會觸發(fā)一個所謂的 Load
事件(這個 Load
事件是窗體所擁有的一種特有事件。這個可能需要你學(xué)習(xí)了比如 Windows Form、WPF 或者相關(guān)其它的窗體框架才能知道它的一些細(xì)節(jié)。這里我們不展開說明這個 Load
事件,只是告訴大家這里我們可以通過事件成員來 +=
一個回調(diào)函數(shù),防止用戶重復(fù)打開窗體)。我們可以在 Load
事件里追加一個執(zhí)行的回調(diào)函數(shù),代碼大概這么寫:
比如這樣的代碼。假設(shè)它被放在剛啟動程序的這個回調(diào)函數(shù)里。我們注意這里我們要用到 using
語句。Mutex
創(chuàng)建出來的實(shí)例,需要傳入三個參數(shù)。第一個參數(shù)我們不用了解得非常深入,它表示給調(diào)用線程一個權(quán)利掌控這個 Mutex
實(shí)例。我們這里只需要主線程管理就可以了,所以置為 false
。第二個參數(shù)是這個 Mutex
實(shí)例給取個名字。這個名字最好是獨(dú)一無二的、和別的程序獨(dú)立開的一個名字。我這里用了一下反射獲取了當(dāng)前運(yùn)行的程序集的全名(比如說我現(xiàn)在創(chuàng)建了一個解決方案項(xiàng)目名叫 HelloWorld
,那么這個全名就是這個 HelloWorld
)。第三個參數(shù)是一個 out
參數(shù),它通過構(gòu)造器調(diào)用后返回出一個結(jié)果,表示當(dāng)前程序是否有別的地方有所使用。
“別的地方有所使用”,好像不好理解是吧。你想想,能夠找到程序名都是一樣的(就那個 mutexName
),那顯然就說明程序開了倆了嘛。這個時候我們這個 out
參數(shù)就返回 false
表示有別的地方還有;如果是 true
的話,就說明這個是第一個程序。
之后,我們把參數(shù)結(jié)果接收一下,然后判斷這個 bool
結(jié)果。如果 bool
返回 false
,就說明有別的相同的程序?qū)嵗淮蜷_了,那么我們此時直接輸出錯誤信息,然后 return
直接退出程序。這大概就是這個 Mutex
類型的用法。
請?zhí)貏e注意,
Mutex
實(shí)例必須要用using
去釋放內(nèi)存(或者手動調(diào)用無參的實(shí)例方法ReleaseMutex
釋放),否則這個內(nèi)存可能會堆積在堆內(nèi)存里導(dǎo)致 GC 一直長時間無法回收它,出現(xiàn)內(nèi)存溢出的問題。
另外,這個 mutexName
可以追加一些別的字符串信息進(jìn)去。因?yàn)樽址侵鹱址容^的,所以如果說遇到相同字符串的話,就說明遇到了相同的東西,程序就會限制你無法繼續(xù)啟動程序。舉個例子,這個名字只限制了程序名,但如果我這個系統(tǒng)有多個用戶,我每一個用戶都想打開它的話,上面的 mutexName
因?yàn)橹挥谐绦蛎年P(guān)系,因此“按用戶只能啟動一個程序?qū)嵗钡臈l件就做不到了。這個時候你可能就需要繼續(xù)往 mutexName
參數(shù)追加額外的信息,比如用戶名等等,這樣可以防止啟動程序的時候重名。
順帶一提,如果要獲取計算機(jī)啟動的這個用戶賬戶的名字是什么,你可以使用
System
命名空間下有一個Environment
類型,它有一個靜態(tài)屬性UserDomainName
和UserName
可以獲取當(dāng)前活躍的(你現(xiàn)在登錄的這個)用戶名。其中UserName
屬性包含敏感信息,因此不建議你隨便使用,特別是直接把這個結(jié)果打印出來顯示。但你可以在后臺操作和使用這個屬性的值,比如拿來區(qū)分用戶賬戶信息等。
Part 2 Semaphore
類型
要說 Mutex
的話,Mutex
只能按單個實(shí)體來限制執(zhí)行。那么如果要按指定數(shù)量的話,Mutex
就不夠了,這個時候我們需要上 Semaphore
了。
Semaphore 確實(shí)是一個生僻詞,一般基本上只有計算機(jī)專業(yè)的同學(xué)會接觸到。這個詞的意思是“信號量”,這么說不夠直觀。你可以想象成一個數(shù)值信息,標(biāo)志同一個實(shí)例只能有多少個。如果打開一個窗口,就占用一個 semaphore 的單位,如果再打開一個窗口,又占用一個 semaphore 的單位;關(guān)閉一個窗口,就會釋放掉一個 semaphore 的單位。這樣的方式可以控制一個相同窗口可以打開多少個。類似這樣的概念的,我們可以使用 Semaphore
類型來完成。
單詞 semaphore 讀作 [?sem??f?r]。
這個稍微復(fù)雜一些,而且用途并不大,所以就不給大家展示例子了。你自己查資料吧。
Part 3 WaitHandle
類型
這個簡單說一下即可。WaitHandle
是剛才這兩個數(shù)據(jù)類型的基類型,它提供了一些方法可以用于控制線程同步。前文介紹的只是簡單的控制窗體是不是打開了,或者打開了幾個,這樣的行為。這些還不屬于線程同步的范疇。由于它們的基類型包含一些線程同步的方法,所以剛才那兩個數(shù)據(jù)類型都具有相同的方法集。
WaitHandle
類型提供了一系列線程同步的方法,先來說的是 WaitAll
方法。WaitAll
方法用于控制線程執(zhí)行,至少得等到所有子線程都執(zhí)行完成了之后,主線程才繼續(xù)運(yùn)行,否則會一直卡?。愃朴谶M(jìn)行了 while
的循環(huán)一樣)。來看一個例子。
簡單說一下這里的一些沒有介紹過的數(shù)據(jù)類型。首先程序進(jìn)入 Main
方法,調(diào)取了線程池的其中兩個線程來執(zhí)行相同的 DoTask
方法。這兩個線程均傳入了參數(shù),是由類型的靜態(tài)只讀字段 WaitHandles
提供的。這個字段是一個 WaitHandle
的數(shù)組,有兩個元素。這兩個元素實(shí)例化賦值的時候用的是一個叫做 AutoResetEvent
類型來實(shí)例化的。這個 AutoResetEvent
實(shí)例是用來通知和告知線程可以繼續(xù)執(zhí)行的一個通信用的類型。先來看后面的代碼我們再來逐步了解。
Main
方法里第 17 行執(zhí)行 WaitHandle
類型里的靜態(tài)方法 WaitAll
,傳入一個參數(shù),是剛才我們的靜態(tài)只讀字段 WaitHandles
,它的傳入是為了讓我們現(xiàn)在執(zhí)行的后臺線程序列和主線程關(guān)聯(lián)起來。因?yàn)槲覀冎挥惺褂妙~外的類型實(shí)例才可以和已經(jīng)開始執(zhí)行了的后臺線程產(chǎn)生關(guān)聯(lián)和通知。
接著,后面是輸出語句了。WaitAll
靜態(tài)方法通過使用 AutoResetEvent
的實(shí)例來控制主線程是否卡住不執(zhí)行下面的。如果所有在 WaitHandles
里給定的綁定上的后臺線程已經(jīng)全部都執(zhí)行完畢了,WaitAll
方法才會通過 WaitHandles
數(shù)組接收到信息,以放行主線程。
再來看 DoTask
方法。這個方法里面是把 state
強(qiáng)制轉(zhuǎn)換成了 AutoResetEvent
的實(shí)例。這是顯然的,因?yàn)槲覀儎偛旁陟o態(tài)只讀字段里就給了實(shí)例化的賦值語句都是 AutoResetEvent
類型的。所以直接強(qiáng)轉(zhuǎn)沒有問題(只是我之前寫的代碼為了嚴(yán)謹(jǐn)所有才讓大家經(jīng)常習(xí)慣于使用 is
和 as
來獲取數(shù)值,避免拋異常)。
接著,第 29 行代碼給 time
進(jìn)行了隨機(jī)賦值的過程,這個 time
用于了后面第 31 行的當(dāng)前線程暫停執(zhí)行多少毫秒的過程。這里是用的隨機(jī)數(shù)來生成了一個固定的數(shù)據(jù)。r
是前面的靜態(tài)只讀字段,是隨機(jī)數(shù)生成器 Random
類型的實(shí)例;而這里 r.Next
則會執(zhí)行產(chǎn)生 2 到 10 之間的隨機(jī)數(shù)。接著把這個隨機(jī)結(jié)果和 1000 相乘表示暫停多少毫秒(換算單位)。
第 30 行是打印顯示這個毫秒信息。第 32 行是在等待了指定的毫秒數(shù)之后,線程恢復(fù)執(zhí)行,于是調(diào)用了 AutoResetEvent
這個類型里的實(shí)例方法 Set
來通知剛才 WaitAll
方法里傳入的這個數(shù)組,我這個線程已經(jīng)好了。
一共我們開始了兩個后臺線程的執(zhí)行,所以 WaitAll
方法會直接等兩個線程都執(zhí)行完成才可以放行。剛才只是介紹的其中一個線程,但另外一個線程的邏輯也是調(diào)用 DoTask
方法,因此我不用再說一遍了。
最后,第 20 到第 23 行代碼輸出顯示程序執(zhí)行用了多久。這個結(jié)果你想想,因?yàn)槭嵌嗑€程,所以應(yīng)該是什么結(jié)果?是不是應(yīng)該是執(zhí)行較長的這個線程的用時,是整個程序的用時?因?yàn)槎嗑€程是并發(fā)執(zhí)行的,所以時間執(zhí)行差距只可能在這個第 29 行的 time
上。而這個 time
越長,等待時間就越長,那么這個線程結(jié)束就越晚。而線程是同時開始的,所以總用時肯定是較長的這個作為結(jié)果才對。
另外,WaitHandle
還有別的方法,比如 WaitOne
方法。這個方法只用讓程序等待一個線程執(zhí)行完成就可以了。如果相同的代碼,只改變 WaitAll
成 WaitOne
的話,那么程序就變成了只要有一個線程結(jié)束就放行的效果,因此這種情況下,運(yùn)行結(jié)果就是較短用時的線程作為整個程序的用時了。
Part 4 ThreadStaticAttribute
特性將字段線程級別私有化
多線程的最后一個內(nèi)容就是介紹一下,如何線程級別私有化字段了。
這個“線程級別私有化”是什么意思呢?考慮一種情況。靜態(tài)字段可以存儲一個全局客觀存在的有效數(shù)值并被多個線程訪問。但我無法做到像是實(shí)例那樣,一個實(shí)例擁有一個數(shù)值的情況。靜態(tài)字段是全局存在的,它只在程序啟動的那個時候初始化后就再也不會創(chuàng)建第二個相同的實(shí)例了。如果在多線程里,我不想這樣,我想一個線程享用一個靜態(tài)實(shí)例,而所有線程都享用的是同一個靜態(tài)字段的話,該怎么辦呢?
問題聽懂了嗎?就是一個靜態(tài)字段只有一個,但我想按線程區(qū)分靜態(tài)字段,使得它們按線程級別獨(dú)立開來而執(zhí)行期間互不影響其數(shù)值,應(yīng)該怎么做。
很簡單,給字段加一個 [ThreadStatic]
就可以了。
請看這個例子。這個例子里,我給 count
字段標(biāo)記上了 ThreadStaticAttribute
特性。雖然它是靜態(tài)字段,但在運(yùn)行程序過程之中,因?yàn)楸淮蛄藰?biāo)簽,所以程序會把這個靜態(tài)字段按線程獨(dú)立開來。也就是說,現(xiàn)在有兩個線程,一個主線程(Main
方法),一個前臺線程(Decrement
方法)在同時執(zhí)行獲取 count
的數(shù)值,并一個自增一個自減。
Thread.Join
實(shí)例方法這個我們之前沒說過。Join
方法表示我當(dāng)前調(diào)用的線程如果優(yōu)先比主線程執(zhí)行完畢就自動終結(jié)(歸并到主線程),然后程序就剩下一個線程了;如果沒有結(jié)束,那么線程就會繼續(xù)執(zhí)行,直到線程執(zhí)行完成,程序才可繼續(xù)執(zhí)行后面的內(nèi)容,有點(diǎn)類似于前面的Wait
相關(guān)方法的概念,只不過它是在Thread
類型里就自帶了一個方法。另請注意。這個方法的錯誤使用會阻塞主線程,因?yàn)樗枰尞?dāng)前執(zhí)行線程執(zhí)行完成后才可繼續(xù)讓父線程往下繼續(xù)。特別是這個語句被放在主線程的代碼段里(比如這個例子里就是這么干的),否則主線程會被卡死,跟之前說的 UI 里放
Thread.Sleep
一樣的道理。
按道理來說因?yàn)槭峭粋€實(shí)例,所以結(jié)果必然是不穩(wěn)定的:因?yàn)椴豢稍佻F(xiàn)性。但因?yàn)?ThreadStaticAttribute
特性的作用會讓兩個線程單獨(dú)享受不同的 count
字段的數(shù)值,因此相當(dāng)于在說,即使 count
是唯一的,但 Main
里和 Decrement
里使用的是不同的實(shí)例,只是它們初始數(shù)值都是 count
給的這個初始數(shù)值 0.01134。所以,結(jié)果應(yīng)該是執(zhí)行完 32767 遍循環(huán)后(short.MaxValue
是 32767),整數(shù)部分的話,一個輸出肯定是 32767,一個則是 -32767。來看結(jié)果:

有人問,欸,不對啊。這個 Main
方法里,初始數(shù)值是 0.01134,所以結(jié)果是對的;但負(fù)數(shù)咋不對,小數(shù)位的數(shù)值哪里去了?小數(shù)位不應(yīng)該是 98866 嗎(1 - 0.01134 = 0.98866)?
這是一個細(xì)節(jié)上的東西。C# 的多線程處理在對對象初始化數(shù)值的時候(特別是字段的時候)有這么一個神奇的約定:由于這個靜態(tài)字段標(biāo)記了 [ThreadStatic]
,所以字段會在程序集啟動的時候自動執(zhí)行初始化。雖然我們是把這個初始化數(shù)值的語句(即那個賦值語句,= 0.01134
)寫在了靜態(tài)字段自己的后面,但實(shí)際上它在后臺和底層代碼里,是在靜態(tài)構(gòu)造器里完成初始化的。
靜態(tài)構(gòu)造器是一個為了解決我們無法三兩句代碼初始化一個靜態(tài)數(shù)據(jù)成員的數(shù)值的時候會用到的一種初始化寫法:
它的寫法是一個 static
關(guān)鍵字,加上類型名,無參,然后帶上大括號,把初始化一個靜態(tài)數(shù)據(jù)成員的語句寫進(jìn)去。這個東西解決了很大一部分無法一句話或一個表達(dá)式初始化靜態(tài)字段成員的問題。
而在多線程里,即使你標(biāo)記 [ThreadStatic]
,也只能說明你這個數(shù)據(jù)會被多個線程獨(dú)立享有和使用,但初始數(shù)值并不是都相同的。也就是說,多線程享有的同一個靜態(tài)字段(標(biāo)記了這個特性的字段)只有和靜態(tài)構(gòu)造器執(zhí)行在同一個線程里,享有的這個字段的初始值才是由靜態(tài)構(gòu)造器初始化后的結(jié)果,而別的線程全部都是零初始值(即 default(T)
表達(dá)式的結(jié)果,T
取決于這個靜態(tài)字段自身的類型)。也就是說,就這個程序來說,只有主線程是跟靜態(tài)構(gòu)造器是一個線程,所以它拿到的 count
字段才是真的 0.01134,而別的線程,全部拿到的 count
的初始值都是 0。正因?yàn)槿绱?,你看到的副線程運(yùn)算下來,是沒小數(shù)位的,因?yàn)椤跏贾稻蜎]小數(shù)位。
Part 5 STAThreadAttribute
修飾主方法
在一些窗體程序里(比如 Windows Form),我們可能會使用 STAThreadAttribute
特性來修飾主方法。這個是干嘛的呢?用于和 COM 組件交互的時候。
Windows Form 是一個全新的窗體框架(它現(xiàn)在有點(diǎn)老了,這里只是相對于 COM 組件而言),它基本上用不到 COM 組件了。但你仍然會看到 Windows Form 程序的 Main
方法上標(biāo)記了這個特性。這是因?yàn)?Windows Form 仍然會在一些地方用到 COM 組件,比如消息框(MessageBox
類型)之類的。
在這些時候,如果你不標(biāo)記這個特性的話,該程序集在運(yùn)行的時候就無法知道我現(xiàn)在在基于什么框架在執(zhí)行。由于 COM 組件早期的實(shí)現(xiàn)問題,可以說是飽受詬病,比如 GC 早就分代算法了,而 COM 組件的框架的內(nèi)存回收機(jī)制還在用計數(shù)引用。
正是因?yàn)檫@些差別,我們必須給當(dāng)前 Main
方法標(biāo)記這個特性。這個特性是為啥標(biāo)記在方法上呢?這是因?yàn)檫\(yùn)行時交互的時候有所處理。Main
方法剛好是程序集的第一個啟動方法,因此它非常特殊。而 Windows Form 是單線程的,所以我們得標(biāo)記該特性進(jìn)行告知,這也是其中一個目的。
STAThreadAttribute
的 STA 是 Single-thread Apartment 的縮寫,即“單線程單元”的意思。與之對比的還有一個 Multi-thread Apartment,即多線程單元。Windows Form 是單線程的,所以我標(biāo)記別的特性,甚至不標(biāo)記特性的話,可能 Windows Form 的程序都無法正常工作。所以這個特性對于一些程序上相當(dāng)重要。
不過,微軟窗體程序做得還是非常棒的。到現(xiàn)在,微軟還搞出了 WPF(Windows 呈現(xiàn)基礎(chǔ))、UWP(統(tǒng)一 Windows 平臺)、MAUI(多平臺應(yīng)用程序 UI)、Windows UI(Windows 桌面端 UI)等不同的窗體框架。Windows Form 也逐漸走向棄用,所以我們這些內(nèi)容都只需要了解一下就行了,說不定以后也沒機(jī)會再接觸到它們了。
Part 6 總結(jié)
各位。非常感謝你看到這里。我們的整個教程到這里就全部結(jié)束了。非常感謝大家一如既往對 C# 教程的支持和熱愛。

哈哈哈哈別被騙了。確實(shí),我們的正統(tǒng) C# 語法就全部結(jié)束了,不過 C# 教程仍然會繼續(xù)更新,給大家介紹從 C# 2 到如今的新語法、新特性的使用方式。