什么是 lock,它如何工作,如何优雅地使用,以及在什么场景下需要用到。本文还将对常见陷阱、最佳实践、与其他同步原语的对比和诊断调试方法进行全面说明。
1. 基本概念
1.1 什么是 lock
lock是 C# 提供的用于多线程同步的语法糖,用来保护共享的可变状态,确保同一时刻只有一个线程可以进入某段临界区(critical section)。- 它用于避免数据竞争(data race)、状态破坏(invariant break)和不可预期的并发错误。
1.2 lock 与 Monitor 的关系
lock(obj) { ... }在编译后等价于:Monitor.Enter(obj); try { ... } finally { Monitor.Exit(obj); }
Monitor是 .NET 提供的底层监视器(monitor)机制,支持:- 进入/退出互斥(Enter/Exit)
- 尝试进入与超时(TryEnter)
- 条件等待与唤醒(Wait/Pulse/PulseAll)
lock是对Monitor的简单、安全封装,自动用try/finally确保异常时也会释放锁。
1.3 内存模型与可见性
- 进入锁(Monitor.Enter)具有“获取(acquire)”语义:之后读到的是其它线程释放锁前的最新写入。
- 退出锁(Monitor.Exit)具有“释放(release)”语义:在退出前对内存的写入对随后进入该锁的线程可见。
- 这意味着:临界区内建立的对象状态,在退出锁后对其他线程可见;读写都应在锁内完成以确保可见性一致。
2. 基本用法
2.1 基本语法与示例
public class Counter
{
private int _value;
private readonly object _gate = new object(); // 私有、只读锁对象
public void Increment()
{
lock (_gate)
{
_value++;
}
}
public int Value
{
get
{
lock (_gate)
{
return _value;
}
}
}
}
要点:
- “读”和“写”都要在同一把锁保护下,否则仍可能出现竞态条件。
lock会在异常时自动释放锁,不会因为异常导致锁永久不释放。
2.2 选择合适的锁对象
- 请使用私有的、不可变的引用类型作为锁对象:
- 推荐:
private readonly object _gate = new object();
- 推荐:
- 避免:
lock(this):公开对象可能被外部代码锁住,造成意外的死锁或性能问题。lock(typeof(SomeType)):类型对象是全局共享的,跨组件/库可能互相影响。lock(someString):字符串驻留(interning)会导致不同位置使用相同字符串字面量锁住同一对象。- 锁定值类型(struct):会装箱产生新的对象实例,导致锁形同虚设。
- 非只读锁对象:被替换后将保护不同的对象,破坏同步语义。
2.3 一个“丢失更新”的例子(对比演示)
// 非线程安全:可能丢失更新
int count = 0;
Parallel.For(0, 1_000_000, _ => count++); // 最终 count < 1_000_000
// 线程安全:
int safeCount = 0;
object gate = new object();
Parallel.For(0, 1_000_000, _ =>
{
lock (gate)
{
safeCount++;
}
});
3. 进阶用法
3.1 带超时的进入(Monitor.TryEnter)
当你不希望线程无限期等待锁时可以使用:
bool lockTaken = false;
try
{
if (!Monitor.TryEnter(_gate, TimeSpan.FromMilliseconds(100), ref lockTaken))
{
// 超时,选择降级方案或报错
return;
}
// 临界区
}
finally
{
if (lockTaken) Monitor.Exit(_gate);
}
适用于:避免长期阻塞、实现非阻塞降级策略或报警。
3.2 条件等待与唤醒(Monitor.Wait/Pulse)
Monitor.Wait/Pulse 是条件变量机制:等待某个条件达成、被其他线程唤醒。
public class BoundedQueue<T>
{
private readonly Queue<T> _queue = new();
private readonly object _gate = new();
private readonly int _capacity;
public BoundedQueue(int capacity) => _capacity = capacity;
public void Enqueue(T item)
{
lock (_gate)
{
while (_queue.Count >= _capacity)
{
Monitor.Wait(_gate); // 释放锁并等待,唤醒后会重新持有锁
}
_queue.Enqueue(item);
Monitor.Pulse(_gate); // 唤醒等待的消费者
}
}
public T Dequeue()
{
lock (_gate)
{
while (_queue.Count == 0)
{
Monitor.Wait(_gate);
}
var item = _queue.Dequeue();
Monitor.Pulse(_gate); // 唤醒等待的生产者
return item;
}
}
}
要点:
- 使用
while而不是if检查条件(避免虚假唤醒、错过唤醒等问题)。 - 正式场景可优先考虑更高层的并发集合,比如
BlockingCollection<T>或System.Threading.Channels。
3.3 双重检查锁定(DCL)与 Lazy 初始化
- DCL 是懒加载的经典模式,但容易被内存重排序坑到。推荐优先使用
Lazy<T>。
// 推荐:Lazy<T>
private static readonly Lazy<Resource> _resource =
new(() => new Resource(), LazyThreadSafetyMode.ExecutionAndPublication);
public Resource Instance => _resource.Value;
如果确需 DCL,自行实现时应使用 Volatile.Read/Write 或 volatile,确保发布安全:
private Resource? _res;
private readonly object _gate = new();
public Resource Instance
{
get
{
var r = Volatile.Read(ref _res);
if (r != null) return r;
lock (_gate)
{
if (_res == null)
{
Volatile.Write(ref _res, new Resource());
}
return _res!;
}
}
}
注意:自己实现 DCL 不如直接用 Lazy<T> 可靠与可读。
4. 常见应用场景
- 保护共享可变状态
- 多线程更新共享计数器、统计数据、对象属性等。
- 复合操作的原子性
- 读取-修改-写入(如从字典取值后再更新)必须在同一锁中完成。
- 保证对象不变式(invariant)
- 多个字段之间的关系必须保持一致,更新期间用锁保护。
- 缓存与懒加载
- 构建缓存填充、单例实例创建等。
- 简单的生产者-消费者协调
- 用
Wait/Pulse管理队列容量与等待线程。
- 用
- 批量读与写
- 在写入期间禁止读,或组合多步操作为原子单元。
5. 最佳实践与代码规范
- 使用专用、私有、只读的锁对象:
private readonly object _gate = new(); - 锁的粒度尽量小:临界区越短越好,减少阻塞时间与竞争。
- 不要在锁内执行:
- 长时间运行的任务(复杂计算、大量 I/O、网络调用)
- 不可控的外部回调(如事件触发、虚方法调用),避免死锁和不可预期阻塞。
- 尽量避免嵌套多个锁;若必须嵌套,统一全局锁顺序,防止死锁。
- 不要把锁对象暴露给外部;避免
lock(this)、lock(typeof(...))、lock(stringLiteral)。 - 读写都使用同一把锁,确保一致的可见性。
- 在调试/断言中可用
Monitor.IsEntered(_gate)进行校验。 - 遇到纯计数等简单操作优先考虑
Interlocked而不是lock,减少开销。 - 不能在
lock内使用await(编译器禁止),异步场景改用SemaphoreSlim或第三方AsyncLock。 - 保持 API 契约明确:方法是线程安全的还是需要外部同步,文档写清楚。
6. 与其他同步原语的对比与取舍
6.1 Interlocked
- 适合:对单个字段的原子增减、交换、比较交换等简单操作。
- 示例:
Interlocked.Increment(ref _count);
var old = Interlocked.Exchange(ref _flag, 1);
- 优点:极轻量;缺点:不适合复合操作或多个字段的不变式维护。
6.2 volatile
- 作用:保证读写不被重排序,并确保读取到最新值,但不提供原子复合操作。
- 适合:简单的“写入后读取”可见性场景;不解决“读-改-写”的竞态问题。
- 常见误区:用
volatile不能替代lock保护复合操作。
6.3 ReaderWriterLockSlim
- 适合:读远多于写的场景。
- 多个读者可同时进入;写者独占。
private readonly ReaderWriterLockSlim _rw = new();
public void Add(User u)
{
_rw.EnterWriteLock();
try { _users.Add(u); }
finally { _rw.ExitWriteLock(); }
}
public User? Find(string id)
{
_rw.EnterReadLock();
try { return _users.FirstOrDefault(x => x.Id == id); }
finally { _rw.ExitReadLock(); }
}
- 注意:逻辑复杂度更高,易误用;慎用升级/降级。
6.4 SemaphoreSlim(支持 async/await)
- 适合:需要异步等待的互斥。
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task DoWorkAsync()
{
await _mutex.WaitAsync();
try
{
// 可以在这里安全地使用 await
await SomeIoAsync();
}
finally
{
_mutex.Release();
}
}
- 与
lock不同:SemaphoreSlim可与await协作;但不是线程“重入”的(reentrant)互斥。
6.5 Mutex
- 跨进程同步(可命名),内核对象,开销相对大。
- 一般进程内使用
lock/Monitor更合适。
6.6 SpinLock / SpinWait
- 忙等自旋,适用于极短临界区、极高竞争下减少内核切换开销的场景。
- 高阶用法,容易引入问题;初学者不建议使用。
6.7 并发集合(ConcurrentDictionary 等)
- 框架提供的高层封装,内部已做细粒度同步或无锁算法。
- 优先使用标准并发集合,而非手写
lock包装普通集合。
7. 常见陷阱与如何规避
- 死锁(Deadlock)
- 原因:循环等待,如线程 A 锁 L1 后等 L2、线程 B 锁 L2 后等 L1。
- 规避:统一锁顺序;减少锁数量;避免在锁内调用外部代码;必要时设定超时。
- 饥饿(Starvation)与不公平(Unfairness)
Monitor不保证公平性;在高竞争下某些线程可能长期获取不到锁。- 可通过调整设计、减少锁持有时间来缓解。
- 在锁内执行 I/O 或耗时任务
- 导致长时间持锁,扩大竞争与阻塞范围。
await与lock- C# 禁止在
lock块中使用await(编译错误),避免锁在异步挂起期间被长期占用。 - 用
SemaphoreSlim来实现“异步互斥”。
- C# 禁止在
- 锁定错误对象
lock(this)、lock(typeof(...))、lock(stringLiteral)、锁定值类型等问题前文已述,务必避免。
- 使用不同的锁保护同一份数据
- 导致伪线程安全(看似加锁,实际仍有竞态);统一锁即可见性与原子性。
- 忘记给读取路径加锁
- 只对写加锁而读不加锁,仍可能读取到中间状态。
lock(null)- 会在运行时抛出 ArgumentNullException(本质是
Monitor.Enter(null))。
- 会在运行时抛出 ArgumentNullException(本质是
- 递归/重入
Monitor是可重入的,同一线程可以多次进入同一把锁;但这增加逻辑复杂度,通常应避免递归在锁内的设计。
8. 诊断与调试
- 快速自检
- 使用
System.Threading.Monitor.IsEntered(_gate)在关键路径加入Debug.Assert。
- 使用
- 观测锁争用
- 使用 PerfView、dotnet-trace、Visual Studio Concurrency Visualizer。
- 订阅 .NET 运行时事件(ContentionStart/Stop 等)分析争用热点。
- 死锁诊断
- 生成进程 Dump,用 WinDbg + SOS/.NET 分析线程堆栈与持有/等待锁情况。
- 在代码中记录锁顺序和关键路径、为 TryEnter 设置超时与日志。
- 性能分析
- 度量临界区时间、锁竞争次数与等待时间;结合高层设计优化(减少共享可变状态、改用并发集合或无锁结构)。
9. 实战示例合集
9.1 线程安全的集合包装
public class SafeList<T>
{
private readonly List<T> _inner = new();
private readonly object _gate = new();
public void Add(T item)
{
lock (_gate)
{
_inner.Add(item);
}
}
public bool Contains(T item)
{
lock (_gate)
{
return _inner.Contains(item);
}
}
public T[] Snapshot()
{
lock (_gate)
{
return _inner.ToArray();
}
}
}
9.2 缓存(简化版,优先用 ConcurrentDictionary)
public class Cache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _dict = new();
private readonly object _gate = new();
public bool TryGet(TKey key, out TValue value)
{
lock (_gate) return _dict.TryGetValue(key, out value!);
}
public TValue GetOrAdd(TKey key, Func<TKey, TValue> factory)
{
lock (_gate)
{
if (_dict.TryGetValue(key, out var existing))
return existing;
var created = factory(key);
_dict[key] = created;
return created;
}
}
}
提示:生产场景优先 ConcurrentDictionary<TKey, TValue>.GetOrAdd,减少手写同步逻辑。
9.3 异步场景下的互斥(用 SemaphoreSlim)
public class AsyncExclusive
{
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task UseAsync(Func<Task> action)
{
await _mutex.WaitAsync();
try
{
await action();
}
finally
{
_mutex.Release();
}
}
}
10. 何时该用 lock,何时不用
- 用
lock:- 多个线程需要安全地读写共享可变数据,并且操作包含多步或需要维护不变式。
- 你需要简单、可读、可靠的互斥方案,且不涉及
await。
- 不用
lock:- 只是简单计数、标志位等:用
Interlocked。 - 异步方法中需要互斥:用
SemaphoreSlim或第三方AsyncLock。 - 读多写少、数据尺寸较大:考虑
ReaderWriterLockSlim或并发集合。 - 能用不可变数据结构或消息传递(Actor/Channel)避免共享状态时,优先无共享或无锁设计。
- 只是简单计数、标志位等:用
11. 快速检查清单(Checklist)
- 是否确实有共享可变状态?能否用不可变或并发集合替代?
- 是否使用了私有、只读的锁对象?
- 是否保证读/写都在同一把锁内?
- 临界区是否足够小,避免耗时操作?
- 是否存在锁嵌套?锁顺序是否统一?
- 是否避免了
lock(this)、lock(typeof(...))、lock(string)、值类型锁等? - 是否需要异步互斥?若是,改用
SemaphoreSlim。 - 是否考虑了超时与降级策略(需要时)?
- 是否具备必要的日志与监控以定位争用或死锁?
12. 参考资料与延伸阅读
- Microsoft Docs: Monitor 类、lock 语句
- Stephen Toub 的并发系列博客(.NET 并发权威资料)
- .NET 并发集合(System.Collections.Concurrent)
- PerfView、dotnet-trace、VS Concurrency Visualizer 使用指南
通过本文,你应当了解:
lock的原理、语义和内存可见性保障;- 如何选择合适的锁对象并写出正确的临界区;
- 在典型场景中如何使用
lock,以及什么时候应该选用其他原语; - 常见陷阱与性能问题,以及如何诊断与优化。
从今天开始,编写线程安全的 C# 代码时,请优先考虑“减少共享、尽量不可变、必要时精确加锁”,让并发更简单、可靠、可维护。
限制对共享资源的并发访问
ASP .NET Core 中的并发访问控制:lock 的陷阱、是否需要分布式锁、推荐实践与代码示例:
本文面向 Web/云端场景,聚焦在 ASP .NET Core 中限制对共享资源的并发访问时如何正确设计。内容包括:
- 在 ASP .NET Core 使用
lock的常见陷阱 - 何时需要分布式锁,何时不需要
- 推荐的设计与实现方案(单机/分布式/异步场景)
- 多种可直接落地的代码示例
1. 在 ASP .NET Core 中直接使用 lock 的陷阱
lock 本质是进程内、同步阻塞、不可与 await 协作的互斥。Web 服务器环境与桌面/控制台应用不同,直接用 lock 常见隐患如下:
-
仅限单进程有效
- Web 常部署为多实例(K8s、WebFarm、Azure App Service 多实例)。
lock只在单一进程内生效,无法跨实例互斥,导致资源在不同实例上仍会并发访问。
- Web 常部署为多实例(K8s、WebFarm、Azure App Service 多实例)。
-
无法在异步管线中使用
lock块内不能await(编译器禁止)。很多 Web 场景需要等待 I/O(数据库、HTTP、存储)。若把 I/O 放到lock里,往往会迫使你使用.Result/.Wait()等“同步阻塞异步”,高风险且易死锁或线程饥饿。
-
阻塞线程、降低吞吐
lock会阻塞请求线程,占用线程池而不释放,容易引发线程池膨胀和上下文切换开销,恶化延迟和吞吐。
-
临界区过大与外部调用
- 在锁内执行外部服务调用/数据库访问/文件 I/O 等长时操作会扩大锁持有时间,造成系统级排队与长尾延迟,甚至雪崩。
-
锁对象选择与可见性错误
lock(this)、lock(typeof(...))、lock(string)、锁定值类型等都可能引入不可控冲突或无效锁。- 读与写未统一用同一把锁,导致“伪线程安全”。
-
进程边界与可靠性
- 应用回收/重启/崩溃会“突然释放”锁,和外部系统状态不一致;
lock不具备恢复/补偿语义。
- 应用回收/重启/崩溃会“突然释放”锁,和外部系统状态不一致;
-
粒度过粗
- 用一个全局
lock将所有请求串行化,极大限制吞吐。应尽可能使用“按键(Key)”的粒度(例如按用户、按资源ID)做互斥。
- 用一个全局
结论:在 ASP .NET Core 中,仅在“单实例 + 极短 CPU 临界区 + 无 await + 明确知道只保护进程内内存状态”时才考虑 lock。否则优先使用异步友好的原语或分布式协调手段。
2. 是否需要分布式锁?决策建议
-
不需要分布式锁(单机方案可行)的典型情形
- 应用部署保证单实例(本地开发、单机工具、后台单进程服务)。
- 互斥只为保护进程内缓存或短暂内存状态,不涉及跨实例共享资源。
- 对最终一致性有容忍,或无需全局排他。
-
优先不要用锁,而用数据层并发控制
- 有中心数据库/存储时,尽量将唯一性/排他性下沉到数据层:
- 数据库唯一约束 + 幂等重试
- 乐观并发(RowVersion/ETag)
- 悲观并发(行级锁、
SELECT … FOR UPDATE/ SQL Server 提示) - SQL Server
sp_getapplock应用级锁
- 这些手段天然跨实例有效,且与数据具有事务一致性。
- 有中心数据库/存储时,尽量将唯一性/排他性下沉到数据层:
-
需要分布式锁的典型情形
- 跨实例对“外部资源”做排他访问(如同一用户的账务操作、同一文件/对象存储Key、第三方接口的配额/令牌更新)。
- 无法通过单一数据库的约束来解决,或资源位于外部系统(对象存储、消息系统)且缺乏自带的并发控制。
- 需要对“临界区”施加跨实例的互斥/租约语义。
-
分布式锁注意事项
- 必须有超时/租约过期机制,避免死锁。
- 建议使用“fencing token(栅栏令牌)”模式:每次获得锁生成单调递增的令牌,后续对外部系统的操作附带该令牌,外部系统只接受令牌更大的调用,避免锁过期后“前任持有者”晚到的写入覆盖新状态。
- 分布式锁是可靠性设施,不是性能设施;存储/网络抖动会带来尾延迟。
3. 推荐方案一览(按优先级与适用场景)
- 优先:数据层并发控制(分布式、强一致或最终一致)
- 数据库唯一键、UPSERT、乐观并发、悲观锁、SQL Server
sp_getapplock。 - 优点:跨实例、事务一致性好;缺点:与 DB 耦合。
- 异步友好的进程内限流/互斥(单机或仅需单机保证)
SemaphoreSlim(支持await),最好按“Key”粒度控制,避免全局串行。- 背压/排队:使用
System.Threading.RateLimiting或ASP .NET Core Rate Limiting中间件。
- 分布式锁(当数据层无法承担或资源在外部)
- Redis 锁(例如 RedLock.net,但要了解其模型与权衡)。
- 云存储租约(Azure Blob Lease)或协调服务(Zookeeper/etcd/Consul Session)。
- 在关键路径配合“fencing token”与超时/心跳。
- 将慢操作出队至后台(避免在请求线程里持锁)
- 使用
Channel+BackgroundService,前端返回 202/队列位置;后台按顺序处理,避免锁持有时间影响请求线程。
4. 代码示例
4.1 单机、异步友好的全局并发限制(中间件)
使用 ASP .NET Core Rate Limiting(.NET 7+),限制全局或按端点并发请求数,避免队列爆炸或下游被打爆。
// Program.cs
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("global-concurrency", context =>
RateLimitPartition.GetNoLimiter("default"))
.AddConcurrencyLimiter("global-concurrency", _ => new ConcurrencyLimiterOptions
{
PermitLimit = 100, // 允许同时处理的请求数
QueueLimit = 1000, // 允许排队的请求数
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/work", async () =>
{
await Task.Delay(200); // 模拟 I/O
return Results.Ok("done");
}).RequireRateLimiting("global-concurrency");
app.Run();
适用:保护下游或本服务,易于落地,避免在应用内手写锁做限流。
4.2 单机、按 Key 的异步互斥(防止同一资源的并发)
用 SemaphoreSlim + ConcurrentDictionary 做“按资源ID”互斥,避免全局串行化。适合缓存填充、去抖/防击穿等。
public sealed class KeyedAsyncLocker<TKey> where TKey : notnull
{
private sealed class Entry
{
public readonly SemaphoreSlim Semaphore = new(1, 1);
public int RefCount;
}
private readonly ConcurrentDictionary<TKey, Entry> _map = new();
public async Task<IDisposable> AcquireAsync(TKey key, CancellationToken ct = default)
{
var entry = _map.AddOrUpdate(key, _ => new Entry { RefCount = 1 }, (_, e) =>
{
Interlocked.Increment(ref e.RefCount);
return e;
});
await entry.Semaphore.WaitAsync(ct).ConfigureAwait(false);
return new Releaser(this, key, entry);
}
private sealed class Releaser : IDisposable
{
private readonly KeyedAsyncLocker<TKey> _owner;
private readonly TKey _key;
private Entry? _entry;
public Releaser(KeyedAsyncLocker<TKey> owner, TKey key, Entry entry)
{
_owner = owner; _key = key; _entry = entry;
}
public void Dispose()
{
var e = Interlocked.Exchange(ref _entry, null);
if (e == null) return;
e.Semaphore.Release();
if (Interlocked.Decrement(ref e.RefCount) == 0)
{
// 尝试清理空锁
_owner._map.TryRemove(new KeyValuePair<TKey, Entry>(_key, e));
e.Semaphore.Dispose();
}
}
}
}
使用示例(避免对同一用户ID同时触发昂贵的后台查询):
public class ProfileService
{
private readonly KeyedAsyncLocker<string> _locker = new();
public async Task<UserProfile> GetOrRefreshAsync(string userId, CancellationToken ct)
{
using (await _locker.AcquireAsync(userId, ct))
{
// 临界区内可 await(使用的是 SemaphoreSlim)
// 例如先查缓存,再决定是否刷新
var cacheHit = await TryGetFromCacheAsync(userId, ct);
if (cacheHit != null) return cacheHit;
var fresh = await LoadFromBackendAsync(userId, ct);
await SaveToCacheAsync(userId, fresh, ct);
return fresh;
}
}
}
注意:
- 这只在单实例内生效。如果有多实例,需要转向数据层或分布式锁。
- 建议直接使用成熟库(如
AsyncKeyedLock),避免自己实现的边界问题。
4.3 数据库优先:SQL Server 的应用级锁(跨实例)
使用 sp_getapplock 获得跨连接/跨实例的命名锁,与事务协作良好。
// 以 Dapper 为例
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
public static class SqlAppLock
{
public static async Task<IDisposable?> AcquireAsync(
SqlConnection conn, SqlTransaction tx, string resource,
int timeoutMs = 5000, CancellationToken ct = default)
{
var result = await conn.ExecuteScalarAsync<int>(
"sp_getapplock",
new
{
Resource = resource, // 例如 $"order:{orderId}"
LockMode = "Exclusive",
LockOwner = "Transaction", // 随事务结束释放
LockTimeout = timeoutMs
},
commandType: CommandType.StoredProcedure,
transaction: tx);
// result >= 0 表示成功
if (result < 0) return null;
return new Releaser(); // 事务提交/回滚时自动释放
}
private sealed class Releaser : IDisposable
{
public void Dispose() { /* no-op: 交给事务释放 */ }
}
}
// 使用示例(事务内串行化同一订单的处理)
public async Task HandleOrderAsync(string orderId, CancellationToken ct)
{
await using var conn = new SqlConnection(_cs);
await conn.OpenAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
using var _ = await SqlAppLock.AcquireAsync(conn, (SqlTransaction)tx, $"order:{orderId}", 5000, ct)
?? throw new TimeoutException("获取分布式锁超时");
// 在同一订单上实现跨实例互斥
await ProcessOrderAsync(conn, (SqlTransaction)tx, orderId, ct);
await tx.CommitAsync(ct);
}
优点:
- 真正跨实例;和数据库事务绑定;实现简单。
缺点: - 绑定 SQL Server。其他数据库请使用其对应的悲观锁机制或等效功能。
4.4 Redis 分布式锁(带租约)
借助 RedLock .net(基于 Redis 的 Redlock 算法实现)。请了解其一致性假设与局限,重要业务建议搭配“fencing token”。
// 安装包:RedLock.net, StackExchange.Redis
using RedLockNet;
using RedLockNet.SERedis;
using RedLockNet.SERedis.Configuration;
var multiplexers = new[]
{
ConnectionMultiplexer.Connect("redis1:6379"),
// 可配置多节点以提升容错
};
IRedLockFactory redlockFactory = RedLockFactory.Create(multiplexers);
public async Task<bool> ProcessResourceAsync(string key, CancellationToken ct)
{
// 资源名建议有前缀
var resource = $"lock:res:{key}";
var expiry = TimeSpan.FromSeconds(10); // 租约时长
var wait = TimeSpan.FromSeconds(5); // 等待锁的时间
var retry = TimeSpan.FromMilliseconds(200);
await using (var redLock = await redlockFactory.CreateLockAsync(resource, expiry, wait, retry, ct))
{
if (!redLock.IsAcquired) return false;
// 可选:获取 fencing token(此库不原生提供单调 token,可用自增键实现)
// var token = await db.StringIncrementAsync("lock:fencing:" + key);
// 在租约内完成操作;长操作需续约或缩短临界区仅做“切换状态”
await DoWorkAsync(key, ct);
return true;
}
}
注意:
- 设置合理的过期与重试。
- 长事务不宜直接放在锁内;可用“状态切换 + 异步后台处理”。
- 对关键一致性需求,推荐使用数据库事务/悲观锁或云原生租约(如 Azure Blob Lease),并在外部系统侧校验 fencing token。
4.5 乐观并发与唯一约束(无锁思路)
用数据库唯一键/行版本控制来“自然串行化/拒绝冲突”,配合重试实现无锁化。
- 唯一键 + 幂等重试(示例)
- 为“业务唯一性”建立唯一索引(如同一用户对同一资源一天内只能创建一次记录)。
- 并发写入时,由数据库拒绝冲突;应用层捕获异常并做幂等返回。
- EF Core 乐观并发(RowVersion)
public class Item
{
public int Id { get; set; }
public string State { get; set; } = "";
public byte[] RowVersion { get; set; } = default!;
}
// 配置 RowVersion 为并发标记(Fluent API 或注解 [Timestamp])
modelBuilder.Entity<Item>().Property(e => e.RowVersion).IsRowVersion();
// 更新时捕获并发异常
try
{
item.State = "Done";
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
// 冲突:可读取最新状态并重试或返回冲突信息
}
优点:无锁、高可用、天然分布式。缺点:需要业务可接受冲突与重试语义。
4.6 将慢操作移出请求上下文(后台队列)
避免在请求线程里持有锁做长时操作,把任务入队,后台顺序处理。
// 简化版本的后台队列
public interface IBackgroundTaskQueue
{
ValueTask QueueAsync(Func<CancellationToken, Task> workItem);
ValueTask<Func<CancellationToken, Task>> DequeueAsync(CancellationToken ct);
}
public sealed class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, Task>> _channel =
Channel.CreateBounded<Func<CancellationToken, Task>>(new BoundedChannelOptions(100)
{
SingleReader = true, SingleWriter = false, FullMode = BoundedChannelFullMode.Wait
});
public ValueTask QueueAsync(Func<CancellationToken, Task> workItem)
=> _channel.Writer.WriteAsync(workItem);
public ValueTask<Func<CancellationToken, Task>> DequeueAsync(CancellationToken ct)
=> _channel.Reader.ReadAsync(ct);
}
public sealed class QueuedHostedService : BackgroundService
{
private readonly IBackgroundTaskQueue _queue;
public QueuedHostedService(IBackgroundTaskQueue queue) => _queue = queue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var work = await _queue.DequeueAsync(stoppingToken);
try { await work(stoppingToken); }
catch (Exception ex) { /* 日志与告警 */ }
}
}
}
在请求中使用:
app.MapPost("/process", async (IBackgroundTaskQueue q, HttpContext ctx) =>
{
// 入队,快速返回 202
await q.QueueAsync(async ct => await DoLongTaskAsync(ct));
return Results.Accepted();
});
适用:长时任务、外部系统交互;避免锁占用请求线程。
5. 小结与落地建议
- 能不用锁就不用锁:优先通过“数据层并发控制 + 幂等 + 重试”解决分布式并发。
- 确需应用层互斥时:
- 单实例与短临界区:
SemaphoreSlim/按 Key 的异步互斥,杜绝lock+await。 - 多实例:分布式锁或云存储租约;关键路径配合 fencing token;设置超时、失败降级。
- 单实例与短临界区:
- 全局并发与排队:使用
ASP .NET Core Rate Limiting中间件或System.Threading.RateLimiting。 - 避免在互斥内执行长时 I/O;可将慢操作放入后台队列,前端快速返回。
- 监控与可观测性:记录锁等待时间、超时率、队列长度;对长尾延迟报警。
6. 实用检查清单
- 你的服务是多实例部署吗?若是,进程内
lock不能满足需求。 - 临界区内是否包含 I/O?如果是,改用
SemaphoreSlim并尽量缩短临界区或改为后台处理。 - 是否能用数据库唯一约束/并发控制替代应用层锁?
- 是否需要“按 Key”互斥而非全局互斥?
- 分布式锁是否设置了超时/租约与续约策略?是否具备 fencing token 设计?
- 出错/超时时是否有降级或重试策略?
- 是否具备日志与指标来观测锁等待、队列长度与失败率?
通过以上策略,你可以在 ASP .NET Core 中用正确的方式限制对共享资源的并发访问:该单机用异步互斥时就用 SemaphoreSlim 与按键粒度;该放数据层就放数据层;不可避免跨实例互斥时,选择可靠的分布式锁或租约机制,并配合幂等与可观测性,保证正确性与可维护性。