第 62 講:多線程(四):并行迭代
接著我們來(lái)說(shuō)一個(gè)多線程解決的一種大的類型的算法模型:并行迭代(Parallel Iteration)。
并行在英語(yǔ)里用的是 parallel 一詞。這個(gè)詞語(yǔ)其實(shí)你在數(shù)學(xué)上就學(xué)過(guò),它其實(shí)是“平行的”的意思,比如表示兩條直線平行之類的。在多線程里,因?yàn)槎鄠€(gè)線程在宏觀上是一起在執(zhí)行的,而且誰(shuí)也不干涉誰(shuí),因此我們可以認(rèn)為是一種“平行的”狀態(tài),因此,在多線程里我們也稱這種現(xiàn)象叫并行。只是在中文里我們沒(méi)有翻譯成“平行迭代”,但意思上是一致的。
Part 1 引例:用 for
循環(huán)計(jì)算圓周率值
為了能夠更加詳細(xì)讓大家了解多線程處理這種情況的情景,我們給大家介紹一個(gè)例子讓大家明白,什么是并行迭代。
如果我現(xiàn)在想要計(jì)算圓周率的數(shù)值的話,顯然我們使用普通的 for
循環(huán)或者別的循環(huán)類型,應(yīng)該可以做到(當(dāng)然這需要一點(diǎn)數(shù)學(xué)知識(shí))。當(dāng)然,我這里我不是給大家介紹數(shù)學(xué)知識(shí)的,所以現(xiàn)在我們要使用多線程來(lái)完成這個(gè)任務(wù),此時(shí)我只給大家展示多線程語(yǔ)法這一塊的部分的內(nèi)容。
先來(lái)看一個(gè)寫(xiě)法。我這里給的一個(gè)叫 PiCalculator.Calculator
的方法就是一個(gè)專門(mén)計(jì)算求圓周率數(shù)值的方法。
我們運(yùn)行起來(lái)。我們?cè)诘却欢螘r(shí)間后,得到了正確的結(jié)果:

