CodeGym /课程 /C# SELF /锁定: lockMonitor

锁定: lockMonitor

C# SELF
第 56 级 , 课程 1
可用

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自动筛选中脱颖而出。

在高性能系统中,还会用到更复杂的同步机制,但 lockMonitor 依然是最常用的基础工具。

7. 使用锁的注意事项和常见错误

最常见的错误:用不同的对象作为锁,导致锁失效。例如:

void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }

如果 ab 不一样,即使都用锁,也不能保证同步。正确做法是用同一个对象作为锁,确保同步的正确性。

总结:始终用同一个对象保护同一份数据,避免用过宽的锁(比如 lock(this)),除非你非常确定没有外部代码会用这个对象作为锁,否则容易引发死锁或其他问题。不要在锁里放耗时操作(如文件、网络),以免阻塞其他线程,影响性能。临界区只做绝对不能并发的事情。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION