dotnet 多線程禁止同時(shí)調(diào)用相同的方法 禁止方法重入調(diào)用 雙檢鎖的設(shè)計(jì)
大家在使用多線程的時(shí)候,是否有關(guān)注過(guò)線程安全的問(wèn)題。如果咱的代碼在使用多線程時(shí),在相同的時(shí)間有多個(gè)線程同時(shí)執(zhí)行相同的方法,此時(shí)也許就存在數(shù)據(jù)安全的問(wèn)題,如多個(gè)線程之間對(duì)相同的內(nèi)存進(jìn)行同時(shí)的讀取和修改。而讓方法在多線程調(diào)用中,相同的時(shí)間會(huì)被多個(gè)線程同時(shí)執(zhí)行某段代碼邏輯的技術(shù)稱為方法重入調(diào)用技術(shù),而禁止方法被同時(shí)調(diào)用也就是禁止方法重入調(diào)用。在 dotnet 里面有多個(gè)方式可以做到禁止方法重入調(diào)用,本文將告訴大家如何做到禁止方法重入調(diào)用
執(zhí)行代碼邏輯的重入是一個(gè)很泛的領(lǐng)域,本文僅僅只和大家聊多線程同時(shí)執(zhí)行某段代碼邏輯時(shí)的重入
在開(kāi)始之前,我需要告訴大家,本文不聊遞歸的方法。遞歸就是方法自身調(diào)用方法自身,或者說(shuō)方法間接調(diào)用了自身,如下面代碼
public?void?Foo(){
????Foo();}
以及間接調(diào)用如下面代碼
????????private?void?A()
????????{
????????????B();
????????}
????????private?void?B()
????????{
????????????A();
????????}
以上代碼的遞歸重入不在本文討論范圍內(nèi)。因?yàn)樵谝粋€(gè)線程執(zhí)行過(guò)程里面,所有的邏輯都是順序執(zhí)行的,除非是遞歸的重入,否則不會(huì)在相同的時(shí)間調(diào)用方法兩次
而對(duì)多線程的應(yīng)用,多個(gè)線程同一時(shí)刻是可以訪問(wèn)相同的方法執(zhí)行相同的代碼邏輯,如果想要讓多線程每次只能有一個(gè)線程執(zhí)行,那么需要使用到鎖等方法??梢允褂玫姆椒ㄓ泻芏啵旅孀屛腋嬖V大家如何做到禁止方法重入調(diào)用

鎖定方法
在 C# 里面可以使用關(guān)鍵詞 lock 加上一個(gè)對(duì)象作為鎖定,在進(jìn)入 lock 的邏輯,只能有一個(gè)線程獲取鎖,因此在 lock 里面的代碼只被一個(gè)線程同時(shí)執(zhí)行
如以下代碼就是標(biāo)準(zhǔn)的鎖定方法的代碼
????????private?void?FooLock()
????????{
????????????lock?(_locker)
????????????{
????????????????//?代碼
????????????}
????????}
????????private?readonly?object?_locker?=?new?object();
需要注意的細(xì)節(jié)是創(chuàng)建一個(gè)空白的對(duì)象?_locker
?作為字段,使用字段而不是局部變量的原因在于 lock 只有在使用相同的對(duì)象才能做到多個(gè)線程進(jìn)入時(shí),只有一個(gè)線程執(zhí)行,其他線程等待。如果是局部變量,那么多個(gè)線程都會(huì)創(chuàng)建自己的局部變量,因此就做不到讓一個(gè)線程執(zhí)行,其他線程等待
其次是這個(gè)?_locker
?應(yīng)該是私有的,采用私有的可以讓整個(gè)鎖的功能在自己內(nèi)部的完全控制的代碼下使用,而不會(huì)擔(dān)心被其他業(yè)務(wù)使用?;谶@個(gè)原因可以了解到使用?lock(this)
?是不推薦的,因?yàn)?this 將會(huì)被其他類所使用,此時(shí)就無(wú)法完全了解這個(gè)鎖使用的對(duì)象使用的地方。盡管自己在開(kāi)發(fā)的時(shí)候可以關(guān)注到,但是在后續(xù)更改中不一定能了解這些細(xì)節(jié),因此也許就會(huì)因此出現(xiàn)相互等待的鎖的坑
最后是這個(gè)對(duì)象應(yīng)該是?readonly
?不可變的,原因在于也許在線程進(jìn)入鎖的時(shí)候,如果是可變的字段,將也許有其他業(yè)務(wù)在其他線程下更改了這個(gè)對(duì)象,也就讓其他線程依然可以執(zhí)行相同的邏輯
而多創(chuàng)建一個(gè)對(duì)象也用不了多少內(nèi)存,關(guān)于對(duì)象使用的內(nèi)存請(qǐng)看?C# CLR 聊聊對(duì)象的內(nèi)存布局 一個(gè)空對(duì)象占用多少內(nèi)存。