程序沒(méi)有問(wèn)題,并且計(jì)算出了正確的圓周率數(shù)值,只是慢了一點(diǎn)。
emmm……我想凡爾賽一波……我小時(shí)候背過(guò)圓周率,所以我至少知道前面 100 位是不是對(duì)的。所以不用查表我就知道這些到底對(duì)不對(duì)(斜眼笑)
這里我要告訴大家的是,這里我使用的圓周率計(jì)算算法是分塊計(jì)算算法。換句話說(shuō),這個(gè)算法允許我們把我們計(jì)算的總長(zhǎng)度拆成一塊一塊的,然后每一個(gè)部分都各自分開(kāi)計(jì)算,并且它們?cè)谟?jì)算中互相都不受影響。這個(gè)數(shù)學(xué)算法實(shí)際上是存在的,你可以自己去查閱一下這個(gè)相關(guān)的數(shù)學(xué)資料來(lái)了解這種互不影響的分塊求圓周率算法。
可以從代碼上看到,我給出的常量 TotalDigits
就是計(jì)算圓周率的總位數(shù)(給的是 100),而 BatchSize
給的是每一個(gè)分塊算法的塊是計(jì)算多少個(gè)位數(shù)(給的是 10)。也就是說(shuō),我這個(gè)算法 for
循環(huán)要執(zhí)行循環(huán) 10 次,是這樣的邏輯關(guān)系。順帶一說(shuō),每一次 +=
運(yùn)算符的右側(cè)計(jì)算出來(lái)的結(jié)果都是一個(gè) string
的實(shí)例,我們用它表示出的是當(dāng)前數(shù)據(jù)段(多少小數(shù)位到多少小數(shù)位)的結(jié)果。
不過(guò),因?yàn)槲沂褂玫氖?for
循環(huán),因此我必須要等到第一個(gè)部分算完后,才能進(jìn)入下一個(gè)部分的計(jì)算。這樣就比較慢,因?yàn)槲颐黠@知道的是,數(shù)學(xué)知識(shí)告訴我們圓周率是可以拆分計(jì)算還是互不影響的。因此我可以考慮讓每一個(gè)部分都獨(dú)立開(kāi)來(lái),反正這個(gè)算法也互不影響。我干脆就開(kāi)線程讓每個(gè)部分全部都獨(dú)立開(kāi)線程跑起來(lái),這樣就可以大大縮短計(jì)算時(shí)間,10 倍的計(jì)算時(shí)間可以通過(guò)多線程技術(shù)縮減成只需要 1 倍的時(shí)間。
不過(guò)問(wèn)題在于,我們?nèi)绾蝿?chuàng)建線程來(lái)完成這個(gè)任務(wù)呢?最容易想到的辦法就是通過(guò)開(kāi) 10 個(gè)線程來(lái)完成任務(wù)。不過(guò)有點(diǎn)麻煩,于是我們考慮使用線程池來(lái)完成。
Part 2 使用線程池完成并行迭代
我們大體思路規(guī)劃好了,現(xiàn)在是改代碼的時(shí)候了!
2-1 改寫(xiě) pi += ...
部分
我們第一個(gè)要注意的點(diǎn)是,我們?cè)谑褂枚嗑€程的時(shí)候我們并不能保證計(jì)算的順序性。因?yàn)槲覀冎熬驼f(shuō)過(guò),多線程會(huì)丟失數(shù)據(jù)的順序計(jì)算的性質(zhì),使得得到的結(jié)果不可再現(xiàn)。
那么,解決這個(gè)的辦法就是預(yù)先制造一個(gè)“池子”存儲(chǔ)結(jié)果。等待結(jié)果計(jì)算完成后我們就可以輸出它們了。這個(gè)所謂的“池子”就是一個(gè)數(shù)組就行。
我們加上 string[] piSeries
的數(shù)組定義語(yǔ)句:
這個(gè)很好理解。因?yàn)槲覀冎苯佣x piSeries
是一個(gè)數(shù)組,每一個(gè)元素都是一個(gè) string
,因?yàn)橹熬驼f(shuō)過(guò)計(jì)算結(jié)果是一個(gè)個(gè)的 string
字符串,然后拼接起來(lái)的。我們這次干脆就預(yù)先創(chuàng)建出來(lái)一個(gè)多線程用的 piSeries
數(shù)組,存儲(chǔ)這些結(jié)果。當(dāng)然,初始的時(shí)候可以直接 new
出來(lái)不賦值,因?yàn)橐膊恍枰o出每一個(gè)元素的初始數(shù)值。
接著,我們改寫(xiě) for
循環(huán)的循環(huán)體里面的 +=
這個(gè)運(yùn)算表達(dá)式。因?yàn)檫@次我們用數(shù)組了,所以我們不用拼接字符串的行為,而是把計(jì)算結(jié)果嘗試賦值到數(shù)組里去。于是代碼就會(huì)改成這樣:
這也很好理解,對(duì)吧。那么這樣就完成了第一步改寫(xiě)。
2-2 將循環(huán)的這段代碼改成線程池調(diào)用
顯然現(xiàn)在還不是多線程的機(jī)制。所以我們還需要使用線程池的基本手段:調(diào)用 QueueUserWorkItem
方法。
然后,這個(gè)傳入的委托類型里面的方法名是 F
。我們接下來(lái)就應(yīng)該是把剛才的 piSeries
計(jì)算賦值語(yǔ)句抄寫(xiě)到 F
方法里。下面創(chuàng)建 F
方法。
抄寫(xiě)完成。
2-3 利用 state
參數(shù)交互 piSeries
和 i
的值
不過(guò)問(wèn)題來(lái)了?,F(xiàn)在 piSeries
和 i
在循環(huán)里是不存在的內(nèi)容。

