本节摘要:异步消除等待,并行增加算力。本节划清两者的分界,讲 Task 的本质与线程池调度、数据并行的三种武器取舍,以及锁、无锁原语与异步锁的适用边界——并发 bug 的根源无一例外是共享可变状态。
上一节结尾提过,这里正面展开。异步解决"一条线程等 IO 白白闲着"的问题:让线程在等待时去干别的,吞吐上升,线程数不涨。并行解决"一段计算太慢"的问题:把计算切成几份同时上多条线程,延迟下降,线程数上升。一个网络服务的高并发靠异步,一个图像处理的高速度靠并行。混淆两者的典型症状是"给 IO 代码套 Parallel.ForEach"——线程翻倍、吞吐不动,因为瓶颈根本不在算力。
Task 与 Task<T> 是"一件将来完成的工作"的表示对象。两种来路:Task.Run(action) 把 CPU 工作排进线程池队列,返回的代表"那段计算";异步方法返回的 Task 代表"那个挂起的流程"(4.1 节)。async 关键字不出现在 Task 的创建里,它只是方法的修饰——很多人把 Task.Run 和 async/await 搞混成一件事,机制上它们完全正交:一个管"把活交给线程池",一个管"挂起恢复"。
// CPU 密集:Task.Run 合理,让调用线程先走 var result = await Task.Run(() => HeavyComputation(data)); // IO 密集:Task.Run 多此一举,库的异步 API 本来就不占线程 var html = await client.GetStringAsync(url);
第二行包一层 Task.Run 是真实的坏味道:异步 API 内部挂起时不占用线程,再包一层只是多一次调度。判别口诀:CPU 活用 Run,IO 活用库。
对一批数据并行处理,三条路线按粒度选择。Parallel.For 与 Parallel.ForEach:一次性把循环并行化,线程池分块执行,阻塞到全部完成——适合程序启动期的批量转换、离线计算:
Parallel.ForEach(images, img => img.ApplyFilter());
PLINQ:第三章 LINQ 的并行版本,AsParallel 挂进查询链,声明式、能与 LINQ 组合,但分区与合并有固定开销,小数据量反而变慢。手动 Task 加 WhenAll:粒度完全自控,适合"每件工作本身是异步 IO"的场景(并发拉取一百个 URL——4.1 节组合器主场,此时 Parallel.ForEach 反而不合适,它设计给同步体的 CPU 并行)。三条路线的选择本质是:块大且同步用 Parallel,可组合查询用 PLINQ,工作本身异步用 WhenAll。
并行体里的第一诫:不要闭包捕获共享变量做累加。sum += item 在多线程下是"读、加、写"三步,交错执行丢更新,结果随机变小。要么用 Interlocked.Add,要么用各路线的内建聚合(PLINQ 的 Aggregate、Parallel 的 localInit/localFinally 重载)。
并发问题千变万化,根源只有一个:多条线程同时读写同一块可变内存。解法按代价从低到高三层。
第一层,不共享。局部变量天然线程隔离(4.1 节说过局部变量活在状态机/栈里,各副本独立);不可变对象创建后不改动,共享零风险;线程本地存储给每线程一份副本。能用这层解决的是设计胜利——3.2 节的不可变集合、消息传递式的并发模型都属此列。
第二层,无锁原语。Interlocked 类的原子操作:Increment、CompareExchange 一条 CPU 指令完成读改写,计数器与简单标志位的首选。Volatile 与内存屏障处理可见性:每线程有自己的缓存视图,一条线程的写入另一条未必立刻看见,volatile 读保证拉新。可见性问题出没于"标志位死循环等另一线程置位"这类代码,出了问题现象是"明明赋值了对方却读不到"。
第三层,锁。lock (obj) { ... } 是 C# 的互斥语法糖(编译成 try/finally 包裹 Monitor 的进出),同一时刻只放一条线程进临界区。2.1 节埋的伏笔在此兑现:锁信息就存在对象头的同步块里,所以锁对象必须是引用类型(lock 值类型编译报错)。用法纪律:锁私有专用对象(别锁 this、别锁 string——第一章讲过字面量全程序共享,锁它等于和陌生人共用一把锁);临界区尽量小(锁内别做 IO、别再进别的锁——两条锁交叉就是死锁的配方);顺序一致的加锁纪律可以整套杜绝死锁。
private readonly object _gate = new(); public void Enqueue(Job j) { lock (_gate) { _queue.Add(j); } // 进、改、出,三行以内 }
异步世界里的锁要换型号:lock 语句块里不能 await(编译器直接禁止——锁会被带过挂起点,恢复线程换了人,锁的释放就没有着落)。跨 await 保护共享资源用 SemaphoreSlim 的 WaitAsync:
private readonly SemaphoreSlim _gate = new(1, 1); public async Task WriteAsync(byte[] data) { await _gate.WaitAsync(); try { await _stream.WriteAsync(data); } finally { _gate.Release(); } }
结构是 async 版的 lock:异步获取、try/finally 释放,挂起期间信号量替你看门。
⚠️ 常见坑的机制注解:偶发计数变少是读改写交错(用 Interlocked);标志位死等是缓存可见性(用 volatile 或信号原语);偶发卡死是锁顺序交叉(画一张"谁等谁"的有向图找环);并发集合损坏是 3.2 节的老话题(换 Concurrent 家族)。
最后一节进入内存本身:GC 分代模型与分配优化,为全部性能话题收口。