如何编写 IAsyncEnumerable 函数以便始终执行清理代码

How to write an IAsyncEnumerable function so that cleanup code always executes

我的 IAsyncEnumerable<T> 函数必须 运行 它的清理代码,无论它 returns 的枚举器是否被正确处理。在下面的示例中,我使用了将字节数组返回到数组池的任务作为强制清理代码的示例。每个测试在完全完成之前停止使用枚举器。除了最后一个测试之外,所有测试都通过 foreach 实用程序正确处理枚举器,但最后一个测试故意不处理枚举器。相反,它允许枚举器超出范围,然后触发垃圾收集以尝试查看系统本身是否可以触发最后的清理代码。

如果每个测试场景的清理代码 运行 正确,则预期输出为:

Starting 'Cancellation token'.
Finalizing 'Cancellation token'.
Starting 'Exception'.
Finalizing 'Exception'.
Starting 'Break'.
Finalizing 'Break'.
Starting 'Forget to dispose'.
Finalizing 'Forget to dispose'.

但是,我从来没有能够创建一个出现最终预期输出行的测试。

这里是测试代码:

using System;
using System.Buffers;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

// -- Cancellation token test --
var cts = new CancellationTokenSource();
await foreach (var (index, bytes) in GetDataPackets("Cancellation token", cts.Token)) {
    if (index == 2)
        cts.Cancel();
}

// -- Thrown exception test --
try {
    await foreach (var (index, bytes) in GetDataPackets("Exception")) {
        if (index == 2)
            throw new Exception("Boom");
    }
} catch { }

// -- With Break test --
await foreach (var (index, bytes) in GetDataPackets("Break")) {
    if (index == 2)
        break;
}

// -- Forget to dispose test --
// Create variables and forget them in another "no inlining" method
// to make sure they have gone out of scope and are available for garbage collection.
await ForgetToDispose();
GC.Collect();
GC.WaitForPendingFinalizers();

[MethodImpl(MethodImplOptions.NoInlining)]
async Task ForgetToDispose() {
    var enumerable = GetDataPackets("Forget to dispose");
    var enumerator = enumerable.GetAsyncEnumerator();
    await enumerator.MoveNextAsync();
    while (enumerator.Current.Index != 2)
        await enumerator.MoveNextAsync();
}

static async IAsyncEnumerable<(int Index, Memory<byte> Bytes)> GetDataPackets(string jobName, [EnumeratorCancellation] CancellationToken cancellationToken = default) {
    Console.WriteLine($"Starting '{jobName}'.");
    var rand = new Random();
    var buffer = ArrayPool<byte>.Shared.Rent(512);
    try {
        for (var i = 0; i < 10; i++) {
            try {
                await Task.Delay(10, cancellationToken);
            } catch (OperationCanceledException) {
                yield break;
            }
            rand.NextBytes(buffer);
            yield return (i, new Memory<byte>(buffer));
        }
    } finally {
        Console.WriteLine($"Finalizing '{jobName}'.");
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

坚持,然后我尝试了一些可能有帮助的想法。 None 他们有:

想法 1:添加一个 using 语句和一个执行清理工作的一次性结构:失败。

static async IAsyncEnumerable<(int Index, Memory<byte> Bytes)> GetDataPackets(string jobName, [EnumeratorCancellation] CancellationToken cancellationToken = default) {
    Console.WriteLine($"Starting '{jobName}'.");
    var rand = new Random();
    var buffer = ArrayPool<byte>.Shared.Rent(512);
    using var disposer = new Disposer(() => {
        Console.WriteLine($"Finalizing '{jobName}'.");
        ArrayPool<byte>.Shared.Return(buffer);
    });
    for (var i = 0; i < 10; i++) {
        try {
            await Task.Delay(10, cancellationToken);
        } catch (OperationCanceledException) {
            yield break;
        }
        rand.NextBytes(buffer);
        yield return (i, new Memory<byte>(buffer));
    }
}

readonly struct Disposer : IDisposable {
    readonly Action _disposeAction;
    public Disposer(Action disposeAction)
        => _disposeAction = disposeAction;
    public void Dispose() {
        _disposeAction();
    }
}

想法 2:使用终结器方法将 Disposer 结构转换为 class,希望它的终结器可能被触发:同样失败。

class Disposer : IDisposable {
    readonly Action _disposeAction;
    public Disposer(Action disposeAction)
        => _disposeAction = disposeAction;
    public void Dispose() {
        _disposeAction();
        GC.SuppressFinalize(this);
    }
    ~Disposer() {
        _disposeAction();
    }
}

除了从头开始编写我自己的枚举器 class,我怎样才能使这个编译器生成的枚举器始终 运行 它的清理代码,即使在未正确处理的终结器线程中也是如此?

My IAsyncEnumerable function must run its cleanup code regardless of whether the enumerator it returns is disposed of correctly.

句号。您不能拥有 any 类型的 运行 托管清理代码,无论它是否被正确处置。这在 .NET 中根本不可能。

可以编写终结器,但终结器不能具有任意代码。通常它们仅限于访问值类型成员和进行一些 p/Invoke-style 调用。例如,它们不能 return 数组池的缓冲区。更一般地说,除了少数例外,它们根本无法调用任何托管代码。它们实际上仅用于清理非托管资源。

所以这与异步枚举器没有任何关系。如果对象未被处置,您不能保证清理代码将是 运行,任何 类型的对象都是这种情况。

最好的解决方案是 运行 清除代码(例如,在异步枚举器函数的 finally 块中)。任何不处理的代码都会造成资源泄漏。这是所有其他 .NET 代码的工作方式,这也是异步枚举器的工作方式。