第八章:C#封装与互操作:将传统异步模式转换为现代异步模式

第八章:C#封装与互操作:将传统异步模式转换为现代异步模式


在现代 .NET 编程中,异步编程已成为构建高效、响应式应用程序的标准方式。随着 async 和 await 关键字的引入,基于任务的异步模式(TAP,Task-based Asynchronous Pattern)成为主流。然而,许多旧的异步模式仍然广泛存在,比如基于事件的异步模式(EAP)、异步编程模型(APM)以及其他传统的异步实现方式。

本章的核心内容是如何将这些老旧的异步模式封装为现代的基于任务的异步方式,方便使用 await 进行异步操作。此外,本章还讨论了如何在不同的异步处理机制之间进行互操作,比如将可观察对象(System.Reactive)与异步流(async streams)或数据流(Dataflow)结合使用。

本章将带你逐步掌握如何封装和转换各种异步模式,使旧代码能够与现代异步编程模式无缝集成。

8.1 将基于事件的异步模式(EAP)封装为异步任务(TAP)

问题背景

在 .NET 的早期版本中,基于事件的异步模式(EAP) 是处理异步操作的常用方式。EAP 通常通过调用一个异步方法(如 OperationAsync)来启动操作,并通过一个事件(如 OperationCompleted)来通知操作的完成或失败。这种模式在使用上显得繁琐,尤其是在现代异步编程环境下,手动处理事件回调显得冗余。

自从 .NET 引入了 asyncawait 语法,基于任务的异步模式(TAP) 成为主流。相比之下,TAP 更加简洁、易于使用,特别是能够以线性代码风格编写异步操作。在现代代码中,我们通常希望将旧的 EAP 异步操作封装为 TAP,以便使用 await 来处理异步操作。

解决方案:使用 TaskCompletionSource<TResult>

为了解决这个问题,我们可以使用 TaskCompletionSource<TResult> 类型,它允许我们手动控制任务的完成状态。通过 TaskCompletionSource<TResult>,我们可以将 EAP 中的事件处理逻辑封装成一个返回 Task<TResult> 的方法,使异步操作能够与 await 一起使用。

示例:封装 WebClient 的 EAP 方法

虽然 WebClient 在现代代码中已经被 HttpClient 所取代,但它是一个很好的例子,展示如何将 EAP 异步模式封装为 TAP。

public static class WebClientExtensions
{
    // 将 WebClient 的 DownloadStringAsync 封装为一个返回 Task<string> 的 TAP 异步方法
    public static Task<string> DownloadStringTaskAsync(this WebClient client, Uri address)
    {
        // 创建 TaskCompletionSource 来控制 Task 的完成状态
        var tcs = new TaskCompletionSource<string>();

        // 定义事件处理程序
        DownloadStringCompletedEventHandler handler = null;
        handler = (sender, e) =>
        {
            // 操作完成后,取消事件订阅
            client.DownloadStringCompleted -= handler;

            // 根据操作的结果设置 Task 的状态
            if (e.Cancelled)
                tcs.TrySetCanceled();  // 操作取消
            else if (e.Error != null)
                tcs.TrySetException(e.Error);  // 操作出错
            else
                tcs.TrySetResult(e.Result);  // 操作成功,返回结果
        };

        // 注册事件处理程序
        client.DownloadStringCompleted += handler;

        try
        {
            // 开始异步操作
            client.DownloadStringAsync(address);
        }
        catch (Exception ex)
        {
            // 如果 DownloadStringAsync 方法本身抛出异常,则捕获并设置 Task 为失败状态
            tcs.TrySetException(ex);
        }

        // 返回 Task,供外部 await 消费
        return tcs.Task;
    }
}

