Channels搭配带超时的CancellationTokenSource释放后出现内存泄漏
完整可复现代码已开源,运行可执行文件后内存会快速飙升,代码主要位于AsyncBlockingQueue.cs类中。
以下代码实现了一个简单的异步「阻塞」队列:
public async Task<T> DequeueAsync( int timeoutInMs = -1, CancellationToken cancellationToken = default) { try { using (CancellationTokenSource cts = this.GetCancellationTokenSource(timeoutInMs, cancellationToken)) { T value = await this._channel.Reader.ReadAsync(cts?.Token ?? cancellationToken).ConfigureAwait(false); return value; } } catch (ChannelClosedException cce) { await Console.Error.WriteLineAsync("Channel is closed."); throw new ObjectDisposedException("Queue is disposed"); } catch (OperationCanceledException) { throw; } catch (Exception ex) { await Console.Error.WriteLineAsync("Dequeue failed."); throw; } } private CancellationTokenSource GetCancellationTokenSource( int timeoutInMs, CancellationToken cancellationToken) { if (timeoutInMs <= 0) { return null; } CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMilliseconds(timeoutInMs)); return cts; }
按如下方式调用时会出现内存泄漏:
try { string message = await this._inputQueue.DequeueAsync(10,cancellationToken).ConfigureAwait(false); } catch(OperationCanceledException){ // 超时 }
内存变化截图:
泄漏根因
问题核心出在CreateLinkedTokenSource生成的链接令牌源的引用持有逻辑:
- 调用
CreateLinkedTokenSource生成的令牌源,会被传入的父CancellationToken的回调列表持有引用。如果调用方传入的是生命周期和应用一致的长驻令牌(比如全局停止运行令牌),即使你用using主动释放了CTS,只要父令牌从未触发取消,这个引用关系会一直存在,导致CTS实例无法被GC回收。 - 当前场景下设置了10ms的短超时,如果队列长期为空,每秒会生成上百个CTS实例,大量未被回收的实例和对应的取消回调不断累积,就会出现内存持续飙升的情况。
修复方案
方案一:拆分等待+读取逻辑,减少无效CTS创建(兼容所有支持Channel的.NET版本)
public async Task<T> DequeueAsync( int timeoutInMs = -1, CancellationToken cancellationToken = default) { try { // 无超时场景直接读取 if (timeoutInMs <= 0) { return await _channel.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); } // 分两步:先等可读,再直接读取,避免ReadAsync长期持有CTS引用 using var timeoutCts = new CancellationTokenSource(timeoutInMs); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { // 等待队列可读/超时/父令牌取消 if (!await _channel.Reader.WaitToReadAsync(linkedCts.Token).ConfigureAwait(false)) { throw new ChannelClosedException(); } // 可读后直接同步读取,不需要再传令牌,避免额外引用 return _channel.Reader.TryRead(out var value) ? value : throw new InvalidOperationException("队列读取失败"); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { throw new OperationCanceledException("队列读取超时"); } } catch (ChannelClosedException) { await Console.Error.WriteLineAsync("通道已关闭"); throw new ObjectDisposedException("队列已释放"); } catch (Exception ex) when (ex is not OperationCanceledException) { await Console.Error.WriteLineAsync("出队操作失败"); throw; } }
方案二:.NET 6+ 优化方案
如果运行环境为.NET 6及以上版本,可以直接使用CancellationTokenSource的池化实现,或者直接用内置的超时判断逻辑,避免每次创建新的CTS实例,大幅降低GC压力。
如果你的业务场景超时调用非常频繁,也可以自行实现CTS复用逻辑,进一步减少对象创建开销。
内容的提问来源于stack exchange,提问作者HooYao
相关产品推荐
相关产品推荐

