一篇文章说通C#中的异步迭代器

今天来写写C#中的异步迭代器 - 机制、概念和一些好用的特性

迭代器的概念

迭代器的概念在C#中出现的比较早,很多人可能已经比较熟悉了。

通常迭代器会用在一些特定的场景中。

举个例子:有一个foreach循环:

foreach (var item in Sources)
{
  Console.WriteLine(item);
}

这个循环实现了一个简单的功能:把Sources中的每一项在控制台中打印出来。

有时候,Sources可能会是一组完全缓存的数据,例如:List<string>:

IEnumerable<string> Sources(int x)
{
  var list = new List<string>();
  for (int i = 0; i < 5; i++)
    list.Add($"result from Sources, x={x}, result {i}");
  return list;
}

这里会有一个小问题:在我们打印Sources的第一个的数据之前,要先运行完整运行Sources()方法来准备数据,在实际应用中,这可能会花费大量时间和内存。更有甚者,Sources可能是一个无边界的列表,或者不定长的开放式列表,比方一次只处理一个数据项目的队列,或者本身没有逻辑结束的队列。

这种情况,C#给出了一个很好的迭代器解决:

IEnumerable<string> Sources(int x)
{
  for (int i = 0; i < 5; i++)
    yield return $"result from Sources, x={x}, result {i}";
}

这个方式的工作原理与上一段代码很像,但有一些根本的区别 - 我们没有用缓存,而只是每次让一个元素可用。

为了帮助理解,来看看foreach在编译器中的解释:

using (var iter = Sources.GetEnumerator())
{
  while (iter.MoveNext())
  {
    var item = iter.Current;
    Console.WriteLine(item);
  }
}

当然,这个是省略掉很多东西后的概念解释,我们不纠结这个细节。但大体的意思是这样的:编译器对传递给foreach的表达式调用GetEnumerator(),然后用一个循环去检查是否有下一个数据(MoveNext()),在得到肯定答案后,前进并访问Current属性。而这个属性代表了前进到的元素。 

上面这个例子,我们通过MoveNext()/Current方式访问了一个没有大小限制的向前的列表。我们还用到了yield迭代器这个很复杂的东西 - 至少我是这么认为的。

我们把上面的例子中的yield去掉,改写一下看看:

IEnumerable<string> Sources(int x) => new GeneratedEnumerable(x);

class GeneratedEnumerable : IEnumerable<string>
{
  private int x;
  public GeneratedEnumerable(int x) => this.x = x;

  public IEnumerator<string> GetEnumerator() => new GeneratedEnumerator(x);

  IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

class GeneratedEnumerator : IEnumerator<string>
{
  private int x, i;
  public GeneratedEnumerator(int x) => this.x = x;

  public string Current { get; private set; }

  object IEnumerator.Current => Current;

  public void Dispose() { }

  public bool MoveNext()
  {
    if (i < 5)
    {
      Current = $"result from Sources, x={x}, result {i}";
      i++;
      return true;
    }
    else
    {
      return false;
    }
  }

