【.NET并发编程 – 09】异步编程中的内存泄漏:那些悄悄耗空你服务器的代码

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 内置了内存分析工具:

  1. 调试性能探查器Alt+F2
  2. 勾选 内存使用情况(.NET 对象分配跟踪)
  3. 在运行期间拍两个 堆快照(Heap Snapshot),对比两次快照中对象数量的变化

重点关注:

  • CancellationTokenSource 对象数量是否在增长
  • System.Threading.Timer / System.Timers.Timer 数量
  • 你自己的业务对象(如 DataPageSubscriberXxx)是否在累积

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 发布者持有订阅者引用,未 -= 订阅者实现 IDisposableDispose-=
async lambda 无法取消 匿名委托没有引用,无法 -= 提前存为变量,显式 -=
静态 Task 集合膨胀 Task 完成后未从集合移除 ConcurrentDictionary,在 finallyTryRemove
状态机大对象长期持有 大对象在 await 前声明 await 后再分配,或用块作用域限制生命周期
孤立 Timer 持续运行 Timer 赋给局部变量,TimerQueue 持有 Timer 赋给字段,随对象 Dispose
PeriodicTimer 循环停不下来 WaitForNextTickAsync 未传 CancellationToken 传入 ctcatch 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 密集型任务里那些让性能打折扣的坑。


参考资源

文章摘自:https://www.cnblogs.com/diamondhusky/p/20137358