第 59 講:多線程(一):多線程的基本概念及用法
多線程(Multi-threading)。多線程往往是眾多編程語(yǔ)言里較難的部分,所以經(jīng)常被許多編程語(yǔ)言放在最后來(lái)給大家介紹。多線程用好了可以讓代碼工作效率提升,但用不好可能會(huì)使得程序出現(xiàn)非常多奇奇怪怪的異常錯(cuò)誤導(dǎo)致程序穩(wěn)定性變差。所以,學(xué)會(huì)它們還需要自己下來(lái)多多思考和使用。
Part 1 何為多線程
多線程是一種機(jī)制,它允許我們把一個(gè)程序分成多個(gè)不同的線程(Thread)來(lái)并行(Parallel)執(zhí)行。當(dāng)然,這個(gè)說(shuō)法初學(xué)肯定是看不太懂的,下面我們來(lái)說(shuō)一下,什么是線程,什么是并行。
你可以把整個(gè)程序比喻成一個(gè)工廠。這個(gè)工廠生產(chǎn)零部件提供給別的地方使用。整個(gè)工廠一般情況下只有一個(gè)小組在完成人物。這個(gè)小組整體被我們稱為一個(gè)線程。換句話說(shuō),一個(gè)程序一般只有一個(gè)線程在運(yùn)作,完成我們想要做的任務(wù)。這個(gè)唯一的線程,我們稱為主線程(Main Thread)。為什么叫主線程呢,線程不就一個(gè)嗎,那還分個(gè)啥主次呢?
實(shí)際上,C# 仍然允許我們創(chuàng)建額外的線程(具象化理解就是,一個(gè)工廠允許招聘額外的小組團(tuán)隊(duì)來(lái)協(xié)同主隊(duì)伍,即主線程完成任務(wù));而其它額外的線程我們都稱為副線程、次要線程或者輔助線程(Auxiliary Thread)。副線程可以有很多,但主線程只有一個(gè),它們是這樣的關(guān)系。
只要包含兩個(gè)及以上的線程數(shù)量的話,我們就可以稱這個(gè)程序使用了多線程技術(shù)。
線程的出現(xiàn)是為了復(fù)雜的行為簡(jiǎn)單化,把同一個(gè)任務(wù)(如果能分化成小零件的話)分線程執(zhí)行,然后歸并到一起形成結(jié)果,加快程序執(zhí)行效率的一種非常棒的技術(shù)。但問(wèn)題就在于,線程一旦發(fā)出,就不容易控制了。你想想這個(gè)道理:我在主線程工作,一旦我把任務(wù)委派給各個(gè)副線程執(zhí)行的話,因?yàn)槲抑还苤骶€程,所以副線程就不再受我控制。如果代碼書(shū)寫(xiě)的內(nèi)容不合適的話,由于無(wú)法在運(yùn)行期間控制別的線程,就會(huì)導(dǎo)致程序出現(xiàn)意想不到的問(wèn)題。所以,對(duì)初學(xué)的朋友來(lái)說(shuō),多線程的弊大于利;但是有了豐富的經(jīng)驗(yàn)之后,多線程能夠幫助我們完成很復(fù)雜的任務(wù),因此對(duì)于他們來(lái)說(shuō)利大于弊。多線程技術(shù)就是這么一個(gè)東西。
Part 2 線程的分類
線程分兩類,前臺(tái)線程(Foreground Thread)和后臺(tái)線程(Background Thread)。
2-1 前臺(tái)線程
前臺(tái)線程是程序一旦發(fā)出就不受控制的線程,它不依賴什么,可以通過(guò) new Thread
的語(yǔ)句來(lái)生成:
假設(shè)我們有一個(gè) ThreadProc
方法(這個(gè) proc 是 process 的縮寫(xiě),如果后面單詞有這類型的縮寫(xiě)的話你先記一下吧……實(shí)際上老外也經(jīng)常用 proc 表示 process 的縮寫(xiě),用在方法里當(dāng)標(biāo)識(shí)符或者標(biāo)識(shí)符一部分),它是一個(gè)無(wú)參無(wú)返回值的方法。然后我們通過(guò)第一行代碼,使用 new Thread
生成 Thread
類型的對(duì)象。這個(gè) Thread
的實(shí)例默認(rèn)情況下,就是前臺(tái)線程。
這個(gè)實(shí)例化行為需要傳入一個(gè)參數(shù)。參數(shù)是一個(gè)叫做 ThreadStart
的委托類型。這個(gè)委托類型大概長(zhǎng)這樣:
是的,它無(wú)參無(wú)返回值。所以我們傳入的方法名必須匹配簽名,也得是無(wú)參無(wú)返回值。這樣的話,一會(huì)兒?jiǎn)?dòng)線程后,它就會(huì)執(zhí)行這個(gè)方法?,F(xiàn)在這么寫(xiě)是為了實(shí)例化的時(shí)候能夠知道我一會(huì)兒線程該做什么。這就是委托類型的方便之處,直接把執(zhí)行方法給參數(shù)化(Parameterize),就可以允許一些方法本身可以以后使用和調(diào)用了。然后,我們認(rèn)為第一行已經(jīng)實(shí)例化了一個(gè)前臺(tái)線程了,現(xiàn)在我們要啟動(dòng)它。很簡(jiǎn)單。Thread
類型里有一個(gè)叫 Start
的實(shí)例方法,你只需要使用 實(shí)例.Start()
就可以開(kāi)始執(zhí)行這個(gè)任務(wù)了。不過(guò)請(qǐng)你注意這幾點(diǎn)。
第一,此時(shí) foregroundThread
是一個(gè)單獨(dú)的線程,一旦發(fā)出就不受控制。如果代碼寫(xiě)得不合適的話,這個(gè)線程怎么都不會(huì)得到終止或者停止運(yùn)行(比如死循環(huán))。
第二,線程是單獨(dú)執(zhí)行的,因此如果你的主線程不管有沒(méi)有終止(比如從 Main
方法里退出了之類),隔壁那個(gè) foregroundThread
都會(huì)不停運(yùn)作。換句話說(shuō),這倆線程是并行(Parallel)執(zhí)行的。
第三,只要程序剩下至少一個(gè)線程沒(méi)有終止掉,程序就不算終止掉。換句話說(shuō),主線程和副線程雖然有主副之分,但它們的行為和目的都是為了程序執(zhí)行更快和分層并行執(zhí)行,所以在線程級(jí)別來(lái)說(shuō)沒(méi)有區(qū)別——它們都是一個(gè)單獨(dú)的線程。我們給它們美其名曰主線程和副線程只是為了區(qū)別和表達(dá)線程之間的關(guān)系。哪怕只剩下一個(gè)副線程執(zhí)行,但主線程已經(jīng)結(jié)束,程序都不算終止。如果副線程陷入死循環(huán),因?yàn)槌绦驘o(wú)法自動(dòng)終止,所以,請(qǐng)任務(wù)管理器伺候。
根據(jù)這個(gè)第二點(diǎn)來(lái)說(shuō),假設(shè)我主線程在 Start
了一個(gè)前臺(tái)線程之后再寫(xiě)了執(zhí)行自己的語(yǔ)句的話,比如這樣:
比如這樣。Main
里在第 4 行調(diào)用 Start
創(chuàng)建了一個(gè)單獨(dú)的線程單獨(dú)執(zhí)行輸出單數(shù)的行為,而在 Main
里也有一個(gè) for
循環(huán),在輸出雙數(shù)。因?yàn)樗鼈冏罱K都使用了 Console.WriteLine
把數(shù)據(jù)打在屏幕上,所以它們是共用同一個(gè)控制臺(tái)的。此時(shí),你看到的程序結(jié)果是如何的呢?你可以猜想一下。
實(shí)際上啥樣的輸出都可能。你甚至可能看到先出來(lái)單數(shù)(ThreadProc
方法里的輸出)然后才是雙數(shù)(Main
方法里的輸出)。而且順序是不確定的,可能并不會(huì)按照 0、1、2、3、4 這樣的序列,而可以是 1、0、3、2、4 或者 0、1、2、4、6 之類的。這個(gè)數(shù)據(jù)輸出的順序可以認(rèn)為是隨機(jī)的,但它并不是隨機(jī)算法生成的隨機(jī)序列,而是不可控的隨機(jī)序列。因?yàn)榫€程單獨(dú)執(zhí)行后,電腦會(huì)調(diào)配主線程和副線程的執(zhí)行關(guān)系,但因?yàn)樗鼈z是“并肩作戰(zhàn)”、“并駕齊驅(qū)”的,所以系統(tǒng)會(huì)認(rèn)為它倆既然都在做任務(wù),干脆就讓隨便哪個(gè)先哪個(gè)后都無(wú)所謂,因此我們是不知道主線程先得到輸出還是副線程先得到輸出的。
這里說(shuō)話其實(shí)不太嚴(yán)謹(jǐn),邏輯大體上是對(duì)的。電腦會(huì)調(diào)度(Schedule)同一個(gè)程序里的不同線程,線程在底層是有優(yōu)先級(jí)等等區(qū)分執(zhí)行高低的量的,但是像是剛才的這種聲明形式下,主線程和副線程是一樣的優(yōu)先級(jí),所以程序完全不考慮誰(shuí)先誰(shuí)后。
有人可能會(huì)問(wèn)我,
Main
方法不就是主線程么?你在Start
副線程的時(shí)候,主線程不是已經(jīng)處于運(yùn)行狀態(tài)了么?那不就說(shuō)明主線程已經(jīng)開(kāi)始運(yùn)行了一段時(shí)間了,那不還是Main
這個(gè)主線程先執(zhí)行?實(shí)際上我這里說(shuō)法是指第 6 行開(kāi)始的這個(gè)for
循環(huán)這部分,和ThreadProc
方法的這個(gè)for
作對(duì)比的。我說(shuō)的意思其實(shí)是,這里第 6 行代碼和第 13 行的這倆for
的執(zhí)行開(kāi)始先后順序是不知道的。可能有些人學(xué)過(guò)多線程,會(huì)覺(jué)得我說(shuō)錯(cuò)了。這個(gè)前臺(tái)線程是有比如
Abort
方法來(lái)終止的。實(shí)際上在 .NET 5 開(kāi)始(實(shí)際上我們這份教程也是基于 .NET 5 的 API 開(kāi)始給大家介紹的),Abort
方法已經(jīng)不再允許使用,并永遠(yuǎn)拋出PlatformNotSupportedException
異常表示在 .NET 平臺(tái)根本不支持使用此方法。所以,此時(shí)的前臺(tái)線程不允許和無(wú)法得到有效終止;而在以前的 API 里,確實(shí)擁有Abort
方法來(lái)終止,寫(xiě)法是foregroundThread.Abort()
,但它的本質(zhì)也是拋異常來(lái)掐斷程序執(zhí)行,然后你需要在foreground.Abort()
的外層包裹一個(gè)try
-catch (ThreadAbortedException)
來(lái)避免程序?qū)用娴闹袛?。而且,我相?dāng)不建議使用這個(gè)方法,因?yàn)樗槐WC線程安全性,這個(gè)后面我們會(huì)提到。
2-2 后臺(tái)線程
前臺(tái)線程是一種開(kāi)始就不受控制的存在。這種東西就非常不好控制,也不安全。所以有了后臺(tái)線程的概念。
后臺(tái)線程是一種受程序自身控制和約束的線程。這種線程會(huì)在主線程終止后自動(dòng)終止,不論你這個(gè)線程做沒(méi)做完。當(dāng)然,如果副線程已經(jīng)完成,主線程終止也跟你這個(gè)線程沒(méi)關(guān)系了(畢竟你這個(gè)副線程現(xiàn)在已經(jīng)結(jié)束了)。后臺(tái)線程從這個(gè)角度來(lái)說(shuō),它比前臺(tái)線程更棒,因?yàn)槿绻笈_(tái)線程陷入死循環(huán)的話,我們可以在主線程設(shè)定執(zhí)行時(shí)間。假設(shè)在調(diào)用 Start
方法后開(kāi)始計(jì)時(shí)。如果副線程執(zhí)行了超過(guò) 10 秒的時(shí)間,我們可以強(qiáng)制終止掉副線程。
首先,我們介紹一下如何實(shí)例化一個(gè)后臺(tái)線程。后臺(tái)線程和前臺(tái)線程的聲明過(guò)程只差一句話:
是的,我們就在第二行代碼上加了這么一句話:實(shí)例.IsBackground = true;
。所有的 Thread
類型實(shí)例都帶有這樣的屬性,它就表示你現(xiàn)在這個(gè)線程是不是后臺(tái)線程。如果是 true
就是后臺(tái)線程,如果是 false
則是前臺(tái)線程。按默認(rèn)情況下來(lái)說(shuō),線程都是前臺(tái)線程,也就是說(shuō)這個(gè)屬性默認(rèn)是 false
。
注意,我們必須在 Start
方法執(zhí)行調(diào)用之前給 IsBackground
賦值,不要寫(xiě)反了。如果你先調(diào)用了 Start
了才來(lái)給 IsBackground
賦值的話,是不會(huì)修改和變更線程信息的,而且會(huì)引起線程級(jí)別的異常,因此必須先 IsBackground
屬性賦值,然后才是 Start
方法。
Part 3 父線程和子線程
這只是一個(gè)概念。從線程 A 里面創(chuàng)建了別的線程的話,此時(shí)線程 A 稱為父線程(Parent Thread),而別的線程就稱為子線程(Child Thread)。
父和子的翻譯方式對(duì)應(yīng)不上 parent 和 child 這兩個(gè)單詞,這可能是文獻(xiàn)的翻譯錯(cuò)誤,但將錯(cuò)就錯(cuò)了。在計(jì)算機(jī)科學(xué)里我們經(jīng)常把高一級(jí)別的東西稱為父級(jí),而低一級(jí)別的叫子級(jí)。但在英語(yǔ)文獻(xiàn)里,為了減少對(duì)性別的特征闡述,采用的是 parent(雙親)和 child(孩子)這兩個(gè)單詞,但中文里翻譯成了父和子,而不是雙親和孩子。比如數(shù)據(jù)結(jié)構(gòu)里二叉樹(shù)里的概念父節(jié)點(diǎn)(也可以寫(xiě)成結(jié)點(diǎn),下同)和子節(jié)點(diǎn)(結(jié)點(diǎn)),雖然也有叫雙親節(jié)點(diǎn)(結(jié)點(diǎn))和孩子節(jié)點(diǎn)(結(jié)點(diǎn))的,但這種說(shuō)法第一是沒(méi)有父子節(jié)點(diǎn)(結(jié)點(diǎn))說(shuō)得多,二來(lái)是不夠正式化。
然后,請(qǐng)不要對(duì)這種術(shù)語(yǔ)詞上進(jìn)行較真。翻譯成父和子并非性別上的歧視,只是一個(gè)翻譯問(wèn)題。
比如,我在主線程里創(chuàng)建了一個(gè) Thread
類型的實(shí)例。不管這個(gè)實(shí)例是前臺(tái)還是后臺(tái)的,因?yàn)槭侵骶€程里創(chuàng)建的,所以我們稱主線程叫父線程,而創(chuàng)建出來(lái)的這個(gè) Thread
實(shí)例則是子線程。
Part 4 其它關(guān)于線程的慣用詞匯
有些其它的詞語(yǔ)我們需要單獨(dú)介紹,因?yàn)樗鼈円脖容^重要。它們不是術(shù)語(yǔ)詞,所以可能找不到對(duì)應(yīng)和合適的英語(yǔ)術(shù)語(yǔ)詞,但是這里口語(yǔ)上用得非常多,所以提一嘴。
開(kāi)一個(gè)線程:創(chuàng)建一個(gè)線程。
掐斷、掐掉、殺掉一個(gè)線程:終止線程。一般用于終止后臺(tái)線程上。
中斷、阻塞線程:讓線程的執(zhí)行操作暫時(shí)中斷和凍結(jié)。中斷并非終止,而更多被理解成暫停。