并發編程 - 線程同步(六)之鎖lock
通過前面對Interlocked類的學習,相信大家對線程同步機制有了更深的理解,今天我們將繼續需要另一種同步機制——鎖lock。

lock是C#語言中的關鍵字,是線程同步機制的一種簡單的互斥鎖實現方式,它可以保證在同一時刻只有一個線程能夠訪問被鎖定的代碼塊。其工作原理也很簡單,就是通過lock創建一個互斥鎖,當一個線程獲取到此互斥鎖則此線程可以進入被lock保護的代碼塊,同時其他線程將被阻塞無法進入此代碼塊,直至第一個線程釋放此互斥鎖,其他線程才可以獲取此互斥鎖并進入代碼塊。

lock的使用也非常簡單,語法如下:
lock (obj)
{
//線程不安全的代碼塊
}
雖然lock使用起來簡單方便,但是使用方式不正確也很容易產生各種奇奇怪怪的問題。
01、避免鎖定this
這種使用方式會導致兩個問題:
1.不可控性:lock(this)鎖定的范圍是整個實例,這也就意味著其他線程可以通過該實例中的其他方法訪問該鎖,進而形成一個實例中多個使用lock(this)的方法之前相互影響。
2.外部可見性:this表示當前實例的引用,它是公共的,因此外部代碼也可以訪問,這也就意味著外部代碼可以通過lock(實例)訪問lock(this)鎖,從而使同步機制失去控制。
下面我們直接看代碼:
public class LockThisExample
{
public void Method1()
{
lock (this)
{
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 通過lock(this)鎖進入 Method1");
Console.WriteLine($"進入時間 {DateTime.Now:HH:mm:ss}");
Console.WriteLine($"開始休眠 5 秒");
Console.WriteLine($"------------------------------------");
Thread.Sleep(5000);
}
}
public void Method2()
{
lock (this)
{
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 通過lock(this)鎖進入 Method2");
Console.WriteLine($"進入時間 {DateTime.Now:HH:mm:ss}");
}
}
}
public static void LockThisRun()
{
var example = new LockThisExample();
var thread1 = new Thread(example.Method1);
var thread2 = new Thread(example.Method2);
var thread3 = new Thread(() =>
{
lock (example)
{
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 通過lock(實例)鎖進入 Method3");
Console.WriteLine($"進入時間 {DateTime.Now:HH:mm:ss}");
Console.WriteLine($"開始休眠 5 秒");
Console.WriteLine($"------------------------------------");
Thread.Sleep(5000);
}
});
thread3.Start();
thread1.Start();
thread2.Start();
}
我們看看代碼執行結果:

這里例子可以很好的說明lock(this)代理的問題,原本可以三個線程并發執行的三段代碼,因為使用了同一個鎖,導致三個線程只能順序執行。其中Method1和Method2體現了同一實例內方法相互影響,Method3和Method1、Method2體現了因為相同實例導致實例內部方法和實例外部方法相互影響。
02、避免鎖定公共對象
這種使用方式會導致兩個問題:
1.全局影響:公共對象,特別是 public static 對象,很大概率會被多個類,甚至多個模塊引用,因此鎖定公共對象很可能導致全局范圍內的同步,大大增加了死鎖、競爭條件的產生的風險。
2.不可預測性:因為公共對象對全局可訪問,因此如果其他模塊鎖定此公共對象,則當出現問題時將難以排除調試問題。
看下面代碼:
public class PublicLock
{
public static readonly object Lock = new object();
}
public class LockPublic1Example
{
public void Method1()
{
lock (PublicLock.Lock)
{
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 通過 lock(公共對象) 鎖進入 Public1");
Console.WriteLine($"進入時間 {DateTime.Now:HH:mm:ss}");
Console.WriteLine($"開始休眠 5 秒");
Console.WriteLine($"------------------------------------");
Thread.Sleep(5000);
}
}
}
public class LockPublic2Example
{
public void Method1()
{
lock (PublicLock.Lock)
{
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 通過 lock(公共對象) 鎖進入 Public2");
Console.WriteLine($"進入時間 {DateTime.Now:HH:mm:ss}");
}
}
}
public static void LockPublicRun()
{
var example1 = new LockPublic1Example();
var example2 = new LockPublic2Example();
var thread1 = new Thread(example1.Method1);
var thread2 = new Thread(example2.Method1);
thread1.Start();
thread2.Start();
}
在看看執行結果:

