并行与并发

文章目录

  • [1、dotnet9 锁对象](#1、dotnet9 锁对象)
    • [1. 最推荐的用法:配合 `lock` 关键字](#1. 最推荐的用法:配合 lock 关键字)
    • [2. 高级用法:使用 `EnterScope` 模式](#2. 高级用法:使用 EnterScope 模式)
    • [3. 核心注意事项(避坑指南)](#3. 核心注意事项(避坑指南))
        • [❌ 不要强制转换为 Object](#❌ 不要强制转换为 Object)
        • [❌ 不支持 async/await](#❌ 不支持 async/await)
      • 总结
  • 使用锁的时机
    • [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. ForAll vs foreach)
      • [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?)
  • 并发集合
      • [1.`ConcurrentDictionary<TKey, TValue>`](#1.ConcurrentDictionary<TKey, TValue>)
      • [2. `ConcurrentQueue<T>` (先进先出)](#2. ConcurrentQueue<T> (先进先出))
        • [核心用法:`Try` 模式](#核心用法:Try 模式)
      • [3. `ConcurrentBag<T>` (无序集合)](#3. ConcurrentBag<T> (无序集合))
      • [4. `BlockingCollection<T>` (生产者-消费者神器)](#4. BlockingCollection<T> (生产者-消费者神器))
      • 总结:应该怎么选?
      • [⚠️ 给逻辑控的特别提示](#⚠️ 给逻辑控的特别提示)
  • 分布式锁

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>,并且有多个线程在写入它。
  • 解决方案:
    1. 首选: 使用 System.Collections.Concurrent 命名空间下的集合(如 ConcurrentQueue, ConcurrentDictionary),通常不需要自己加锁,性能更好。
    2. 次选: 如果必须用 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 锁数据库锁

总结建议

  1. 能不用就不用: 优先设计无状态(Stateless)的代码。
  2. 能用原子类就用原子类: 简单的计数用 Interlocked
  3. 能用并发集合就用并发集合: 队列用 ConcurrentQueue
  4. 必须用时锁范围要小: 锁的代码块越短越好。千万不要在 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?

这也是逻辑判断的一部分。多线程是有"开销"的(创建线程、上下文切换、结果合并)。

  1. 数据量小: 如果列表只有几百条,PLINQ 的启动开销比直接算还要慢。
  2. I/O 密集型: 如果你的操作是读数据库、读文件、请求 API,不要用 PLINQ 。PLINQ 是为了压榨 CPU 的计算能力。对于 I/O 操作,应该用 async/await (Task.WhenAll)。
  3. 非线程安全操作: 不要在 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 (并行执行不同的任务)

这个方法允许你同时执行几个完全不相关的方法。

场景: 在打开盘点界面时,你需要从三个不同的源加载数据。

  1. 从 SQL Server 加载资产基础资料。
  2. 从 Redis 加载当前的实时库存缓存。
  3. 向 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); 
});

解决方案:

  1. 使用并发集合:ConcurrentBag<T>, ConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T> 代替 List<T>
  2. 使用锁 (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> (生产者-消费者神器)

这是最高级的封装,它通常包裹着一个 ConcurrentQueueConcurrentStack

  • 场景: 限流与等待 。如果 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);
    }
});

总结:应该怎么选?

在你的国企资产管理项目中:

  1. ConcurrentDictionary : 必用。用于存储当前房间的实时盘点状态(Key=EPC, Value=状态),用于 UI 变绿的逻辑。
  2. ConcurrentQueue : 推荐。用于缓冲从 PDA 接收到的原始字符串,解耦硬件接收和业务解析。
  3. ConcurrentBag : 可选 。如果你用 Parallel.ForEach 跑报表统计,用它来收集结果。
  4. BlockingCollection : 进阶。如果你发现程序内存暴涨,用它来限制队列长度。

⚠️ 给逻辑控的特别提示

在使用并发集合时,不要信任 CountIsEmpty 属性作为逻辑判断依据

  • 错误逻辑: if (!queue.IsEmpty) { queue.TryDequeue(...); }
  • 原因: 在判断 !IsEmptytrue 的那一纳秒之后,另一个线程可能瞬间把最后一个元素拿走了。等你执行 TryDequeue 时,它已经空了。
  • 正确逻辑: 直接调用 TryDequeueTryAdd,根据返回的 bool 值来判断是否成功。Action is truth.

分布式锁

1. 核心逻辑原理:抢坑位

从逻辑上讲,分布式锁的本质就一句话:"原子性的占坑"

它利用了 Redis 的单线程特性和 SETNX (Set if Not Exists) 逻辑。

理想状态的逻辑流:

  1. 抢锁: 客户端 A 发送 SETNX lock_key "1"
    • Redis 返回 True (写入成功) -> A 拿到锁,开始干活。
    • Redis 返回 False (由于 key 已存在) -> A 没抢到,排队或放弃。
  2. 解锁: 客户端 A 干完活,发送 DEL lock_key
  3. 循环: 坑位空出来了,客户端 B 上位。

2. 逻辑漏洞与哲学演进 (The Evolution of Logic)

单纯的 SETNX 在工程实践中存在三个巨大的逻辑漏洞。理解这三个漏洞,你才能真正掌握分布式锁。

漏洞一:死锁 (Deadlock)

  • 场景: 客户端 A 抢到了锁,刚要干活,突然断电了 (或崩溃了)。A 永远没有机会发送 DEL 指令。
  • 后果: 这个锁永远存在,所有人都被卡死。
  • 逻辑补丁: 引入超时机制 (TTL)
    • 指令进化: SET key value EX 10 NX (设置值并设置 10 秒过期,且仅当 key 不存在时执行)。这必须是一个原子操作

漏洞二:误删锁 (The Wrongful Unlock)

  • 场景:
    1. A 拿到锁,有效期 10 秒。
    2. A 的业务逻辑因为卡顿执行了 15 秒。
    3. 在第 10 秒时,Redis 自动删除了 A 的锁。
    4. B 趁机抢到了锁。
    5. 第 15 秒,A 醒了,执行 DEL结果 A 删除了 B 正在持有的锁!
    6. 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)** 机制:如果你的任务执行时间超过了锁的有效期,它会自动帮你给锁"续命",防止漏洞二发生。

  1. 安装 Nuget: RedLock.net
  2. 代码实现:
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 分布式锁:

  1. 防止库存超卖/变负:
    • 场景: 两个 PDA 同时扫描同一个 SKU 出库。
    • 锁 Key: lock:stock:{ProductId}
    • 逻辑: 锁住该商品 -> 读库存 -> 减库存 -> 写库存 -> 释放锁。
  2. 避免重复生成单据:
    • 场景: 用户手抖,连续点击了两次"创建盘点单"按钮。
    • 锁 Key: lock:create_bill:{UserId}
    • 逻辑: 锁住用户 ID 2秒,如果第二次请求进来发现有锁,直接拦截提示"请勿重复提交"。
  3. 定时任务去重:
    • 场景: 你部署了两台服务器,都有定时任务"每天凌晨 1 点同步 ERP 数据"。你不希望两台机器同时跑这个任务。
    • 锁 Key: lock:job:sync_erp
    • 逻辑: 谁抢到锁谁跑,抢不到的直接跳过(Fail-fast)。

总结

  • 原理: SET ... NX (原子占坑)。

  • 必须解决的问题: 死锁(用过期时间解决)、误删(用 UUID 解决)、原子性(用 Lua 解决)、任务过长(用看门狗解决)。

  • C# 建议: 不要自己造轮子写 Lua 脚本,直接用 RedLock.net,既安全又符合 .NET 9 的异步编程习惯。

    复制代码
    // 逻辑:抛出异常或者稍后重试
          Console.WriteLine("该托盘正在被其他人操作,请稍后。");
      }

    }

    }

    4. 你的业务场景:WMS 中的应用

    在你的国企展览园/仓储系统中,这几个地方必须用 Redis 分布式锁:

    1. 防止库存超卖/变负:
      • 场景: 两个 PDA 同时扫描同一个 SKU 出库。
      • 锁 Key: lock:stock:{ProductId}
      • 逻辑: 锁住该商品 -> 读库存 -> 减库存 -> 写库存 -> 释放锁。
    2. 避免重复生成单据:
      • 场景: 用户手抖,连续点击了两次"创建盘点单"按钮。
      • 锁 Key: lock:create_bill:{UserId}
      • 逻辑: 锁住用户 ID 2秒,如果第二次请求进来发现有锁,直接拦截提示"请勿重复提交"。
    3. 定时任务去重:
      • 场景: 你部署了两台服务器,都有定时任务"每天凌晨 1 点同步 ERP 数据"。你不希望两台机器同时跑这个任务。
      • 锁 Key: lock:job:sync_erp
      • 逻辑: 谁抢到锁谁跑,抢不到的直接跳过(Fail-fast)。

    总结

    • 原理: SET ... NX (原子占坑)。
    • 必须解决的问题: 死锁(用过期时间解决)、误删(用 UUID 解决)、原子性(用 Lua 解决)、任务过长(用看门狗解决)。
    • C# 建议: 不要自己造轮子写 Lua 脚本,直接用 RedLock.net,既安全又符合 .NET 9 的异步编程习惯。
相关推荐
提笔了无痕1 小时前
go web开发表单知识及表单处理详解
前端·后端·golang·web
5***V9331 小时前
SQL 基础 BETWEEN 的常见用法
数据库·sql·mybatis
世洋Blog1 小时前
Unity开发微信小游戏-合理的规划使用YooAsset
unity·c#·微信小游戏
甜味弥漫1 小时前
JavaScript新手必看系列之预编译
前端·javascript
小哀21 小时前
🌸 入职写了一个月全栈next.js 感想
前端·后端·ai编程
用户010269271861 小时前
swift的inout的用法
前端
用户6600676685391 小时前
搞懂作用域链与闭包:JS底层逻辑变简单
前端·javascript
yinuo1 小时前
前端跨页面通讯终极指南②:BroadcastChannel 用法全解析
前端
没落英雄2 小时前
简单了解 with
前端·javascript