写过异步代码的同学都熟悉 CancellationToken——传一个取消令牌,让异步操作可以被外部打断。但如果遇到业务取消 + 网络超时双重需求,你知道怎么优雅地组合吗?如果资源释放涉及异步操作,你知道 IAsyncDisposable 应该怎么正确实现吗?
这篇文章从项目里的 TelnetClient(一个生产级 Telnet 协议客户端)入手,深入讲解两个 .NET 异步编程中高频但常被忽略的高级模式:CancellationTokenSource.CreateLinkedTokenSource 组合超时、IAsyncDisposable 异步资源管理。
一、场景:网络客户端需要什么样的取消机制?
TelnetClient 基于 Socket 实现 Telnet 协议连接,对外暴露三个核心异步方法:
- ConnectAsync(host, port, cancellationToken) —— 建立连接
- SendAsync(text, cancellationToken) —— 发送数据
- ReadUntilAsync(delimiter, cancellationToken) —— 读取直到匹配结束符
这三个方法都接收调用方传入的 cancellationToken,代表业务层取消(用户关闭页面、请求超时等)。但作为底层网络库,每个操作还需要自己的超时保护——比如连接不能超过 10 秒、单次发送不能超过 10 秒、单次接收不能超过 30 秒。
需求很明确:既要响应业务取消,又要在超时后自动终止。
二、CreateLinkedTokenSource:优雅的”或”语义取消
最直接的想法是传两个 CancellationToken,但 Socket 的 ConnectAsync 只接受一个。.NET 提供了 CancellationTokenSource.CreateLinkedTokenSource,它的作用是:创建一个新的 CTS,当传入的任意一个 Token 被取消时,新 CTS 也自动取消。
`csharp using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(ConnectTimeout);
await socket.ConnectAsync(new IPEndPoint(address, port), timeoutCts.Token); `
逐行拆解:
- CreateLinkedTokenSource(cancellationToken) —— 把业务层传入的 cancellationToken 包装成一个临时 CTS。只要业务取消了,这个临时 CTS 就跟着取消。
- imeoutCts.CancelAfter(ConnectTimeout) —— 额外设置一个 10 秒的超时。时间到了也会取消这个临时 CTS。
- imeoutCts.Token —— Socket API 只需要一个 Token,但实际上接收到了两个取消信号中的任意一个。
这就是 “或”语义(OR):业务取消 或 超时,哪个先来就先触发。
完整的连接超时实现
TelnetClient.ConnectAsync 有个细节:它会先 Dns.GetHostAddressesAsync 拿到目标主机的所有 IP,然后逐个尝试连接——这在双栈服务器(IPv4 + IPv6)上非常实用。整个循环中每个 Socket 连接都套了一层超时 CTS:
`csharp public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default) { var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
Exception lastException = null;
foreach (var address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ConnectTimeout);
await socket.ConnectAsync(new IPEndPoint(address, port), timeoutCts.Token);
_socket = socket;
return;
}
catch (Exception ex)
{
lastException = ex;
socket.Dispose();
}
}
} `
关键点: - using var timeoutCts —— 每次循环迭代结束立即释放 CTS,避免积压。 - imeoutCts.CancelAfter(ConnectTimeout) 在失败时仍被 using 自动 Dispose,不会泄漏。 - 每个 Socket 连接有独立的 10 秒预算。
三、Send/Receive 同样受益于组合超时
发送需要处理大文件分片:
`csharp private async Task SendBytesAsync(ReadOnlyMemory
try
{
var offset = 0;
while (offset < data.Length)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(SendTimeout);
var sent = await socket.SendAsync(data[offset..], SocketFlags.None, timeoutCts.Token);
offset += sent;
}
}
finally { _sendLock.Release(); }
} `
接收逻辑每一轮 Receive 都有独立超时,还要处理 Pending Text 机制(TCP 粘包问题):
`csharp private async Task
try
{
while (true)
{
var pendingResult = TryConsumePending(findEndIndex);
if (pendingResult is not null) return pendingResult;
var buffer = new byte[8192];
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ReceiveTimeout);
var received = await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, timeoutCts.Token);
if (received == 0) throw new IOException("远端已关闭连接。");
var parseResult = _parser.Parse(buffer.AsSpan(0, received));
// ...
}
}
finally { _readLock.Release(); }
} `
四、using var CancellationTokenSource:为什么这么关键?
csharp // 反例:没有 Dispose var cts = new CancellationTokenSource(); cts.CancelAfter(10000); await DoSomethingAsync(cts.Token); // cts 永远没被 Dispose!
CancellationTokenSource 实现了 IDisposable,内部维护着: - 注册回调链表 - CancelAfter 设置的定时器 - 某些场景下的内核对象
正确姿势:始终用 using var。配合 CreateLinkedTokenSource 的返回值,块结束时自动 Dispose。
五、IAsyncDisposable:SemaphoreSlim 的正确释放方式
TelnetClient 用 SemaphoreSlim 保护 Socket 读写:
csharp private readonly SemaphoreSlim _readLock = new(1, 1); private readonly SemaphoreSlim _sendLock = new(1, 1); private Socket _socket; private bool _disposed;
释放顺序很关键:先标记 disposed,再断开 Socket 让阻塞线程退出,最后释放 SemaphoreSlim。
`csharp public ValueTask DisposeAsync() { if (_disposed) return ValueTask.CompletedTask; _disposed = true;
Disconnect(); // 先断开 Socket
_readLock.Dispose(); // 再释放 Semaphore
_sendLock.Dispose();
GC.SuppressFinalize(this);
return ValueTask.CompletedTask;
}
public void Dispose() { if (_disposed) return; _disposed = true; Disconnect(); _readLock.Dispose(); _sendLock.Dispose(); GC.SuppressFinalize(this); } `
同时实现 IDisposable 兼容旧代码。两个 Dispose 共享 _disposed 标记,用 GC.SuppressFinalize 防止终结器重复调用。
调用方最佳实践:
csharp await using var telnet = new TelnetClient(); await telnet.ConnectAsync(host, port); // ...
六、实战:带超时的 HTTP 客户端
`csharp public sealed class TimeoutHttpClient : IAsyncDisposable, IDisposable { private readonly HttpClient _httpClient; private bool _disposed;
public TimeoutHttpClient() { _httpClient = new HttpClient(); }
public async Task<string> GetAsync(string url, CancellationToken externalToken, int timeoutMs = 10_000)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken);
timeoutCts.CancelAfter(timeoutMs);
try
{
return await _httpClient.GetStringAsync(url, timeoutCts.Token);
}
catch (OperationCanceledException) when (!externalToken.IsCancellationRequested)
{
throw new TimeoutException($"请求 {url} 在 {timeoutMs}ms 内未响应");
}
}
public ValueTask DisposeAsync()
{
if (_disposed) return ValueTask.CompletedTask;
_disposed = true;
_httpClient.Dispose();
GC.SuppressFinalize(this);
return ValueTask.CompletedTask;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_httpClient.Dispose();
GC.SuppressFinalize(this);
}
} `
catch (OperationCanceledException) when (!externalToken.IsCancellationRequested) 区分业务取消和超时取消——前者正常,后者抛 TimeoutException。
七、常见坑与最佳实践总结
坑 1:忘了 Dispose 组合 CTS
CreateLinkedTokenSource 返回的 CTS 不用 using,内部注册回调不会被移除。高并发下会回调链表无限增长。
坑 2:CancelAfter 后再手动 Cancel
csharp var cts = new CancellationTokenSource(); cts.CancelAfter(5000); cts.Cancel(); // 两者都用会有定时器清理竞态
建议:要么 CancelAfter,要么手动 Cancel,不要同时用。
坑 3:Dispose 顺序不对
先释放了 SemaphoreSlim,后面还在 WaitAsync 的线程会拿 ObjectDisposedException。正确顺序:先断阻塞 IO,再释放锁。
坑 4:AsyncDisposable 没有同时实现 IDisposable
有些框架只认 IDisposable。同时实现两个接口是向后兼容的安全做法。
总结
| 模式 | 解决什么问题 | 关键代码 |
|---|---|---|
| CreateLinkedTokenSource | 业务取消 + 网络超时组合 | using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); |
| CancelAfter | 单次操作超时保护 | cts.CancelAfter(timeoutMs); |
| using var CTS | 防止回调注册泄漏 | 每次操作都包一层 using |
| IAsyncDisposable | 正确释放异步资源 | public ValueTask DisposeAsync() |
| Dispose 顺序 | 防止释放时抛异常 | 先断 IO,再释放锁 |
| GC.SuppressFinalize | 防止终结器重复释放 | Dispose 末尾调用 |
组合超时让网络操作在弱网和业务取消时都能及时退出;IAsyncDisposable + 正确释放顺序让客户端在高并发下不会泄漏资源。两者结合,就是健壮异步网络客户端的基石。