文章目录
- [1、dotnet9 锁对象](#1、dotnet9 锁对象)
-
- [1. 最推荐的用法:配合 `lock` 关键字](#1. 最推荐的用法:配合
lock关键字) - [2. 高级用法:使用 `EnterScope` 模式](#2. 高级用法:使用
EnterScope模式) - [3. 核心注意事项(避坑指南)](#3. 核心注意事项(避坑指南))
-
-
- [❌ 不要强制转换为 Object](#❌ 不要强制转换为 Object)
- [❌ 不支持 async/await](#❌ 不支持 async/await)
- 总结
-
- [1. 最推荐的用法:配合 `lock` 关键字](#1. 最推荐的用法:配合
- 使用锁的时机
-
- [1. "检查后执行" (Check-then-Act) 的场景](#1. “检查后执行” (Check-then-Act) 的场景)
- [2. 修改非线程安全的集合 (List, Dictionary)](#2. 修改非线程安全的集合 (List, Dictionary))
- [3. 初始化"昂贵"的单例资源 (Lazy Initialization)](#3. 初始化“昂贵”的单例资源 (Lazy Initialization))
- [4. 聚合计算 (Aggregation) / 累加器](#4. 聚合计算 (Aggregation) / 累加器)
-
- [⚠️ 重要区分:C# 锁 vs Redis 锁](# 锁 vs Redis 锁)
- 总结建议
- PLinq
-
- [1. 关键特性与陷阱 (逻辑避坑指南)](#1. 关键特性与陷阱 (逻辑避坑指南))
-
- [A. 顺序问题 (Ordering)](#A. 顺序问题 (Ordering))
- [B. `ForAll` vs `foreach`](#B.
ForAllvsforeach) - [C. 什么时候 **不应该** 用 PLINQ?](#C. 什么时候 不应该 用 PLINQ?)
- [2. 总结](#2. 总结)
- Parallel
-
- [1. `Parallel.ForEach` (最常用)](#1.
Parallel.ForEach(最常用)) -
- [2. `Parallel.Invoke` (并行执行不同的任务)](#2.
Parallel.Invoke(并行执行不同的任务)) - [3. `Parallel.For` (并行索引循环)](#3.
Parallel.For(并行索引循环)) - [🚨 逻辑陷阱:线程安全 (The Logic Trap)](#🚨 逻辑陷阱:线程安全 (The Logic Trap))
- [🔥 .NET 9 / 6+ 的现代写法:`Parallel.ForEachAsync`](#🔥 .NET 9 / 6+ 的现代写法:
Parallel.ForEachAsync) - [总结:PLINQ 还是 Parallel?](#总结:PLINQ 还是 Parallel?)
- [2. `Parallel.Invoke` (并行执行不同的任务)](#2.
- [1. `Parallel.ForEach` (最常用)](#1.
- 并发集合
-
-
- [1.`ConcurrentDictionary<TKey, TValue>`](#1.
ConcurrentDictionary<TKey, TValue>) - [2. `ConcurrentQueue<T>` (先进先出)](#2.
ConcurrentQueue<T>(先进先出)) -
- [核心用法:`Try` 模式](#核心用法:
Try模式)
- [核心用法:`Try` 模式](#核心用法:
- [3. `ConcurrentBag<T>` (无序集合)](#3.
ConcurrentBag<T>(无序集合)) - [4. `BlockingCollection<T>` (生产者-消费者神器)](#4.
BlockingCollection<T>(生产者-消费者神器)) - 总结:应该怎么选?
- [⚠️ 给逻辑控的特别提示](#⚠️ 给逻辑控的特别提示)
- [1.`ConcurrentDictionary<TKey, TValue>`](#1.
-
- 分布式锁
-
- [1. 核心逻辑原理:抢坑位](#1. 核心逻辑原理:抢坑位)
- [2. 逻辑漏洞与哲学演进 (The Evolution of Logic)](#2. 逻辑漏洞与哲学演进 (The Evolution of Logic))
- [3. C# 实现方案 (.NET 9)](# 实现方案 (.NET 9))
-
- [方案 A:手写实现 (用于理解原理,不推荐生产使用)](#方案 A:手写实现 (用于理解原理,不推荐生产使用))
- [方案 B:使用 RedLock.net (生产环境推荐)](#方案 B:使用 RedLock.net (生产环境推荐))
- [4. 你的业务场景:WMS 中的应用](#4. 你的业务场景:WMS 中的应用)
- 总结
1、dotnet9 锁对象
这个新类主要为了解决传统 Monitor(即 lock(obj) 背后使用的机制)性能开销较大且混用对象头(Object Header)的问题。它的性能更好,语法也更加清晰。
以下是具体的使用方法和场景:
1. 最推荐的用法:配合 lock 关键字
C# 13 编译器对 System.Threading.Lock 做了特殊优化。你只需要把以前锁定的 object 类型换成 System.Threading.Lock,其他代码完全不用变。
旧写法 (.NET 8 及以前):
C#
public class InventoryService
{
// 以前我们习惯用 object
private readonly object _syncRoot = new();
public void UpdateStock()
{
lock (_syncRoot) // 底层编译为 Monitor.Enter/Exit
{
// 线程安全操作
}
}
}
新写法 (.NET 9):
cs
using System.Threading; // 引用命名空间
public class InventoryService
{
// 现在直接使用 Lock 类型
private readonly Lock _syncRoot = new();
public void UpdateStock()
{
// 语法完全一样,但底层性能更强
lock (_syncRoot)
{
// 线程安全操作
}
}
}
原理: 当编译器发现 lock 的目标是 System.Threading.Lock 类型时,它不再生成 Monitor 代码,而是生成调用 _syncRoot.EnterScope() 的代码。
2. 高级用法:使用 EnterScope 模式
如果你喜欢更显式的控制范围(Scope),或者不能使用 lock 关键字的场景,可以使用 using 配合 EnterScope。
C#
private readonly Lock _lock = new();
public void ProcessData()
{
// EnterScope 返回一个 ref struct,离开 using 作用域时自动释放锁
using (_lock.EnterScope())
{
Console.WriteLine("正在处理关键任务...");
}
// 这里锁已经释放
}
这种写法非常优雅,它利用了 ref struct 的特性,确保锁一定会被释放(类似 C++ 的 std::lock_guard)。
3. 核心注意事项(避坑指南)
❌ 不要强制转换为 Object
如果你把 Lock 对象转为 object 再去锁,编译器会退化回旧的 Monitor 模式,丧失性能优势,甚至可能引发警告。
C#
Lock myLock = new();
object obj = myLock;
lock (obj) { ... } // ❌ 性能退化,编译器会警告
lock (myLock) { ... } // ✅ 正确,启用 .NET 9 新特性
❌ 不支持 async/await
System.Threading.Lock 是线程锁(Thread-affinity),这意味着"谁加锁,必须由谁解锁"。
- 不能 在
lock块内部使用await(编译会报错)。 - 如果你需要在异步代码中加锁,请继续使用
SemaphoreSlim。
异步场景(继续使用旧方案):
C#
private readonly SemaphoreSlim _asyncLock = new(1, 1);
public async Task DoAsyncWork()
{
await _asyncLock.WaitAsync(); // ✅ 异步锁
try
{
await Task.Delay(1000);
}
finally
{
_asyncLock.Release();
}
}
总结
在 .NET 9 中,只要你的代码是同步 的(不需要 await),请无脑将所有的 private readonly object _lock 替换为 private readonly Lock _lock。这能以最小的改动获得更好的性能和语义。
使用锁的时机
使用锁的核心原则非常简单:当且仅当多个线程同时访问同一个"共享"且"可变"的资源时,才需要加锁。
如果是只读 数据,不需要锁;如果是局部变量(每个线程自己独有的),也不需要锁。
1. "检查后执行" (Check-then-Act) 的场景
这是最容易产生 Bug 的地方。当你需要"先读取状态,根据状态决定是否修改"时,必须加锁,把读取和修改变成一个原子操作。
-
场景: 内存中缓存了待处理的任务列表,多个后台线程去取任务。
-
错误代码(无锁):
C#
if (taskList.Count > 0) // 线程A和线程B可能同时看到 Count > 0 { var task = taskList[0]; taskList.RemoveAt(0); // 线程A删除了,线程B再删就会报错或删错 } -
正确代码(使用 .NET 9 Lock):
C#
private readonly Lock _lock = new(); lock (_lock) // 锁住整个判断和执行过程 { if (taskList.Count > 0) { var task = taskList[0]; taskList.RemoveAt(0); } }
2. 修改非线程安全的集合 (List, Dictionary)
C# 中的 List<T>, Dictionary<TKey, TValue>, HashSet<T> 都不是 线程安全的。如果在多线程环境下对它们进行 Add, Remove 或扩容操作,会导致数据丢失、死循环或抛出异常。
- 时机: 当你有一个全局的
static List<Log>或者单例服务中的private List<User>,并且有多个线程在写入它。 - 解决方案:
- 首选: 使用
System.Collections.Concurrent命名空间下的集合(如ConcurrentQueue,ConcurrentDictionary),通常不需要自己加锁,性能更好。 - 次选: 如果必须用
List,则在所有读写该List的地方加锁。
- 首选: 使用
3. 初始化"昂贵"的单例资源 (Lazy Initialization)
有些资源(如加载巨大的配置档、建立特殊的硬件连接)很耗时,你只希望初始化一次。如果多个线程同时发现它为空,可能会初始化多次,浪费资源。
-
时机: 双重检查锁定(Double-Check Locking)。
-
代码示例:
C#private HeavyResource? _resource; private readonly Lock _initLock = new(); public HeavyResource GetResource() { if (_resource != null) return _resource; // 第一次检查(无锁,为了性能) lock (_initLock) { if (_resource == null) // 第二次检查(有锁,为了安全) { _resource = new HeavyResource(); } } return _resource; }
4. 聚合计算 (Aggregation) / 累加器
虽然简单的 i++ 看起来是一行代码,但在 CPU 层面它是三步(读、改、写),多线程下会被覆盖。
- 时机: 统计 API 被调用的总次数、统计扫描到的总包裹数。
- 优化方案:
- 如果只是简单的整数加减,不要用
lock,使用Interlocked.Increment(ref _count)。它的速度比lock快几十倍。 - 如果是复杂的对象状态更新(例如:更新订单状态并记录时间),则必须用
lock。
- 如果只是简单的整数加减,不要用
⚠️ 重要区分:C# 锁 vs Redis 锁
作为一个做仓储系统(WMS)的开发者,你必须区分进程锁 和分布式锁:
| 锁类型 | 关键字/工具 | 保护范围 | 典型 WMS 场景 |
|---|---|---|---|
| 进程内锁 | lock (C#), Monitor, SemaphoreSlim |
当前程序的内存。只防同一个服务器内部的线程冲突。 | 内存中的 List 缓存、防止本地日志文件写入冲突。 |
| 分布式锁 | Redis (RedLock), SQL Server (sp_getapplock) |
跨服务器/跨进程。防止多台服务器、多个部署实例冲突。 | 防止超卖(多个 PDA 同时扣减同一个 DB 库存)、生成全局唯一单号。 |
什么时候用哪个?
- 如果你的变量在 C# 内存里 (如
static变量),用 C#lock。 - 如果你的数据在 数据库里 ,且可能有多个 IIS/API 实例同时访问,必须用 Redis 锁 或 数据库锁。
总结建议
- 能不用就不用: 优先设计无状态(Stateless)的代码。
- 能用原子类就用原子类: 简单的计数用
Interlocked。 - 能用并发集合就用并发集合: 队列用
ConcurrentQueue。 - 必须用时锁范围要小: 锁的代码块越短越好。千万不要在
lock里面做 IO 操作(读库、发 HTTP 请求),这会把整个系统卡死。(先将数据读到内存中再进行处理)
PLinq
PLINQ (Parallel LINQ) 全称是 Parallel Language Integrated Query(并行语言集成查询)。
简单来说,它是 LINQ 的"多线程版本"。它利用现代计算机的多核 CPU 架构,将一个大的数据集合切分成多个小块,在多个线程上同时进行处理,最后合并结果。
1. 关键特性与陷阱 (逻辑避坑指南)
PLINQ 虽然强大,但如果逻辑不清,很容易踩坑。
A. 顺序问题 (Ordering)
-
逻辑现象: 多线程执行是并行的,谁先跑完不一定。所以 PLINQ 默认不保证输出顺序。
-
解决方案: 如果你需要结果和输入顺序一致(比如按时间排序的日志),必须显式调用
.AsOrdered()。C#// 结果是乱序的 (速度最快) var list1 = data.AsParallel().Select(x => x * 2).ToList(); // 结果顺序与 data 一致 (速度稍慢,因为要整理顺序) var list2 = data.AsParallel().AsOrdered().Select(x => x * 2).ToList();
B. ForAll vs foreach
如果你只是想并行执行动作,而不是生成新列表,不要用 .ToList() 后再 foreach。
-
错误逻辑:
.AsParallel().ToList()(这里已经合并回单线程了) ->foreach(单线程遍历)。 -
正确逻辑: 使用
.ForAll()。C#// 并行地对每个元素执行操作,不进行合并 rawTags.AsParallel().ForAll(tag => { Console.WriteLine($"Processing {tag} on thread {Thread.CurrentThread.ManagedThreadId}"); });
C. 什么时候 不应该 用 PLINQ?
这也是逻辑判断的一部分。多线程是有"开销"的(创建线程、上下文切换、结果合并)。
- 数据量小: 如果列表只有几百条,PLINQ 的启动开销比直接算还要慢。
- I/O 密集型: 如果你的操作是读数据库、读文件、请求 API,不要用 PLINQ 。PLINQ 是为了压榨 CPU 的计算能力。对于 I/O 操作,应该用
async/await(Task.WhenAll)。 - 非线程安全操作: 不要在 PLINQ 的 lambda 表达式里去修改外部的一个公共变量(比如
count++),除非你加锁(但加锁会严重降低性能)。
2. 总结
在你的 .NET 9 + Vben + SQL Server 项目中:
- 在 WebAPI 层: 当你需要处理 PDA 传来的大量盘点数据,进行复杂的内存计算(如对比、解析、复杂的规则校验)时,使用 PLINQ。
- 在 数据库 层: 继续使用 SQL Server 的查询能力或 EF Core。不要把所有数据拉到内存里用 PLINQ 做
Where筛选,那是数据库的工作。
Parallel
Parallel 是 C# 中 System.Threading.Tasks 命名空间下的一个静态类。
如果说 PLINQ 是"并行版的 SQL 查询"(侧重于数据转换和筛选),那么 Parallel 就是**"并行版的循环"**(侧重于执行动作和指令)。
作为一个喜欢逻辑的程序员,你可以这样理解:
- 普通循环 (
for/foreach): 一个工人,按顺序一件件做任务。 Parallel: 一个工头。他拿到一堆任务,瞬间招募了一群工人(线程),把任务分发下去同时做,并负责等待所有人都做完。
它主要有三个核心方法,非常适合你的 WMS(仓储管理系统)场景。
1. Parallel.ForEach (最常用)
这是 foreach 的多线程版本。它会自动根据你的 CPU 核心数,把一个集合(如 List<T>)切分成块,并行处理。
场景: 你有一批刚扫到的 RFID 原始数据(HEX码),需要经过复杂的校验逻辑(比如 CRC 校验、解密)才能变成业务对象。这是典型的 CPU 密集型任务。
C#
List<string> rawHexTags = GetRawTagsFromHardware();
// 线程安全的集合,用于存放结果
ConcurrentBag<AssetInfo> resultList = new ConcurrentBag<AssetInfo>();
// 使用 Parallel.ForEach 替代 foreach
Parallel.ForEach(rawHexTags, (hexTag) =>
{
// --- 这里面的代码会在多核上并行执行 ---
// 1. 执行耗时的 CPU 运算
var asset = HeavyComputeCheck(hexTag);
// 2. 如果校验通过,加入结果集
if (asset != null)
{
// 注意:必须用并发集合,不能用普通的 List.Add
resultList.Add(asset);
}
});
// --- 到这里时,所有线程都执行完毕 ---
2. Parallel.Invoke (并行执行不同的任务)
这个方法允许你同时执行几个完全不相关的方法。
场景: 在打开盘点界面时,你需要从三个不同的源加载数据。
- 从 SQL Server 加载资产基础资料。
- 从 Redis 加载当前的实时库存缓存。
- 向 RFID 硬件发送"初始化"指令。
普通写法得等 A 完再做 B,Parallel.Invoke 让它们同时开始。
C#
public void InitInventoryPage()
{
Parallel.Invoke(
() =>
{
// 任务 1
var dbData = LoadFromSqlServer();
Console.WriteLine("数据库加载完毕");
},
() =>
{
// 任务 2
var redisData = LoadFromRedis();
Console.WriteLine("缓存加载完毕");
},
() =>
{
// 任务 3
HardwareController.InitReader();
Console.WriteLine("硬件初始化完毕");
}
);
// 只有当上面三个任务全部完成后,才会执行到这里
Console.WriteLine("所有准备工作就绪,开始盘点!");
}
3. Parallel.For (并行索引循环)
这是 for (int i = 0; i < n; i++) 的并行版本。适用于处理数组,或者你知道确切循环次数的场景。
场景: 你的仓库有 100 排货架,逻辑上互不干扰,你要计算每一排的利用率。
C#
int shelfCount = 100;
double[] utilizationRates = new double[shelfCount];
Parallel.For(0, shelfCount, i =>
{
// i 是当前的索引 (0 到 99)
utilizationRates[i] = CalculateShelfUtilization(i);
});
🚨 逻辑陷阱:线程安全 (The Logic Trap)
这是新手使用 Parallel 最容易崩溃的地方。
错误示范:
C#
List<int> list = new List<int>();
Parallel.For(0, 1000, i =>
{
// ❌ 致命错误!
// List<T> 不是线程安全的。
// 多个人同时往一个框里扔球,球会撞飞,或者计数器出错。
list.Add(i);
});
解决方案:
- 使用并发集合: 用
ConcurrentBag<T>,ConcurrentDictionary<TKey, TValue>,ConcurrentQueue<T>代替List<T>。 - 使用锁 (Lock): 虽然可以解决,但会降低并行效率(大家都要排队进锁)。
🔥 .NET 9 / 6+ 的现代写法:Parallel.ForEachAsync
既然你用的是 .NET 9,这是你需要重点关注的新特性。
传统的 Parallel.ForEach 是阻塞的(它会占用线程池的线程等待 CPU 计算完成)。但现在的 WebAPI 开发中,我们大量的操作是 I/O(读库、调 HTTP 接口)。
如果你的循环里涉及异步操作 (比如 await context.SaveChangesAsync()),千万不要 用 Parallel.ForEach,而要用 Parallel.ForEachAsync。
场景: 盘点结束后,要把结果批量上传给另外一个 ERP 系统(HTTP 请求)。
C#
List<InventoryRecord> records = GetRecords();
// 限制并发度为 5 (防止把对方服务器打挂)
var options = new ParallelOptions { MaxDegreeOfParallelism = 5 };
await Parallel.ForEachAsync(records, options, async (record, cancellationToken) =>
{
// ✅ 这里可以使用 await
// 这是一个非阻塞的并行循环,非常适合 WebAPI
await _httpClient.PostAsJsonAsync("https://erp-system/api/update", record, cancellationToken);
});
总结:PLINQ 还是 Parallel?
- 用 PLINQ (
.AsParallel): 当你依然想保持 Linq 风格 的链式编程,且主要是为了处理数据流(筛选、映射、排序)时。 - 用 Parallel (
Parallel.ForEach): 当你需要执行复杂的动作,或者不需要返回集合,只需要修改外部状态时。 - 用 Parallel Async (
Parallel.ForEachAsync): 任何涉及 I/O 操作(数据库、网络请求)的循环。
并发集合
在 C# 的 System.Collections.Concurrent 命名空间下,微软为我们提供了一套线程安全的高性能集合类。
作为一个追求逻辑和效率的程序员,你可以把它们理解为**"自带红绿灯的数据容器"。普通的集合(如 List, Dictionary)在多线程并发读写时会报错或数据损坏,而并发集合内部使用了高效的 无锁(Lock-free)或 细粒度锁**算法,允许多个线程同时进出,而无需你手动写 lock。
以下是四个最常用的并发集合及其使用场景,结合你的 RFID 盘点业务:
1.ConcurrentDictionary<TKey, TValue>
这是 Dictionary<TKey, TValue> 的线程安全版本。它是你项目中最重要的工具。
- 场景: 你的 RFID"内存快照"。多个线程同时解析 PDA 传来的标签,需要同时查询或更新这些标签的状态(是否已读)。
- 特点: 读取完全无锁,写入使用细粒度锁,性能极高。
核心用法:原子操作
不要用 if (!dict.ContainsKey(key)) dict.Add(key, val),因为在多线程下这不安全(非原子性)。要用它特有的原子方法:
C#
var assets = new ConcurrentDictionary<string, AssetInfo>();
// 1. TryAdd: 尝试添加,如果已存在则失败(不会报错)
bool success = assets.TryAdd("EPC-001", new AssetInfo { ... });
// 2. GetOrAdd: 如果存在就返回旧的,不存在就创建新的并返回 (非常适合缓存)
// 逻辑:给我这个ID的对象,如果没有,就立刻用这个工厂函数造一个给我
var item = assets.GetOrAdd("EPC-002", (key) => new AssetInfo { Id = key });
// 3. AddOrUpdate: 如果不存在就添加,如果存在就更新
// 逻辑:记录最后一次扫描时间。不管之前有没有,现在都要变成最新的时间
assets.AddOrUpdate("EPC-003",
addValue: new AssetInfo { LastScan = DateTime.Now }, // 没找到时添加这个
updateValueFactory: (key, oldVal) => { // 找到了就用这个逻辑更新
oldVal.LastScan = DateTime.Now;
return oldVal;
});
2. ConcurrentQueue<T> (先进先出)
这是 Queue<T> 的线程安全版本。
- 场景: 数据缓冲管道。硬件接收线程(Reader Thread)疯狂接收 RFID 数据,直接丢进队列;后台处理线程(Worker Thread)从队列另一头拿出来慢慢存数据库。
- 特点: 无锁算法实现,速度非常快。
核心用法:Try 模式
永远不要先检查 Count > 0 再去 Dequeue,因为在你检查和取值之间,别的线程可能已经把它拿走了。
C#
var packetQueue = new ConcurrentQueue<string>();
// 生产者:入队
packetQueue.Enqueue("HEX-DATA-001");
// 消费者:出队
// 逻辑:尝试拿一个出来,如果拿到了(success=true),result里就有值
if (packetQueue.TryDequeue(out string result))
{
Console.WriteLine($"处理数据: {result}");
}
else
{
Console.WriteLine("队列空了,歇会儿");
}
3. ConcurrentBag<T> (无序集合)
它对应的是 List<T>,但它是无序的。
- 场景: Parallel.ForEach 的收集袋。当你用并行循环处理一堆数据,想把处理结果扔到一个集合里,最后一起保存。你不在乎谁先谁后,只要都在就行。
- 特点: 对"同一个线程存取"做了极致优化。
核心用法
C#
var validAssets = new ConcurrentBag<AssetInfo>();
Parallel.ForEach(rawTags, (tag) =>
{
var asset = ParseAndValidate(tag);
if (asset != null)
{
// 多个线程同时Add,完全安全,不需要lock
validAssets.Add(asset);
}
});
// 注意:遍历 ConcurrentBag 也是线程安全的
foreach (var item in validAssets) { ... }
4. BlockingCollection<T> (生产者-消费者神器)
这是最高级的封装,它通常包裹着一个 ConcurrentQueue 或 ConcurrentStack。
- 场景: 限流与等待 。如果 PDA 扫描速度太快(每秒 1000 次),数据库处理不过来,内存会爆。
BlockingCollection可以设置上限(比如 500)。如果队列满了,生产者线程会被"卡住"(Block),直到消费者处理掉一些腾出空间。 - 特点: 自带"红绿灯"和"停车位"。
核心用法
C#
// 限制容量为 100 个,超过就会阻塞生产者
var buffer = new BlockingCollection<string>(100);
// 任务 A:生产者 (模拟 RFID 接收)
Task.Run(() => {
while(true) {
string tag = Reader.Read();
// 如果 buffer 满了,这行代码会暂停执行,直到有空位
buffer.Add(tag);
}
});
// 任务 B:消费者 (模拟入库)
Task.Run(() => {
// GetConsumingEnumerable 是一个魔法迭代器
// 它会一直循环。如果队列空了,它会阻塞等待,直到有新数据进来
// 它是永动机,除非你调用 buffer.CompleteAdding()
foreach (var tag in buffer.GetConsumingEnumerable())
{
SaveToDb(tag);
}
});
总结:应该怎么选?
在你的国企资产管理项目中:
ConcurrentDictionary: 必用。用于存储当前房间的实时盘点状态(Key=EPC, Value=状态),用于 UI 变绿的逻辑。ConcurrentQueue: 推荐。用于缓冲从 PDA 接收到的原始字符串,解耦硬件接收和业务解析。ConcurrentBag: 可选 。如果你用Parallel.ForEach跑报表统计,用它来收集结果。BlockingCollection: 进阶。如果你发现程序内存暴涨,用它来限制队列长度。
⚠️ 给逻辑控的特别提示
在使用并发集合时,不要信任 Count 和 IsEmpty 属性作为逻辑判断依据。
- 错误逻辑:
if (!queue.IsEmpty) { queue.TryDequeue(...); } - 原因: 在判断
!IsEmpty为true的那一纳秒之后,另一个线程可能瞬间把最后一个元素拿走了。等你执行TryDequeue时,它已经空了。 - 正确逻辑: 直接调用
TryDequeue或TryAdd,根据返回的bool值来判断是否成功。Action is truth.
分布式锁
1. 核心逻辑原理:抢坑位
从逻辑上讲,分布式锁的本质就一句话:"原子性的占坑"。
它利用了 Redis 的单线程特性和 SETNX (Set if Not Exists) 逻辑。
理想状态的逻辑流:
- 抢锁: 客户端 A 发送
SETNX lock_key "1"。- Redis 返回
True(写入成功) -> A 拿到锁,开始干活。 - Redis 返回
False(由于 key 已存在) -> A 没抢到,排队或放弃。
- Redis 返回
- 解锁: 客户端 A 干完活,发送
DEL lock_key。 - 循环: 坑位空出来了,客户端 B 上位。
2. 逻辑漏洞与哲学演进 (The Evolution of Logic)
单纯的 SETNX 在工程实践中存在三个巨大的逻辑漏洞。理解这三个漏洞,你才能真正掌握分布式锁。
漏洞一:死锁 (Deadlock)
- 场景: 客户端 A 抢到了锁,刚要干活,突然断电了 (或崩溃了)。A 永远没有机会发送
DEL指令。 - 后果: 这个锁永远存在,所有人都被卡死。
- 逻辑补丁: 引入超时机制 (TTL) 。
- 指令进化:
SET key value EX 10 NX(设置值并设置 10 秒过期,且仅当 key 不存在时执行)。这必须是一个原子操作。
- 指令进化:
漏洞二:误删锁 (The Wrongful Unlock)
- 场景:
- A 拿到锁,有效期 10 秒。
- A 的业务逻辑因为卡顿执行了 15 秒。
- 在第 10 秒时,Redis 自动删除了 A 的锁。
- B 趁机抢到了锁。
- 第 15 秒,A 醒了,执行
DEL。结果 A 删除了 B 正在持有的锁! - C 趁机抢锁,B 和 C 同时在操作,锁失效。
- 逻辑补丁: 解铃还须系铃人 (Identity Check) 。
- 实现: 锁的值不能是简单的 "1",必须是一个唯一 ID (UUID/Guid)。
- 解锁逻辑:
if (redis.get(key) == my_uuid) { redis.del(key) }
漏洞三:原子性缺口 (Atomicity Gap)
- 场景: 上面的"解锁逻辑"是两步操作(Get 和 Del)。如果在 Get 之后、Del 之前,锁刚好过期被 B 抢走,A 依然会执行 Del 误删 B 的锁。
- 逻辑补丁: Lua 脚本 。
- Redis 保证一段 Lua 脚本执行期间,不会插入其他命令。
3. C# 实现方案 (.NET 9)
在 .NET 生态中,最底层通常使用 StackExchange.Redis,但为了处理上述复杂的逻辑(特别是"看门狗"续期),强烈建议使用成熟的封装库 RedLock.net。
方案 A:手写实现 (用于理解原理,不推荐生产使用)
这是一个包含上述逻辑补丁的基础实现:
C#
public async Task<bool> DoJobWithLockAsync(string lockKey, Func<Task> job)
{
var redis = ConnectionMultiplexer.Connect("localhost");
var db = redis.GetDatabase();
// 生成唯一的身份标识
string token = Guid.NewGuid().ToString();
// 1. 原子加锁 (SET key value NX PX 10000)
// 抢锁,设置 10秒 过期
bool isLocked = await db.StringSetAsync(lockKey, token, TimeSpan.FromSeconds(10), When.NotExists);
if (!isLocked) return false; // 抢锁失败
try
{
// 2. 执行业务
await job();
return true;
}
finally
{
// 3. 原子解锁 (使用 Lua 脚本)
string luaScript = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end";
await db.ScriptEvaluateAsync(LuaScript.Prepare(luaScript), new { key = (RedisKey)lockKey, value = token });
}
}
方案 B:使用 RedLock.net (生产环境推荐)
它自动处理了**"看门狗" (Watchdog)** 机制:如果你的任务执行时间超过了锁的有效期,它会自动帮你给锁"续命",防止漏洞二发生。
- 安装 Nuget:
RedLock.net - 代码实现:
C#
using RedLockNet.SERedis;
using RedLockNet.SERedis.Configuration;
// 初始化工厂 (一般单例)
var multiplexers = new List<RedLockMultiplexer> { ConnectionMultiplexer.Connect("localhost") };
var redLockFactory = RedLockFactory.Create(multiplexers);
public async Task ProcessInventoryAsync(string palletId)
{
string resource = $"lock:pallet:{palletId}";
TimeSpan expiry = TimeSpan.FromSeconds(30);
// 尝试获取锁
using (var redLock = await redLockFactory.CreateLockAsync(resource, expiry))
{
if (redLock.IsAcquired)
{
// --- 获取锁成功 ---
// 只要代码还在这个 using 块里运行,RedLock 会自动并在后台不断延长锁的有效期
// 确保你不用担心业务执行太久导致锁过期
await UpdateDatabaseLogic();
// using 结束时会自动安全释放锁
}
else
{
// --- 获取锁失败 ---
// 逻辑:抛出异常或者稍后重试
Console.WriteLine("该托盘正在被其他人操作,请稍后。");
}
}
}
4. 你的业务场景:WMS 中的应用
在你的国企展览园/仓储系统中,这几个地方必须用 Redis 分布式锁:
- 防止库存超卖/变负:
- 场景: 两个 PDA 同时扫描同一个 SKU 出库。
- 锁 Key:
lock:stock:{ProductId} - 逻辑: 锁住该商品 -> 读库存 -> 减库存 -> 写库存 -> 释放锁。
- 避免重复生成单据:
- 场景: 用户手抖,连续点击了两次"创建盘点单"按钮。
- 锁 Key:
lock:create_bill:{UserId} - 逻辑: 锁住用户 ID 2秒,如果第二次请求进来发现有锁,直接拦截提示"请勿重复提交"。
- 定时任务去重:
- 场景: 你部署了两台服务器,都有定时任务"每天凌晨 1 点同步 ERP 数据"。你不希望两台机器同时跑这个任务。
- 锁 Key:
lock:job:sync_erp - 逻辑: 谁抢到锁谁跑,抢不到的直接跳过(Fail-fast)。
总结
-
原理:
SET ... NX(原子占坑)。 -
必须解决的问题: 死锁(用过期时间解决)、误删(用 UUID 解决)、原子性(用 Lua 解决)、任务过长(用看门狗解决)。
-
C# 建议: 不要自己造轮子写 Lua 脚本,直接用 RedLock.net,既安全又符合 .NET 9 的异步编程习惯。
// 逻辑:抛出异常或者稍后重试 Console.WriteLine("该托盘正在被其他人操作,请稍后。"); }}
}
4. 你的业务场景:WMS 中的应用
在你的国企展览园/仓储系统中,这几个地方必须用 Redis 分布式锁:
- 防止库存超卖/变负:
- 场景: 两个 PDA 同时扫描同一个 SKU 出库。
- 锁 Key:
lock:stock:{ProductId} - 逻辑: 锁住该商品 -> 读库存 -> 减库存 -> 写库存 -> 释放锁。
- 避免重复生成单据:
- 场景: 用户手抖,连续点击了两次"创建盘点单"按钮。
- 锁 Key:
lock:create_bill:{UserId} - 逻辑: 锁住用户 ID 2秒,如果第二次请求进来发现有锁,直接拦截提示"请勿重复提交"。
- 定时任务去重:
- 场景: 你部署了两台服务器,都有定时任务"每天凌晨 1 点同步 ERP 数据"。你不希望两台机器同时跑这个任务。
- 锁 Key:
lock:job:sync_erp - 逻辑: 谁抢到锁谁跑,抢不到的直接跳过(Fail-fast)。
总结
- 原理:
SET ... NX(原子占坑)。 - 必须解决的问题: 死锁(用过期时间解决)、误删(用 UUID 解决)、原子性(用 Lua 解决)、任务过长(用看门狗解决)。
- C# 建议: 不要自己造轮子写 Lua 脚本,直接用 RedLock.net,既安全又符合 .NET 9 的异步编程习惯。
- 防止库存超卖/变负: