异步 / 多线程并发(高频八股 · 详解版)

工业上位机、采集卡、串口/网口通信、多设备并发读写------面试和实战都绕不开这套知识。本文按「概念 → 原理 → API → 项目代码 → 坑」展开,并结合项目里的 SimpleHybirdLock 讲透混合锁。

学习路径建议: 一~三打底 → 五吃透锁 → 四/六对接采集与 UI → 七背避坑 → 八~十一默写加分。


目录

  1. [概念:异步 / 多线程 / 并发 / 并行](#概念:异步 / 多线程 / 并发 / 并行)

  2. Thread、Task、线程池

  3. [async / await 深挖](#async / await 深挖)

  4. [Channel 与生产者-消费者](#Channel 与生产者-消费者)

  5. [线程同步全家桶 + SimpleHybirdLock](#线程同步全家桶 + SimpleHybirdLock)

  6. [跨线程 UI:Invoke / BeginInvoke / Dispatcher](#跨线程 UI:Invoke / BeginInvoke / Dispatcher)

  7. 避坑:泄漏、死锁、竞态

  8. 并发集合与内存可见性

  9. [并行编程 Parallel / PLINQ](#并行编程 Parallel / PLINQ)

  10. 落地架构与完整示例

  11. [速记表 / 面试题 / 排查清单](#速记表 / 面试题 / 排查清单)


一、先分清:异步、多线程、并发、并行

1.1 一张表定调

概念 本质 是否一定多核 典型场景
多线程 多个执行流由 OS 调度交替/并行 否(单核也能「假并行」) 后台采集循环、定时轮询
异步 等待期间交出线程,完成后用延续继续 否;多数 I/O 几乎不占线程 串口、TCP、DB、文件、HTTP
并发 同一时间段内推进多件事(可交错) 否 UI + 采集 + 日志同时「在跑」
并行 同一时刻多核真正同时算 是 Parallel.For、多设备独立 Task

1.2 面试金句(务必能背)

  • async/await 不等于 新开线程;await 一个已经完成的 Task 甚至可能 同步继续执行。

  • 界面卡死常见三因:① UI 线程同步阻塞;② .Result / .Wait() 死锁;③ 过高频 Invoke 刷控件。

  • 高频采集卡顿:采集太快、处理太慢、无缓冲 → 生产者-消费者 + 有界队列/Channel。

  • 同步 ≠ 互斥:同步是「协调」;互斥只是同步手段之一(还有事件、信号量、屏障等)。

1.3 该用哪种?决策树

复制代码
需要等待 I/O / 定时?
  ├─ 是 → async/await(优先官方 *Async API)
  └─ 否 → 是否 CPU 密集、可拆分?
            ├─ 是 → Task.Run / Parallel(注意别堵 UI)
            └─ 否 → 普通同步代码即可
​
需要共享可变状态?
  └─ 是 → 锁 / 并发集合 / 串行化到单消费者(Channel)
​
需要高频采样 + 慢处理?
  └─ 是 → 有界缓冲 + 专用采集线程/Task + UI 节流

二、Thread、Task 与线程池

2.1 Thread(少用,但要懂)

复制代码
var t = new Thread(() =>
{
    // 长循环采集可以放这里
})
{
    IsBackground = true, // 进程退出时不挡主进程结束
    Name = "DAQ-Thread",
    Priority = ThreadPriority.Normal
};
t.Start();
// t.Join(); // 等待结束;慎用 Abort(已过时)
点 说明
前台/后台 默认前台;IsBackground=true 时进程可带着它一起退出
代价 创建、栈空间、上下文切换都比池化贵
适用 长期占用 的专用循环(读卡、阻塞式串口),避免占着线程池不还
过时 Suspend/Resume/Abort 不要用;用协作式取消

2.2 Task 是什么

Task / Task<TResult> 表示 一次操作的生命周期,不是线程本身。

状态(简化):Created → WaitingToRun → Running → RanToCompletion / Faulted / Canceled。

复制代码
Task.Run(() => DoCpuWork());                    // 线程池
await stream.ReadAsync(buf, 0, buf.Length);     // I/O 异步,通常不额外占线程
常用创建与组合
API 用途 注意
Task.Run CPU / 快速离开同步上下文 别包一层已有的 async I/O(假异步)
Task.Factory.StartNew 细粒度选项 默认调度器行为易踩坑,新手优先 Task.Run
Task.FromResult / CompletedTask 已完成 实现接口时返回「立即完成」
Task.Delay 异步延时 替代 Thread.Sleep(不堵线程)
Task.WhenAll 全完成 任一异常会进 AggregateException / await 时展开
Task.WhenAny 先完成者 其余任务仍在跑,记得取消或观察异常
Task.FromCanceled / FromException 构造终态 测试/适配层
TaskCreationOptions(知道即可)
  • LongRunning:提示调度器可用专用线程(长阻塞循环可考虑)。

  • AttachedToParent:父子任务关系,复杂且少用。

  • 面试常问:StartNew + async 委托容易返回 Task<Task>,需 Unwrap()。

2.3 TaskCompletionSource(自己造「可 await 事件」)

把回调型 API 转成 Task:

复制代码
Task<byte[]> ReadOnceAsync(SerialPort sp, CancellationToken ct)
{
    var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
    SerialDataReceivedEventHandler handler = null;
    handler = (s, e) =>
    {
        try
        {
            var buf = new byte[sp.BytesToRead];
            sp.Read(buf, 0, buf.Length);
            sp.DataReceived -= handler;
            tcs.TrySetResult(buf);
        }
        catch (Exception ex) { tcs.TrySetException(ex); }
    };
    ct.Register(() => { sp.DataReceived -= handler; tcs.TrySetCanceled(ct); });
    sp.DataReceived += handler;
    return tcs.Task;
}

要点:TrySet* 只生效一次;延续建议 RunContinuationsAsynchronously,避免在回调线程上直接跑大量延续逻辑。

2.4 线程池(ThreadPool)深说

.NET 线程池要点:

  1. 工作项队列 + 工作线程集合 ;Task.Run、Timer、不少 async 延续都进池。

  2. 线程创建昂贵 → 复用;池会根据吞吐 注入/回收 线程。

  3. 禁止 在池线程里无限阻塞(Sleep、同步串口死等、lock 长时间):会导致 线程池饥饿(其它 Task 排队卡死,看起来像「整站假死」)。

  4. 全局可看:ThreadPool.GetMinThreads / SetMinThreads(调优慎用;先修阻塞)。

复制代码
// ❌ 错误:池线程里永久阻塞读硬件
ThreadPool.QueueUserWorkItem(_ =>
{
    while (true)
    {
        var data = ReadDeviceSync(); // 可能阻塞数百 ms~数秒
        Process(data);
    }
});
​
// ✅ 正确方向之一:专用长命线程
new Thread(AcquireLoop) { IsBackground = true, Name = "DAQ" }.Start();
​
// ✅ 或:异步 API + Channel,不长期占池线程硬等

工作者线程 vs I/O 完成端口线程: 传统说法里线程池分 worker 与 I/O;现代更多统一调度,面试说到「别在池里干长阻塞」即可。

2.5 ConfigureAwait(false)

复制代码
await SomeIoAsync().ConfigureAwait(false);

含义:完成后续续 不必 marshal 回原 SynchronizationContext(WinForms/WPF UI 上下文)。

位置 建议
类库、通信库、DAL 多用 ConfigureAwait(false)
UI 事件末尾要改控件 不要 乱加;需要 UI 线程时保持默认或显式 Invoke
ASP.NET Core 默认无 SyncContext,影响较小;类库仍常写 false 保持习惯

2.6 ValueTask(加分)

热路径上已常同步完成时减少分配:ValueTask / ValueTask<T>。注意:不要 多次 await 同一个 ValueTask;需要多次消费先 .AsTask()。通信库热路径面试可提一句。


三、async / await 深挖

3.1 编译器状态机(直觉版)

async 方法被编译成类似状态机的结构(实现 IAsyncStateMachine):

  1. 调用即启动状态机;返回给调用方一个 Task(可能尚未完成)。

  2. 遇到 await 且操作未完成 → 挂起、注册延续、释放当前线程。

  3. 操作完成后(可能在其它线程)驱动状态机下一状态。

  4. return / 异常 → 完成返回的 Task(RanToCompletion / Faulted)。

复制代码
async Task<int> ReadAsync()
{
    byte[] buf = new byte[1024];
    int n = await stream.ReadAsync(buf, 0, buf.Length); // 可能挂起
    return n;
}

已完成优化: 若 await 的 awaitable 已完成,可能 同步 走进下一行,不调度线程。

3.2 SynchronizationContext 与死锁

WinForms / 老 ASP.NET / WPF 会安装同步上下文,默认 await 后想回到原上下文。

复制代码
// ❌ UI 线程
var result = GetDataAsync().Result; // 或 .Wait() / GetAwaiter().GetResult()

死锁链条:

  1. UI 线程被 .Result 堵住,消息泵停转。

  2. GetDataAsync 内部某次 await 完成后,延续要 回到 UI 才能继续。

  3. UI 正堵着等 Task → Task 又等 UI → 死锁。

正确:

复制代码
private async void btnLoad_Click(object sender, EventArgs e)
{
    try
    {
        btnLoad.Enabled = false;
        var data = await GetDataAsync(); // 让出 UI 消息泵
        label1.Text = data;
    }
    catch (Exception ex) { MessageBox.Show(ex.Message); }
    finally { btnLoad.Enabled = true; }
}

控制台 / 多数库代码:GetAwaiter().GetResult() 风险小于 UI,但仍可能聚合异常,优先 await。

3.3 async void vs async Task vs async ValueTask

返回类型 适用 异常 可 await
async Task / Task<T> 默认选择 进入 Task,可观察 是
async void 仅 UI 事件处理器 可能直接冲向同步上下文,难捕获 否
async ValueTask 热路径少分配 同 Task,使用有限制 是(规则更严)

事件里 async void 必须 try/catch 包住整段逻辑。

3.4 异常传播细节

复制代码
async Task BoomAsync()
{
    throw new InvalidOperationException("x");
}

// await 时直接拿到 InvalidOperationException
await BoomAsync();

// WhenAll:多个失败可能变成 AggregateException(视消费方式)
try { await Task.WhenAll(t1, t2); }
catch (Exception ex) { /* 通常是其中一个,或需看任务.Exception */ }

「忽略」未观察的 Task: 火后不管的 Task.Run(...) 若 Faulted,.NET 上可能在终结时触发 UnobservedTaskException(行为随版本/配置变化)------后台任务至少要 ContinueWith 记日志或挂到生命周期管理器里。

3.5 CancellationToken(必须会)

复制代码
private CancellationTokenSource _cts;

void Start()
{
    _cts = new CancellationTokenSource();
    _ = PollAsync(_cts.Token); // 保存 Task 引用,关闭时观察异常
}

void Stop() => _cts.Cancel();

async Task PollAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        ct.ThrowIfCancellationRequested();
        var frame = await ReadFrameAsync(ct);
        await _channel.Writer.WriteAsync(frame, ct);
    }
}

要点:

  • 取消是 协作式:被调方要检查 Token / 传进底层 API。

  • CancelAfter、CreateLinkedTokenSource(窗体关闭 + 超时联动)很实用。

  • 取消抛 OperationCanceledException;调用方与真正故障区分对待。

3.6 常见反模式

反模式 问题 改法
Task.Run(() => File.ReadAllText(...)) 假装异步 仍占池线程做同步 I/O File.ReadAllTextAsync
async 方法里无 await 无意义开销,还可能警告 去掉 async 或真异步
await 后共享可变字段无同步 延续线程不确定 volatile/Interlocked/锁或封送到单线程
同方法 lock + await 不允许 / 设计错误 锁外 await;用 SemaphoreSlim
复制代码
// ❌ 编译错误:不能跨 await 持有 lock
lock (_sync) {
    await DoAsync();
}

// ✅
await _sem.WaitAsync();
try { await DoAsync(); }
finally { _sem.Release(); }

四、Channel:高频采集防卡顿的数据流缓冲

4.1 问题模型

复制代码
采集卡 / 中断 / DataReceived  ─极快─►  UI 绘图 / 入库 / 协议解析  ─较慢─
                │
                ▼ 若同步直推
         UI 卡顿、丢包、线程堵死、内存疯涨

解法:解耦生产与消费 ,中间加 有界 缓冲 + 明确「满了怎么办」。

技术 平台 特点
System.Threading.Channels .NET Core / 5+ 专为 async、高性能通道设计
BlockingCollection<T> 含 Framework 阻塞式 Take/Add,现场大量在用
ConcurrentQueue + 手动信号 各版本 灵活,要自己写等待逻辑

4.2 有界 Channel 完整模式

复制代码
var channel = Channel.CreateBounded<Sample>(new BoundedChannelOptions(10_000)
{
    FullMode = BoundedChannelFullMode.DropOldest, // 实时性优先
    SingleReader = true,  // 单消费者可优化内部实现
    SingleWriter = true   // 单生产者同样
});

async Task ProduceAsync(CancellationToken ct)
{
    try
    {
        while (!ct.IsCancellationRequested)
        {
            Sample s = ReadCardFast(); // 回调里只做「拷贝出最小数据」
            await channel.Writer.WriteAsync(s, ct);
        }
    }
    finally
    {
        channel.Writer.Complete(); // 通知读者结束
    }
}

async Task ConsumeAsync(CancellationToken ct)
{
    var batch = new List<Sample>(256);
    var lastUi = DateTime.UtcNow;

    await foreach (var s in channel.Reader.ReadAllAsync(ct))
    {
        batch.Add(s);
        ProcessOffline(s); // 解析、告警判断、写内存环形缓冲

        // UI 最多 ~25fps
        if ((DateTime.UtcNow - lastUi).TotalMilliseconds >= 40 && batch.Count > 0)
        {
            var snapshot = batch.ToArray();
            batch.Clear();
            lastUi = DateTime.UtcNow;
            UiSafe(() => ChartAppend(snapshot)); // BeginInvoke
        }
    }
}

4.3 FullMode 选型

模式 行为 适用
Wait 满则阻塞/异步等待写 不能丢点(计量、审计);允许背压减速采集
DropOldest 丢最旧保最新 实时曲线、监控大屏
DropNewest 丢本次最新 少见;偏「历史优先」
DropWrite 本次写入失败 自行 TryWrite + 丢包计数告警

4.4 .NET Framework:BlockingCollection

复制代码
var bag = new BlockingCollection<Sample>(boundedCapacity: 10_000);

// 生产
bag.Add(sample);          // 满则阻塞;或 TryAdd(sample, timeout)
// 结束
bag.CompleteAdding();

// 消费
foreach (var s in bag.GetConsumingEnumerable(ct))
{
    Process(s);
}

4.5 背压、丢包计数、批量落库

实战三板斧:

  1. 有界 :防止无限 List.Add 把内存吃光。

  2. 指标 :dropped, queueLength, produceHz, consumeHz 做面板。

  3. 批量 :入库 batch 500 或 每 200ms flush,避免每点一次 SQL。

4.6 避坑补充

  • 硬件/串口回调中:只入队,不做解析/UI/数据库。

  • SingleWriter/SingleReader 说了就必须遵守,否则数据竞争。

  • 窗体关闭:Cancel → Complete → await 消费者结束 → 再 Dispose 串口。

  • 多生产者:SingleWriter=false,或每设备一个 Channel 再汇总。


五、线程同步:lock / Monitor / AutoResetEvent / 混合锁

5.0 同步原语地图

复制代码
用户态偏快                    混合                     内核态偏稳/贵
Interlocked / volatile   →   Monitor(lock)      →   Mutex(跨进程)
SpinLock / SpinWait          SemaphoreSlim          Named Event
                             SimpleHybirdLock        Auto/ManualResetEvent
                             ManualResetEventSlim

短临界区 → 用户态或 Monitor; 需要跨进程 → Mutex / 命名 Event; async 路径 → SemaphoreSlim.WaitAsync(不要在 async 里 lock)。

5.1 lock 与 Monitor

lock (obj) ≈ Monitor.Enter/Exit:

复制代码
private readonly object _sync = new object();

void Critical()
{
    lock (_sync)
    {
        // 临界区
    }
}

完整展开(体现异常安全):

复制代码
bool lockTaken = false;
try
{
    Monitor.Enter(_sync, ref lockTaken);
    // 临界区
}
finally
{
    if (lockTaken) Monitor.Exit(_sync);
}

规则:

  • 锁 私有引用类型对象 ;禁止 lock(this)、lock(typeof(T))、lock("字符串")(字符串驻留导致跨模块同锁)。

  • 临界区要 短:算索引、改集合、换引用;别在里面串口读写/SQL。

  • 可重入 :同一线程可重复 Enter(深度计数),但别依赖复杂重入设计。

  • TryEnter(timeout):拿不到就超时,避免死等。

Monitor.Wait / Pulse(条件变量)
复制代码
lock (_sync)
{
    while (!_ready)            // 必须 while 防虚假唤醒
        Monitor.Wait(_sync);   // 释放锁并等待
    // 被 Pulse 后重新持锁
}
lock (_sync)
{
    _ready = true;
    Monitor.Pulse(_sync);      // 或 PulseAll
}

5.2 Interlocked(无锁原子)

复制代码
Interlocked.Increment(ref _count);
Interlocked.CompareExchange(ref _state, 1, 0); // CAS:期望 0 才写成 1
Interlocked.Exchange(ref _flag, 1);

适用:计数、状态机标志、无锁简单发布。SimpleHybirdLock 的 m_waiters 就靠它。

5.3 AutoResetEvent / ManualResetEvent / Slim 版

类型 Set 后 唤醒数量 场景
AutoResetEvent 自动变未信号 通常一个 生产者通知「有一个活」
ManualResetEvent 保持信号直到 Reset 多个 「大门打开,大家一起过」
ManualResetEventSlim 同 Manual,混合自旋 多个 短等待更优
CountdownEvent N 次 Signal 完成后放行 --- 多任务汇合
Barrier 多阶段会合 --- 分阶段并行算法
复制代码
var are = new AutoResetEvent(false);
are.WaitOne();   // 阻塞直到 Set(可带 timeout)
are.Set();       // 放行一个等待者

代价: 经典 Event 走内核,切换贵。无竞争仍频繁 WaitOne 会慢 → 催生混合锁 / Slim。

5.4 Mutex / Semaphore / SemaphoreSlim / ReaderWriterLockSlim

原语 作用 备注
Mutex 互斥,可跨进程(命名) 重,支持遗弃检测
Semaphore 限流 N,可跨进程 内核
SemaphoreSlim 进程内限流 / 异步互斥 首选 :WaitAsync
ReaderWriterLockSlim 多读单写 读多写少的配置表
复制代码
private readonly SemaphoreSlim _bus = new SemaphoreSlim(1, 1);
​
async Task<object> ModbusAsync(...)
{
    await _bus.WaitAsync();
    try { return await SendReceiveAsync(...); }
    finally { _bus.Release(); }
}

5.5 SpinLock / SpinWait(知道场景)

极短临界区可自旋不睡眠;持锁期间 禁止 做重活、禁止重入。一般业务用 lock 即可,面试了解「短而热」才 Spin。

5.6 为何需要「混合锁」

工业现场:同一 RS485 总线 / 同一 SerialPort 上的收发必须互斥。

理想性能:

  1. 无竞争(占绝大多数):纯用户态原子操作拿锁,不解系统调用。

  2. 有竞争:落入内核事件睡眠,不空转烧 CPU。

Monitor、ManualResetEventSlim、自研 SimpleHybirdLock 都是这个思路。

5.7 吃透 SimpleHybirdLock(项目实战)

路径:thinger.ModbusRTULib\ModbusRTU.cs。 SendAndReceive 全程:

复制代码
hybirdLock.Enter();
try
{
    // DiscardInBuffer → Write → 循环 Read → 拼包
}
finally
{
    hybirdLock.Leave();
}

完整核心:

复制代码
public sealed class SimpleHybirdLock : IDisposable
{
    private Int32 m_waiters = 0;                              // 用户态计数
    private AutoResetEvent m_waiterLock = new AutoResetEvent(false); // 内核等待
​
    public void Enter()
    {
        // 0→1:无人持锁,直接成功(快路径,无内核)
        if (Interlocked.Increment(ref m_waiters) == 1)
            return;
        // 否则已有持锁者/排队者 → 睡眠
        m_waiterLock.WaitOne();
    }
​
    public void Leave()
    {
        // 减到 0:无排队,直接返回
        if (Interlocked.Decrement(ref m_waiters) == 0)
            return;
        // 还有人在 WaitOne → 放行一个
        m_waiterLock.Set();
    }
​
    public bool IsWaiting => m_waiters != 0;
​
    public void Dispose()
    {
        m_waiterLock.Close(); // 内核句柄必须关
    }
}
时序推演(两人)
步骤 m_waiters 行为
A Enter 0→1 快路径返回,持锁
B Enter 1→2 WaitOne 睡眠
A Leave 2→1 Set,唤醒 B
B 执行临界区 1 持锁
B Leave 1→0 无等待者,结束

三人时:Leave 每次 Set 一个,靠 AutoResetEvent 串行交接;计数保证「还有人在排」。

与 lock/Monitor 对比
维度 SimpleHybirdLock lock / Monitor
快路径 Interlocked 同样先用户态(CLR 实现)
可重入 否 是
超时 无(可改 WaitOne(ms)) TryEnter
条件等待 无 Wait/Pulse
async 不支持 也不支持跨 await → 用 SemaphoreSlim
教学价值 看清「混合」二字 生产默认选项
必须牢记的坑
  1. 不可重入 :同线程二次 Enter → 计数≥2 → 自己 WaitOne → 自死锁。

  2. 必须 try/finally:异常漏 Leave → 全员饿死。

  3. Dispose :Close 事件句柄,防泄漏。

  4. 非公平:不保证 FIFO,连续快路径可能「插队」观感(多数够用)。

  5. Leave 线程 必须是逻辑上的持锁者(实现本身不校验所有者)。

可写改进版(面试加分)
复制代码
public bool TryEnter(int msTimeout)
{
    if (Interlocked.Increment(ref m_waiters) == 1)
        return true;
    if (m_waiterLock.WaitOne(msTimeout))
        return true;
    // 超时:把计数退回去,注意与 Leave/Set 的竞态 —— 生产级要实现得更严谨
    Interlocked.Decrement(ref m_waiters);
    return false;
}

生产更推荐直接:

复制代码
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
// Enter: await _lock.WaitAsync(ct);
// Leave: _lock.Release();

5.8 锁的层级与「总线锁」实战心法

RS485 多从站:

复制代码
应用层:设备A.Read / 设备B.Write  都走同一 SerialPort
        ↓
     总线锁(SimpleHybirdLock / SemaphoreSlim)
        ↓
   一次完整:发请求 → 等响应 → 校验 → 返回
        ↓
     再给下一请求

切忌:线程1 只锁「写」,线程2 锁「读」------仍会交织。应锁 整段事务(正是 SendAndReceive 外包一层)。


六、跨线程 UI:Invoke / BeginInvoke 底层原理

6.1 线程亲和性

WinForms / WPF 控件绑定创建它们的线程。只有该线程的消息泵处理 Paint、输入、属性变更才安全。

后台直接改 Text:

  • WinForms:常抛 InvalidOperationException(开启检查时)。

  • 未检查时:偶发花屏、崩溃,更难查。

6.2 WinForms 消息泵与封送

简化模型:

复制代码
UI 线程:
  while (GetMessage(out msg))
  {
      TranslateMessage(ref msg);
      DispatchMessage(ref msg); // 含控件消息 + 跨线程委托包
  }

Control.Invoke / BeginInvoke:把委托 Post 到该控件创建线程的消息队列。

API 同步性 调用方行为 高频采集
Invoke 同步 阻塞直到 UI 跑完委托 易拖垮采集/放大死锁面
BeginInvoke 异步 投递后立即返回 更合适,需注意队列堆积
InvokeRequired --- 是否需要封送 判断用
复制代码
private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
{
    var sp = (SerialPort)sender;
    var buf = new byte[sp.BytesToRead];
    sp.Read(buf, 0, buf.Length);
    // 此处只做最小工作;重活可入 Channel

    if (IsDisposed || !IsHandleCreated) return;

    BeginInvoke(new Action(() =>
    {
        receiveCount += buf.Length;
        AppendLog(buf); // 仍建议再节流
    }));
}

与串口笔记一致:DataReceived 在辅助线程;更新 UI 必须封送。

6.3 WPF Dispatcher

复制代码
Dispatcher.Invoke(() => { /* 同步 */ });
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { }));
await Dispatcher.InvokeAsync(() => label.Content = "ok"); // 推荐

优先级:Send > Normal > Background > ContextIdle... 高频刷新用较低优先级,少抢输入/布局。

CheckAccess() 类似 InvokeRequired 反向:true 表示已在 Dispatcher 线程。

6.4 SynchronizationContext 统一视角

复制代码
SynchronizationContext.Current.Post(_ => UpdateUi(), null);  // 类似 BeginInvoke
SynchronizationContext.Current.Send(_ => UpdateUi(), null);   // 类似 Invoke

在 UI 线程捕获 SynchronizationContext.Current 存起来,库代码也能回贴 UI。async/await 默认正是靠它回帖。

6.5 IProgress<T>:优雅回报表进度

复制代码
var progress = new Progress<int>(p => progressBar1.Value = p); // 构造于 UI 线程
await Task.Run(() => Work(progress));

void Work(IProgress<int> progress)
{
    for (int i = 0; i <= 100; i++)
    {
        // ...
        progress.Report(i); // 自动封送到创建 Progress 的上下文
    }
}

6.6 杜绝界面卡死 · 完整 Checklist

  1. UI 不做重活:解析、CRC、写库、磁盘全甩后台。

  2. 禁 UI 线程 Result / Wait / GetResult。

  3. 高频:BeginInvoke + 时间节流 / 批量(如 30fps),防消息队列爆炸。

  4. 避免 lock 内 Invoke;避免 UI 持业务锁再等后台。

  5. 关闭:先 Cancel 采集 → 等任务结束 → -= 事件 → Close 串口 → 再关窗。

  6. Invoke 前:IsHandleCreated && !IsDisposed(WinForms)。

  7. 日志控件设上限(行数/字符),AppendText 无限涨也会卡。

  8. 调试期可开 Control.CheckForIllegalCrossThreadCalls = true(WinForms 默认 debug 常开)。

BeginInvoke 堆积问题

采集 1kHz 每次 BeginInvoke → 队列几十万委托 → UI 假死。

对策:原子标志「已有刷新排队」;或定时器 33ms 刷环形缓冲最新数据。

复制代码
private int _uiPending; // 0/1

void RequestUiRefresh()
{
    if (Interlocked.CompareExchange(ref _uiPending, 1, 0) != 0) return;
    BeginInvoke(new Action(() =>
    {
        try { RedrawFromBuffer(); }
        finally { Interlocked.Exchange(ref _uiPending, 0); }
    }));
}

七、避坑专题(面试高频 + 现场事故)

7.1 内存泄漏

(1)事件未注销(最常见)

委托是 强引用:发布者握住订阅者。

复制代码
// 单例 / 长寿命设备
DeviceHub.Instance.DataReceived += form.OnData;

// 窗体关了但没 -=  → Form 整图泄漏(控件、图片、缓冲全活着)

修复:

  • FormClosing / Dispose 里 -=。

  • 短订阅用局部函数并成对注销。

  • 静态事件尤其要命,必须成对。

复制代码
protected override void OnFormClosing(FormClosingEventArgs e)
{
    _cts?.Cancel();
    if (_port != null) _port.DataReceived -= OnDataReceived;
    base.OnFormClosing(e);
}
(2)串口 / Socket / 数据库不释放
复制代码
// ❌ Open 后异常分支没 Close → 端口「占用中」
// ✅
using (var sp = new SerialPort(name, baud))
{
    sp.Open();
    // ...
} // Dispose → Close
  • SerialPort 不 Close:下次 Open 失败,现场传说「必须拔线」。

  • SqlConnection / EF DbContext / DataReader:不释放 → 连接池耗尽,表现为随机超时。

  • 模式:实现 IDisposable,通信类持有串口则自身也要 Dispose 链。

(3)其它泄漏源
对象 问题
Timer(System.Threading / Forms) 回调抓住外层 this
CancellationTokenSource 未 Dispose
AutoResetEvent / Mutex 内核句柄
无界队列 逻辑泄漏,最终 OOM
大 byte[] 入队未复用 GC 压力、卡顿(可用 ArrayPool)
未结束的 Task 闭包 抓住 Form / 控件

7.2 死锁图谱

场景 机制 对策
UI .Result + await 回 UI 上下文死锁 全程 async
锁顺序 A→B vs B→A 经典ABBA 全局统一顺序;缩小锁范围
lock + 同步 Invoke,UI 再抢锁 锁与消息泵交叉 锁外封送;UI 不拿业务锁
混合锁嵌套 Enter 不可重入 禁止;或改 Monitor
双端同步 RPC 无超时 通信死等 超时、CTS、看门狗
线程池饥饿 池线程全阻塞 专用线程 / async I/O / 限并发
WhenAll 等自己 任务自引用 理清依赖

诊断: VS「并行堆栈窗口」、WinDbg、记录「谁持有哪把锁」日志;通信层强制超时。

7.3 多设备并发竞态(Race)

复制代码
设备1 Task ──┐
设备2 Task ──┼──► 同一 SerialPort / 全局 receiveBuffer / 全局「事务状态」
设备3 Task ──┘

症状:CRC 偶发失败、串台、A 的响应被 B 读走、压力大才复现。

原则:

  1. 共享可变状态 → 同步或消灭共享(每设备一连接)。

  2. RS485 共享总线 → 事务级总线锁。

  3. check-then-act 必须同锁:

复制代码
// ❌
if (!_map.ContainsKey(id)) _map[id] = Create(id);

// ✅
lock (_sync)
{
    if (!_map.ContainsKey(id))
        _map[id] = Create(id);
}
// 或
_map.GetOrAdd(id, Create);
  1. 禁止多线程共用一个 MemoryStream/List<byte> 接收缓存而不锁。

  2. UI ObservableCollection 必须在 UI 线程改,或先后台做 List,再一次性封送替换。

竞态分类(加分表述)
  • 读改写 :count++ 非原子。

  • ABA / 过期状态:收到旧包当新包。

  • 初始化竞态 :双检锁缺 volatile/Lazy<T>。

  • 通讯半包:拆包粘包未按协议状态机,多线程更乱。

单例双检锁复习:

复制代码
if (_instance == null)
{
    lock (_gate)
    {
        if (_instance == null)
            _instance = new Foo(); // 配合 Lazy<T> 更稳妥
    }
}

7.4 其它坑速查

坑 说明
Thread.Abort 废弃;状态损坏
吞异常 catch { return false; } 导致误报
无限 Task.Run 打设备 SemaphoreSlim 限流
Random 静态多线程 用 ThreadLocal/Random.Shared
List/Dictionary 跨线程 锁或并发集合
关窗不取消 Task ObjectDisposed + 飞尸回调
async void 业务方法 改 async Task
文化/编码在后台乱设 编码固定 UTF8/GB2312 写死

八、并发集合与内存可见性

8.1 常用并发集合

类型 场景
ConcurrentQueue<T> 无锁友好队列
ConcurrentBag<T> 无序包
ConcurrentStack<T> 栈
ConcurrentDictionary<K,V> 字典,注意 GetOrAdd 工厂可能执行多次
BlockingCollection<T> 阻塞生产消费
ConcurrentBag<T> vs Channel 新项目优先 Channel(async)

误区: 「用了 ConcurrentQueue 就一切线程安全」------你对多个集合的组合操作仍可能竞态,要整体设计。

8.2 volatile、可见性、指令重排(八股点到)

复制代码
volatile bool _running = true;
  • volatile:防止部分缓存/重排导致「写了别人看不见」(教育简化说法)。

  • .NET 内存模型更细;实践中标志位常用 volatile 或 Interlocked。

  • 不能 用 volatile 替代锁做 count++ 复合操作。

发布对象常用模式:构造完再 Volatile.Write(ref _pub, obj) 或放入 ConcurrentDictionary / lock 内赋值。


九、并行编程 Parallel / PLINQ(简述)

适合 CPU 密集、无 UI、数据可分片:

复制代码
Parallel.For(0, n, i => Compute(i));
​
var q = sources.AsParallel()
               .WithDegreeOfParallelism(Environment.ProcessorCount)
               .Select(Heavy)
               .ToList();

注意:

  • 有共享状态必须线程安全。

  • 不适合强顺序设备通信。

  • 异常进 AggregateException。

  • UI 程序里别对小任务滥开并行(调度开销可能亏)。


十、推荐落地架构与完整示例

10.1 架构图

复制代码
┌──────────────────┐   有界 Channel/BlockingCollection   ┌─────────────────┐
│ 采集线程 / Task   │ ─────────────────────────────────► │ 消费者 Task       │
│ 读卡·收报文·入队  │         Sample / Frame              │ 解析·告警·批量入库 │
└────────┬─────────┘                                     └────────┬────────┘
         │ 共享 RS485/SerialPort                                    │ 节流 30fps
         │ SimpleHybirdLock / SemaphoreSlim                         ▼
         ▼                                                 BeginInvoke/Dispatcher
   硬件 / 从站设备                                              曲线·表格·状态灯

10.2 窗体生命周期伪代码

复制代码
CancellationTokenSource _cts;
Channel<Sample> _channel;
Task _producer, _consumer;

void btnStart_Click(...)
{
    _cts = new CancellationTokenSource();
    _channel = Channel.CreateBounded<Sample>(...);
    _producer = Task.Factory.StartNew(
        () => ProduceLoop(_cts.Token),
        _cts.Token,
        TaskCreationOptions.LongRunning,
        TaskScheduler.Default);
    _consumer = ConsumeAsync(_cts.Token);
}

async void btnStop_Click(...)
{
    _cts.Cancel();
    _channel.Writer.TryComplete();
    try { await Task.WhenAll(_producer, _consumer); }
    catch (OperationCanceledException) { }
    _port.DataReceived -= OnData;
    _port.Close();
}

10.3 面试闭环答法(30 秒版)

用 async/await 做 I/O,避免堵 UI;长采集用专用线程或 LongRunning,短 CPU 用线程池;高频数据走有界 Channel 做缓冲和背压;共享串口用混合锁或 SemaphoreSlim 互斥整段事务;UI 用 BeginInvoke/Dispatcher 并节流;成对注销事件、Dispose 串口和数据库;统一锁顺序与超时,防止死锁和多设备竞态。


十一、速记对照表

需求 选型
不堵 UI 的 I/O async/await + *Async API
CPU 重活 Task.Run / Parallel
长期阻塞读卡 Thread 或 LongRunning
高频生产消费 Channel / BlockingCollection
短临界区 lock
异步互斥 / 限流 SemaphoreSlim
信号通知 AutoResetEvent / ManualResetEventSlim
跨进程互斥 Mutex
理解混合锁 SimpleHybirdLock(Interlocked + ARE)
跨线程 WinForms Invoke / BeginInvoke
跨线程 WPF Dispatcher.InvokeAsync
进度回写 UI IProgress<T> / Progress<T>
可取消 CancellationTokenSource
回调变 await TaskCompletionSource
并发字典/队列 Concurrent*

十二、面试自测题(建议默写)

基础

  1. async 一定会开新线程吗?什么时候会用到线程池?

  2. Task 和 Thread 本质区别?何时宁可 new Thread?

  3. 为什么 UI 里 task.Result 会死锁?画调用链。

  4. async void 为什么危险?唯一合法场景?

同步与项目代码

  1. lock 底层是什么?为何不能 lock(this)?

  2. SimpleHybirdLock 快路径/慢路径分别是什么?推演 A、B 两线程。

  3. 为何混合锁不可重入?同线程二次 Enter 会怎样?

  4. AutoResetEvent 和 ManualResetEvent 区别?

  5. async 方法里如何做互斥?(不能用 lock 跨 await)

采集与 UI

  1. Channel DropOldest vs Wait 怎么选?

  2. Invoke 和 BeginInvoke 哪个更容易拖慢串口接收?为何还可能卡 UI?

  3. 如何把 1kHz 数据刷新到曲线却保持界面流畅?

避坑

  1. 事件造成 Form 泄漏如何复现与修复?

  2. 串口不 Dispose 的现场现象?数据库不释放呢?

  3. 三台设备共用 RS485,如何避免报文竞态?锁粒度锁什么?

  4. 什么是线程池饥饿?如何避免?


十三、现场排查清单(可打印)

界面卡死

  • UI 是否 Sleep / 同步串口 / .Result?
  • 是否每包同步 Invoke?队列是否堆积?
  • 是否死锁(并行堆栈看到互相等待)?

偶发通讯错误

  • 多线程是否共享 Port 无总线锁?
  • 接收缓冲是否共享且无同步?
  • 超时是否过短/过长?

内存 / 句柄

  • 事件是否 -=?
  • 串口、CTS、EventWaitHandle 是否 Dispose?
  • 队列是否有界?日志控件是否截断?

关停不干净

  • 是否 Cancel → 等 Task → 再关口?
  • 关闭后是否还有 BeginInvoke?

附录 A:名词英中对照

English 中文
Thread Pool 线程池
Synchronization Context 同步上下文
Marshal / Post 封送 / 投递
Critical Section 临界区
Race Condition 竞态条件
Deadlock 死锁
Starvation 饥饿
Backpressure 背压
Continuations 延续
Cooperative Cancellation 协作式取消

附录 B:与本仓库其它笔记的关联

笔记 关联点
串口.md DataReceived 辅助线程 + Invoke 更新 UI
Modbus 项目 SimpleHybirdLock 总线级互斥,保护 SendAndReceive
设计模式.md 多线程单例 lock + 双检锁 / volatile
C#WPF.md Dispatcher、属性通知与线程

建议:面试前能手写 SimpleHybirdLock + 口述 Channel 背压 + 画 UI 死锁三步图,即可覆盖绝大多数「异步与多线程」八股与工业上位机追问。

相关推荐
周杰伦fans1 小时前
8GB显存下模型量化实战指南
人工智能·后端·c#
samble3 小时前
从零搭建灌装监控系统(十二):配置系统,原子写入与容错
c#·wpf·mvvm·modbus·工业控制
流形填表6 小时前
题库去重实战:基于题干指纹的重复题清理
开发语言·c#
开开心心就好12 小时前
以图搜图找重复图片,本地工具离线就能用
javascript·智能手机·ffmpeg·c#·ocr·word·音视频
QQ_216962909613 小时前
基于C#(Asp.net)电竞陪玩信息管理系统的设计与实现
java·大数据·spring boot·微信小程序·c#·云计算·asp.net
未来之窗软件服务1 天前
C# 截图源码支持自动保存-东方仙盟
c#·仙盟创梦ide·东方仙盟·电脑工具
UIU1141 天前
-7 / 2 在 C 里是 -3,在 Python 里是 -4,谁算错了?
c++·学习·c#
wflynn2 天前
GitHub 今日推荐|REDox:64 位 token 表示结构化数据,内存占用降 70% 支持多格式互转
开源·c#·github
驰骋工作流2 天前
C#/.NET 开源BPM工作流引擎6大常用对比选型分析驰骋BPM驰骋低代码低代码工作流引擎
开源·c#·.net
en.en..2 天前
C语言 fp与fd 区别详解
c#