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

工业上位机、采集卡、串口/网口通信、多设备并发读写------面试和实战都绕不开这套知识。本文按「概念 → 原理 → 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> 表示 一次操作的生命周期,不是线程本身。

状态(简化):CreatedWaitingToRunRunningRanToCompletion / 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。

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

  • 取消抛 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 说了就必须遵守,否则数据竞争。

  • 窗体关闭:CancelCompleteawait 消费者结束 → 再 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);

适用:计数、状态机标志、无锁简单发布。SimpleHybirdLockm_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。

MonitorManualResetEventSlim、自研 SimpleHybirdLock 都是这个思路。

5.7 吃透 SimpleHybirdLock(项目实战)

路径:thinger.ModbusRTULib\ModbusRTU.csSendAndReceive 全程:

复制代码
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. DisposeClose 事件句柄,防泄漏。

  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. 避免 lockInvoke;避免 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 内存模型更细;实践中标志位常用 volatileInterlocked

  • 不能 用 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
长期阻塞读卡 ThreadLongRunning
高频生产消费 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. TaskThread 本质区别?何时宁可 new Thread

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

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

同步与项目代码

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

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

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

  4. AutoResetEventManualResetEvent 区别?

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

采集与 UI

  1. Channel DropOldest vs Wait 怎么选?

  2. InvokeBeginInvoke 哪个更容易拖慢串口接收?为何还可能卡 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 死锁三步图,即可覆盖绝大多数「异步与多线程」八股与工业上位机追问。

相关推荐
逝水无殇2 小时前
C# 反射详解
开发语言·后端·c#
神秘的MT2 小时前
SQL Server 分页查询 ROW_NUMBER () OFFSET...FETCH游标(Cursor) 数据插入
数据库·sql·学习·c#·winform
我才是银古2 小时前
一个人写出的 3D 几何引擎:Ara3D SDK 技术深度剖析
c#·三维几何·ai平台
逝水无殇15 小时前
C# 异常处理详解
开发语言·后端·c#
玖玥拾16 小时前
C# 语言进阶(十五)C# 游戏服务端 MySQL 数据库
服务器·开发语言·网络·数据库·mysql·c#
逝水无殇18 小时前
C# 文件的输入与输出详解
开发语言·数据库·后端·c#
唐青枫1 天前
看懂 IL 的关键:C#.NET 栈机执行模型实战拆解
c#·.net
我才是银古1 天前
从零构建 Windows 桌面 RPA 框架:Go 与 .NET 的跨语言协奏
c#·go·rpa
逝水无殇1 天前
C# 正则表达式详解
开发语言·后端·正则表达式·c#