可以發(fā)現(xiàn)有這樣的問(wèn)題。這怎么辦呢?我們當(dāng)然是考慮使用這個(gè) state
參數(shù)了。因?yàn)槲覀冎熬驼f(shuō)過(guò),state
參數(shù)可以交互從外部傳入的變量、表達(dá)式等等數(shù)據(jù)信息。可問(wèn)題是,我現(xiàn)在有兩個(gè)參數(shù),這怎么辦呢?
不知道你想到了嗎?我們知道一點(diǎn),state
為了兼容很多賦值情況,所以使用了 object
類型,這個(gè)所有類型的基類型。那么除了我們自定義的類、結(jié)構(gòu)、接口等數(shù)據(jù)類型外,是不是還存在一種可以存儲(chǔ)多個(gè)數(shù)值的情況?是的,數(shù)組。
我們將兩個(gè)數(shù)據(jù) piSeries
和 i
按照一個(gè)數(shù)組形式傳入進(jìn)去。
state
參數(shù)的位置上。是的,QueueUserWorkItem
這里稍微啰嗦一點(diǎn)??赡苣銜?huì)非常不習(xí)慣
new object[] { piSeries, i }
的表達(dá)式的內(nèi)容。你可能會(huì)覺(jué)得,我一個(gè)piSeries
和一個(gè)i
兩個(gè)連數(shù)據(jù)類型都不同的變量居然可以當(dāng)成元素賦值給new object[]
這樣的數(shù)組初始化器里。實(shí)際上這樣做是有道理的,而且語(yǔ)法上也行得通。因?yàn)樗蓄愋停ǔ酥羔槪┒际?object
類型的子類型,因此int
難道就不是object
的子類型了嗎?string[]
就不是object
的子類型了嗎?string[]
的基類型是Array
,但Array
的基類型還是object
的啊,可別忘了這一點(diǎn)。正是因?yàn)檫@樣的兼容,所以所有類型往“模糊類型”上轉(zhuǎn)換的話都是隱式轉(zhuǎn)換,所以這里我們的嚴(yán)謹(jǐn)語(yǔ)法應(yīng)該是
new object[] { (object)i, (object)(Array)piSeries }
的。不過(guò),由于轉(zhuǎn)型是正常的(換句話說(shuō),這種轉(zhuǎn)換是廢話,因?yàn)槲覀儚倪壿嬀湍苊靼走@樣是正確的轉(zhuǎn)換,所以編譯器難道就不知道了嗎),因此 C# 知道我們這么轉(zhuǎn)型是正確的,所以不用寫(xiě)出來(lái)這些強(qiáng)制轉(zhuǎn)換的符號(hào)。從另外一個(gè)角度來(lái)說(shuō),因?yàn)?
int
和string[]
都能兼容的數(shù)據(jù)類型只能是object[]
了, 因此我們這里必須構(gòu)造的是new object[] { ... }
;另外,希望你一定不要把這個(gè)賦值過(guò)程和之前簡(jiǎn)單說(shuō)過(guò)的數(shù)組協(xié)變的概念搞混淆。這個(gè)不是數(shù)組協(xié)變。
接著,我們需要改寫(xiě) F
方法里的代碼。這個(gè)代碼怎么改寫(xiě)呢?我們完全可以認(rèn)為,F
方法的參數(shù) state
就是我們這里的 new object[] { piSeries, i }
的數(shù)值構(gòu)成的數(shù)組了,但是,我們?yōu)榱藭?shū)寫(xiě)的語(yǔ)法嚴(yán)謹(jǐn)一些,我們還是寫(xiě)上一些 is
的類型判斷吧。
首先看 state
是不是 object[]
。如果不是的話退出方法,否則強(qiáng)制轉(zhuǎn)換成 object[]
后繼續(xù)判斷。接著我們來(lái)看里面的兩個(gè)元素是不是第一個(gè)一定是 string[]
而第二個(gè)一定是 int
對(duì)象。因?yàn)槲覀冏铋_(kāi)始傳參的時(shí)候是第一個(gè) piSeries
(string[]
類型的)第二個(gè)是 i
(int
類型的),所以順序和數(shù)組長(zhǎng)度都需要判斷一下。
判斷完成后,我們強(qiáng)制轉(zhuǎn)換過(guò)去,然后開(kāi)始接收數(shù)值。最后那一句話就是我們最開(kāi)始的那個(gè)賦值語(yǔ)句。至此我們基本上就完成了代碼的修改。
2-4 輸出結(jié)果信息
是的,我們現(xiàn)在最后來(lái)把結(jié)果給輸出顯示一下。不過(guò)有一個(gè)小問(wèn)題是,我現(xiàn)在是多線程計(jì)算的,所以每一個(gè)數(shù)值不知道啥時(shí)候計(jì)算完成,我至少得等著所有的結(jié)果都計(jì)算完畢后才可顯示它們(也就是最后才對(duì) piSeries
數(shù)組作拼接,然后輸出結(jié)果)。可我把握不了這個(gè)度啊,我不知道什么時(shí)候這 10 段小數(shù)位才都能成功全部計(jì)算完成。
目前我們的辦法可以用 Thread.Sleep
方法來(lái)卡住主線程,然后等待一段時(shí)間后,我們就可以大概認(rèn)為都計(jì)算完成了,接著才把輸出語(yǔ)句寫(xiě)出來(lái)。這一點(diǎn)和上一節(jié)的 Thread.Sleep
用法是一致的。這里我們大概估計(jì)一下,大概 1 秒就可以完成任務(wù),因此我們直接書(shū)寫(xiě)一句 Thread.Sleep(1000)
,然后才是顯示字符串結(jié)果。
Thread.Sleep
,等待完成后才是輸出。不要寫(xiě)反了。原因不用我再說(shuō)了吧。
我們來(lái)看一下整個(gè)程序的完整代碼。
我們運(yùn)行一下程序。

可以看到結(jié)果仍然是正確的。那么至此我們就完成了整個(gè)并行迭代的代碼的書(shū)寫(xiě)。
哦對(duì)。代碼缺少了
PiCalculator
的部分。如果你確實(shí)需要圓周率計(jì)算的代碼,可以查看拷貝賦值代碼。
Part 3 稍微總結(jié)一下
本節(jié)內(nèi)容給大家介紹了如何使用并行迭代的代碼。其實(shí),C# 在有了泛型之后,就開(kāi)始各種花里胡哨的 API 了,所以類似上面的這些功能,可能一句代碼就可以搞定了。不過(guò)因?yàn)檎Z(yǔ)法的順序性,因?yàn)闆](méi)有講泛型,所以還講不了那個(gè)內(nèi)容。不過(guò)我們直接憑空實(shí)現(xiàn)了自己的并行迭代的功能,也是相當(dāng)了不起呢。
一句話總結(jié):并行迭代基本上等價(jià)于
for
或者foreach
循環(huán)里套上多線程語(yǔ)句的調(diào)用。只不過(guò),我在這里沒(méi)有介紹foreach
的情況,因?yàn)椤瓕?shí)在是不知道什么例子比較合適;而且就算是找到例子了,實(shí)現(xiàn)手段、方式基本上和for
循環(huán)的類型是一樣的,可能差別就只是換一個(gè)關(guān)鍵字(從for
改成foreach
這樣),所以我也沒(méi)有必要單獨(dú)講一下這個(gè)。不過(guò),并行迭代也不一定只是使用一個(gè)單純的
for
循環(huán)而已,它可能會(huì)嵌套for
循環(huán)之類的,比如兩層甚至更多層的循環(huán)。這里只是給大家介紹一下如何書(shū)寫(xiě)并行迭代的代碼,所以沒(méi)有考慮使用復(fù)雜的例子。
下一節(jié)內(nèi)容我們將開(kāi)始探討線程的同步問(wèn)題。線程同步難度可能比多線程本身還要大,所以要打起精神來(lái),這是我們目前語(yǔ)法板塊的最后一個(gè)部分了。在完成多線程的講解之后,我們將開(kāi)始 C# 新語(yǔ)法的板塊,從 C# 2 到目前 C# 的版本,所有語(yǔ)法都會(huì)給大家介紹。