代码解释

  1. TaskCompletionSource:我们使用 TaskCompletionSource<string> 来创建一个 Task<string>,这个任务的状态将由事件处理程序控制。

  2. 事件处理程序:通过 DownloadStringCompletedEventHandler,我们处理异步操作的完成状态:

    • 如果异步操作被取消,调用 tcs.TrySetCanceled()
    • 如果操作发生错误,调用 tcs.TrySetException(e.Error)
    • 如果操作成功,调用 tcs.TrySetResult(e.Result),将结果存储在 Task 中。
  3. 事件订阅:在调用异步操作之前,我们订阅 DownloadStringCompleted 事件,并在操作完成后取消订阅,防止内存泄漏。

  4. 启动异步操作:调用 DownloadStringAsync,启动 Web 请求。如果该方法本身抛出异常(如地址无效),则捕获异常并通过 tcs.TrySetException(ex) 传递给任务。

  5. 返回 Task:最终返回 Task<string>,使得调用方可以通过 await 等待任务的完成。

使用示例

封装完成后,客户端代码就可以像使用任何现代 TAP 异步方法一样,使用 DownloadStringTaskAsync

public async Task DownloadExampleAsync()
{
    using (var client = new WebClient())
    {
        Uri address = new Uri("https://www.example.com");

        try
        {
            // 通过 await 来消费封装后的异步操作
            string result = await client.DownloadStringTaskAsync(address);

            // 操作成功,处理结果
            Console.WriteLine($"Downloaded content: {result}");
        }
        catch (Exception ex)
        {
            // 操作失败,处理异常
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

在这个示例中,DownloadStringTaskAsync 方法被 await 异步等待,成功时返回下载的字符串,失败时捕获并处理异常。

讨论

虽然这个示例使用了 WebClient,但实际上,WebClient 已经有了内置的 DownloadStringTaskAsync 方法,并且在现代代码中,通常推荐使用 HttpClient 来进行异步 Web 请求。然而,这个示例展示了如何将基于事件的异步模式(EAP)封装为基于任务的异步模式(TAP)。

可以使用类似的模式将其他旧的 EAP 异步方法封装为 TAP,例如:

  • 文件操作
  • 数据库操作
  • 长时间运行的后台任务

异常处理

在 EAP 模式中,异步操作可能会由于各种原因失败(例如网络问题),这些失败将通过事件中的 Error 属性传递。为了在 Task 中正确处理这些错误,应该在事件处理程序中调用 TaskCompletionSource.SetException。此外,某些 EAP 异步操作方法(如 DownloadStringAsync)在调用时可能会抛出同步异常,因此我们需要捕获这些异常,并通过 TaskCompletionSource.TrySetException 传递给 Task

小结

  • EAP 模式:通过事件通知异步操作的完成或失败,使用起来相对复杂。
  • TAP 模式:通过 Task 处理异步操作,结合 async/await,使代码更加简洁、易读。
  • 封装 EAP 为 TAP:使用 TaskCompletionSource<TResult> 来封装 EAP 异步操作,将事件处理转换为任务形式。
  • 实用性:这种封装技术非常有用,尤其是当你需要与旧代码或第三方库交互时,可以让它们与现代异步编程模式兼容。

8.2 封装异步编程模型(APM)为异步任务

问题背景

在 .NET 的早期版本中,异步编程模型(APM) 是处理异步操作的常见方式。这种模式依赖于 BeginOperationEndOperation 方法来启动和完成异步操作,并通过 IAsyncResult 对象来跟踪异步操作的状态。虽然 APM 仍在一些旧的 API 中使用,但这种模式相对繁琐,尤其是在现代异步编程中,我们希望能够使用 await 来处理异步操作。

为了让 APM 异步操作兼容现代的 基于任务的异步模式(TAP),我们可以将 BeginOperationEndOperation 封装为返回 Task 的方法。这样,我们就可以使用 asyncawait 来处理这些异步操作。

解决方案:使用 Task.Factory.FromAsync

封装 APM 的最佳方式是使用 Task.Factory.FromAsync 方法。FromAsync 可以将 BeginOperationEndOperation 方法封装成一个返回 Task 的方法,帮助我们将 APM 模式转换为 TAP 模式。

Task.Factory.FromAsync 实际上是一个简单的封装器,它内部使用了 TaskCompletionSource<TResult> 来处理异步操作的完成状态,但提供了更简洁的 API。通过 FromAsync,你可以轻松地将 APM 异步操作转换为任务形式。

示例:封装 WebRequest 的 APM 方法

以下示例展示了如何将 WebRequestBeginGetResponseEndGetResponse 方法封装为一个异步的 Task<WebResponse> 方法,使其可以与 await 一起使用:

public static class WebRequestExtensions
{
    // 封装 BeginGetResponse 和 EndGetResponse 为异步方法
    public static Task<WebResponse> GetResponseAsync(this WebRequest request)
    {
        // 使用 Task.Factory.FromAsync 封装 APM 方法
        return Task<WebResponse>.Factory.FromAsync(
            request.BeginGetResponse,  // Begin 方法
            request.EndGetResponse,    // End 方法
            null                       // state 对象,通常为 null
        );
    }
}

代码解释

  1. Task.Factory.FromAsync:用于将 APM 方法封装为 Task。它接收两个主要参数:

    • BeginOperation:APM 异步操作的开始方法(如 BeginGetResponse)。
    • EndOperation:APM 异步操作的结束方法(如 EndGetResponse)。
  2. null 作为 state 参数:通常,FromAsync 的最后一个参数是 object state,用于传递异步回调时的状态对象。在现代异步编程中,这个 state 通常不再需要,所以我们传递 null

  3. 完整的封装:这个扩展方法为 WebRequest 提供了一个名为 GetResponseAsync 的异步方法,使得我们可以用 await 来处理 HTTP 请求的响应,而不需要手动处理 BeginGetResponseEndGetResponse 的回调。

使用示例

封装完成后,我们可以像使用任何现代异步方法一样,使用 GetResponseAsync

public async Task FetchDataAsync()
{
    var request = WebRequest.Create("https://www.example.com");

    try
    {
        // 使用封装后的异步方法,等待请求的响应
        WebResponse response = await request.GetResponseAsync();

        // 处理响应
        using (var stream = response.GetResponseStream())
        using (var reader = new StreamReader(stream))
        {
            string content = await reader.ReadToEndAsync();
            Console.WriteLine($"Response Content: {content}");
        }
    }
    catch (Exception ex)
    {
        // 处理异常
        Console.WriteLine($"Error: {ex.Message}");
    }
}

在这个示例中,GetResponseAsyncawait 异步等待,成功时返回 WebResponse,失败时捕获并处理异常。整个过程不再需要手动处理 APM 回调,代码更加简洁。

Task.Factory.FromAsync 的其他重载

Task.Factory.FromAsync 提供了多个重载,允许你根据不同的 APM 方法来选择适当的封装方式。主要的重载包括:

  1. 无参数的 Begin 方法

    如果 BeginOperation 不接受任何参数(除了 AsyncCallbackobject state),可以使用以下重载:

    public static Task<TResult> FromAsync(
        Func<AsyncCallback, object, IAsyncResult> beginMethod,
        Func<IAsyncResult, TResult> endMethod,
        object state
    );
    
  2. 带参数的 Begin 方法

    如果 BeginOperation 需要其他参数,可以使用类似以下的重载:

    public static Task<TResult> FromAsync<TArg1>(
        Func<TArg1, AsyncCallback, object, IAsyncResult> beginMethod,
        Func<IAsyncResult, TResult> endMethod,
        TArg1 arg1,
        object state
    );
    

    例如,如果 BeginOperation 接受一个参数 TArg1,你需要将该参数传递给 FromAsync

讨论

Task.Factory.FromAsync 是封装 APM 异步方法的推荐方式,因为它语法简洁,避免了直接处理 TaskCompletionSource<TResult> 的复杂性。然而,使用 FromAsync 时需要特别注意以下几点:

  • 不要提前调用 BeginOperationFromAsyncBeginOperation 参数应该是对 BeginOperation 方法的引用,而不是调用该方法。如果在调用 FromAsync 之前调用了 BeginOperation,你将迫使 FromAsync 使用低效的实现。

  • 为什么传递 null:在现代异步编程中,AsyncState 通常不再需要,因此传递 null 是合理的。AsyncState 主要用于旧的 APM 回调机制,在新的 async/await 语法下,它的作用已被闭包代替。

  • 异常处理:和 TAP 方法一样,你可以在 await 语法中捕获 APM 方法中抛出的异常,这样可以使代码的异常处理逻辑更加一致。


8.3 将非标准异步操作封装为异步任务

有时候,你会遇到一些不遵循标准异步模式的操作。本节将讨论如何将这些非标准的异步对象封装为可 await 的任务,使其符合 TAP 的标准。

问题背景

在现代异步编程中,asyncawait 是处理异步操作的标准方式。然而,在某些情况下,我们可能会遇到一些非标准的异步操作,这些操作并不遵循 APM(异步编程模型)或 EAP(基于事件的异步模式)的标准。这类操作可能使用回调函数、第三方库或某种自定义的异步模式,因此需要手动将它们封装为 基于任务的异步模式(TAP),从而允许我们使用 await 来处理这些非标准的异步操作。

解决方案:使用 TaskCompletionSource<T>

TaskCompletionSource<T> 是一个非常灵活的工具,允许我们手动创建和完成 Task<T>。通过 TaskCompletionSource<T>,可以为任何异步操作创建一个 Task<T>

  • 当操作成功时,调用 SetResult
  • 当操作发生错误时,调用 SetException
  • 当操作被取消时,调用 SetCanceled

即使遇到非标准的异步模式,例如基于回调的异步操作,TaskCompletionSource<T> 也可以帮助我们将其封装为 Task<T>,从而使异步操作可以与 await 配合使用。

示例:封装自定义回调的异步操作

假设我们有一个非标准的异步操作接口,它使用回调函数来返回异步操作的结果或错误:

public interface IMyAsyncHttpService
{
    // 非标准的异步操作,使用回调来返回结果或异常
    void DownloadString(Uri address, Action<string, Exception> callback);
}

在这个接口中,DownloadString 方法启动一个异步下载操作,完成后会调用回调函数,回调函数会传递结果(字符串)或异常。

现在,我们希望将这个非标准的异步操作封装为一个返回 Task<string> 的方法,使得它可以通过 await 来使用,如下所示:

public static class MyAsyncHttpServiceExtensions
{
    // 将非标准的回调异步操作封装为 Task<string>
    public static Task<string> DownloadStringAsync(this IMyAsyncHttpService httpService, Uri address)
    {
        // 创建 TaskCompletionSource 用于控制 Task<string> 的完成状态
        var tcs = new TaskCompletionSource<string>();

        // 启动非标准的异步操作,并传递回调函数
        httpService.DownloadString(address, (result, exception) =>
        {
            // 根据回调函数中的结果,设置 Task 的状态
            if (exception != null)
                tcs.TrySetException(exception);  // 异常发生,设置 Task 失败
            else
                tcs.TrySetResult(result);        // 操作成功,设置 Task 成功并返回结果
        });

        // 返回 Task,供外部 await
        return tcs.Task;
    }
}

代码解释

  1. TaskCompletionSource:我们使用 TaskCompletionSource<string> 来创建一个 Task<string>,这个任务的状态将由回调函数的执行结果控制。

  2. 回调函数:非标准的异步操作通过回调函数 Action<string, Exception> 来通知操作结果或错误。我们在回调函数中根据返回的 exception 判断操作是否成功或失败:

    • 如果回调传递了异常,调用 tcs.TrySetException(exception) 将任务标记为失败。
    • 如果操作成功,调用 tcs.TrySetResult(result) 将任务标记为成功并返回结果。
  3. 启动异步操作:通过 httpService.DownloadString 启动异步操作,并传递了回调函数。

  4. 返回 Task:我们返回 Task<string>,使得调用方可以使用 await 来异步等待操作的完成。

使用示例

封装完成后,客户端代码就可以像使用任何现代 TAP 异步方法一样,使用 DownloadStringAsync

public async Task FetchDataAsync(IMyAsyncHttpService httpService, Uri address)
{
    try
    {
        // 使用封装后的异步方法,等待下载结果
        string result = await httpService.DownloadStringAsync(address);

        // 处理下载结果
        Console.WriteLine($"Downloaded content: {result}");
    }
    catch (Exception ex)
    {
        // 处理异常
        Console.WriteLine($"Error: {ex.Message}");
    }
}

在这个示例中,DownloadStringAsyncawait 异步等待,成功时返回下载的字符串,失败时捕获并处理异常。

确保异步操作总能完成

在使用 TaskCompletionSource<T> 封装非标准异步操作时,确保任务总能完成非常重要。如果任务永远不会完成(例如,回调函数没有被触发),那么调用方将永远等待该任务,这会导致严重的性能和用户体验问题。

有几种常见的情况需要特别注意:

  • 回调函数不会被触发:确保回调函数无论成功还是失败,都会被触发。如果某些情况下回调不会被调用,任务将永远不会完成。
  • 异常处理:确保所有异常情况被正确处理并传递给 TaskCompletionSource<T>。在某些非标准的异步模式中,异常可能不会通过回调函数传递,因此你可能需要在回调函数中显式捕获异常并调用 SetException
  • 任务取消:如果异步操作支持取消,应该确保在取消时调用 TaskCompletionSource<T>.SetCanceled(),以便调用方能够正确处理取消操作。

讨论

通过 TaskCompletionSource<T>,我们可以将几乎任何异步模式封装为 TAP 异步操作。无论是基于回调的异步方法、事件驱动的异步操作,还是其他非标准的异步模式,TaskCompletionSource<T> 提供了一种灵活的方式来将异步操作与 async/await 结合使用。

拓展:处理更复杂的异步操作

某些非标准的异步操作可能会涉及更多的回调、状态或其他复杂情况。在这种情况下,仍然可以使用 TaskCompletionSource<T> 来封装异步操作,但需要更加小心地处理各种边界条件。

例如,如果一个异步操作支持取消,并且通过一个额外的回调函数来通知取消状态,那么你可能需要在封装时处理这种取消逻辑:

public static Task<string> DownloadStringAsyncWithCancellation(
    this IMyAsyncHttpService httpService, Uri address, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<string>();

    // 注册取消操作
    cancellationToken.Register(() => tcs.TrySetCanceled());

    // 启动非标准的异步操作
    httpService.DownloadString(address, (result, exception) =>
    {
        if (exception != null)
            tcs.TrySetException(exception);  // 异常发生
        else
            tcs.TrySetResult(result);        // 操作成功
    });

    return tcs.Task;
}

在这个示例中,我们使用 CancellationToken.Register 注册了一个取消回调,如果调用方取消了任务,TaskCompletionSource<T> 将调用 TrySetCanceled 来取消任务。


8.4 用 Task 封装并行计算

问题

假设你需要执行 CPU 密集型 的并行处理,并希望在 UI 线程 中使用 await 来等待其完成。直接调用并行方法(如 Parallel.ForEach 或 PLINQ)可能会阻塞 UI 线程,导致界面无响应。为了避免阻塞,需要将并行计算封装为异步任务。

解决方案

通过将并行计算包装在 Task.Run 中,可以将处理任务推送到线程池中执行,确保 UI 线程不会被阻塞:

await Task.Run(() => Parallel.ForEach(...));

这种做法将并行任务放入线程池,Task.Run 返回一个 Task,UI 线程可以异步等待这个任务的完成。

讨论

这是一个简单但有效的方式,常被忽略。通过使用 Task.Run,可以将并行计算移交给线程池处理,而主线程(特别是 UI 线程)则可以继续响应用户操作。注意,这种方法主要适用于 客户端 UI 应用程序

服务器端(如 ASP.NET)环境中,服务器已经具有内置的并行处理机制,因此不建议再使用 Task.Run 执行并行计算,避免不必要的线程池争用。

小结

  • 客户端应用:将 CPU 密集型并行计算封装在 Task.Run 中,避免阻塞 UI 线程。
  • 服务器端应用:避免在服务器端代码中使用 Task.Run 进行并行处理。

这个方法简单实用,尤其适合需要保持 UI 响应的场景。


8.5 将可观察对象(Observable)封装为异步任务

问题

假设你有一个 可观察流(IObservable),并希望使用 await 来异步消耗它。根据需求,你可能关注流中的不同事件:最后一个事件、下一个事件,或是所有事件。

解决方案

System.Reactive 库提供了工具,使得你可以通过 await 来处理可观察对象中的事件。根据需求,可以选择不同的操作符。

  1. 等待最后一个事件:使用 LastAsync 或直接 await 可观察对象。此时,await 会订阅流并在流结束时返回最后的元素。

    IObservable<int> observable = ...;
    int lastElement = await observable.LastAsync();
    // 或者直接:
    int lastElement = await observable;
    
  2. 等待下一个事件:使用 FirstAsyncawait 会订阅流并在第一个事件到达后立即完成,并取消订阅。

    IObservable<int> observable = ...;
    int nextElement = await observable.FirstAsync();
    
  3. 等待所有事件:使用 ToList,将整个流中的所有事件收集到一个列表中,直到流结束。

    IObservable<int> observable = ...;
    IList<int> allElements = await observable.ToList();
    

讨论

在使用 await 消耗可观察流时,System.Reactive 库提供了所需的工具。需要注意的是:

  • LastAsyncToList 会等待到流结束,而 FirstAsync 只会等待下一个事件。
  • 如果需要更多控制,可以使用 TakeBuffer 等运算符来异步等待特定数量的事件,无需等到流结束。

如果需要将可观察对象封装为 Task<T>,可以使用 ToTask,它会返回一个 Task<T>,在流的最后一个值到达时完成:

Task<int> task = observable.ToTask();

小结

  • LastAsync:等待流结束,返回最后一个事件。
  • FirstAsync:等待下一个事件。
  • ToList:等待所有事件并收集到列表中。
  • ToTask:将可观察对象封装为 Task<T>,返回最后一个值。

System.Reactive 提供了丰富的工具,帮助你根据需求选择合适的操作来异步处理可观察流。

8.6 使用 System.Reactive 创建异步任务的可观察包装器

问题

假设你有异步操作,并需要将它们与 可观察对象(Observable) 合并处理。例如,你希望一个 Task<T> 或异步方法能够作为 IObservable<T> 使用。

解决方案

System.Reactive 提供了将 Task<T> 转换为 IObservable<T> 的方法,使得异步操作可以以可观察流的形式处理。常用的转换方法有以下几种:

  1. ToObservable:将一个已经启动的 Task<T> 转换为 IObservable<T>。它会立即开始异步操作,并将结果作为流中的单个元素返回。

    IObservable<HttpResponseMessage> GetPage(HttpClient client) {
        Task<HttpResponseMessage> task = client.GetAsync("http://exampleurl");
        return task.ToObservable();
    }
    
  2. StartAsync:立即启动异步操作,并允许取消。如果订阅被取消,则异步操作也会被取消。

    IObservable<HttpResponseMessage> GetPage(HttpClient client) {
        return Observable.StartAsync(token => client.GetAsync("http://exampleurl", token));
    }
    
  3. FromAsync:创建一个“冷”可观察对象,它只会在订阅时启动异步操作,并支持取消。这种方式每次订阅都会启动一个新的异步操作。

    IObservable<HttpResponseMessage> GetPage(HttpClient client) {
        return Observable.FromAsync(token => client.GetAsync("http://exampleurl", token));
    }
    
  4. SelectMany:用于处理多个异步操作。这个运算符允许你针对可观察流中的每个事件启动异步操作。例如,针对每个 URL 触发一个 HTTP 请求:

    IObservable<HttpResponseMessage> GetPages(IObservable<string> urls, HttpClient client) {
        return urls.SelectMany((url, token) => client.GetAsync(url, token));
    }
    

讨论

System.Reactive 库在 async 出现之前便已存在,但通过这些运算符,它可以与 async 代码无缝集成。虽然有其他 System.Reactive 的运算符可以实现类似功能,但 ToObservableStartAsyncFromAsyncSelectMany 是更高效、直接的选择。

  • ToObservable/StartAsync:适用于已经启动的异步操作。
  • FromAsync:适用于需要在订阅时启动的异步操作。
  • SelectMany:用于对事件流中的每个事件触发异步操作。

8.7 异步流与数据流网格的互操作

问题

假设你的解决方案中一部分使用了 异步流,另一部分使用了 数据流网格(例如 TPL 数据流块),现在需要在两者之间传递数据。

解决方案

System.Threading.Channels 提供了将通道作为异步流消耗的便捷方式(参考第九章)。而要将 TPL 数据流块作为异步流消耗,可以通过扩展方法将其封装为异步流。

以下是一个扩展方法示例,将数据流块包装为异步流:

public static class DataflowExtensions
{
    // 尝试从数据流块接收一个项
    public static bool TryReceiveItem<T>(this ISourceBlock<T> block, out T value)
    {
        if (block is IReceivableSourceBlock<T> receivableSourceBlock)
            return receivableSourceBlock.TryReceive(out value);

        try
        {
            value = block.Receive(TimeSpan.Zero);
            return true;
        }
        catch (TimeoutException)
        {
            value = default;
            return false;
        }
        catch (InvalidOperationException)
        {
            value = default;
            return false;
        }
    }

    // 将数据流块封装为异步流
    public static async IAsyncEnumerable<T> ReceiveAllAsync<T>(
        this ISourceBlock<T> block,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        while (await block.OutputAvailableAsync(cancellationToken).ConfigureAwait(false))
        {
            while (block.TryReceiveItem(out var value))
            {
                yield return value;
            }
        }
    }
}

通过这个扩展方法,可以将任何输出数据流块作为异步流来消耗:

var multiplyBlock = new TransformBlock<int, int>(value => value * 2);
multiplyBlock.Post(5);
multiplyBlock.Post(2);
multiplyBlock.Complete();

await foreach (int item in multiplyBlock.ReceiveAllAsync())
{
    Console.WriteLine(item);
}

反向操作:将异步流写入数据流块

你还可以将 异步流 写入 数据流块,通过一个循环拉取异步流中的项并传递给数据流块:

public static async Task WriteToBlockAsync<T>(
    this IAsyncEnumerable<T> enumerable,
    ITargetBlock<T> block,
    CancellationToken token = default)
{
    try
    {
        await foreach (var item in enumerable.WithCancellation(token).ConfigureAwait(false))
        {
            await block.SendAsync(item, token).ConfigureAwait(false);
        }
        block.Complete();
    }
    catch (Exception ex)
    {
        block.Fault(ex);
    }
}

讨论

  • ReceiveAllAsync:将数据流块转化为异步流,便于使用 await foreach 来消费。
  • WriteToBlockAsync:将异步流转化为数据流块的输入源,并在流结束时完成块。

8.8 可观察对象与数据流网格的互操作

问题

假设你的解决方案中一部分使用了 System.Reactive 可观察对象,另一部分使用了 数据流网格(TPL 数据流块),现在需要让两者交互。

解决方案

System.Reactive数据流网格 虽然是为不同场景设计的,但它们可以很好地协同工作。你可以将数据流块作为可观察对象的输入源,或将可观察流作为数据流块的终点。

  1. 将数据流块作为可观察流的输入:可以通过调用 AsObservable,将数据流块转换为可观察对象。

    var buffer = new BufferBlock<int>();
    IObservable<int> integers = buffer.AsObservable();
    
    integers.Subscribe(
        data => Trace.WriteLine(data),
        ex => Trace.WriteLine(ex),
        () => Trace.WriteLine("Done")
    );
    
    buffer.Post(13);  // 向缓冲块发布数据
    
    • AsObservable 会将数据流块的完成或错误转化为可观察流的完成或错误。
    • 如果数据流块抛出异常,异常会被包装成 AggregateException,类似于数据流块之间传播错误的方式。
  2. 将可观察流作为数据流块的终点:通过 AsObserver,可以让数据流块订阅一个可观察流。

    IObservable<DateTimeOffset> ticks = 
        Observable.Interval(TimeSpan.FromSeconds(1))
            .Timestamp()
            .Select(x => x.Timestamp)
            .Take(5);  // 只取5个事件
    
    var display = new ActionBlock<DateTimeOffset>(x => Trace.WriteLine(x));
    ticks.Subscribe(display.AsObserver());
    
    try 
    {
        display.Completion.Wait();  // 等待流完成
        Trace.WriteLine("Done.");
    } 
    catch (Exception ex) 
    {
        Trace.WriteLine(ex);
    }
    
    • 可观察流的完成事件会转化为数据流块的完成。
    • 可观察流的错误会传递到数据流块,并导致块的错误。

讨论

从概念上看,数据流块可观察对象 有许多相似之处:它们都可以处理数据流,并能够理解完成和错误。不过,它们的设计目标不同——数据流网格更适合异步并行编程,而 System.Reactive 则专注于响应式编程。正因为它们的交集,二者的互操作性相当良好,能够在各自擅长的领域中协同工作。


8.9 将可观察对象转换为异步流

问题

假设你的解决方案中使用了 System.Reactive 可观察对象,但你希望将它们转换为 异步流(IAsyncEnumerable) 来消耗。

解决方案

可观察对象基于 推式模型,而异步流基于 拉式模型。为了将可观察对象转为异步流,需要保存可观察流的通知,直到异步流的消费者请求它们。System.Linq.Async 库提供了一个简单的转换方法:

IObservable<long> observable = Observable.Interval(TimeSpan.FromSeconds(1));
IAsyncEnumerable<long> enumerable = observable.ToAsyncEnumerable();

这个 ToAsyncEnumerable 扩展方法可以通过 System.Linq.Async 的 NuGet 包获取。不过,它使用了 不受限制的队列,可能导致内存问题,尤其当生产者比消费者快时。

自定义实现

你也可以使用 Channel 自行实现类似的转换:

public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IObservable<T> observable)
{
    Channel<T> buffer = Channel.CreateUnbounded<T>();

    using (observable.Subscribe(
        value => buffer.Writer.TryWrite(value),
        error => buffer.Writer.Complete(error),
        () => buffer.Writer.Complete()))
    {
        await foreach (T item in buffer.Reader.ReadAllAsync())
        {
            yield return item;
        }
    }
}

虽然简单,但这种方法也使用了 不受限制的队列,适合消费者最终能跟上生产者的场景。如果不能跟上,内存将耗尽。

受限制的队列

为避免内存问题,使用 受限制的队列 控制缓冲区大小,当队列满时丢弃最早的项:

public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IObservable<T> observable, int bufferSize)
{
    var bufferOptions = new BoundedChannelOptions(bufferSize)
    {
        FullMode = BoundedChannelFullMode.DropOldest
    };
    Channel<T> buffer = Channel.CreateBounded<T>(bufferOptions);

    using (observable.Subscribe(
        value => buffer.Writer.TryWrite(value),
        error => buffer.Writer.Complete(error),
        () => buffer.Writer.Complete()))
    {
        await foreach (T item in buffer.Reader.ReadAllAsync())
        {
            yield return item;
        }
    }
}

讨论

  • 如果生产者速度快于消费者,必须选择 缓冲限制 生产者项。使用受限制队列时,队列满了可以丢弃最早的项。
  • 还可以使用可观察运算符如 ThrottleSample 来限制生产者项(参考第七章)。
  • 背压 是另一种控制生产者的方法,但 System.Reactive 尚未实现标准化的背压机制。
posted @ 2024-12-09 16:07  平元兄  阅读(40)  评论(0编辑  收藏  举报