  void IEnumerator.Reset() => throw new NotSupportedException();
}

这样写完,对照上面的yield迭代器,理解工作过程就比较容易了:

首先,我们给出一个对象IEnumerable。注意,IEnumerable和IEnumerator是不同的。

当我们调用Sources时,就创建了GeneratedEnumerable。它存储状态参数x,并公开了需要的IEnumerable方法。

后面,在需要foreach迭代数据时,会调用GetEnumerator(),而它又调用GeneratedEnumerator以充当数据上的游标。

MoveNext()方法逻辑上实现了for循环,只不过,每次调用MoveNext()只执行一步。更多的数据会通过Current回传过来。另外补充一点:MoveNext()方法中的return false对应于yield break关键字,用于终止迭代。

是不是好理解了?

下面说说异步中的迭代器。

异步中的迭代器

上面的迭代,是同步的过程。而现在Dotnet开发工作更倾向于异步,使用async/await来做,特别是在提高服务器的可伸缩性方面应用特别多。

上面的代码最大的问题,在于MoveNext()。很明显,这是个同步的方法。如果它运行需要一段时间,那线程就会被阻塞。这会让代码执行过程变得不可接受。

我们能做得最接近的方法是异步获取数据:

async Task<List<string>> Sources(int x) {...}

但是,异步获取数据并不能解决数据缓存延迟的问题。

好在,C#为此特意增加了对异步迭代器的支持:

public interface IAsyncEnumerable<out T>
{
  IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
  T Current { get; }
  ValueTask<bool> MoveNextAsync();
}
public interface IAsyncDisposable
{
  ValueTask DisposeAsync();
}

注意,从.NET Standard 2.1和.NET Core 3.0开始,异步迭代器已经包含在框架中了。而在早期版本中,需要手动引入:

# dotnet add package Microsoft.Bcl.AsyncInterfaces
目前这个包的版本号是5.0.0。

还是上面例子的逻辑:

IAsyncEnumerable<string> Source(int x) => throw new NotImplementedException();

看看foreach可以await后的样子:

await foreach (var item in Sources)
{
  Console.WriteLine(item);
}

编译器会将它解释为:

await using (var iter = Sources.GetAsyncEnumerator())
{
  while (await iter.MoveNextAsync())
  {
    var item = iter.Current;
    Console.WriteLine(item);
  }
}

这儿有个新东西:await using。与using用法相同,但释放时会调用DisposeAsync,而不是Dispose,包括回收清理也是异步的。

这段代码其实跟前边的同步版本非常相似,只是增加了await。但是,编译器会分解并重写异步状态机,它就变成异步的了。原理不细说了,不是本文关注的内容。

那么,带有yield的迭代器如何异步呢?看代码:

async IAsyncEnumerable<string> Sources(int x)
{
  for (int i = 0; i < 5; i++)
  {
    await Task.Delay(100); // 这儿模拟异步延迟
    yield return $"result from Sources, x={x}, result {i}";
  }
}

嗯,看着就舒服。

这就完了?图样图森破。异步有一个很重要的特性:取消。

那么,怎么取消异步迭代?

异步迭代的取消

异步方法通过CancellationToken来支持取消。异步迭代也不例外。看看上面IAsyncEnumerator<T>的定义,取消标志也被传递到了GetAsyncEnumerator()方法中。

那么,如果是手工循环呢?我们可以这样写:

await foreach (var item in Sources.WithCancellation(cancellationToken).ConfigureAwait(false))
{
  Console.WriteLine(item);
}

这个写法等同于:

var iter = Sources.GetAsyncEnumerator(cancellationToken);
await using (iter.ConfigureAwait(false))
{
  while (await iter.MoveNextAsync().ConfigureAwait(false))
  {
    var item = iter.Current;
    Console.WriteLine(item);
  }
}

没错,ConfigureAwait也适用于DisposeAsync()。所以最后就变成了:

await iter.DisposeAsync().ConfigureAwait(false);

异步迭代的取消捕获做完了,接下来怎么用呢?

看代码:

IAsyncEnumerable<string> Sources(int x) => new SourcesEnumerable(x);
class SourcesEnumerable : IAsyncEnumerable<string>
{
  private int x;
  public SourcesEnumerable(int x) => this.x = x;

  public async IAsyncEnumerator<string> GetAsyncEnumerator(CancellationToken cancellationToken = default)
  {
    for (int i = 0; i < 5; i++)
    {
      await Task.Delay(100, cancellationToken); // 模拟异步延迟
      yield return $"result from Sources, x={x}, result {i}";
    }
  }
}

如果有CancellationToken通过WithCancellation传过来,迭代器会在正确的时间被取消 - 包括异步获取数据期间(例子中的Task.Delay期间)。当然我们还可以在迭代器中任何一个位置检查IsCancellationRequested或调用ThrowIfCancellationRequested()。

此外,编译器也会通过[EnumeratorCancellation]来完成这个任务,所以我们还可以这样写:

async IAsyncEnumerable<string> Sources(int x, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
  for (int i = 0; i < 5; i++)
  {
    await Task.Delay(100, cancellationToken); // 模拟异步延迟
    yield return $"result from Sources, x={x}, result {i}";
  }
}

这个写法与上面的代码其实是一样的,区别在于加了一个参数。

实际应用中,我们有下面几种写法上的选择:

// 不取消
await foreach (var item in Sources)

// 通过WithCancellation取消
await foreach (var item in Sources.WithCancellation(cancellationToken))

// 通过SourcesAsync取消
await foreach (var item in SourcesAsync(cancellationToken))

// 通过SourcesAsync和WithCancellation取消
await foreach (var item in SourcesAsync(cancellationToken).WithCancellation(cancellationToken))

// 通过不同的Token取消
await foreach (var item in SourcesAsync(tokenA).WithCancellation(tokenB))

几种方式区别于应用场景,实质上没有区别。对两个Token的方式,任何一个Token被取消时,任务会被取消。

总结

同步迭代其实在各个代码中用的都比较多,但异步迭代用得很好。一方面,这是个相对新的东西,另一方面,是会有点绕,所以很多人都不敢碰。

今天这个,也是个人的一些经验总结,希望对大家理解迭代能有所帮助。

到此这篇关于C#中异步迭代器的文章就介绍到这了,更多相关C#中异步迭代器内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 深入了解c# 迭代器和列举器

