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

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

通過特性
在 dotnet 里面可以使用?MethodImpl
?特性表示當前這個方法只能讓一個線程進入,其他線程將需要等待
????????[MethodImpl(MethodImplOptions.Synchronized)]
????????private?void?F1()
????????{
????????}
使用 MethodImplOptions.Synchronized 的本質(zhì)就和上文的定義?_locker
?對象的方法類似,只是具體實現(xiàn)機制由 CLR 決定
當前的 CLR 將會在實例方法,也就是非靜態(tài)的方法,使用?this
?作為鎖定對象。在靜態(tài)方法使用對象的 Type 作為鎖定的對象
如果這個類型不是私有的類型,那么盡量不要使用 MethodImpl 這個方法禁止沖入。原因是在實例方法使用?this
?作為鎖定對象,而其他代碼也許也會將這個實例作為鎖定的對象,此時也許如下面代碼所示有兩個線程在相互等待
????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 僅是為了讓兩個線程按照如下順序執(zhí)行和相互等待,線程1將會拿到 Program 實例,用這個實例作為鎖定的對象。然后線程1需要等待線程2執(zhí)行完成之后才會退出鎖定。而線程2在線程1執(zhí)行進入鎖定之后才會開始執(zhí)行,開始執(zhí)行的時候調(diào)用了 F1 方法,調(diào)用之后執(zhí)行完成
而在上面代碼里面,調(diào)用 F1 執(zhí)行的過程,在當前 CLR 的實現(xiàn),將會嘗試拿到自身作為鎖定對象。而 F1 的自身也就是 Program 的實例,此時被線程1作為鎖定對象,因此線程2需要等待線程1不再將 Program 的實例作為鎖定的對象之后才會執(zhí)行 F1 方法。而此時的線程1在等待線程2執(zhí)行完成才會退出鎖定,而線程2在等待線程1退出鎖定才會執(zhí)行完成。因此兩個線程在相互等待
這樣的邏輯代碼是在 F1 方法定義的時候無法了解的,這就是為什么不建議使用?MethodImpl
?的原因。即使在開發(fā)的時候采用的是私有的類,但是后續(xù)更改的時候也許就將他開放了,而后續(xù)有逗比開發(fā)者參與開發(fā),將某個對象作為鎖定的對象。

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

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