1. 引言
让我们来看一个之前课程中熟悉的例子。假设我们有一个公共的成功计数器,用在我们的(非常简单的)应用中。
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // 不是原子操作!
}
}
// 启动两个线程:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Counter: {counter}");
多次运行这段代码,你几乎永远看不到 200_000!为什么?两个线程不断互相干扰,有时会同时读取变量,然后都增加,最后写入相同的结果。这样一来,部分自增操作就“丢失”了。
这就是所谓的“竞态条件”或 Race Condition。如果不遵守“排队”的规则,线程就会在数据上“打架”。
什么是临界区?
临界区 — 这是只允许一个线程同时执行的代码片段。回到厨房比喻:就像水龙头一样,如果两个人同时试图用同一个水槽洗脸,水花和牙膏就会到处都是。我们约定:一个时间只允许一个人用浴室!
在我们的例子中,临界区就是这行:counter++。
2. 关键字 lock
在 C# 中,有一种简洁又安全的方法来定义临界区,就是用 lock 关键字。它帮我们隐藏了底层的同步机制,确保在任何时刻,只有一个线程可以进入被保护的代码块。
如何使用 lock
语法如下:
lock (lockerObject)
{
// 这段代码一次只允许一个线程执行
}
lockerObject — 这是任何在程序生命周期内存在的对象。通常这样定义:
private static object locker = new object();
注意:千万不要用字符串、数字或者其他可能被外部访问的对象作为锁对象!只用私有的、你自己控制的对象。
改进我们的例子
private static object locker = new object();
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
lock (locker)
{
counter++; // 现在是原子操作了!
}
}
}
这样,无论有多少个线程同时调用,只有一个会进入锁定区域。最终结果一定是 200_000,大家都满意!
3. lock 内部是怎么工作的?
类 System.Threading.Monitor
实际上,lock 关键字底层调用的是 System.Threading.Monitor 类。它就像一个门卫,只允许持有“通行证”的线程进入。
等价的写法(虽然不推荐):
Monitor.Enter(locker);
try
{
// 临界区
}
finally
{
Monitor.Exit(locker);
}
这里,Monitor.Enter 会阻塞其他线程,直到当前线程退出临界区。一定要记得在最后调用 Monitor.Exit,否则其他线程会一直等待,程序就会“死锁”——就像 Windows 更新卡住一样。
对比表:lock vs. 手动使用 Monitor
| 方法 | 错误安全性 | 写起来方便 | 灵活性 |
|---|---|---|---|
lock(obj) |
是 | 是 | 否 |
Monitor |
只有配合 try/finally | 否 | 是 |
大部分情况下,建议用 lock。只有需要特殊功能,比如带超时的锁定,才用 Monitor。
4. lock 的参数:可以用什么,不能用什么?
新手常犯的错误:用字符串或其他“可见”的对象作为锁。例如:
lock ("mylock") { /*...*/ } // 很不好!
原因是字符串是 interned(全局唯一的),容易和别的库冲突,导致死锁或程序崩溃。正确做法是用私有对象:
private readonly object myLock = new object();
lock (myLock)
{
// 只有你的代码知道 myLock
}
5. lock 示例:控制台输出同步
来练练手!写个小程序,让两个线程打印字符串,但打印操作也用锁保护,保证输出不乱套。
private static object consoleLock = new object();
void PrintMessages(string name)
{
for (int i = 0; i < 5; i++)
{
lock (consoleLock)
{
Console.WriteLine($"{name}: Сообщение {i + 1}");
Thread.Sleep(50); // 模拟处理时间
}
}
}
Thread t1 = new Thread(() => PrintMessages("Поток 1"));
Thread t2 = new Thread(() => PrintMessages("Поток 2"));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
结果:输出的字符串一行接一行,没有杂乱。这个技巧常用在日志记录,避免“乱码”。
6. 一些实用的细节
手动控制锁:高级用法 Monitor
如果你觉得 lock 不够用,比如想试试不等待就进入临界区,可以用 Monitor.TryEnter:
if (Monitor.TryEnter(locker, 100)) // 等待 100 毫秒
{
try
{
// 临界区
}
finally
{
Monitor.Exit(locker);
}
}
else
{
Console.WriteLine("未能在 100 毫秒内获得锁");
}
这样可以避免程序“卡死”,比如提示用户或者做其他事情,等待资源变得可用。
可视化:锁的工作流程(示意图)
可以用流程图表示锁的工作流程:
flowchart LR
A[线程1:想进入临界区]
B[线程2:想进入临界区]
C[locker 空闲]
D[线程1 执行临界区代码]
E[线程2 等待]
F[线程1 退出锁]
G[线程2 获取锁]
A -- 检查 locker --> C
C -- locker 空闲 --> D
B -- 检查 locker --> D
D -- 锁已占用 --> E
D -- 执行完毕 --> F
F -- 释放 locker --> G
E -- 现在 locker 空闲 --> G
锁的性能影响
锁的工作原理很简单:每次只允许一个线程执行临界区。这保证了数据一致性,但也意味着:线程越多,等待越久。为了性能考虑,建议只把绝对必要的代码放在锁内,尽量缩小临界区范围。
生活技巧:如果临界区只占用几毫秒,没问题;如果耗时长(比如网络请求、文件操作),就把耗时操作放到锁外面,只在必要时更新共享状态。
面试和实际应用
在任何涉及多线程的严肃项目中,面试官都可能问:“如果两个线程同时访问同一变量怎么办?” 展示你的锁代码,能让你的简历在HR自动筛选中脱颖而出。
在高性能系统中,还会用到更复杂的同步机制,但 lock 和 Monitor 依然是最常用的基础工具。
7. 使用锁的注意事项和常见错误
最常见的错误:用不同的对象作为锁,导致锁失效。例如:
void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }
如果 a 和 b 不一样,即使都用锁,也不能保证同步。正确做法是用同一个对象作为锁,确保同步的正确性。
总结:始终用同一个对象保护同一份数据,避免用过宽的锁(比如 lock(this)),除非你非常确定没有外部代码会用这个对象作为锁,否则容易引发死锁或其他问题。不要在锁里放耗时操作(如文件、网络),以免阻塞其他线程,影响性能。临界区只做绝对不能并发的事情。
GO TO FULL VERSION