    大家好,这是 [C#.NET 拾遗补漏] 系列的第 07 篇文章. 在 C# 中,大多数方法都是通过 return 语句立即把程序的控制权交回给调用者,同时也会把方法内的本地资源释放掉.而包含 yield 语句的方法则允许在依次返回多个值给调用者的期间保留本地资源,等所有值都返回结束时再释放掉本来资源,这些返回的值形成一组序列被调用者使用.在 C# 中,这种包含 yield 语句的方法.属性或索引器就是迭代器. 迭代器中的 yield 语句分为两种: yeild return,把程序控制权交回调

  • C#学习笔记整理-迭代器模式介绍

    什么是迭代器模式? 迭代器模式(Iterator):提供一种方法顺序访问一个聚合对象中各个元素,而又不暴露该对象的内部表示. 何时使用迭代器模式? 当需要访问一个聚合对象,而且不管这些对象是什么都需要遍历的时候,需要考虑使用迭代器模式. 迭代器模式的组成 Iterator:迭代器抽象类,用于定义得到开始对象,对到下一个对象,判断是否到结尾,当前对象等抽象方法,统一接口. ConcreteAggregate:保存聚合对象. ConcreteIterator:继承于Iterator,实现具体如何对聚

  • C#迭代器模式(Iterator Pattern)实例教程

    本文以实例形式简单简述了C#迭代器模式的实现方法,分享给大家供大家参考.具体方法如下: 一般来说,迭代器模式的需求来自:需要对一些集合进行迭代,而迭代的方式可能有很多种. 说到迭代,动作大致包括设置第一个位置,获取下一个位置元素,判断是否迭代结束,获取当前位置元素,大致就这么些.把这些迭代动作封装到一个接口中. public interface IIterator { void First(); string Next(); bool IsDone(); string Current(); }

  • C#中使用迭代器处理等待任务

     介绍 可能你已经阅读 C#5 关于 async 和 await 关键字以及它们如何帮助简化异步编程的,可惜的是在升级VS2010后短短两年时间,任然没有准备好升级到VS2012,在VS2010和C#4中不能使用异步关键字,你可能会想 "如果我能在VS 2010中写看起来同步的方法,但异步执行.我的代码会更清晰." 看完这篇文章后,您将能够做到这一点.我们将开发一个小的基础结构代码,让我们写"看起来同步的方法,但异步执行"的方法,这个VS2012 异步关键字一样,

  • C#特性 迭代器(下) yield以及流的延迟计算

    从0遍历到20(不包括20),输出遍历到的每个元素,并将大于2的所有数字放到一个IEnumerable<int>中返回 解答1:(我以前经常这样做) static IEnumerable<int> WithNoYield() { IList<int> list = new List<int>(); for (int i = 0; i < 20; i++) { Console.WriteLine(i.ToString()); if(i > 2) l

  • 一篇文章说通C#中的异步迭代器

    今天来写写C#中的异步迭代器 - 机制.概念和一些好用的特性 迭代器的概念 迭代器的概念在C#中出现的比较早,很多人可能已经比较熟悉了. 通常迭代器会用在一些特定的场景中. 举个例子:有一个foreach循环: foreach (var item in Sources) { Console.WriteLine(item); } 这个循环实现了一个简单的功能:把Sources中的每一项在控制台中打印出来. 有时候,Sources可能会是一组完全缓存的数据,例如:List<string>: IEn

  • 一篇文章说通C#的属性Attribute

    属性Attributes这个东西,用好了可以省N多代码. 一.属性 属性Attributes在C#中很常用,但事实上很多人对这个东西又很陌生. 从概念上讲,属性提供的是将元数据关系到元素的一种方式. 属性使用的样子,应该都见过: [Flags] //Attribute public enum DayOfWeek { Sunday = 1, Monday = 2, Tuesday = 4, Wednesday = 8, Thursday = 16, Friday = 32, Saturday =

  • 详解nodejs中的异步迭代器

    前言 从 Node.jsv10.0.0 开始,异步迭代器就出现中了,最近它们在社区中的吸引力越来越大.在本文中,我们将讨论异步迭代器的作用,还将解决它们可能用于什么目的的问题. 什么是异步迭代器 那么什么是异步迭代器?它们实际上是以前可用的迭代器的异步版本.当我们不知道迭代的值和最终状态时,可以使用异步迭代器,最终我们得到可以解决{value:any,done:boolean}对象的 promise.我们还获得了 for-await-of 循环,以帮助我们循环异步迭代器.就像 for-of 循环

  • 如何通过一篇文章了解Python中的生成器

    目录 前言 生成器也是迭代器 生成器推导式 无限生成器 生成器实际用法 1. 读取文件行 2.读取文件内容 高级生成器用法 总结 前言 生成器很容易实现,但却不容易理解.生成器也可用于创建迭代器,但生成器可以用于一次返回一个可迭代的集合中一个元素.现在来看一个例子: def yrange(n): i = 0 while i < n: yield i i += 1 每次执行 yield 语句时,函数都会生成一个新值. “生成器”这个词被混淆地用来表示生成的函数和它生成的内容. 当调用生成器函数时,

  • 一篇文章了解Python中常见的序列化操作

    0x00 marshal marshal使用的是与Python语言相关但与机器无关的二进制来读写Python对象的.这种二进制的格式也跟Python语言的版本相关,marshal序列化的格式对不同的版本的Python是不兼容的. marshal一般用于Python内部对象的序列化. 一般地包括: 基本类型 booleans, integers,floating point numbers,complex numbers 序列集合类型 strings, bytes, bytearray, tupl

  • 一篇文章了解c++中的new和delete

    目录 new expression delete expression new[]和new() new[]和delete[] new的内存分布 placement new new失败处理 捕捉异常 禁用new的异常 new-handler 重载 重载全局的::operator new 重载局部的Foo::operator new 重载placement new 总结 new expression new一个类型,会创建一个该类型的内存,然后调用构造函数,最后返回该内存的指针 注意:该操作是原子性

  • 一篇文章带你了解Java中ThreadPool线程池

    目录 ThreadPool 线程池的优势 线程池的特点 1 线程池的方法 (1) newFixedThreadPool (2) newSingleThreadExecutor (3) newScheduledThreadPool (4) newCachedThreadPool 2 线程池底层原理 3 线程池策略及分析 拒绝策略 如何设置maximumPoolSize大小 ThreadPool 线程池的优势 线程池做的工作主要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些

  • 一篇文章理解阻塞、非阻塞、同步、异步

    目录 理解阻塞.非阻塞.同步.异步 阻塞 非阻塞 同步 异步 总结 理解阻塞.非阻塞.同步.异步 首先说明,这些都是在特点场景下或者相对情况的词汇,OK,接下来开门见山. 阻塞 可以很直观的理解,就如节假日高速路出口收费站一样,上图片: 9个收费亭,同时来了一大波车,这时候同一时刻只能有9辆车在收费,剩下的车都在只能在后面排队等待,这就是现实中很直观的阻塞现象.这9个收费亭,就是一个瓶颈,或许画为这样更符合大家对瓶颈二字的理解: 第1张图中,高速公路源源不断的车辆到来,和第二张图的效果其实表示一

  • java识别一篇文章中某单词出现个数的方法

    本文实例讲述了java识别一篇文章中某单词出现个数的方法.分享给大家供大家参考.具体如下: 1. java代码: import java.io.DataInputStream; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.util.StringTokenizer; import java.util.regex.Matche

  • 一篇文章轻松搞懂Java中的自旋锁

    前言 锁作为并发共享数据,保证一致性的工具,在JAVA平台有多种实现(如 synchronized 和 ReentrantLock等等 ) .这些已经写好提供的锁为我们开发提供了便利. 在之前的文章<一文彻底搞懂面试中常问的各种"锁" >中介绍了Java中的各种"锁",可能对于不是很了解这些概念的同学来说会觉得有点绕,所以我决定拆分出来,逐步详细的介绍一下这些锁的来龙去脉,那么这篇文章就先来会一会"自旋锁". 正文 出现原因 在我们的

随机推荐