在 C# 里,lock
关键字就是对 Monitor.Enter/Exit
的简写。它的作用是保证“同一时刻只有一个线程能进入被保护的代码块”,从而避免多个线程同时修改同一个共享状态导致竞态条件(race condition)。
一、结合Jog 的例子讲解
// MotionService 内部,用于保护 _isJogging 标志位
private static volatile bool _isJogging = false;
private static readonly object _jogLock = new object();
public static void JogStart(...)
{
// 1. 用 lock 把后面的检查和赋值变成“原子”操作
lock (_jogLock)
{
if (_isJogging)
return; // 已经有线程在 Jog,就不再启动新的
_isJogging = true; // 否则立刻把标志置为 true
}
// … 后面启动后台循环的逻辑 …
}
为什么要 lock?
假设有两个线程几乎同时调用JogStart()
,如果没有lock
,它们都可能先执行if (_isJogging)
(此时还是false
),然后同时进入,然后又同时把_isJogging = true
,最后就启动了两条后台循环,违反了“同一时刻只有一个 Jog” 的设计初衷。lock (_jogLock)
做了什么?- 某线程执行到
lock
时,会尝试“拿到”_jogLock
的内部锁(Monitor) - 如果别的线程已拿到,就阻塞等待,直到那线程执行完
lock
块、离开后释放锁 - 拿到锁后,该线程才能进入大括号内,执行检查和赋值
- 结束
}
时自动释放锁,其他线程才有机会进入
- 某线程执行到
这样,你就把“检查标志”+“设置标志”整个过程当成一个不可分割的操作,彻底杜绝并发竞态。
二、再举一个常见例子:线程安全的银行账户
假设有一个 BankAccount
类,多个线程可能同时给同一个账户存取款。我们要保证余额永远不会因为并发而乱掉,就可以用 lock
:
public class BankAccount
{
private decimal _balance = 0m;
private readonly object _balanceLock = new object();
public void Deposit(decimal amount)
{
// 存款操作必须互斥
lock (_balanceLock)
{
_balance += amount;
}
}
public void Withdraw(decimal amount)
{
// 取款操作也必须互斥,并在余额足够时才扣款
lock (_balanceLock)
{
if (_balance < amount)
throw new InvalidOperationException("余额不足");
_balance -= amount;
}
}
public decimal GetBalance()
{
// 如果你也想在读余额时保证最新一致,可以加锁;否则可直接返回
lock (_balanceLock)
{
return _balance;
}
}
}
_balanceLock
:保护decimal _balance
的私有对象- 同一时刻,只能有一个线程在执行
Deposit
或Withdraw
中被lock
包围的部分 - 如果两个线程同时存取,第二个会在
lock
阻塞,等第一个操作完成才继续
总结
lock(obj) { ... }
:等同于Monitor.Enter(obj); try { ... } finally { Monitor.Exit(obj); }
使用原则:
- 用私有的
readonly
对象做锁,不要lock(this)
或lock(typeof(...))
- 把所有访问共享状态(变量/集合/字段)的代码都包在
lock
里 - 尽量让锁内代码简短,避免长时间占用锁导致其他线程饥饿
- 用私有的
效果:保证多线程环境下对共享数据的“检查-修改”操作是原子的,消除竞态,确保程序行为可预测、不会乱跑。
在 C# 里,lock
语句后面必须跟一个 引用类型的“同步对象”(sync object),它的作用就是充当「看门人」:任何线程在进入 lock(obj){ … }
这一段代码前,都要先尝试“拿到”这个对象的监视器(Monitor);如果已经被别的线程拿走,就会在这里阻塞,直到对方执行完 lock
块、释放锁。
补充
一、为什么要 private static readonly object _jogLock = new object();
专门的“锁”对象
- 你要给
lock
一个「值唯一且不会被外部改动」的对象来做锁。 new object()
生成一个全新的、空白的对象实例,除了用来锁,它不会被当成其它用途也不会被别的代码意外取锁。
- 你要给
private
- 锁对象对外不可见,避免外部其他代码也去锁它,减少死锁风险。
static
- 因为
MotionService
中所有成员(如_isJogging
、JogStart
)都是静态的,所以锁也必须静态的,才能跨所有调用者、所有线程保护同一块共享状态。
- 因为
readonly
- 一旦初始化后,这个引用永远指向同一个对象,保证锁的一致性;如果别人把它指向别的对象,就可能拿不到原来的锁。
// 定义一把专用的“锁”
private static readonly object _jogLock = new object();
二、为什么用 lock(_jogLock)
而不是 lock(this)
或者锁字符串?
锁 “this” 有风险
- 如果别人也
lock(someInstance)
,就可能和你无意中互相等待;而且外部很容易拿到this
,耦合度高。
- 如果别人也
锁字符串或 Type 对象更危险
- 字符串常量会被 CLR 统一(interning),多处同名字符串可能共用同一个锁,容易引发意外死锁;
lock(typeof(SomeClass))
同理,会和任何锁这个 Type 的代码互相影响。
最佳实践
- 总是为每个需要保护的“共享资源”声明一个 私有的、专用的、不可被外部访问的
readonly object
,只在内部用它做lock
。
- 总是为每个需要保护的“共享资源”声明一个 私有的、专用的、不可被外部访问的
三、lock(_jogLock)
的设计目的
public static void JogStart(...)
{
lock (_jogLock)
{
if (_isJogging) return; // ※ 原子检查:
_isJogging = true; // 先检查、再设置,都在同一把锁里,一次性完成
}
// … 启动后台 Jog 逻辑 …
}
- 原子性:把「看
_isJogging
标志」和「写_isJogging = true
」这两步放在同一个锁里,绝不被其它线程打断。 - 竞态保护:任何时候只有拿到
_jogLock
的线程才可能进入这段代码,第二个线程会被挂起在lock
处,等到第一个线程释放锁后再来检测_isJogging
,确保“同一时刻”最多只有一个线程把标志从false
变成true
。
四、再举一个例子:线程安全的计数器
public class SafeCounter
{
private int _count = 0;
private readonly object _countLock = new object();
public void Increment()
{
lock (_countLock)
{
// 下面两步必须原子进行,不能被其他线程同时执行
_count = _count + 1;
}
}
public int GetValue()
{
lock (_countLock)
{
return _count;
}
}
}
_countLock
就是专门保护_count
的锁。Increment()
在加 1 前先拿锁,确保两个线程不会同时读取旧值并写回相同的新值。GetValue()
也可以加锁,确保读到的是最新且一致的值。
小结
锁对象:私有、专用、不变 (
private readonly object _lock = new object()
)lock(obj){…}
:把一段关键代码变成「同一时间只有一个线程能进」设计原则:
- 不锁
this
、不锁字符串、不锁 Type。 - 每个类/资源用自己的私有锁对象。
- 锁范围要尽可能小,只包围需要原子执行的那几行。
- 不锁