通過(guò)特性
在 dotnet 里面可以使用?MethodImpl
?特性表示當(dāng)前這個(gè)方法只能讓一個(gè)線程進(jìn)入,其他線程將需要等待
????????[MethodImpl(MethodImplOptions.Synchronized)]
????????private?void?F1()
????????{
????????}
使用 MethodImplOptions.Synchronized 的本質(zhì)就和上文的定義?_locker
?對(duì)象的方法類似,只是具體實(shí)現(xiàn)機(jī)制由 CLR 決定
當(dāng)前的 CLR 將會(huì)在實(shí)例方法,也就是非靜態(tài)的方法,使用?this
?作為鎖定對(duì)象。在靜態(tài)方法使用對(duì)象的 Type 作為鎖定的對(duì)象
如果這個(gè)類型不是私有的類型,那么盡量不要使用 MethodImpl 這個(gè)方法禁止沖入。原因是在實(shí)例方法使用?this
?作為鎖定對(duì)象,而其他代碼也許也會(huì)將這個(gè)實(shí)例作為鎖定的對(duì)象,此時(shí)也許如下面代碼所示有兩個(gè)線程在相互等待
????class?Program
????{
????????static?void?Main(string[]?args)
????????{
????????????var?program?=?new?Program();
????????????var?autoResetEvent?=?new?AutoResetEvent(false);
????????????var?manualResetEvent?=?new?ManualResetEvent(false);
????????????var?task1?=?Task.Run(()?=>
????????????{
????????????????lock?(program)
????????????????{
????????????????????//?用于讓?task1?執(zhí)行到這里才讓?task2?執(zhí)行
????????????????????autoResetEvent.Set();
????????????????????//?用于等待?task2?執(zhí)行完成
????????????????????manualResetEvent.WaitOne();
????????????????}
????????????});
????????????var?task2?=?Task.Run(()?=>
????????????{
????????????????//?用于等待?task1?執(zhí)行
????????????????autoResetEvent.WaitOne();
????????????????//?調(diào)用禁止沖入的方法
????????????????program.F1();
????????????????//?如果上面代碼調(diào)用返回,那么讓?tas1?繼續(xù)執(zhí)行
????????????????manualResetEvent.Set();
????????????});
????????????Task.WaitAll(task1,?task2);
????????}
????????[MethodImpl(MethodImplOptions.Synchronized)]
????????private?void?F1()
????????{
????????????Console.WriteLine("執(zhí)行邏輯");
????????}
????}
上面代碼的 AutoResetEvent 和 ManualResetEvent 僅是為了讓兩個(gè)線程按照如下順序執(zhí)行和相互等待,線程1將會(huì)拿到 Program 實(shí)例,用這個(gè)實(shí)例作為鎖定的對(duì)象。然后線程1需要等待線程2執(zhí)行完成之后才會(huì)退出鎖定。而線程2在線程1執(zhí)行進(jìn)入鎖定之后才會(huì)開(kāi)始執(zhí)行,開(kāi)始執(zhí)行的時(shí)候調(diào)用了 F1 方法,調(diào)用之后執(zhí)行完成
而在上面代碼里面,調(diào)用 F1 執(zhí)行的過(guò)程,在當(dāng)前 CLR 的實(shí)現(xiàn),將會(huì)嘗試拿到自身作為鎖定對(duì)象。而 F1 的自身也就是 Program 的實(shí)例,此時(shí)被線程1作為鎖定對(duì)象,因此線程2需要等待線程1不再將 Program 的實(shí)例作為鎖定的對(duì)象之后才會(huì)執(zhí)行 F1 方法。而此時(shí)的線程1在等待線程2執(zhí)行完成才會(huì)退出鎖定,而線程2在等待線程1退出鎖定才會(huì)執(zhí)行完成。因此兩個(gè)線程在相互等待
這樣的邏輯代碼是在 F1 方法定義的時(shí)候無(wú)法了解的,這就是為什么不建議使用?MethodImpl
?的原因。即使在開(kāi)發(fā)的時(shí)候采用的是私有的類,但是后續(xù)更改的時(shí)候也許就將他開(kāi)放了,而后續(xù)有逗比開(kāi)發(fā)者參與開(kāi)發(fā),將某個(gè)對(duì)象作為鎖定的對(duì)象。

雙檢鎖
太子說(shuō)以下的誤導(dǎo)性特別高,請(qǐng)小伙伴在大人們的指導(dǎo)下觀看
雙檢鎖又稱雙險(xiǎn)鎖(也許是沒(méi)有 雙險(xiǎn)鎖 這個(gè)名字的),本質(zhì)上是讓方法在多線程下只執(zhí)行一次,和上文的用途有點(diǎn)不相同。上文的方法是只有一個(gè)線程執(zhí)行,其他線程等待。而雙檢鎖是讓一個(gè)線程執(zhí)行,其他線程不執(zhí)行的代碼設(shè)計(jì)方法
雙檢鎖有多個(gè)不同的寫(xiě)法,采用雙檢鎖僅僅只是為了提升性能,而如果不為了提升性能,可以采用如下更直觀的實(shí)現(xiàn)方法,盡管準(zhǔn)確來(lái)說(shuō)以下不是雙檢鎖的寫(xiě)法
????????private?void?F2()
????????{
????????????lock?(_locker)
????????????{
????????????????if?(_isDoing)
????????????????{
????????????????????return;
????????????????}
????????????????_isDoing?=?true;
????????????????//?執(zhí)行代碼
????????????}
????????}
????????private?bool?_isDoing;
????????private?readonly?object?_locker?=?new?object();
這個(gè)方法就是用來(lái)判斷是否執(zhí)行的邏輯,如果執(zhí)行過(guò)了,那么將不再執(zhí)行。上面方法在使用?lock (_locker)
?可以讓方法里面的代碼只有一個(gè)線程同時(shí)執(zhí)行,此時(shí)對(duì)?_isDoing
?的讀取和修改將會(huì)是線程安全的,因此可以通過(guò)此判斷而解決重入問(wèn)題
但上面方法因?yàn)槟J(rèn)需要進(jìn)入?lock (_locker)
?一次鎖定,而 lock 盡管性能已經(jīng)足夠好了,但是依然在性能敏感的邏輯上,會(huì)影響整體的性能。為什么 lock 的性能已經(jīng)足夠好了,因?yàn)槟J(rèn)的 lock 是一個(gè)混合鎖,也就是一個(gè)會(huì)使用用戶態(tài)和內(nèi)核態(tài)的鎖。在進(jìn)入 lock 時(shí),此時(shí)將會(huì)使用自旋鎖,在等待一段時(shí)候之后才會(huì)進(jìn)行線程鎖等。在開(kāi)始進(jìn)入自旋鎖,此時(shí)的邏輯大概就是?while (true)
?的循環(huán)判斷邏輯。進(jìn)入自旋鎖可以做到?jīng)]有線程上下文切換,也就是當(dāng)前線程依然在執(zhí)行中。如果這段代碼很快就能進(jìn)入執(zhí)行,此時(shí)的速度是非??斓?。相當(dāng)于在循環(huán)里面做判斷布爾
當(dāng)然,如果在 lock 一直沒(méi)有進(jìn)入執(zhí)行,那么將會(huì)從自旋鎖退出進(jìn)入線程鎖,而線程鎖將會(huì)涉及到線程上下文的切換,此時(shí)的速度將會(huì)比較慢
當(dāng)然了我很難用幾句話描述清楚 lock 的底層原理,以上描述,就當(dāng)看著玩
為了更好的提升性能,也就是一段代碼其實(shí)大部分時(shí)候進(jìn)入的時(shí)候都是被執(zhí)行過(guò)的,不需要再次被執(zhí)行,此時(shí)可以采用雙檢鎖的寫(xiě)法。先判斷布爾值,然后再進(jìn)入鎖定,再進(jìn)行判斷,請(qǐng)看代碼
????????private?void?F2()
????????{
????????????if?(_isDoing)
????????????{
????????????????return;
????????????}
????????????lock?(_locker)
????????????{
????????????????if?(_isDoing)
????????????????{
????????????????????return;
????????????????}
????????????????_isDoing?=?true;
????????????????//?執(zhí)行代碼
????????????}
????????}
????????private?bool?_isDoing;
????????private?readonly?object?_locker?=?new?object();
可以對(duì)比上面代碼,使用雙檢鎖的標(biāo)準(zhǔn)寫(xiě)法里面,就是先判斷布爾字段的值,然后再進(jìn)入鎖。在大部分進(jìn)入的時(shí)候方法都執(zhí)行完成時(shí),此時(shí)的判斷布爾值就能讓方法返回,而不需要進(jìn)入鎖,可以提升不少的性能
而在剛好第一次執(zhí)行的時(shí)候,多個(gè)線程如果都進(jìn)入判斷布爾值時(shí),此時(shí)判斷不是線程安全的。但是沒(méi)關(guān)系,因?yàn)楹罄m(xù)會(huì)進(jìn)入?lock (_locker)
?然后再次判斷,這就是 雙檢鎖 這個(gè)名字的原因了
而如大家所見(jiàn),上面代碼的復(fù)雜度確實(shí)比較高,也需要占用兩個(gè)本地字段。更加優(yōu)雅但是比較難理解的禁止方法重入多次調(diào)用的寫(xiě)法可以使用 Interlocked 類的方法,在 Interlocked 類的 Exchange 方法提供了對(duì) int 等基礎(chǔ)類型的原子修改,可以在將某個(gè)值進(jìn)行原子修改之后返回原先的值。而原子修改是線程安全的,也就是多個(gè)線程如果同時(shí)進(jìn)入原子修改,此時(shí)不會(huì)存在線程安全問(wèn)題
使用 Interlocked 的寫(xiě)法如下
????????private?void?F2()
????????{
????????????var?doingCount?=?Interlocked.Exchange(ref?_doingCount,?1);
????????????if?(doingCount?==?0)
????????????{
????????????????//?執(zhí)行代碼
????????????????Console.WriteLine("執(zhí)行邏輯");
????????????}
????????}
????????private?int?_doingCount;
可以看到,上面代碼每次都進(jìn)入?Interlocked.Exchange
?的邏輯,而只有一次能返回 0 的值,因此也就只能執(zhí)行一次。這個(gè)方法的性能將會(huì)更好,但是寫(xiě)法上會(huì)比較難以理解,需要了解 Interlocked 以及原子修改的原理才比較好理解上面的寫(xiě)法。但實(shí)際上用了 Interlocked 就不算雙檢鎖了,只是思想上和雙檢鎖差不多。使用 Interlocked 的方法可以獲取極高的性能
如果你想要將如上代碼用于對(duì)象的初始化,那么上面兩個(gè)寫(xiě)法其實(shí)有本質(zhì)的不同,不同之處在于用 雙檢鎖 的寫(xiě)法可以讓線程阻塞,在首次對(duì)象初始化過(guò)程中,其他線程能使用到執(zhí)行線程的執(zhí)行結(jié)果。而使用 Interlocked 是只讓一個(gè)線程執(zhí)行,其他線程跳過(guò),而不能用到對(duì)象初始化的結(jié)果。因此在 Interlocked 的用法上面,不適合用來(lái)讓對(duì)象初始化一次的業(yè)務(wù)。

