栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > C/C++/C#

一个 Task 不够,又来一个 ValueTask ,真的学懵了!

C/C++/C# 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

一个 Task 不够,又来一个 ValueTask ,真的学懵了!

一:背景 1. 讲故事

前几天在项目中用 MemoryStream 的时候意外发现 ReadAsync 方法多了一个返回 ValueTask 的重载,真是日了狗了,一个 Task 已经够学了,又来一个 ValueTask,晕,方法签名如下:


    public class MemoryStream : Stream
    {
 public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default(CancellationToken))
 {
 }
    }

既然是新玩意,我就比较好奇,看看这个 ValueTask 是个啥玩意,翻翻源码看看类定义:


    public readonly struct ValueTask : IEquatable>
    {
    }

原来是搞了一个 值类型的Task,无数的优化经验告诉我,值类型相比引用类型要节省空间的多,不信的话可以用 windbg 去校验一下,分别在 List 中灌入 1000 个Task 和 1000 个 ValueTask,看看所占空间大小。


0:000> !clrstack -l
OS Thread Id: 0x44cc (0)
 Child SP IP Call Site
0000004DA3B7E630 00007ffaf84329a6 ConsoleApp2.Program.Main(System.String[]) [E:net5ConsoleApp1ConsoleApp2Program.cs @ 17]
    LOCALS:
 0x0000004DA3B7E6E8 = 0x000001932896ac78
 0x0000004DA3B7E6E0 = 0x000001932897e700
0:000> !objsize 0x000001932896ac78
sizeof(000001932896AC78) = 80056 (0x138b8) bytes (System.Collections.Generic.List`1[[System.Threading.Tasks.Task`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib]])
0:000> !objsize 0x000001932897e700
sizeof(000001932897E700) = 16056 (0x3eb8) bytes (System.Collections.Generic.List`1[[System.Threading.Tasks.ValueTask`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib]])

上面的代码可以看出, 1000 个 Task 需占用 80056 byte,1000 个 ValueTask 需占用 16056 byte,相差大概 5 倍,空间利用率确实得到了大大提升,除了这个, ValueTask 还想解决什么问题呢?

二:ValueTask 原理分析 1. 从 MemoryStream 中寻找答案

大家可以仔细想一想,既然 MemoryStream 中多了一个 ReadAsync 扩展,必然是现存的 ReadAsync 不能满足某些业务,那不能满足什么业务呢? 只能从方法源码中寻找答案,简化后的代码如下:


public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
	if (cancellationToken.IsCancellationRequested)
	{
		return Task.FromCanceled(cancellationToken);
	}

	int num = Read(buffer, offset, count);
	Task lastReadTask = _lastReadTask;
	return (lastReadTask != null && lastReadTask.Result == num) ? lastReadTask : (_lastReadTask = Task.FromResult(num));
}

看完这段代码,不知道大家有没有什么疑惑? 反正我是有疑惑的。

2. 我的疑惑 1) 异步 竟然包装了 cpu 密集型操作

C# 引入异步本质上是用来解决 IO 密集型 的场景,利用磁盘驱动器的强势介入进而释放了调用线程,提高线程的利用率和吞吐率,而恰恰这里的 ReadAsync 中的 Read 其实是一个简单的纯内存操作,也就是 CPU 密集型 的场景,这个时候用异步来处理其实没有任何效果可言,说严重一点就是为了异步而异步,或许就是为了统一异步编程模型吧。

2) CPU 密集型处理速度瞬息万里

纯内存操作速度是相当快的,1s内可达千万次执行,那有什么问题呢? 这问题大了,大家看清楚了,这个 ReadAsync 返回的是一个 Task 对象,这就意味着瞬间会在托管堆中生成千万个 Task 对象,造成的后果可能就是 GC 不断痉挛,严重影响程序的性能。

3. 语言团队的解决方案

可能基于我刚才聊到的二点,尤其是第二点,语言团队给出了 ValueTask 这个解决方案,毕竟它是值类型,也就不会在托管堆上分配任何内存,和GC就没有任何关系了,有些朋友会说,空口无凭,Talk is cheap. Show me the code 。

三:Task 和 ValueTask 在 MemoryStream 上的演示 1. Task 的 ReadAsync 演示

为了方便讲解,我准备灌入一段文字到 MemoryStream 中去,然后再用 ReadAsync 一个 byte 一个 byte 的读出来,目的就是让 while 多循环几次,多生成一些Task对象,代码如下:


    class Program
    {
 static void Main(string[] args)
 {
     var content = GetContent().Result;

     Console.WriteLine(content);

     Console.ReadKey();
 }

 public static async Task GetContent()
 {
     string str = " 一般情况是:学生不在意草稿纸摆放在桌上的位置(他通常不会把纸摆正),总是顺手在空白处演算,杂乱无序。但是,我曾见到有位学生在草稿纸上按顺序编号。他告诉我,这样做的好处是:无论是考试还是做作业,在最后检验时,根据编号,他很快就能找到先前的演算过程,这样大概可以省下两三分钟。这个习惯,可能会跟着他一辈子,他的一生中可以有无数个两三分钟,而且很可能会有几次关键的两三分钟。";

     using (MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(str)))
     {
  byte[] bytes = new byte[1024];

  ms.Seek(0, SeekOrigin.Begin);
  int cursor = 0;
  var offset = 0;
  int count = 1;

  while ((offset = await ms.ReadAsync(bytes, cursor, count)) != 0)
  {
      cursor += offset;
  }

  return Encoding.UTF8.GetString(bytes, 0, cursor);
     }
 }
    }

输出结果是没有任何问题的,接下来用 windbg 看一看托管堆上生成了多少个 Task。。。


