4.2 任务并行与同步原语


4.2 任务并行与同步原语

本节摘要:异步消除等待,并行增加算力。本节划清两者的分界,讲 Task 的本质与线程池调度、数据并行的三种武器取舍,以及锁、无锁原语与异步锁的适用边界——并发 bug 的根源无一例外是共享可变状态。

先分家:异步与并行是两个问题

上一节结尾提过,这里正面展开。异步解决"一条线程等 IO 白白闲着"的问题:让线程在等待时去干别的,吞吐上升,线程数不涨。并行解决"一段计算太慢"的问题:把计算切成几份同时上多条线程,延迟下降,线程数上升。一个网络服务的高并发靠异步,一个图像处理的高速度靠并行。混淆两者的典型症状是"给 IO 代码套 Parallel.ForEach"——线程翻倍、吞吐不动,因为瓶颈根本不在算力。

Task 是什么:调度的单位

TaskTask<T> 是"一件将来完成的工作"的表示对象。两种来路:Task.Run(action) 把 CPU 工作排进线程池队列,返回的代表"那段计算";异步方法返回的 Task 代表"那个挂起的流程"(4.1 节)。async 关键字不出现在 Task 的创建里,它只是方法的修饰——很多人把 Task.Runasync/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.ForParallel.ForEach:一次性把循环并行化,线程池分块执行,阻塞到全部完成——适合程序启动期的批量转换、离线计算:

Parallel.ForEach(images, img => img.ApplyFilter());

PLINQ:第三章 LINQ 的并行版本,AsParallel 挂进查询链,声明式、能与 LINQ 组合,但分区与合并有固定开销,小数据量反而变慢。手动 TaskWhenAll:粒度完全自控,适合"每件工作本身是异步 IO"的场景(并发拉取一百个 URL——4.1 节组合器主场,此时 Parallel.ForEach 反而不合适,它设计给同步体的 CPU 并行)。三条路线的选择本质是:块大且同步用 Parallel,可组合查询用 PLINQ,工作本身异步用 WhenAll

并行体里的第一诫:不要闭包捕获共享变量做累加。sum += item 在多线程下是"读、加、写"三步,交错执行丢更新,结果随机变小。要么用 Interlocked.Add,要么用各路线的内建聚合(PLINQ 的 Aggregate、Parallel 的 localInit/localFinally 重载)。

共享状态:并发 bug 的唯一根源

并发问题千变万化,根源只有一个:多条线程同时读写同一块可变内存。解法按代价从低到高三层。

第一层,不共享。局部变量天然线程隔离(4.1 节说过局部变量活在状态机/栈里,各副本独立);不可变对象创建后不改动,共享零风险;线程本地存储给每线程一份副本。能用这层解决的是设计胜利——3.2 节的不可变集合、消息传递式的并发模型都属此列。

第二层,无锁原语。Interlocked 类的原子操作:IncrementCompareExchange 一条 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 保护共享资源用 SemaphoreSlimWaitAsync

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 家族)。

本节要点回顾

  • 异步增吞吐、并行降延迟:IO 瓶颈用异步,算力瓶颈用并行,别用错药方;
  • Task.Run 给 CPU 活,IO 活直接用库的异步 API
  • 数据并行三选:同步大块 Parallel、可组合 PLINQ、异步工作 WhenAll;
  • 共享可变状态是万恶之源:三层解法——不共享、原子操作、锁,从低往高选;
  • lock 里禁 await:跨挂起点的互斥交给 SemaphoreSlim 的异步等待。

最后一节进入内存本身:GC 分代模型与分配优化,为全部性能话题收口。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U