在C#的多线程编程场景中,当多个线程需要同时操作同一个共享资源时,很容易出现资源竞争导致的数据错误问题,lock语句就是用来解决这类线程同步问题的核心语法之一。它通过给共享资源加锁的方式,保证同一时间只有一个线程能够访问被保护的代码块,避免多线程并发操作带来的数据不一致风险。

lock语句的基本语法
lock语句的语法结构非常简单,基本格式如下:
// 定义用于加锁的对象,通常是私有的、只读的引用类型
private readonly object _lockObj = new object();
// lock语句的使用格式
lock (_lockObj)
{
// 需要被保护的共享资源操作代码
// 同一时间只有一个线程能进入这个代码块
}
需要注意,作为锁的对象必须满足几个要求:首先是引用类型,值类型不能作为锁对象;其次最好是私有、只读的,避免在程序运行过程中被修改引用,导致锁失效。通常我们会单独定义一个私有的object变量作为锁对象,不要使用this、typeof(类名)这类公共对象作为锁,否则很容易引发意外的锁冲突。
lock语句的实现原理
lock语句其实是C#提供的语法糖,编译器会将它转换为对Monitor类的调用,上面的lock代码会被编译为如下等价逻辑:
private readonly object _lockObj = new object();
// 等价于lock的代码
bool lockTaken = false;
try
{
Monitor.Enter(_lockObj, ref lockTaken);
// 被保护的代码块
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lockObj);
}
}
从编译后的代码可以看出,lock通过Monitor.Enter尝试获取锁,如果锁已经被其他线程持有,当前线程就会阻塞等待;当被保护的代码块执行完成后,无论是否出现异常,都会在finally块中通过Monitor.Exit释放锁,保证锁一定会被正确释放,避免死锁问题。
lock使用的常见场景
lock语句主要适用于以下场景:
- 多个线程需要修改同一个全局变量、静态变量或者实例字段的场景
- 操作共享的集合、队列等数据结构,且操作不是线程安全的场景
- 执行需要保证原子性的多步操作,比如先读取数据再修改再写入的完整流程
场景示例:多线程计数
下面是一个没有使用lock的多线程计数示例,会出现计数错误:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static int _count = 0;
// 没有加锁,多个线程同时修改_count会出现问题
static void Main()
{
Task[] tasks = new Task[10];
for (int i = 0; i < 10; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
_count++; // 非原子操作,多线程下会丢失更新
}
});
}
Task.WaitAll(tasks);
Console.WriteLine($"最终计数结果:{_count}"); // 结果通常小于10000
}
}
加上lock之后就可以得到正确的结果:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static int _count = 0;
// 定义私有的锁对象
private static readonly object _lockObj = new object();
static void Main()
{
Task[] tasks = new Task[10];
for (int i = 0; i < 10; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
lock (_lockObj)
{
_count++; // 加锁后同一时间只有一个线程执行自增
}
}
});
}
Task.WaitAll(tasks);
Console.WriteLine($"最终计数结果:{_count}"); // 结果固定为10000
}
}
lock使用的注意事项
使用lock语句时需要避免以下几类问题:
避免锁粒度过大
锁保护的代码块应该尽可能小,只包裹真正需要同步的共享资源操作代码,不要把无关的逻辑放到lock内部,否则会导致线程阻塞时间变长,降低程序的并发性能。
避免嵌套锁
尽量不要在一个lock内部再使用另一个lock,很容易引发死锁问题,比如线程A持有锁1等待锁2,线程B持有锁2等待锁1,两个线程就会一直阻塞。
不要锁公共对象
不要使用this、typeof(当前类)这类公共可访问的对象作为锁,外部代码也可能对这些对象加锁,会导致意外的锁竞争,甚至死锁。比如下面的用法是错误的:
// 错误示例:锁公共对象
public class Test
{
public void Method()
{
lock (this) // 外部代码也可能对Test实例加锁,容易冲突
{
// 操作代码
}
}
}
lock不能锁值类型
值类型作为锁对象时,每次装箱都会生成新的对象,锁会失效,因此必须使用引用类型作为锁对象。
lock和其他同步方式的区别
C#中还有Mutex、Semaphore、Interlocked等线程同步方式,它们和lock的区别如下:
| 同步方式 | 适用场景 | 性能 | 跨进程支持 |
|---|---|---|---|
| lock(基于Monitor) | 同一进程内的线程同步,保护少量共享资源操作 | 较高 | 不支持 |
| Interlocked | 简单的原子操作,比如自增、交换等 | 最高 | 不支持 |
| Mutex | 跨进程的线程同步,或者需要等待超时、可重入的场景 | 较低 | 支持 |
| Semaphore | 控制同时访问资源的线程数量,比如限制最多3个线程同时操作 | 中等 | 支持 |
在实际开发中,如果是同一进程内的简单共享资源同步,优先使用lock语句,它的语法简单且性能较好;如果是跨进程同步或者需要更复杂的控制逻辑,再选择其他同步方式。