工业上位机、采集卡、串口/网口通信、多设备并发读写------面试和实战都绕不开这套知识。本文按「概念 → 原理 → API → 项目代码 → 坑」展开,并结合项目里的 SimpleHybirdLock 讲透混合锁。
学习路径建议: 一~三打底 → 五吃透锁 → 四/六对接采集与 UI → 七背避坑 → 八~十一默写加分。
目录
-
[概念:异步 / 多线程 / 并发 / 并行](#概念:异步 / 多线程 / 并发 / 并行)
-
[async / await 深挖](#async / await 深挖)
-
[Channel 与生产者-消费者](#Channel 与生产者-消费者)
-
[线程同步全家桶 + SimpleHybirdLock](#线程同步全家桶 + SimpleHybirdLock)
-
[跨线程 UI:Invoke / BeginInvoke / Dispatcher](#跨线程 UI:Invoke / BeginInvoke / Dispatcher)
-
[并行编程 Parallel / PLINQ](#并行编程 Parallel / PLINQ)
-
[速记表 / 面试题 / 排查清单](#速记表 / 面试题 / 排查清单)
一、先分清:异步、多线程、并发、并行
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 线程池要点:
-
工作项队列 + 工作线程集合 ;
Task.Run、Timer、不少 async 延续都进池。 -
线程创建昂贵 → 复用;池会根据吞吐 注入/回收 线程。
-
禁止 在池线程里无限阻塞(
Sleep、同步串口死等、lock长时间):会导致 线程池饥饿(其它 Task 排队卡死,看起来像「整站假死」)。 -
全局可看:
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):
-
调用即启动状态机;返回给调用方一个
Task(可能尚未完成)。 -
遇到
await且操作未完成 → 挂起、注册延续、释放当前线程。 -
操作完成后(可能在其它线程)驱动状态机下一状态。
-
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()
死锁链条:
-
UI 线程被
.Result堵住,消息泵停转。 -
GetDataAsync内部某次await完成后,延续要 回到 UI 才能继续。 -
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 背压、丢包计数、批量落库
实战三板斧:
-
有界 :防止无限
List.Add把内存吃光。 -
指标 :
dropped,queueLength,produceHz,consumeHz做面板。 -
批量 :入库
batch 500或每 200msflush,避免每点一次 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 上的收发必须互斥。
理想性能:
-
无竞争(占绝大多数):纯用户态原子操作拿锁,不解系统调用。
-
有竞争:落入内核事件睡眠,不空转烧 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 |
| 教学价值 | 看清「混合」二字 | 生产默认选项 |
必须牢记的坑
-
不可重入 :同线程二次
Enter→ 计数≥2 → 自己WaitOne→ 自死锁。 -
必须 try/finally:异常漏 Leave → 全员饿死。
-
Dispose :
Close事件句柄,防泄漏。 -
非公平:不保证 FIFO,连续快路径可能「插队」观感(多数够用)。
-
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
-
UI 不做重活:解析、CRC、写库、磁盘全甩后台。
-
禁 UI 线程
Result/Wait/GetResult。 -
高频:BeginInvoke + 时间节流 / 批量(如 30fps),防消息队列爆炸。
-
避免
lock内Invoke;避免 UI 持业务锁再等后台。 -
关闭:先
Cancel采集 → 等任务结束 →-=事件 → Close 串口 → 再关窗。 -
Invoke 前:
IsHandleCreated && !IsDisposed(WinForms)。 -
日志控件设上限(行数/字符),
AppendText无限涨也会卡。 -
调试期可开
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/ EFDbContext/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 读走、压力大才复现。
原则:
-
共享可变状态 → 同步或消灭共享(每设备一连接)。
-
RS485 共享总线 → 事务级总线锁。
-
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);
-
禁止多线程共用一个
MemoryStream/List<byte>接收缓存而不锁。 -
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* |
十二、面试自测题(建议默写)
基础
-
async一定会开新线程吗?什么时候会用到线程池? -
Task和Thread本质区别?何时宁可new Thread? -
为什么 UI 里
task.Result会死锁?画调用链。 -
async void为什么危险?唯一合法场景?
同步与项目代码
-
lock底层是什么?为何不能lock(this)? -
SimpleHybirdLock快路径/慢路径分别是什么?推演 A、B 两线程。 -
为何混合锁不可重入?同线程二次 Enter 会怎样?
-
AutoResetEvent和ManualResetEvent区别? -
async 方法里如何做互斥?(不能用 lock 跨 await)
采集与 UI
-
Channel
DropOldestvsWait怎么选? -
Invoke和BeginInvoke哪个更容易拖慢串口接收?为何还可能卡 UI? -
如何把 1kHz 数据刷新到曲线却保持界面流畅?
避坑
-
事件造成 Form 泄漏如何复现与修复?
-
串口不 Dispose 的现场现象?数据库不释放呢?
-
三台设备共用 RS485,如何避免报文竞态?锁粒度锁什么?
-
什么是线程池饥饿?如何避免?
十三、现场排查清单(可打印)
界面卡死
- 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 死锁三步图,即可覆盖绝大多数「异步与多线程」八股与工业上位机追问。