0:000> !dumpheap -type Task -stat
Statistics:
MT    Count    TotalSize Class Name
00007ffaf2404650 1    24 System.Threading.Tasks.Task+<>c
00007ffaf24042b0 1    40 System.Threading.Tasks.TaskFactory
00007ffaf23e3848 1    64 System.Threading.Tasks.Task
00007ffaf23e49d0 1    72 System.Threading.Tasks.Task`1[[System.String, System.Private.CoreLib]]
00007ffaf23e9658 2   144 System.Threading.Tasks.Task`1[[System.Int32, System.Private.CoreLib]]
Total 6 objects

从托管堆上看,我去,Task 为啥只有两个呢?,了,难道我推演错啦??? 不可能的,看看源码去。


public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
    	int num = Read(buffer, offset, count);
		Task lastReadTask = _lastReadTask;
		return (lastReadTask != null && lastReadTask.Result == num) ? lastReadTask : (_lastReadTask = Task.FromResult(num));
}

上面最后一句代码不知道大家有没有看懂,MemoryStream 用了 _lastReadTask 玩了一个小技巧,只要 num 相同返回的都是一个 Task,如果不同则会生成新的 Task 对象,显然这是根据特定场景进行优化的,为了普适性,我肯定要绕过这个技巧,做法就是每次 num 数字不一样就可以了,将 while 修改成代码如下:


 while ((offset = await ms.ReadAsync(bytes, cursor, count++ % 2 == 0 ? 1 : 2)) != 0)
 {
     cursor += offset;
 }

然后再用 windbg 看一下:


0:000> !dumpheap -type Task -stat
Statistics:
MT    Count    TotalSize Class Name
00007ffaf7f04650 1    24 System.Threading.Tasks.Task+<>c
00007ffaf7f042b0 1    40 System.Threading.Tasks.TaskFactory
00007ffaf7ee3848 1    64 System.Threading.Tasks.Task
00007ffaf7ee49d0 1    72 System.Threading.Tasks.Task`1[[System.String, System.Private.CoreLib]]
00007ffaf7ee9658      371 26712 System.Threading.Tasks.Task`1[[System.Int32, System.Private.CoreLib]]
Total 375 objects

从最后一行代码可以看到 Count=371,哈哈,这要是千万级的,那这里的 Task 有多恐怖可想而知哈。

2. ValueTask 的 ReadAsync 演示

前面例子的危害性大家也清楚了,这种场景下解决方案自然就是C#团队提供的新 ReadAsync 方法,代码如下:


   class Program
   {
 static void Main(string[] args)
 {
     var content = GetContent().Result;

     Console.WriteLine(content);

     Console.ReadKey();
 }

 public static async Task GetContent()
 {
     string str = " 一般情况是:学生不在意草稿纸摆放在桌上的位置(他通常不会把纸摆正),总是顺手在空白处演算,杂乱无序。但是,我曾见到有位学生在草稿纸上按顺序编号。他告诉我,这样做的好处是:无论是考试还是做作业,在最后检验时,根据编号,他很快就能找到先前的演算过程,这样大概可以省下两三分钟。这个习惯,可能会跟着他一辈子,他的一生中可以有无数个两三分钟,而且很可能会有几次关键的两三分钟。";

     using (MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(str)))
     {
  byte[] bytes = new byte[1024];

  Memory memory = new Memory(bytes);

  ms.Seek(0, SeekOrigin.Begin);
  int cursor = 0;
  var offset = 0;
  var count = 1;

  while ((offset = await ms.ReadAsync(memory.Slice(cursor, count++ % 2 == 0 ? 1 : 2))) != 0)
  {
      cursor += offset;
  }

  return Encoding.UTF8.GetString(bytes, 0, cursor);
     }
 }
    }

很开心,用 ValueTask 也实现了同样的功能,而且还不给 GC 添任何麻烦,不信的话,用windbg 校验下:


0:000> !dumpheap -type Task -stat
Statistics:
MT    Count    TotalSize Class Name
00007ffaf23f7bf0 1    24 System.Threading.Tasks.Task+<>c
00007ffaf23f7850 1    40 System.Threading.Tasks.TaskFactory
00007ffaf23c3848 1    64 System.Threading.Tasks.Task
00007ffaf23c49d0 1    72 System.Threading.Tasks.Task`1[[System.String, System.Private.CoreLib]]
Total 4 objects

0:000> !dumpheap -type ValueTask -stat
Statistics:
MT    Count    TotalSize Class Name
Total 0 objects

可以看到,托管堆上没有任何踪迹,简直就是完美。

四: ValueTask 真的完美吗?

如果真是完美的话,我相信底层框架中都会改成 ValueTask,而现实并没有,也就说明 ValueTask 只是某一些场景下的优选方案,如果你明白了上面两个案例,你应该会明白 ValueTask 特别适合于那些 CPU 密集型的 异步任务,因为是个假异步,当你 await 的时候,其实结果已经出来了,毕竟人家是纯内存操作,不和底层的驱动器打交道,速度自然相当快。

struct 在多线程模式下有很多种限制,如果用的不当,会有太多的潜在问题和不确定性,你可以想一想为啥 lock 锁中大多会用引用类型,而不是值类型,其实是一样的道理,所以它注定是一个高阶玩法,相信 95% 的朋友在项目开发中都不会用到,用用 Task 就好了,基本包治百病 

五:总结

从 ValueTask 要解决的问题上可以看出C#语言团队对高并发场景下的性能优化已经快走火入魔了,而且现有类库中 99% 的方法还是采用 Task,所以普通玩家还是老老实实的用 Task 吧,现实中还没有遇到在这个上面碰到性能瓶颈的,高能的还是留给高阶玩家吧!

> 更多高质量干货:参见我的 GitHub: github.com/ctripxchuang/dotnetfly

转载请注明:文章转载自 www.mshxw.com
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号