5

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

 3 years ago
source link: https://www.cnblogs.com/huangxincheng/p/13953101.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

1. 讲故事

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


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

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


    public readonly struct ValueTask<TResult> : IEquatable<ValueTask<TResult>>
    {
    }

原来是搞了一个 值类型的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:\net5\ConsoleApp1\ConsoleApp2\Program.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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
	if (cancellationToken.IsCancellationRequested)
	{
		return Task.FromCanceled<int>(cancellationToken);
	}

	int num = Read(buffer, offset, count);
	Task<int> 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<string> 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);
            }
        }
    }

214741-20201110120232145-1929496437.png

输出结果是没有任何问题的,接下来用 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<int> 为啥只有两个呢?,🐂👃了,难道我推演错啦??? 不可能的,看看源码去。


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

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

                Memory<byte> memory = new Memory<byte>(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: dotnetfly

图片名称

Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK