近期跑步的時(shí)間少了,寫作靈感盡失店归,已經(jīng)好久沒有更新文章了。所幸前段時(shí)間看了一些關(guān)于多線程的資料酪我,故而有了這篇文章消痛,也算是一個(gè)階段性的總結(jié)吧。
一. 先普及一下知識(shí)
開始扯淡之前先來貼一些關(guān)于線程的文字都哭。
1)進(jìn)程與線程有何區(qū)別
有人在StackOverflow概括得比較全面秩伞,線程其實(shí)就是輕量級(jí)的進(jìn)程逞带。一般進(jìn)程都有自己的一部分獨(dú)立的系統(tǒng)資源,彼此是隔離的纱新。為了能使不同的進(jìn)程之間能夠互相訪問資源并進(jìn)行協(xié)調(diào)工作展氓,則需要通過進(jìn)程間的通信。而線程則采用共享內(nèi)存空間的形式脸爱,多個(gè)線程可以共享同一份內(nèi)存空間遇汞。相比起進(jìn)程,雖然線程看起來占用內(nèi)存空間少了簿废,但是卻會(huì)出現(xiàn)資源競(jìng)爭(zhēng)的情況空入。
2)并行與并發(fā)
采用多線程或者多進(jìn)程的方式進(jìn)行開發(fā)的方式稱為并發(fā),有些語言甚至可以充分利用電腦CPU的多核特征實(shí)現(xiàn)并行捏鱼。那么并行與并發(fā)又有何區(qū)別执庐?這里有比較有意思的解答Github。
簡(jiǎn)單來講导梆,并行就是多個(gè)任務(wù)同時(shí)執(zhí)行轨淌,而并發(fā)則是同一時(shí)間段多個(gè)任務(wù)交替執(zhí)行。并行強(qiáng)調(diào)的是同一時(shí)間點(diǎn)兩個(gè)任務(wù)同時(shí)執(zhí)行看尼,而并發(fā)強(qiáng)調(diào)的是同一時(shí)間段兩個(gè)任務(wù)同時(shí)執(zhí)行递鹉。
二. Ruby中的高并發(fā)
傳統(tǒng)意義上的Ruby--MRI,有Thread這個(gè)類藏斩,這樣看來它是支持多線程的躏结,我們可以用這個(gè)類來創(chuàng)建多個(gè)線程實(shí)例。然而在MRI里面我們卻只能實(shí)現(xiàn)并發(fā)狰域,它并不能活用電腦的CPU多核特征實(shí)現(xiàn)并行操作媳拴。下面我分場(chǎng)景來講解一下相關(guān)的概念。
1) GIL的約束
得益于GIL(全局解析器鎖)的存在兆览,MRI只能夠?qū)崿F(xiàn)并發(fā)屈溉,并無法充分利用CPU的多核特征,實(shí)現(xiàn)并行任務(wù)抬探,進(jìn)而減少程序運(yùn)行時(shí)間子巾。
在MRI里面線程只有在拿到GIL鎖的時(shí)候才能夠運(yùn)行,即便我們創(chuàng)建了多個(gè)線程小压,本質(zhì)上也就只有一個(gè)線程實(shí)例能夠拿到GIL线梗,如此看來某一時(shí)刻便只能有一個(gè)線程在運(yùn)行。
可以考慮下面這樣的場(chǎng)景:
老師給小藍(lán)安排了除草任務(wù)怠益,小藍(lán)為了加快速度呼喚了好友小張仪搔,然而除草任務(wù)需要有鋤頭才能進(jìn)行。為此溉痢,即便有好友相助但鋤頭卻只有一把所以兩個(gè)人無法同時(shí)完成除草的任務(wù)僻造,只有拿到鋤頭使用權(quán)的一方才能夠進(jìn)行除草憋他。這把鋤頭就像是解析器中的GIL,把小張跟小藍(lán)想象成被創(chuàng)建的兩個(gè)線程髓削,當(dāng)兩個(gè)人的工作效率一樣的時(shí)候竹挡,受限于鋤頭這個(gè)約束并無法同時(shí)進(jìn)行除草任務(wù),只能夠交替使用鋤頭立膛,本質(zhì)上并不會(huì)減少工作時(shí)間揪罕,反而會(huì)在換人的時(shí)候(上下文切換)耗費(fèi)掉一定的時(shí)間。
在一些場(chǎng)景下創(chuàng)建更多的線程并不能真正地減少程序的運(yùn)行時(shí)間宝泵,反而有可能會(huì)隨著進(jìn)程數(shù)量的增加而增加切換上下文的開銷好啰,從而導(dǎo)致程序變得更慢。
2) 多線程的作用
從前面的故事可以看出儿奶,由于GIL的存在框往,多線程并不能減少程序運(yùn)行的時(shí)間,反而會(huì)因?yàn)殚_的線程太多闯捎,導(dǎo)致上下文切換開銷變大椰弊,從而增加程序的運(yùn)行時(shí)間。那么多線程是否就沒有作用了瓤鼻?是不是Ruby的Thread類在實(shí)際場(chǎng)景下它就是個(gè)擺設(shè)秉版?
當(dāng)然不是,請(qǐng)大家看下面兩個(gè)場(chǎng)景:
場(chǎng)景1: 單線程坦克大戰(zhàn)
在不能運(yùn)用計(jì)算機(jī)多核的程序設(shè)計(jì)語言里面茬祷,我們每個(gè)時(shí)間點(diǎn)只有一個(gè)線程在工作清焕。回想我們小時(shí)后玩的坦克大戰(zhàn)的游戲祭犯。假設(shè)我們有坦克A秸妥, 坦克B。坦克A需要向上移動(dòng)100px沃粗,坦克B需要向右移動(dòng)300px筛峭。如果我們是單線程的話,那么只能夠等坦克A向上移動(dòng)100px之后陪每,坦克B才能向右移動(dòng)300px,這樣的游戲體驗(yàn)肯定是很糟糕的镰吵,這就是我們的串行開發(fā)所不能適用的場(chǎng)景檩禾,它的特點(diǎn)是必須要先完成一個(gè)任務(wù)然后再開始其他任務(wù)。
運(yùn)行起來大概就像下面這樣:
場(chǎng)景2: 多線程坦克大戰(zhàn)
如果我們采用多線程實(shí)現(xiàn)方式便能夠很好地解決這種問題了疤祭。線程默認(rèn)由操作系統(tǒng)進(jìn)行調(diào)度盼产,在某個(gè)時(shí)間段內(nèi)這些線程幾乎是交替使用CPU。這樣就可能實(shí)現(xiàn)勺馆,在坦克A向右移動(dòng)一小段位置(可能是1~2px)后坦克B的線程占用了CPU戏售,坦克B得以向上移動(dòng)一小段位置侨核,如此循環(huán)反復(fù)直到任務(wù)完成。另外灌灾,由于兩個(gè)線程之間上下文切換是很快的用戶幾乎察覺不到中間的停頓搓译,因此可以給用戶造成一種“兩輛坦克同時(shí)移動(dòng)”的假象。
感覺就像下面這種行為:
3) 多線程需要額外保障
我們前面也說了線程是輕量級(jí)的進(jìn)程锋喜,并且他們共享內(nèi)存空間些己,這會(huì)造成什么問題呢?
考慮下面這種場(chǎng)景:
假設(shè)如果我們?yōu)槟硞€(gè)操作創(chuàng)建了10個(gè)線程嘿般,而每個(gè)線程都會(huì)依賴于前一個(gè)線程的值段标。如果在一個(gè)線程任務(wù)處理過程中,系統(tǒng)把CPU讓給了另外線程炉奴,然而前面的線程任務(wù)只是處理了一半逼庞,其他線程無法享受這個(gè)線程的任務(wù)成果,這便可能導(dǎo)致最終結(jié)果與我們期望不符瞻赶。
這樣說可能有點(diǎn)迷糊赛糟,我舉個(gè)Ruby例子來說明一下這一點(diǎn)水援。
a = 0
threads = (1..10).map do |i|
Thread.new(i) do |i|
c = a
sleep(rand(0..1))
c += 10
sleep(rand(0..1))
a = c
end
end
threads.each { |t| t.join }
puts a
這段代碼要實(shí)現(xiàn)的功能很簡(jiǎn)單筹淫,只是把變量a累加10次浮梢,每次加10拇颅,并且開了10個(gè)線程去完成這個(gè)任務(wù)校读。正常情況下我們期望的值是a == 100
再登,然而事實(shí)卻是
> ruby a.rb
10
> ruby a.rb
10
> ruby a.rb
30
> ruby a.rb
20
怎么可能有這種隨機(jī)的值耕陷?如果不信的話你可以把程序拷貝到自己的電腦去運(yùn)行一下谜慌。出現(xiàn)這種情況的原因是字旭,當(dāng)我們的操作執(zhí)行到一半的時(shí)候其他線程介入了对湃,導(dǎo)致了數(shù)據(jù)混亂。這里為了突出問題遗淳,我們采用了sleep方法來把控制權(quán)讓給其他線程拍柒,而在現(xiàn)實(shí)中,線程間的上下文切換是由操作系統(tǒng)來調(diào)度屈暗,我們很難分析出它的具體行為拆讯。
我們減少線程數(shù)量來分析一下:
線程1執(zhí)行了c = a
之后讓出了系統(tǒng)控制權(quán),然后線程二執(zhí)行了c = a; c += 10; a = c
养叛,我們得到a == 10 && a == c
种呐。 然而這個(gè)時(shí)候控制權(quán)讓回給線程1,它繼續(xù)往下執(zhí)行c += 10, a = c
弃甥。因?yàn)樵诰€程1的上下文中c
還是原來的數(shù)值0爽室,所以執(zhí)行這個(gè)操作之后我們會(huì)得到a == 10 && a == c
,并且覆蓋了線程二的操作結(jié)果淆攻。因此最終我們無法得到我們心目中的值20
阔墩。而且線程越多這個(gè)過程可能會(huì)越亂嘿架,更加難以分析。
為了解決這種問題啸箫,我們常用的方法是給某個(gè)代碼塊或者變量加鎖耸彪。它使得加鎖部分被一個(gè)線程訪問的時(shí)候不允許其他線程介入。修改后的代碼如下
a = 0
mutex = Mutex.new
threads = (1..10).map do |i|
Thread.new(i) do |i|
# 加鎖
mutex.synchronize do
c = a
sleep(rand(0..1))
c += 10
sleep(rand(0..1))
a = c
end
end
end
threads.each { |t| t.join }
puts a
加上鎖之后我們的運(yùn)行結(jié)果就能得到保證了
> ruby a.rb
100
> ruby a.rb
100
PS: 從前面的知識(shí)可以知道筐高,如果是只能運(yùn)用計(jì)算機(jī)單核所形成的并發(fā)操作其實(shí)并不能真正提高程序的運(yùn)行效率搜囱,反而會(huì)因?yàn)樯舷挛牡那袚Q而拖慢程序運(yùn)行速度,如果再加上鎖機(jī)制的話就更會(huì)增加程序運(yùn)行的開銷柑土。因此當(dāng)我們想使用多線程的時(shí)候要考慮到使用場(chǎng)景中并發(fā)的必要性蜀肘。這里是否真的需要多線程?我們需要多少線程稽屏?是否需要加鎖扮宠?會(huì)不會(huì)有更好的解決方案?
4) 協(xié)程
上面說了線程的種種問題狐榔,它是由系統(tǒng)調(diào)度的坛增,我們很難控制并預(yù)測(cè)它的行為。在不能夠充分利用計(jì)算機(jī)多核的情況下它們運(yùn)行起來可能比串行程序還要慢薄腻。那現(xiàn)在說一個(gè)高端點(diǎn)的線程-協(xié)程收捣。
協(xié)程也是線程的一種,但是它與傳統(tǒng)的線程還是有點(diǎn)區(qū)別庵楷,Ruby在1.9之后開始支持Fiber
罢艾,使用它就可以很容易地寫出協(xié)程的程序。在某些場(chǎng)景下(如Web領(lǐng)域)協(xié)程會(huì)比線程更加適用尽纽。
我舉個(gè)比較簡(jiǎn)單的場(chǎng)景的去聊聊這個(gè)事情
小藍(lán)被布置了需要完成語文咐蚯,數(shù)學(xué),英語三門功課弄贿,預(yù)計(jì)每門功課需要一個(gè)小時(shí)的時(shí)間去完成春锋。那完成3門功課則大約需要3個(gè)小時(shí)。小藍(lán)可以采用下面兩種工作方式
系統(tǒng)調(diào)度線程的方式
如果使用系統(tǒng)默認(rèn)線程調(diào)度的方式來工作差凹,我們以5分鐘作為一個(gè)時(shí)間片段期奔,每5分鐘小藍(lán)就需要換一門功課去做,比如正在做著語文危尿,然后5分鐘之后會(huì)切換到數(shù)學(xué)能庆,或者英語,也有一定幾率繼續(xù)做語文脚线。做5分鐘后,又再次進(jìn)行切換弥搞。如此反復(fù)邮绿,直到3門功課都完成之后小藍(lán)就可以休息了渠旁。
這看起來有點(diǎn)瘋狂,如果你讓小藍(lán)按這樣的方式去寫作業(yè)的話船逮,估計(jì)不到3個(gè)小時(shí)他就已經(jīng)瘋了顾腊。另外,如此頻繁的上下文切換挖胃,最終所耗費(fèi)的時(shí)間肯定會(huì)大于3個(gè)小時(shí)杂靶。它的任務(wù)流程大概就像這樣子
協(xié)程的工作方式
從上面的工作方式可以看出,系統(tǒng)默認(rèn)的線程調(diào)度方式在有些場(chǎng)景下會(huì)過度消耗計(jì)算資源酱鸭,無故增加運(yùn)行時(shí)間吗垮,并且有點(diǎn)反人類。從人類的角度去考慮如何完成這3門功課凹髓,更人性化的做法會(huì)是先完成一門然后再去完成下一門烁登。我們或許可以把這個(gè)過程想象成一隊(duì)列。
這個(gè)過程就有點(diǎn)像線程之間可以相互協(xié)作來完成任務(wù)蔚舀,避免不必要的上下文切換饵沧,具體是否把控制權(quán)讓給其他線程,交給哪個(gè)線程赌躺,將由當(dāng)前這個(gè)線程來決定狼牺,在某種程度上可以減少了不必要的線程切換。工作流程大概像這樣
Ruby1.9引入了Fiber后使得我們可以在代碼中使用協(xié)程礼患。下面是一個(gè)簡(jiǎn)單的例子
require 'fiber'
fiber1 = Fiber.new do
puts "In Fiber 1"
Fiber.yield
end
fiber2 = Fiber.new do
puts "In Fiber 2"
fiber1.transfer
puts "Never see this message"
end
fiber3 = Fiber.new do
puts "In Fiber 3"
end
fiber2.resume
fiber3.resume
Fiber是Ruby用于創(chuàng)建協(xié)程的類是钥,在正式調(diào)度之前我們先創(chuàng)建fiber1~3
三個(gè)協(xié)程用例,然后在后面的代碼中對(duì)實(shí)例進(jìn)行調(diào)度讶泰。首先是fiber2
被調(diào)度咏瑟,然后從fiber2
內(nèi)部去把控制權(quán)轉(zhuǎn)移給fiber1
, 故而"Never see this message"這條信息不會(huì)被打印痪署。最后fiber1
在內(nèi)部把控制權(quán)轉(zhuǎn)交給主線程码泞,這個(gè)時(shí)候主線程繼續(xù)往下執(zhí)行,開始調(diào)度fiber3
狼犯。最后的輸出結(jié)果是
In Fiber 2
In Fiber 1
In Fiber 3
咋一看這個(gè)例子似乎沒有什么特別余寥,雖然它可以讓我們很方便地調(diào)度三個(gè)協(xié)程實(shí)例,但是實(shí)際上這種控制流我們即便使用平時(shí)的串行程序也能夠?qū)崿F(xiàn)悯森,只需要定義三個(gè)方法宋舷,然后按照上面的順序去調(diào)用就行了。那我再舉一個(gè)例子來突出一下協(xié)程
fiber1 = Fiber.new do
puts "fiber1 first resume"
Fiber.yield
puts "fiber1 second resume"
Fiber.yield
puts "fiber1 third resume"
end
fiber2 = Fiber.new do
puts "fiber2 first resume"
Fiber.yield
puts "fiber2 second resume"
end
fiber1.resume #1
fiber2.resume #2
fiber2.resume #3
fiber1.resume #4
fiber1.resume #5
fiber1.resume #6
以上這段代碼會(huì)輸出什么瓢姻?答案是
fiber1 first resume
fiber2 first resume
fiber2 second resume
fiber1 second resume
fiber1 third resume
tread.rb:22:in `resume': dead fiber called (FiberError)
from tread.rb:22:in `<main>'
代碼分析: 我們首先創(chuàng)建fiber1
, fiber2
這兩個(gè)實(shí)例祝蝠,先在主線程調(diào)度fiber1
(代碼#1),fiber1
輸出了第一次被調(diào)度的信息,然后通過Fiber#yield
讓出控制權(quán)給主線程绎狭,主線程便繼續(xù)執(zhí)行代碼细溅,調(diào)度fiber2
(代碼#2)。fiber2
輸出了自己第一次被調(diào)度的信息之后就通過Fiber#yield
讓出控制權(quán)給主線程儡嘶,主線程便繼續(xù)往下執(zhí)行代碼#3喇聊。fiber2
再次被調(diào)度后輸出了自己第二次被調(diào)度的信息,同時(shí)它也運(yùn)行到代碼塊的末尾蹦狂,讓出了控制權(quán)誓篱。主線程接著執(zhí)行代碼#4, #5, #6。而#6這段代碼我特地空了一行凯楔,因?yàn)樵?5的時(shí)候fiber1
已經(jīng)完成所有任務(wù)了窜骄,如果再次調(diào)度的話,我們會(huì)收到錯(cuò)誤信息啼辣,警告我們當(dāng)前被調(diào)度的協(xié)程已經(jīng)dead了啊研。
如果用串行代碼來實(shí)現(xiàn)上面這么詭異的控制流會(huì)有點(diǎn)困難吧,這也是協(xié)程的可貴之處鸥拧,線程之間似乎是在相互協(xié)作著一起工作党远。得益于這種特性我們可以寫出非租塞的,吞吐量更高的Web服務(wù)富弦。在Python領(lǐng)域就有一款叫Tornado的框架非彻涤椋火熱,便是以這種機(jī)制來實(shí)現(xiàn)的腕柜。
5) 真并行
既然官方版本的Ruby(MRI)會(huì)加上GIL導(dǎo)致我們沒有辦法活用CPU的多核济似,使得我們只能夠?qū)崿F(xiàn)并發(fā)操作,沒有辦法實(shí)現(xiàn)并行盏缤。既然GIL這么礙事砰蠢,拿掉不就好了?
回到最初的故事唉铜,如果小藍(lán)跟小張的除草任務(wù)不需要用鋤頭就能夠完成台舱,或者人手都有一把鋤頭的話,那么兩個(gè)人便能夠同時(shí)進(jìn)行除草任務(wù)了潭流,而不需要交替執(zhí)行竞惋。只要安排合理,勢(shì)必會(huì)比一個(gè)人完成除草任務(wù)更節(jié)省時(shí)間灰嫉,這便是并行的工作方式拆宛。
在Ruby的世界中確實(shí)存在一些已經(jīng)去除了GIL的實(shí)現(xiàn),其中包括Rubinius 以及 jruby他們的底層分別用的是C++以及Java實(shí)現(xiàn)的讼撒,除了去除GIL鎖之外浑厚,他們還做了其他方面的優(yōu)化股耽。某些場(chǎng)景下他們都有著比MRI更好的性能,更詳細(xì)比較可以猛戳這里钳幅。目前比較火的Rails服務(wù)器Puma也說到
Today, Puma runs on all Ruby implementations, but will always run best on any implementation that provides true parallelism.
或許得益于這些Ruby的實(shí)現(xiàn)豺谈,像Rails這些Web框架將會(huì)有更好的性能吧。
三. 尾聲
以上是我對(duì)線程高并發(fā)這些概念的一些簡(jiǎn)單總結(jié)贡这,如果有誤解的還望指正。
PS: 所有的動(dòng)畫例子為了方便起見我都用JavaScript實(shí)現(xiàn)厂榛,代碼托管到這里盖矫,前端初學(xué)者可以上去看看具體實(shí)現(xiàn),前端高手請(qǐng)忽略击奶。
很感謝你能讀到這里辈双。