【问题标题】:Is it OK to cache IAsyncEnumerable for multiple consumers?可以为多个消费者缓存 IAsyncEnumerable 吗?
【发布时间】:2021-02-10 22:33:18
【问题描述】:

我正在研究用 IAsyncEnumerable 替换某些常规 C# 事件模式实例的效果。这将通过延迟实例化/激活 IAsyncEnumerable 并缓存该引用以供所有调用者/侦听器使用来完成。一些快速测试(见下文)表明这是可行的,但我还没有看到其他以这种方式使用 IAsyncEnumerable 的在线示例。

我意识到这并不是 IAsyncEnumerable 的创建目的,在这种情况下,大多数人会提倡使用 ReactiveX (https://github.com/dotnet/reactive)。但是,我希望分析一下为什么要或不希望按照描述的方式执行此操作(而不仅仅是如何使用 Rx 执行此操作)。我在下面提供了几个例子。我的候选事件模式替换更多的是事件流(例如从串行连接或 UDP 套接字产生的反序列化消息等)

示例 1:

  class Program
  {
    public static async Task Main( string[] args )
    {
      // Cache an async enumerable (imagine stream of events e.g. deserialized messages arriving)
      var asyncEnumerable = GetNumbersAsync( 10 );

      // Now multiple consumers want to respond to incoming events/messages (in this case just numbers)
      await Task.WhenAll( Task.Run( () => ProcessNumbersAsync( 1, asyncEnumerable ) ), Task.Run( () => ProcessNumbersAsync( 2, asyncEnumerable ) ) );

      Console.WriteLine( "DONE!");
    }
    
    private static async Task ProcessNumbersAsync( int id, IAsyncEnumerable<int> numbers )
    {
      await foreach ( var n in numbers )
        Console.WriteLine( $"{id}: Processing {n}" );
    }

    private static async IAsyncEnumerable<int> GetNumbersAsync( int maxNumber )
    {
      // This would really be async read operations from a remote source
      for ( var i = 0; i < maxNumber; i++ )
      {
        await Task.Delay( 100 );
        yield return i;
      }
    }
  }

这会产生我作为此模式用户想要的输出:

1: Processing 0
2: Processing 0
2: Processing 1
1: Processing 1
2: Processing 2
1: Processing 2
2: Processing 3
1: Processing 3
2: Processing 4
1: Processing 4
2: Processing 5
1: Processing 5
1: Processing 6
2: Processing 6
1: Processing 7
2: Processing 7
2: Processing 8
1: Processing 8
2: Processing 9
1: Processing 9
DONE!

前面的示例将每个使用者放在不同的线程上,但根据上下文(可能是 WPF 应用程序),同一线程上可能有多个使用者(IEnumerable 不可能,但使用 IAsyncEnumerable 打开门)。以下是在控制台应用程序中,但可以想象在 WPF 应用程序的 UI 线程上创建生产者和消费者。

示例 2:

  class Program
  {
    public static async Task Main( string[] args )
    {
      var producer = new Producer();
      var consumer1 = new Consumer( 1, producer );
      var consumer2 = new Consumer( 2, producer );
      var consumer3 = new Consumer( 3, producer );

      await Task.WhenAll( consumer1.ConsumeMessagesAsync(), consumer2.ConsumeMessagesAsync(), Task.Run( async () => await consumer3.ConsumeMessagesAsync() ) );

      Console.WriteLine( "DONE!");
    }

    // Singleton producer
    private interface IProducer
    {
      IAsyncEnumerable<int> GetMessagesAsync();
    }

    // Transient consumer
    private interface IConsumer
    {
      Task ConsumeMessagesAsync();
    }

    private class Producer : IProducer
    {
      private const int _maxFakeMessages = 10;
      private readonly object _mutex = new Object();

      private IAsyncEnumerable<int> _actualIncomingMessagesEnumerable;
      
      public IAsyncEnumerable<int> GetMessagesAsync()
      {
        // TODO: use AsyncEx AsyncLock
        lock ( _mutex )
        {
          if ( _actualIncomingMessagesEnumerable == null)
            _actualIncomingMessagesEnumerable = ReadIncomingMessagesAsync();
        }

        return _actualIncomingMessagesEnumerable;
      }

      private static async IAsyncEnumerable<int> ReadIncomingMessagesAsync()
      {
        for ( var i = 0; i < _maxFakeMessages; i++ )
        {
          await Task.Delay( 100 );
          yield return i;
        }
      }
    }

    private class Consumer
    {
      private readonly int _id;
      private readonly IProducer _producer;
      public Consumer( int id,  IProducer producer )
      {
        _id = id;
        _producer = producer;
      }

      public async Task ConsumeMessagesAsync()
      {
        await foreach( var n in _producer.GetMessagesAsync() )
          Console.WriteLine( $"{_id}: Processing {n}" );
      }
    }
  }

同样,作为用户,我想要的输出是:

1: Processing 0
2: Processing 0
3: Processing 0
2: Processing 1
3: Processing 1
1: Processing 1
2: Processing 2
1: Processing 2
3: Processing 2
1: Processing 3
2: Processing 3
3: Processing 3
1: Processing 4
2: Processing 4
3: Processing 4
3: Processing 5
1: Processing 5
2: Processing 5
1: Processing 6
3: Processing 6
2: Processing 6
2: Processing 7
1: Processing 7
3: Processing 7
3: Processing 8
2: Processing 8
1: Processing 8
1: Processing 9
3: Processing 9
2: Processing 9
DONE!

这样的模式固有的一个好处是消费者/调用者可以让他们的回调/项目类型-T-处理代码发生在他们自己的同步上下文中。 SerialPort 或 Timer 或其他来源的事件通常会发生在后台线程上,而用户(尤其是在 UI 线程上)可能需要执行他们自己的同步。在这种情况下,UI 线程上的使用者总是可以让他们的代码发生在 UI 线程上,而控制台应用程序中的用户会让它发生在线程池上。

我错过了什么吗?

【问题讨论】:

  • 这不是一般情况下可以回答的问题。如果枚举器正在访问共享状态,那么您需要了解它们将如何相互影响并确保正确同步。如果没有共享状态并且特定序列被设计为正确生成多个独立的枚举器,那么它就可以正常工作。
  • @Servy 我可以在一些帮助下完善这个问题。我假设生产者(创建 IEnumerableAsync)是应用程序中可用的唯一单例实例,并且将有一个(并且只有一个)唯一的方法调用返回 INumerableAsync。想象一下,该方法是一个循环(直到应用程序关闭/对象拆除等),它处理来自与外部服务的连接的消息,最后这个循环比单个断开/连接/错误/等更有效。并且是应用生命周期中唯一需要的。因此,没有其他要同步的调用。
  • 重要的是细节。在不知道您希望这些序列具有的特定行为以及您如何实际实现它的情况下,没有人可以告诉您是否已经实现了您想要的语义。仅仅知道您有一个由服务连接支持的序列并不足以让我们告诉您这是否有效。
  • 作为旁注,GetMessagesAsync 不完全是异步方法,Async 后缀可能不适合这种情况。我个人将其命名为GetMessagesStream
  • @KyleP 它只被调用一次。如果您多次调用它(在这种特殊情况下),它的行为没有任何不同,但是您可以编写一个在这两种情况下表现不同的方法。当然,该方法中定义的代码会执行多次,尽管该方法只被调用一次,这是迭代器块完成转换的结果。

标签: c# .net-core async-await iasyncenumerable nito.asyncex


【解决方案1】:

让我们稍微改变你的第一个例子的“事件源”的实现,GetNumbersAsync 方法:

private static int _current = 0;
private static async IAsyncEnumerable<int> GetNumbersAsync(int maxNumber)
{
    // This would really be async read operations from a remote source
    for (var i = 0; i < maxNumber; i++)
    {
        await Task.Delay(100);
        yield return Interlocked.Increment(ref _current);
    }
}

这是更改后的输出:

1: Processing 1
2: Processing 2
2: Processing 4
1: Processing 3
2: Processing 5
1: Processing 6
1: Processing 8
2: Processing 7
2: Processing 9
1: Processing 10
1: Processing 12
2: Processing 11
1: Processing 14
2: Processing 13
1: Processing 15
2: Processing 16
1: Processing 17
2: Processing 18
1: Processing 19
2: Processing 20

每个消费者都收到不同的“事件”!

虽然您示例中的IAsyncEnumerable 是一个缓存实例,但每次您尝试使用await foreach 语句枚举它时,都会创建一个新的IAsyncEnumerator,其生命周期与此特定枚举有关。 IAsyncEnumerators 既不是线程安全的也不是可重用的,如果你尝试缓存一个并在消费者之间共享它,每个消费者在没有同步的情况下调用它的 MoveNextAsync 方法,你会得到未定义的行为。

如果您想要一个可以随时安全订阅/取消订阅的IAsyncEnumerables 源,并将所有消息传播给可能以不同速度使用它们的订阅者,那么它远不及缓存创建的IAsyncEnumerable通过 C# 迭代器(包含 yield 语句的方法)。您可以找到 AsyncEnumerableSource here 的实现。

【讨论】:

  • 我假设循环只迭代一次,并且在“yield return”语句中,每个使用await foreach 迭代的用户都会收到相同的项目。 AsyncEnumerableSource 确实是我所描述的概念。我是否正确理解,即使我只调用一次执行循环的方法(包含 yield 语句),它实际上被执行了多次,也许是同时执行的(取决于情况),每个执行await foreach 的人?
  • @KyleP 是的,没错。每个foreach 都会启动迭代器的新执行。如果您熟悉 Reactive Extensions,您可能会发现此行为类似于 Observable.Create 方法创建的“冷”IObservables。我不确定术语 hot and cold 是否适用于 IAsyncEnumerables。我想不出“热门IAsyncEnumerable”会是什么样子。
  • @TheodorZoulias 它会在您调用方法时执行工作,而不是在您获得枚举器然后向该枚举器询问项目之后才执行任何工作。不过,您需要使用非迭代器块实现(至少部分实现)。
  • @Servy 嗯,也许吧。但是IAsyncEnumerable 有时间维度。它不只是“执行工作”。它的工作可能会永远持续下去。例如,您会说传播鼠标移动的IAsyncEnumerable&lt;Point&gt; 序列是热序列吗?如果消费者决定使用计时器枚举此序列,每分钟拉一个Point,该怎么办?检索一小时前发生的鼠标移动对我来说听起来不是很! ?
  • @TheodorZoulias “执行工作”并不一定是指“造成副作用”,我只是指“它正在执行一些代码”,即使该代码只能通过序列的结果观察到.使用产生鼠标移动的序列示例,您可以创建一个序列,该序列每分钟产生一次鼠标移动从创建 IAsyncEnumerable 开始,这将是热的,或者每分钟产生一次移动的序列分钟开始获取第一个值时,这将是冷的。
【解决方案2】:

看起来频道就是您要查找的内容。

An Introduction to System.Threading.Channels

Working with Channels in .NET

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2015-07-04
    • 2022-06-13
    • 2018-03-16
    • 2019-02-26
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多