更復(fù)雜的需求
如果我要求限制執(zhí)行某個(gè)方法的線程數(shù)量,要求只能讓兩個(gè)線程去執(zhí)行某個(gè)方法或任務(wù),那么此時(shí)我將和你推薦我的開(kāi)源庫(kù)?dotnet-campus/AsyncWorkerCollection: 高性能的多線程異步工具庫(kù)
這是一個(gè)在 GitHub 完全開(kāi)源的庫(kù),基于非常友好的 MIT 開(kāi)源協(xié)議,請(qǐng)看?https://github.com/dotnet-campus/AsyncWorkerCollection
如上文的需求,限制執(zhí)行某個(gè)方法的數(shù)量,其實(shí)就是生產(chǎn)者消費(fèi)者模式,可以使用?AsyncWorkerCollection?庫(kù)的 AsyncQueue 類實(shí)現(xiàn)這個(gè)功能,詳細(xì)請(qǐng)看?dotnet 使用 AsyncQueue 創(chuàng)建高性能內(nèi)存生產(chǎn)者消費(fèi)者隊(duì)列
如果我要求執(zhí)行方法的時(shí)候,如果有多個(gè)線程調(diào)用,那么在方法執(zhí)行過(guò)程中,多次進(jìn)來(lái)的線程都不做實(shí)際的執(zhí)行,而是等待當(dāng)前在執(zhí)行方法的線程執(zhí)行完成之后,取出執(zhí)行的返回值作為其他線程的執(zhí)行方法的返回值。此時(shí)可以使用 KeepLastReentrancyTask 類
如果需要支持本機(jī)內(nèi)多線程調(diào)用某一確定的任務(wù)的執(zhí)行,任務(wù)僅執(zhí)行一次,多次調(diào)用均返回相同結(jié)果。此時(shí)可以使用?ExecuteOnceAwaiter?類