写过异步代码的同学都熟悉 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); `

逐行拆解:

  1. CreateLinkedTokenSource(cancellationToken) —— 把业务层传入的 cancellationToken 包装成一个临时 CTS。只要业务取消了,这个临时 CTS 就跟着取消。
  2. imeoutCts.CancelAfter(ConnectTimeout) —— 额外设置一个 10 秒的超时。时间到了也会取消这个临时 CTS。
  3. 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 data, CancellationToken cancellationToken) { var socket = GetSocket(); await _sendLock.WaitAsync(cancellationToken);

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 ReadUntilAsync(Func<string, int> findEndIndex, CancellationToken cancellationToken) { var socket = GetSocket(); await _readLock.WaitAsync(cancellationToken);

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 + 正确释放顺序让客户端在高并发下不会泄漏资源。两者结合,就是健壮异步网络客户端的基石。