在C#的多线程编程场景中,当多个线程需要同时访问同一个共享资源时,很容易出现数据竞争导致的逻辑错误,lock关键字是微软提供的一种轻量级线程同步方案,用于保证临界区代码的互斥执行。

lock关键字的实现原理
lock关键字本质上是Monitor类的语法糖,编译器会将lock代码块转换为Monitor.Enter和Monitor.Exit的调用,同时会自动处理异常场景下的锁释放,避免死锁问题。我们来看一段简单的lock使用示例:
using System;
using System.Threading;
using System.Threading.Tasks;
class Counter
{
// 共享资源
private int _count = 0;
// 锁对象,必须是引用类型
private readonly object _lockObj = new object();
public void Increment()
{
// lock关键字保证同一时间只有一个线程能执行下面的代码块
lock (_lockObj)
{
_count++;
Console.WriteLine($"当前线程ID:{Thread.CurrentThread.ManagedThreadId},计数:{_count}");
}
}
}
class Program
{
static void Main()
{
Counter counter = new Counter();
// 创建10个并行任务操作共享计数
Parallel.For(0, 10, i => counter.Increment());
}
}
上面的代码经过编译器处理后,大致等价于下面的Monitor实现逻辑:
public void Increment()
{
bool lockTaken = false;
try
{
// 尝试获取锁,lockTaken标记是否成功获取锁
Monitor.Enter(_lockObj, ref lockTaken);
_count++;
Console.WriteLine($"当前线程ID:{Thread.CurrentThread.ManagedThreadId},计数:{_count}");
}
finally
{
// 只有成功获取锁的情况下才释放锁,避免异常
if (lockTaken)
{
Monitor.Exit(_lockObj);
}
}
}
当线程进入lock代码块时,会尝试获取锁对象的互斥锁,如果锁已经被其他线程持有,当前线程会进入等待状态,直到持有锁的线程释放锁之后,等待的线程才会被唤醒并获取锁进入临界区。整个过程保证了临界区代码在同一时间只会有一个线程执行。
lock关键字的适用场景
lock关键字适合以下场景使用:
- 需要保护简单的共享资源,比如基础类型的共享变量、集合对象的增删改操作,且临界区代码执行时间较短的场景。
- 不需要复杂的同步功能,比如不需要等待某个条件触发、不需要跨进程的同步,只需要保证单进程内多线程互斥访问的场景。
- 临界区逻辑简单,没有复杂的嵌套锁需求,避免因为锁的嵌套使用导致死锁问题。
- 不需要设置锁的超时时间,只要获取锁就一直等待直到锁被释放的场景。
使用lock关键字的注意事项
在使用lock关键字时,需要注意以下几点:
锁对象的选择
锁对象必须是引用类型,不能使用值类型,因为值类型装箱后每次都是新的对象,无法实现锁的效果。同时不要使用this作为锁对象,也不要使用string类型作为锁对象,因为字符串有驻留机制,不同的字符串变量可能指向同一个实例,会导致意外的锁竞争。最好的做法是定义一个私有的只读引用类型对象作为锁对象。
避免锁粒度过大
lock代码块内的代码应该尽量精简,只放需要同步的操作,不要把不需要同步的逻辑放到lock内部,否则会延长锁的持有时间,降低多线程的并发性能。
避免嵌套锁
尽量不要在一个lock代码块内部再获取其他锁,如果必须要使用嵌套锁,需要保证所有线程获取锁的顺序一致,否则很容易出现死锁问题。
不要锁定公共对象
锁对象应该是类的私有成员,不要暴露给外部,否则外部代码也可能锁定同一个对象,导致不可预期的阻塞问题。
lock与其他同步方式的对比
如果场景需要更复杂的同步功能,比如需要设置获取锁的超时时间、需要等待某个条件满足再继续执行,那么lock关键字就不太适用,这时候可以考虑使用Monitor类的完整功能、Semaphore、Mutex等同步工具。如果是跨进程的同步需求,lock也无法满足,需要使用Mutex或者操作系统的同步机制。
当只需要简单的单进程内多线程互斥访问共享资源,且临界区逻辑简单、执行时间短时,lock关键字是最优的选择,它语法简洁,性能也优于其他重量级的同步方式。