09. 异步编程中的内存泄漏:那些悄悄耗空你服务器的代码
本章 GitHub 仓库:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。
本章导读
本文目标:识别异步代码中最隐蔽的 5 类内存泄漏,掌握每种泄漏的根本原因和对应修复方案,让你的服务跑一年也内存稳如狗。
我见过最让人崩溃的 Bug,不是程序启动就崩,而是跑了三天后内存持续上涨, 最后 OOM。
重启一下,内存回来了,然后再跑几天……如此循环。查代码查了两天,代码看起来完全没问题,每个 await 都写了,每个 try-catch 都加了,甚至还专门优化过一遍。
最后发现,罪魁祸首是一行:
// 请求处理方法里,每次请求创建一个 CTS,从来不 Dispose
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
每秒 100 个请求,每个请求一个带超时的 CancellationTokenSource,每个 CTS 内部有一个 Timer,全部不释放……三天下来,内存里有将近 2600 万个 游荡的 Timer 对象。
这,就是异步编程的内存泄漏——不会立刻崩,但会慢慢把你的服务器掏空。
今天我们来系统地把这 5 类泄漏搞清楚。
️ 前置建议:建议先掌握 CancellationToken(第 06 章)和异步最佳实践(第 08 章)。
什么是”内存泄漏”?C# 不是有 GC 吗?
很多人会问:C# 有垃圾回收,怎么还会内存泄漏?
这个问题问得很好。正确答案是:GC 只回收”没有任何引用指向”的对象。如果某个”还活着”的对象持有了你以为已经”不用了”的对象的引用,GC 就不会回收它——这就是泄漏。
在异步编程中,持有引用的”幕后黑手”往往是:
| 幕后黑手 | 你以为已经消亡 | 实际上还活着 |
|---|---|---|
CancellationTokenSource 内部 Timer |
方法返回了 | Timer 还在系统级队列里跑 |
| 事件发布者的委托链 | 订阅者对象离开了作用域 | 发布者还持有订阅者的引用 |
| 静态 Task 集合 | Task 完成了 | 集合还持有 Task 引用,Task 状态机还持有局部变量 |
| 孤立的 Timer 回调 | 方法返回了 | GC 不保证立即收,Timer 还在跑 |
| Channel Reader 的 await | 逻辑上”没数据了” | Writer 没 Complete,Reader 永远挂在那里 |
记住这张表,后面每种泄漏都能在这里找到根源。
泄漏场景 1:CancellationTokenSource 忘记 Dispose
1.1 它内部到底有什么?
很多人把 CancellationTokenSource 当成普通对象用,用完就不管了。但它内部并不简单:
CancellationTokenSource
├── WaitHandle(操作系统内核对象,有数量上限!)
├── 回调链表(所有 token.Register(...) 注册的回调)
└── Timer(如果你用了 CancelAfter 或 new CTS(timeout) 带超时构造函数)
其中带超时的 CTS 内部有一个 System.Threading.Timer,这个 Timer 会在系统级的 TimerQueue 中持续存活,直到被 Dispose。GC 不会主动回收它——因为 TimerQueue 持有它的引用。
1.2 高频请求场景下的灾难
// 每次请求创建一个带超时的 CTS,从不 Dispose
public async Task<string> HandleRequestAsync(int id)
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
// ↑ 内部创建了一个 Timer,30 秒后触发取消
var result = await FetchDataAsync(id, cts.Token);
return result;
// 方法结束,cts 离开作用域
// 但 Timer 还在 TimerQueue 里,等待 30 秒...
// 没有 Dispose,Timer 只能等超时自然触发后才被清理
}
假设你的服务每秒处理 100 个请求:
- 1 分钟后:6000 个游荡的 Timer
- 30 分钟后:180,000 个游荡的 Timer(后续请求的 Timer 陆续到期销毁,但仍在高水位振荡)
1.3 修复:永远 using,不留活口
// 短生命周期 CTS:using 确保方法返回前即时 Dispose
public async Task<string> HandleRequestAsync(int id)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
// ↑ 方法结束时自动调用 cts.Dispose()
// ↑ Dispose 会立即停止内部 Timer,释放 WaitHandle,清空回调链
return await FetchDataAsync(id, cts.Token);
}
一行 using 解决问题。就这么简单,但很多人就是忘。
1.4 Register 回调:别忘了 Dispose 注册句柄
CancellationToken.Register 会把你的回调加入 CTS 内部的链表。只要 CTS 还活着,链表就活着,链表里的闭包(可能捕获了很多外部对象)就活着。
// 注册了回调,但持有大对象的闭包一直挂在 CTS 上
var cts = new CancellationTokenSource();
var bigData = new byte[1024 * 1024]; // 1MB
cts.Token.Register(() =>
{
_ = bigData.Length; // 闭包捕获了 bigData
});
// 即使你"不再需要" bigData,只要 cts 不 Dispose,bigData 就无法 GC
// 持有注册句柄,用完后 Dispose
using var cts = new CancellationTokenSource();
var bigData = new byte[1024 * 1024]; // 1MB
using var registration = cts.Token.Register(() =>
{
_ = bigData.Length;
});
await DoWorkAsync(cts.Token);
// using 结束 → registration.Dispose():从链表中移除回调
// using 结束 → cts.Dispose():释放所有内部资源
1.5 服务级 CTS:绑定到服务生命周期
如果你的 CTS 需要活得更久(比如 BackgroundService 的整个运行期间),就把它绑定到服务的 Dispose:
public sealed class MyBackgroundService : IDisposable
{
// CTS 作为字段,生命周期与服务绑定
private readonly CancellationTokenSource _cts = new();
private Task? _runningTask;
public void Start()
{
_runningTask = RunLoopAsync(_cts.Token);
}
private async Task RunLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
await DoWorkAsync(token);
}
}
// 服务销毁时:先取消,再释放
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
泄漏场景 2:事件订阅忘记取消
2.1 事件是”双向引用”
事件(event)在 .NET 中的本质是委托链表。当你订阅一个事件:
publisher.DataReceived += subscriber.HandleData;
发生的事情是:publisher 内部的委托链表获得了一个指向 subscriber 的引用。
这意味着,只要 publisher 还活着,subscriber 就无法被 GC——即使你认为 subscriber 已经”用完了”、”不需要了”。
GC Root
└── publisher(长生命周期对象,比如全局单例)
└── DataReceived 委托链表
├── subscriber1.HandleData ← subscriber1 被持有!
├── subscriber2.HandleData ← subscriber2 被持有!
└── subscriber3.HandleData ← subscriber3 被持有!
2.2 经典场景:ViewModel / 请求处理器
public class DataPage // 用户每次打开页面创建一个
{
private readonly byte[] _pageData = new byte[512 * 1024]; // 512KB 页面数据
public DataPage(GlobalEventBus eventBus)
{
// 订阅全局事件,但从不取消
eventBus.NewDataArrived += OnNewData;
// 即使用户关闭页面,这个 DataPage 对象也无法被 GC
// 因为 eventBus(全局单例)还持有它的引用
}
private void OnNewData(object? sender, string data)
{
Console.WriteLine($"页面收到新数据: {data}");
_ = _pageData.Length;
}
// 没有在任何地方取消订阅
}
用户反复打开关闭页面,每次都创建一个新的 DataPage,每个都带着 512KB 的数据……全部被 eventBus 拉着,GC 无能为力。
2.3 修复:IDisposable + 取消订阅
public sealed class DataPage : IDisposable
{
private readonly byte[] _pageData = new byte[512 * 1024];
private readonly GlobalEventBus _eventBus;
private bool _disposed;
public DataPage(GlobalEventBus eventBus)
{
_eventBus = eventBus;
_eventBus.NewDataArrived += OnNewData; // 订阅
}
private void OnNewData(object? sender, string data)
{
if (_disposed) return; // 防止 Dispose 后还被调用
_ = _pageData.Length;
}
// 关闭页面时 Dispose,取消订阅,断开发布者对我们的引用
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_eventBus.NewDataArrived -= OnNewData; // ← 关键:-= 取消订阅
// 现在 eventBus 不再持有我们的引用,GC 可以回收整个 DataPage
}
}
// 使用方:页面关闭时 Dispose
using var page = new DataPage(eventBus);
// 或者:
// page.Dispose(); // 在关闭回调里调用
2.4 async lambda 的订阅:必须持有引用才能取消
这是很多人会忽略的细节:
// 匿名 lambda 无法取消订阅
publisher.DataReceived += async (sender, data) =>
{
await ProcessAsync(data);
};
// 你没有办法 -= 取消这个订阅,因为每个 lambda 实例都是新的委托对象
// 持有 lambda 引用
Func<string, Task> handler = async data =>
{
await ProcessAsync(data);
};
publisher.AsyncDataReceived += handler;
// ... 用完后 ...
publisher.AsyncDataReceived -= handler; // 可以正确取消
2.5 高级方案:弱事件(WeakEventManager)
如果你的发布者和订阅者生命周期难以对齐,可以考虑弱事件模式:
// 发布者持有订阅者的弱引用
// 如果订阅者被 GC 了,弱引用自动失效,触发事件时自动跳过
public class WeakEventManager<TArgs>
{
private readonly List<WeakReference<Action<object?, TArgs>>> _handlers = new();
public void Subscribe(Action<object?, TArgs> handler)
{
_handlers.Add(new WeakReference<Action<object?, TArgs>>(handler));
}
public void Publish(object? sender, TArgs args)
{
_handlers.RemoveAll(wr => !wr.TryGetTarget(out _)); // 清理已 GC 的引用
foreach (var wr in _handlers.ToList())
{
if (wr.TryGetTarget(out var handler))
handler(sender, args);
}
}
}
️ 弱事件有一个陷阱:如果你用匿名 lambda 订阅,lambda 对象本身可能很快被 GC(因为没有强引用),导致订阅”消失”。必须把 handler 存到字段里保持强引用。
泄漏场景 3:未完成的 Task 被静态集合持有
3.1 “任务追踪”的常见失误
很多服务会用一个静态集合追踪”正在进行的任务”,防止任务被 GC 过早回收或用于监控:
// 这个集合只进不出
private static readonly List<Task> _activeTasks = new();
public void StartBackgroundWork(int id)
{
var task = DoHeavyWorkAsync(id);
_activeTasks.Add(task); // 添加
// 从不移除已完成的 Task
// Task 完成后,_activeTasks 还持有引用
// Task 的 async 状态机(含所有局部变量、闭包)也跟着活着
}
async 方法的每次调用都会创建一个状态机对象(编译器生成的类),这个状态机在 await 期间会持有所有局部变量——包括那些”看起来已经不用了”的大对象。如果 Task 被外部集合持有,状态机就一直活着。
3.2 async 状态机的”大对象陷阱”
// ️ 这个方法的状态机会持有 buffer 直到方法结束
private async Task ProcessAsync()
{
var buffer = new byte[1024 * 1024]; // 1MB
// ↑ 状态机字段,会被保留到 await 结束
await Task.Delay(TimeSpan.FromMinutes(1)); // 漫长的等待
// 整整 1 分钟,buffer 都被状态机持有
_ = buffer[0]; // 用到了 buffer,所以 JIT 不会优化掉
}
更好的写法:把大对象的声明推迟到 await 之后,或者用代码块限制其作用域:
// await 完成后再分配大对象
private async Task ProcessAsync()
{
await Task.Delay(TimeSpan.FromMinutes(1)); // 等待期间没有大对象
// await 结束后才分配,生命周期最短
var buffer = new byte[1024 * 1024];
_ = buffer[0];
}
// 或者用块作用域限制大对象的可见范围
private async Task ProcessAsyncWithScope()
{
await Task.Delay(100); // 第一个 await
string result;
{
var buffer = new byte[1024 * 1024]; // 只在块内存在
result = Convert.ToBase64String(buffer[..16]);
// buffer 离开块,下次 GC 即可回收
}
// 即使还有后续 await,buffer 也不在状态机的"活跃字段"里了
await Task.Delay(100); // 第二个 await,buffer 已经可以被回收
Console.WriteLine(result);
}
3.3 修复:任务完成后从集合移除
// 用字典管理,任务完成后自动移除自身
private readonly ConcurrentDictionary<int, Task> _activeTasks = new();
public void StartBackgroundWork(int id)
{
var task = TrackableWorkAsync(id);
_activeTasks.TryAdd(id, task);
}
private async Task TrackableWorkAsync(int id)
{
try
{
await DoHeavyWorkAsync(id);
}
finally
{
// 无论成功失败,任务结束时从字典中移除
_activeTasks.TryRemove(id, out _);
}
}
泄漏场景 4:Timer 不释放——后台的”幽灵”
4.1 孤立的 Timer:局部变量 ≠ 会被 GC
很多人以为 Timer 是局部变量,方法返回后就会被 GC。并不是。
System.Threading.Timer 在创建时会向系统级的 TimerQueue 注册,TimerQueue 持有 Timer 的引用,所以 GC 不会回收它——直到 Dispose 被调用,或者 Timer 被从 TimerQueue 中移除。
// Timer 是局部变量,但方法返回后它还在运行!
public void StartPolling()
{
var bigCapture = new byte[512 * 1024]; // 512KB,被闭包捕获
var timer = new System.Threading.Timer(_ =>
{
_ = bigCapture.Length; // 闭包持有 bigCapture
Console.WriteLine("Timer 触发!");
}, null, 0, 1000);
// 方法返回,timer 局部变量消失
// 但 TimerQueue 还持有 timer,timer 还持有 bigCapture
// 结果:bigCapture 永远无法被 GC
}
4.2 修复 1:Timer 赋给字段,随对象生命周期管理
// Timer 作为字段,实现 IDisposable 确保释放
public sealed class PollingService : IDisposable
{
private readonly System.Threading.Timer _timer; // 字段,生命周期明确
public PollingService()
{
_timer = new System.Threading.Timer(OnTick, null, 0, 1_000);
}
private void OnTick(object? state)
{
Console.WriteLine("轮询...");
}
public void Dispose()
{
_timer.Dispose(); // Dispose 让 Timer 从 TimerQueue 中移除
}
}
4.3 修复 2:PeriodicTimer 要传入 CancellationToken
.NET 6 引入的 PeriodicTimer 是更现代的方案,天然支持 async/await,但停止循环需要正确传入 CancellationToken:
// 没传 CancellationToken,外部取消无效
private async Task RunBadLoopAsync(CancellationToken outerToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync()) // ← 没传 outerToken!
{
await DoWorkAsync();
}
// 即使 outerToken 被取消,这个循环也不会停
}
// 正确:传入 CancellationToken
private async Task RunGoodLoopAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
try
{
while (await timer.WaitForNextTickAsync(ct)) // 传入 ct
{
await DoWorkAsync(ct);
}
}
catch (OperationCanceledException)
{
// 正常取消,不是错误
}
// using 块结束,timer.Dispose() 自动调用
}
4.4 System.Timers.Timer 的双重陷阱
System.Timers.Timer 有两个需要处理的地方:事件取消订阅 + Dispose:
// 完整的 System.Timers.Timer 使用姿势
using var timer = new System.Timers.Timer(1_000);
System.Timers.ElapsedEventHandler handler = (_, _) =>
{
DoWork();
};
timer.Elapsed += handler;
timer.Start();
await Task.Delay(5_000); // 运行 5 秒
timer.Stop();
timer.Elapsed -= handler; // 先取消事件订阅
// using 块结束,timer.Dispose()
泄漏场景 5:Channel 使用不当——Reader 的”无尽等待”
5.1 Writer 忘记 Complete:消费者永远不退出
Channel 是 .NET 里最推荐的生产者-消费者通信原语。但它有一个大坑:
Reader.ReadAllAsync() 只有在 Writer.Complete() 被调用后才会结束。如果 Writer 消失了但没有 Complete,Reader 就永远挂在那里。
// 生产者写完就"跑路",忘记 Complete
public async Task ProduceSomeData(ChannelWriter<string> writer)
{
await writer.WriteAsync("数据1");
await writer.WriteAsync("数据2");
// 方法结束,writer 对象消失
// 没有调用 writer.Complete()
// 消费者的 ReadAllAsync 会永远等待下一条数据!
}
// 消费者永远不退出
public async Task ConsumeData(ChannelReader<string> reader)
{
await foreach (var item in reader.ReadAllAsync())
{
Console.WriteLine(item);
}
// ← 永远到不了这里
}
后果:ConsumeData 对应的 Task 永远处于 WaitingForActivation 状态,持有所有关联资源(包括 reader、Channel 内部缓冲区、以及消费者任务本身的所有闭包对象),无法释放。
5.2 修复:try-finally 确保 Complete 一定被调用
// try-finally 是铁律,无论生产者因为什么原因退出,Complete 一定被调用
public async Task ProduceSafelyAsync(ChannelWriter<string> writer)
{
Exception? error = null;
try
{
await writer.WriteAsync("数据1");
await writer.WriteAsync("数据2");
// 可能抛异常的操作...
}
catch (Exception ex)
{
error = ex;
throw;
}
finally
{
// 正常结束:Complete() 通知 Reader 没有更多数据了
// 异常结束:TryComplete(error) 把异常传递给 Reader
writer.TryComplete(error);
}
}
更简洁的写法(当你不需要传播异常时):
public async Task ProduceSafelyAsync(ChannelWriter<string> writer)
{
try
{
for (int i = 0; i < 100; i++)
await writer.WriteAsync($"数据{i}");
}
finally
{
writer.TryComplete(); // ← 一行,保证 Reader 能退出
}
}
5.3 异常传播:让 Reader 知道生产者崩了
// 生产者
try
{
await FetchAndWriteAsync(writer);
}
catch (Exception ex)
{
// 把异常传给 Channel,Reader 的 ReadAllAsync 会重新抛出这个异常
writer.TryComplete(ex);
}
// 消费者可以正常捕获
try
{
await foreach (var item in reader.ReadAllAsync())
{
Process(item);
}
}
catch (MyCustomException ex)
{
// 这里能捕获到生产者传来的异常,做相应处理
logger.LogError(ex, "生产者出错了");
}
5.4 无界 Channel 的”内存炸弹”
Channel.CreateUnbounded<T>() 没有容量上限,如果生产速度远超消费速度,队列会无限增长:
// 无界 Channel + 生产快消费慢 = 内存炸弹
var channel = Channel.CreateUnbounded<byte[]>();
// 生产者每 10ms 写 100KB,消费者每 100ms 处理一条
// 10 秒后:队列堆积约 100 条 × 100KB = 10MB
// 1 分钟后:约 60MB,且还在增长...
// 有界 Channel + 背压(Back Pressure)
var channel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(10)
{
FullMode = BoundedChannelFullMode.Wait, // 队列满时,WriteAsync 自动等待
});
// 生产者被自动限速,内存占用最多 10 × 100KB = 1MB,可控!
BoundedChannelFullMode 的几种策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
Wait |
WriteAsync 等待直到有空位 | 不允许丢数据,且生产者可以等 |
DropOldest |
丢弃最旧的消息 | 允许丢数据,保留最新 |
DropNewest |
丢弃最新的消息(即本条) | 允许丢数据,保留已有 |
DropWrite |
WriteAsync 立即返回 false | 生产者自己决定如何处理被拒绝的数据 |
️ 怎么发现内存泄漏?
代码级别的审查只能防患于未然,已经存在的泄漏需要工具辅助。
6.1 Visual Studio 诊断工具
Visual Studio 内置了内存分析工具:
- 调试 → 性能探查器(
Alt+F2) - 勾选 内存使用情况(.NET 对象分配跟踪)
- 在运行期间拍两个 堆快照(Heap Snapshot),对比两次快照中对象数量的变化
重点关注:
CancellationTokenSource对象数量是否在增长System.Threading.Timer/System.Timers.Timer数量- 你自己的业务对象(如
DataPage、SubscriberXxx)是否在累积
6.2 dotMemory(JetBrains)
更专业的内存分析工具,可以:
- 自动检测常见内存泄漏模式
- 对象留存路径分析:一键看清楚”是谁持有了这个对象,为什么 GC 没回收”
- 时间线视图:看内存随时间的增长趋势,快速定位泄漏时间点
6.3 代码层面的自检清单
养成习惯,每次 Code Review 时快速过一遍:
□ 所有 CancellationTokenSource 都有 using 或在 Dispose 中释放?
□ 所有 CancellationToken.Register 的返回值(IDisposable)都处理了?
□ 所有事件订阅(+=)都有对应的取消(-=)?
□ 所有 Timer 都赋给了字段,并在 Dispose 中释放?
□ PeriodicTimer 的循环是否传入了 CancellationToken?
□ Channel Writer 是否在 try-finally 中调用了 TryComplete?
□ 是否有无界 Channel,且生产速度可能远超消费速度?
□ 有没有静态集合持有 Task/对象引用,且只增不减?
本章速查表
| 泄漏类型 | 根本原因 | 修复方案 |
|---|---|---|
| CTS 内部 Timer 堆积 | 带超时的 CTS 不 Dispose | 所有 CTS 用 using,长生命周期绑定 Dispose() |
| 回调闭包持有大对象 | Register 返回的句柄没有 Dispose |
用 using var registration = token.Register(...) |
| 订阅者无法 GC | 发布者持有订阅者引用,未 -= |
订阅者实现 IDisposable,Dispose 时 -= |
| async lambda 无法取消 | 匿名委托没有引用,无法 -= |
提前存为变量,显式 -= |
| 静态 Task 集合膨胀 | Task 完成后未从集合移除 | 用 ConcurrentDictionary,在 finally 中 TryRemove |
| 状态机大对象长期持有 | 大对象在 await 前声明 |
await 后再分配,或用块作用域限制生命周期 |
| 孤立 Timer 持续运行 | Timer 赋给局部变量,TimerQueue 持有 |
Timer 赋给字段,随对象 Dispose |
| PeriodicTimer 循环停不下来 | WaitForNextTickAsync 未传 CancellationToken |
传入 ct,catch OperationCanceledException |
| Channel Reader 永久挂起 | Writer 未调用 Complete() |
Writer 在 try-finally 中调用 TryComplete() |
| 无界 Channel 内存爆炸 | 生产快消费慢,队列无限增长 | 改用 CreateBounded,设置 FullMode = Wait |
本章总结
今天我们系统梳理了异步编程中最隐蔽的 5 类内存泄漏,它们有一个共同特点:代码逻辑上看起来没问题,甚至运行起来短期内也没问题,但随着时间积累,内存会慢慢涨上去,直到服务 OOM 重启。
核心心法只有一句话:
谁创建,谁负责释放。创建了 CTS,Dispose 它;订阅了事件,取消它;启动了 Timer,停止并 Dispose 它;打开了 Channel,Complete 它。
不要依赖 GC 来帮你做”善后”——GC 只回收没有引用的对象,而在异步编程里,各种隐藏的引用链比你想象的要多得多。
下一章:Parallel 与 PLINQ——榨干 CPU 的正确姿势。当 I/O 不再是瓶颈,我们来聊聊如何让多核 CPU 真正跑满,以及 CPU 密集型任务里那些让性能打折扣的坑。
参考资源
- Stephen Toub – Async ValueTask Pooling in .NET 5
- David Fowler – Async Guidance (内存泄漏部分)
- Microsoft Docs – Channel Class
- Microsoft Docs – PeriodicTimer
文章摘自:https://www.cnblogs.com/diamondhusky/p/20137358