可以發現因為鎖定了同一個公共對象,導致兩個不同線程的不同實例,還是產生互相爭搶鎖的問題。
03、避免鎖定字符串
在C#中,字符串因其不可變性和字符串池的原因,在整個程序中一個字符串一旦創建就不會更改,如果對其修改則產生新的字符串對象,而原字符串對象保持不變;同時如果創建兩個相同內容的字符串,則它們共享同一個內存地址。
這就導致鎖定字符串極其危險尤其危險,因為整個程序中任何給定字符串都只有一個實例,而在整個程序中只有鎖定相同內容的字符串都會形成競爭條件。
public class LockString1Example
{
public void Method1()
{
lock ("abc")
{
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 通過 lock(字符串) 鎖進入 String1");
Console.WriteLine($"進入時間 {DateTime.Now:HH:mm:ss}");
Console.WriteLine($"開始休眠 5 秒");
Console.WriteLine($"------------------------------------");
Thread.Sleep(5000);
}
}
}
public class LockString2Example
{
public void Method1()
{
lock ("abc")
{
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 通過 lock(字符串) 鎖進入 String2");
Console.WriteLine($"進入時間 {DateTime.Now:HH:mm:ss}");
}
}
}
public static void LockStringRun()
{
var example1 = new LockString1Example();
var example2 = new LockString2Example();
var thread1 = new Thread(example1.Method1);
var thread2 = new Thread(example2.Method1);
thread1.Start();
thread2.Start();
}
我們看看執行結果:

可以發現雖然在兩個類中分別使用了兩個字符串“abc”,但對于整個程序來說它們都指向了同一個實例,因此共用了一把鎖。
04、小心鎖定非readonly對象
這是因為如果鎖對象為非只讀對象,就可能發生某個lock代碼塊中修改鎖對象,從而導致
鎖對象變更,進而使得其他線程可以暢通無阻的進入該代碼塊。
如下示例:
public class LockNotReadonlyExample
{
private object _lock = new object();
public void Method1()
{
lock (_lock)
{
_lock = new object();
var threadId = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine($"線程 {threadId} 進入 Method1 , 時間 {DateTime.Now:HH:mm:ss}");
Console.WriteLine($"------------------------------------");
Thread.Sleep(5000);
}
}
}
public static void LockNotReadonlyRun()
{
var example = new LockNotReadonlyExample();
var thread1 = new Thread(example.Method1);
var thread2 = new Thread(example.Method1);
var thread3 = new Thread(example.Method1);
thread1.Start();
thread2.Start();
thread3.Start();
}
再來看執行結果:

可以發現三個線程幾乎同時進入,lock根本就沒有起到鎖的作用。
05、小心鎖定靜態對象
對于是否需要鎖定靜態對象取決于你的需求。
1.如果要在靜態方法中使用lock時,則鎖定的對象也必須要是靜態對象。
2.如果希望類的每個實例都有獨立的鎖對象,則鎖定非靜態對象。
3.如果希望類的所有實例共享同一個鎖,則鎖定靜態對象。
代碼示例如下:
public class LockStaticExample
{
//這是一個實例字段,意味著類的每個實例都會有一個獨立的鎖對象。
//如果你希望類的每個實例有自己獨立的鎖來控制并發訪問,這種方式更合適。
private readonly object _lock1 = new object();
//這是一個靜態字段,意味著類的所有實例共享同一個鎖對象。
//如果你希望類的所有實例都共享同一個鎖來同步對某個靜態資源訪問,這種方式更合適。
private static readonly object _lock2 = new object();
public void Method1()
{
lock (_lock1)
{
// 臨界區代碼
}
}
public void Method2()
{
lock (_lock2)
{
// 臨界區代碼
}
}
public static void Method3()
{
lock (_lock2)
{
// 臨界區代碼
}
}
}
這是因為靜態字段是所有實例共享的,其內存地址在整個程序的生命周期內是唯一的,所有實例訪問同一個內存地址,因此鎖定靜態對象時要特別小心。
注:測試方法代碼以及示例源碼都已經上傳至代碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner

浙公網安備 33010602011771號