目录
一、什么是临界区?
二、lock关键字的用途
三、lock的基本用法
四、lock关键字的工作原理
五、示例1-保护共享变量
六、示例2-与Monitor类配合实现线程间信号传递
七、注意事项
八、 常见误区
九、替代方案
一、什么是临界区?
在多线程编程中,临界区是指一段需要互斥访问的代码块,通常涉及对共享资源的操作。为了避免多个线程同时操作共享资源而导致数据竞争或状态不一致,我们需要对临界区代码进行保护。
下面通过一个简单的例子来说明什么是临界区代码,以及如何使用 lock 来保护它。
示例:两个线程操作共享变量
假设我们有一个共享变量 _counter,两个线程分别对其进行递增操作。如果不对临界区代码进行保护,可能会导致数据竞争和错误结果。
二、lock关键字的用途
lock关键字在C#中用于确保当一个线程访问某个资源时,其他线程不能同时访问该资源,从而避免了数据竞争和不一致性的问题。它通过提供一种简单的方式实现线程同步,保证多线程环境下共享资源的安全访问。
三、lock的基本用法
private static readonly object _lock = new object(); // 锁对象
lock (object)
{// 需要同步的代码块
}
- object 是一个引用类型的对象,通常称为锁对象。
- 当线程进入lock语句时,会尝试获取锁对象的互斥锁。如果成功获取锁,则执行代码块;否则,线程会被阻塞,直到锁被释放。
四、lock关键字的工作原理
lock实际上是Monitor.Enter和Monitor.Exit的语法糖
当线程进入lock语句时:
- 调用Monitor.Enter(object)获取锁。
- 如果锁已被其他线程占用,则当前线程会被挂起,直到锁被释放。
当线程退出lock语句时:
- 调用Monitor.Exit(object)释放锁。
五、示例1-保护共享变量
假设有一个共享变量_counter,多个线程可能会同时修改它的值。为了确保线程安全,可以使用lock来保护对_counter的访问。
using System;
using System.Threading;class Program
{private static int _counter = 0;private static readonly object _lock = new object();static void Main(){Thread t1 = new Thread(IncrementCounter);Thread t2 = new Thread(IncrementCounter);t1.Start();t2.Start();t1.Join();t2.Join();Console.WriteLine($"Final Counter Value: {_counter}");}static void IncrementCounter(){for (int i = 0; i < 100000; i++){lock (_lock){_counter++;}}}
}
解释:
- _lock是一个静态对象,作为锁对象。
- 每次访问_counter时,都会通过lock确保只有一个线程能够修改它。
- 最终输出的结果是200000,因为所有线程的操作都被正确同步了。
六、示例2-与Monitor类配合实现线程间信号传递
假设我们有两个线程:
- 线程A:等待某个条件满足后再继续执行。
- 线程B:负责触发这个条件,并通知线程A继续执行。
using System;
using System.Threading;class Program
{private static readonly object _lock = new object();private static bool _isReady = false; // 共享的状态变量static void Main(string[] args){Thread threadA = new Thread(DoWorkA);Thread threadB = new Thread(DoWorkB);threadA.Start();threadB.Start();threadA.Join();threadB.Join();}static void DoWorkA(){lock (_lock){Console.WriteLine("Thread A: Waiting for signal...");// 等待条件满足while (!_isReady){Monitor.Wait(_lock); // 释放锁并等待}Console.WriteLine("Thread A: Received signal. Continuing work.");}}static void DoWorkB(){Thread.Sleep(2000); // 模拟一些工作lock (_lock){Console.WriteLine("Thread B: Preparing to signal...");// 设置条件为 true_isReady = true;// 通知等待的线程Monitor.Pulse(_lock);Console.WriteLine("Thread B: Signal sent.");}}
}
注意:即使线程A和线程B中的临界区代码不同,只要它们使用的是同一个锁对象 _lock,系统就会保证这两个线程不会同时执行这些临界区代码。
七、注意事项
(1) 锁对象的选择
- 锁对象必须是引用类型(如object、string等),不能是值类型(如int、struct等)。
- 推荐使用专用的私有对象作为锁对象,而不是公共对象或字符串常量。例如:
private static readonly object _lock = new object();
- 不要使用this作为锁对象,因为外部代码可能也会锁定该对象,导致死锁风险。
- 锁对象尽量设置为readonly,如果 _lock 是一个普通的对象(非 readonly),它可能会被意外修改或重新赋值,在这种情况下,其他线程可能尝试锁定一个新的 _lock 对象,而不是原来的对象,从而破坏了同步逻辑。
(2) 避免死锁
- 死锁是指两个或多个线程互相等待对方释放锁,导致程序无法继续运行。
- 避免死锁的方法包括:
- 确保锁的获取顺序一致。
- 尽量减少锁的范围,只锁定必要的代码块。
(3) 性能影响
- 使用lock会导致线程阻塞,因此可能会影响性能。
- 对于高并发场景,可以考虑使用其他同步机制(如SemaphoreSlim、ReaderWriterLockSlim等)。
八、 常见误区
(1) 锁的范围过大
- 如果锁的范围过大,会导致线程阻塞时间过长,降低程序的并发性能。
- 应尽量缩小锁的范围,只锁定需要同步的代码部分。
(2) 忘记释放锁
- 在正常情况下,lock语句会自动释放锁。但如果在lock代码块中抛出异常且未正确处理,可能会导致锁无法释放。
- 确保lock代码块中的逻辑是安全的,避免抛出未捕获的异常。
九、替代方案
对于某些特定的应用场景,可以考虑使用以下替代方案:
- Monitor类:直接使用Monitor可以提供更细粒度的控制。
- Mutex:适用于跨进程的同步。
- SemaphoreSlim:适用于限制并发线程数。
- ReaderWriterLockSlim:适用于读多写少的场景。