我的编程空间,编程开发者的网络收藏夹
学习永远不晚

深入理解.NET中的异步

短信预约 -IT技能 免费直播动态提醒
省份

北京

  • 北京
  • 上海
  • 天津
  • 重庆
  • 河北
  • 山东
  • 辽宁
  • 黑龙江
  • 吉林
  • 甘肃
  • 青海
  • 河南
  • 江苏
  • 湖北
  • 湖南
  • 江西
  • 浙江
  • 广东
  • 云南
  • 福建
  • 海南
  • 山西
  • 四川
  • 陕西
  • 贵州
  • 安徽
  • 广西
  • 内蒙
  • 西藏
  • 新疆
  • 宁夏
  • 兵团
手机号立即预约

请填写图片验证码后获取短信验证码

看不清楚,换张图片

免费获取短信验证码

深入理解.NET中的异步

一、前言

网上有很多关于 .NET async/await 的介绍,但是很遗憾,很少有正确的,甚至说大多都是“从现象编原理”都不过分。

最典型的比如通过前后线程 ID 来推断其工作方式、在 async 方法中用 Thread.Sleep 来解释 Task 机制而导出多线程模型的结论、在 Task.Run 中包含 IO bound 任务来推出这是开了一个多线程在执行任务的结论等等。

看上去似乎可以解释的通,可是很遗憾,无论是从原理还是结论上看都是错误的。

要了解 .NET 中的 async/await 机制,首先需要有操作系统原理的基础,否则的话是很难理解清楚的,如果没有这些基础而试图向他人解释,大多也只是基于现象得到的错误猜想。

二、初看异步

说到异步大家应该都很熟悉了,2012 年 C# 5 引入了新的异步机制:Task,并且还有两个新的关键字 awaitasync,这已经不是什么新鲜事了,而且如今这个异步机制已经被各大语言借鉴,如 JavaScript、TypeScript、Rust、C++ 等等。

下面给出一个简单的对照:

语言 调度单位 关键字/方法
C# Task<>、ValueTask<> async、await
C++ std::future<> co_await
Rust std::future::Future<> .await
JavaScript、TypeScript Promise<> async、await

当然,这里这并不是本文的重点,只是提一下,方便大家在有其他语言经验的情况下(如果有),可以认识到 C# 中 Taskasync/await 究竟是一个和什么可以相提并论的东西。

三、多线程编程

在该异步编程模型诞生之前,多线程编程模型是很多人所熟知的。一般来说,开发者会使用 Threadstd::thread 之类的东西作为线程的调度单位来进行多线程开发,每一个这样的结构表示一个对等线程,线程之间采用互斥或者信号量等方式进行同步。

多线程对于科学计算速度提升等方面效果显著,但是对于 IO 负荷的任务,例如从读取文件或者 TCP 流,大多数方案只是分配一个线程进行读取,读取过程中阻塞该线程:


void Main()
{
    while (true)
    {
        var client = socket.Accept();
        new Thread(() => ClientThread(client)).Start();
    }
}

void ClientThread(Socket client)
{
    var buffer = new byte[1024];
    while (...)
    {
        // read and block
        client.Read(buffer, 0, 1024); 
    }
}

上述代码中,Main 函数在接收客户端之后即分配了一个新的用户线程用于处理该客户端,从客户端接收数据。client.Read() 执行后,该线程即被阻塞,即使阻塞期间该线程没有任何的操作,该用户线程也不会被释放,并被操作系统不断轮转调度,这显然浪费了资源。

另外,如果线程数量多起来,频繁在不同线程之间轮转切换上下文,线程的上下文也不小,会浪费掉大量的性能。

四、异步编程

因此对于此工作内容(IO),我们在 Linux 上有了 epoll/io_uring 技术,在 Windows 上有了 IOCP 技术用以实现异步 IO 操作。

(这里插句题外话,吐槽一句,Linux 终于知道从 Windows 抄作业了。先前的 epoll 对比 IOCP 简直不能打,被 IOCP 全面打压,io_uring 出来了才好不容易能追上 IOCP,不过 IOCP 从 Windows Vista 时代开始每一代都有很大的优化,io_uring 能不能追得上还有待商榷)

这类 API 有一个共同的特性就是,在操作 IO 的时候,调用方控制权被让出,等待 IO 操作完成之后恢复先前的上下文,重新被调度继续运行。

所以表现就是这样的:

假设我现在需要从某设备中读取 1024 个字节长度的数据,于是我们将缓冲区的地址和内容长度等信息封装好传递给操作系统之后我们就不管了,读取什么的让操作系统去做就好了。

操作系统在内核态下利用 DMA 等方式将数据读取了 1024 个字节并写入到我们先前的 buffer 地址下,然后切换到用户态将从我们先前让出控制权的位置,对其进行调度使其继续执行。

你可以发现这么一来,在读取数据期间就没有任何的线程被阻塞,也不存在被频繁调度和切换上下文的情况,只有当 IO 操作完成之后才会被重新调度并恢复先前让出控制权时的上下文,使得后面的代码继续执行。

当然,这里说的是操作系统的异步 IO 实现方式,以便于读者对异步这个行为本身进行理解,和 .NET 中的异步还是有区别,Task 本身和操作系统也没什么关系。

五、Task (ValueTask)

说了这么久还是没有解释 Task 到底是个什么东西,从上面的分析就可以得出,Task 其实就是一个所谓的调度单位,每个异步任务被封装为一个 Task 在 CLR 中被调度,而 Task 本身会运行在 CLR 中的预先分配好的线程池中。

总有很多人因为 Task 借助线程池执行而把 Task 归结为多线程模型,这是完全错误的。

这个时候有人跳出来了,说:你看下面这个代码


static async Task Main()
{
    while (true)
    {
        Console.WriteLine(Environment.CurrentManagedThreadId);
        await Task.Delay(1000);
    }
}

输出的线程 ID 不一样欸,你骗人,这明明就是多线程!对于这种言论,我也只能说这些人从原理上理解的就是错误的。

当代码执行到 await 的时候,此时当前的控制权就已经被让出了,当前线程并没有在阻塞地等待延时结束;待 Task.Delay() 完毕后,CLR 从线程池当中挑起了一个先前分配好的已有的但是空闲的线程,将让出控制权前的上下文信息恢复,使得该线程恰好可以从先前让出的位置继续执行下去。这个时候,可能挑到了先前让出前所在的那个线程,导致前后线程 ID 一致;也有可能挑到了另外一个和之前不一样的线程执行下面的代码,使得前后的线程 ID 不一致。在此过程中并没有任何的新线程被分配了出去。

当然,在 WPF 等地方,因为利用了 SynchronizationContext 对调度行为进行了控制,所以可以得到和上述不同的结论,和这个相关的还有 .ConfigureAwait() 的用法,但是这里不是本文重点,因此就不做展开。

在 .NET 中由于采用 stackless 的做法,这里需要用到 CPS 变换,大概是这么个流程:


using System;
using System.Threading.Tasks;

public class C 
{
    public async Task M()
    {
        var a = 1;
        await Task.Delay(1000);
        Console.WriteLine(a);
    }
}

编译后:


public class C
{
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <M>d__0 : IAsyncStateMachine
    {
        public int <>1__state;

        public AsyncTaskMethodBuilder <>t__builder;

        private int <a>5__2;

        private TaskAwaiter <>u__1;

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                TaskAwaiter awaiter;
                if (num != 0)
                {
                    <a>5__2 = 1;
                    awaiter = Task.Delay(1000).GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 0);
                        <>u__1 = awaiter;
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                        return;
                    }
                }
                else
                {
                    awaiter = <>u__1;
                    <>u__1 = default(TaskAwaiter);
                    num = (<>1__state = -1);
                }
                awaiter.GetResult();
                Console.WriteLine(<a>5__2);
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <>t__builder.SetResult();
        }

        void IAsyncStateMachine.MoveNext()
        {
            //ILSpy generated this explicit interface implementation from .override directive in MoveNext
            this.MoveNext();
        }

        [DebuggerHidden]
        private void SetStateMachine(IAsyncStateMachine stateMachine)
        {
            <>t__builder.SetStateMachine(stateMachine);
        }

        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
            this.SetStateMachine(stateMachine);
        }
    }

    [AsyncStateMachine(typeof(<M>d__0))]
    public Task M()
    {
        <M>d__0 stateMachine = default(<M>d__0);
        stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
        stateMachine.<>1__state = -1;
        stateMachine.<>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }
}

可以看到,原来的变量 a 被塞到了 <a>5__2 里面去(相当于备份上下文),Task 状态的转换后也是靠着调用 MoveNext(相当于状态转换后被重新调度)来接着驱动代码执行的,里面的 num 就表示当前的状态,num 如果为 0 表示 Task 完成了,于是接着执行下面的代码 Console.WriteLine(<a>5__2);

但是上面和经典的多线程编程的那一套一样吗?不一样。

至于 ValueTask 是个什么玩意,官方发现,Task 由于本身是一个 class,在运行时如果频繁反复的分配和回收会给 GC 造成不小的压力,因此出了一个 ValueTask,这个东西是 struct,分配在栈上,这样的话就不会给 GC 造成压力了,减轻了开销。不过也正因为 ValueTask 是会在栈上分配的值类型结构,因此提供的功能也不如 Task 全面。

六、Task.Run

由于 .NET 是允许有多个线程的,因此也提供了 Task.Run 这个方法,允许我们将 CPU bound 的任务放在上述的线程池之中的某个线程上执行,并且允许我们将该负载作为一个 Task 进行管理,仅在这一点才和多线程的采用线程池的编程比较像。

对于浏览器环境(v8),这个时候是完全没有多线程这一说的,因此你开的新的 Promise 其实是后面利用事件循环机制,将该微任务以异步的方式执行。

想一想在 JavaScript 中,Promise 是怎么用的:


let p = new Promise((resolve, reject) => {
    // do something
    let success = true;
    let result = 123456;

    if (success) {
        resolve(result);
    }
    else {
        reject("failed");
    }
})

然后调用:


let r = await p;
console.log(r); // 输出 123456

你只需要把这一套背后的驱动器:事件循环队列,替换成 CLR 的线程池,就差不多是 .NET 的 Task 相对 JavaScript 的 Promise 的工作方式了。

如果你把 CLR 线程池线程数量设置为 1,那就和 JavaScript 这套几乎差不多了(虽然实现上还是有差异)。

这时有人要问了:“我在 Task.Run 里面套了好几层 Task.Run,可是为什么层数深了之后里面的不执行了呢?” 这是因为上面所说的线程池被耗尽了,后面的 Task 还在排着队等待被调度。

七、自己封装异步逻辑

了解了上面的东西之后,相信对 .NET 中的异步机制应该理解得差不多了,可以看出来这一套是名副其实的 coroutine,并且在实现上是 stackless 的。至于有的人说的什么状态机什么的,只是实现过程中利用的手段而已,并不是什么重要的东西。

那我们要怎么样使用 Task 来编写我们自己的异步代码呢?

事件驱动其实也可以算是一种异步模型,例如以下情景:

A 函数调用 B 函数,调用发起后就直接返回不管了(BeginInvoke),B 函数执行完成后触发事件执行 C 函数。


private event Action CompletedEvent;

void A()
{
    CompletedEvent += C;
    Console.WriteLine("begin");
    ((Action)B).BeginInvoke();
}

void B()
{
    Console.WriteLine("running");
    CompletedEvent?.Invoke();
}

void C()
{
    Console.WriteLine("end");
}

那么我们现在想要做一件事,就是把上面的事件驱动改造为利用 async/await 的异步编程模型,改造后的代码就是简单的:


async Task A()
{
    Console.WriteLine("begin");
    await B();
    Console.WriteLine("end");
}

Task B()
{
    Console.WriteLine("running");
    return Task.CompletedTask;
}

你可以看到,原本 C 函数的内容被放到了 A 调用 B 的下面,为什么呢?其实很简单,因为这里 await B(); 这一行以后的内容,本身就可以理解为 B 函数的回调了,只不过在内部实现上,不是直接从 B 进行调用的回调,而是 A 先让出控制权,B 执行完成后,CLR 切换上下文,将 A 调度回来继续执行剩下的代码。

如果事件相关的代码已经确定不可改动(即不能改动 B 函数),我们想将其封装为异步调用的模式,那只需要利用 TaskCompletionSource 即可:


private event Action CompletedEvent;

async Task A()
{
    // 因为 TaskCompletionSource 要求必须有一个泛型参数
    // 因此就随便指定了一个 bool
    // 本例中其实是不需要这样的一个结果的
    // 需要注意的是从 .NET 5 开始
    // TaskCompletionSource 不再强制需要泛型参数
    var tsc = new TaskCompletionSource<bool>();
    // 随便写一个结果作为 Task 的结果
    CompletedEvent += () => tsc.SetResult(false);

    Console.WriteLine("begin");
    ((Action)B).BeginInvoke();
    await tsc.Task;
    Console.WriteLine("end");
}

void B()
{
    Console.WriteLine("running");
    CompletedEvent?.Invoke();
}

顺便提一句,这个 TaskCompletionSource<T> 其实和 JavaScript 中的 Promise<T> 更像。SetResult() 方法对应 resolve()SetException() 方法对应 reject()。.NET 比 JavaScript 还多了一个取消状态,因此还可以 SetCancelled() 表示任务被取消了。

八、同步方式调用异步代码

说句真的,一般能有这个需求,都说明你的代码写的有问题,但是如果你无论如何都想以阻塞的方式去等待一个异步任务完成的话:


Task t = ...
t.GetAwaiter().GetResult();

祝你好运,这相当于,t 中的异步任务开始执行后,你将当前线程阻塞,然后等到 t 完成之后再唤醒,可以说是:毫无意义,而且很有可能因为代码编写不当而导致死锁的发生。

九、void async 是什么?

最后有人会问了,函数可以写 async Task Foo(),还可以写 async void Bar(),这有什么区别呢?

对于上述代码,我们一般调用的时候,分别这么写:


await Foo();
Bar();

可以发现,诶这个 Bar 函数不需要 await 诶。为什么呢?

其实这和用以下方式调用 Foo 是一样的:


_ = Foo();

换句话说就是调用后瞬间就直接抛掉不管了,不过这样你也就没法知道这个异步任务的状态和结果了。

十、await 必须配合 Task/ValueTask 才能用吗?

当然不是。

在 C# 中只要你的类中包含 GetAwaiter() 方法和 bool IsCompleted 属性,并且 GetAwaiter() 返回的东西包含一个 GetResult() 方法、一个 bool IsCompleted 属性和实现了 INotifyCompletion,那么这个类的对象就是可以 await 的。


public class MyTask<T>
{
    public MyAwaiter<T> GetAwaiter()
    {
        return new MyAwaiter<T>();
    }
}

public class MyAwaiter<T> : INotifyCompletion
{
    public bool IsCompleted { get; private set; }
    public T GetResult()
    {
        throw new NotImplementedException();
    }
    public void OnCompleted(Action continuation)
    {
        throw new NotImplementedException();
    }
}

public class Program
{
    static async Task Main(string[] args)
    {
        var obj = new MyTask<int>();
        await obj;
    }
}

十一、结语

本文至此就结束了,感兴趣的小伙伴可以多多学习一下操作系统原理,对 CLR 感兴趣也可以去研究其源代码:https://github.com/dotnet/runtime 。

.NET 的异步和线程密不可分,但是和多线程编程方式和思想是有本质不同的,也希望大家不要将异步和多线程混淆了,而这有联系也有区别。

从现象猜测本质是大忌,可能解释的通但是终究只是偶然现象,而且从原理上看也是完全错误的,甚至官方的实现代码稍微变一下可能立马就无法解释的通了。

总之,通过本文希望大家能对异步和 .NET 中的异步有一个更清晰的理解。

以上就是深入理解.NET中的异步的详细内容,更多关于.NET 异步的资料请关注编程网其它相关文章!

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

深入理解.NET中的异步

下载Word文档到电脑,方便收藏和打印~

下载Word文档

猜你喜欢

深入理解Android中的Handler异步通信机制

一、问题:在Android启动后会在新进程里创建一个主线程,也叫UI线程(非线程安全)这个线程主要负责监听屏幕点击事件与界面绘制。当Application需要进行耗时操作如网络请求等,如直接在主线程进行容易发生ANR错误。所以会创建子线程来
2022-06-06

C#中的异步编程:深入理解async和await

C#中的async和await关键字为异步编程提供了一种直观且高效的方法。通过掌握这些技术并遵循最佳实践,开发者可以构建出更加响应迅速、性能优越的应用程序。同时,正确处理异步操作中的异常也是确保程序稳定性和可靠性的关键。

深入理解异步事件机制

通过了解异步设计的由来,来深入理解异步事件机制。代码地址什么是异步同步并发(Concurrency)线程(Thread)I/O多路复用异步(Asynchronous)回调(Callback)参考文献为了深入理解异步的概念,就必须先了解异步设
2023-01-31

异步编程在C#中的应用:深入理解Task

C#中的异步编程是一种强大的技术,可以提高应用程序的响应性和效率。通过使用async、await、Task和Task等类型和关键字,你可以轻松地编写高效且响应迅速的代码。
C#Task编程2024-11-29

深入浅析Servlet 3.0/3.1 中的异步处理

深入浅析Servlet 3.0/3.1 中的异步处理?相信很多没有经验的人对此束手无策,为此本文总结了问题出现的原因和解决方法,通过这篇文章希望你能解决这个问题。在Servlet 3.0之前,Servlet采用Thread-Per-Requ
2023-05-31

如何深入理解 java.lang.IllegalArgumentException 异常?(深入理解java.lang.IllegalArgumentException异常)

在Java编程中,java.lang.IllegalArgumentException异常是一个常见且重要的异常类型。它通常在方法接收到无效或不合法的参数时抛出,用于指示调用者提供了不符合方法预期的参数值。一、异常概述
如何深入理解 java.lang.IllegalArgumentException 异常?(深入理解java.lang.IllegalArgumentException异常)
Java2024-12-18

C#.NET 拾遗补漏之理解 C# 中的异步流

异步流(Asynchronous Stream)就是这两种功能的结合体,它实现了以异步的方式生成和消费一组数据系列的迭代器。

深入理解spring boot异步调用方式@Async

本文主要给大家介绍了关于spring boot异步调用方式@Async的相关内容,分享出来供大家参考学习,下面来一起看看详细的介绍:1.使用背景在日常开发的项目中,当访问其他人的接口较慢或者做耗时任务时,不想程序一直卡在耗时任务上,想程序能
2023-05-31

深入理解 Redux 数据流和异步过程管理

前端框架实现了数据驱动视图变化的功能,我们用 template 或者 jsx 描述好了数据和视图的绑定关系,然后就只需要关心数据的管理了。
数据流Redux2024-12-02

深入理解java中的异或运算符

Java中的位运算符中有一个叫做异或的运算符,符号为(^)或者 Xor异或8个字总结 相同出0 不同出1int a=1;int b=1;System.out.println(a^b);这里则输出 0int a=12;int b=0;System.out.pri
深入理解java中的异或运算符
2016-04-08

深入理解.NET对象的内存布局

.NET对象的内存布局分为对象头、实例字段和填充三个部分。对象头包含元数据,如类型指针和垃圾回收数据。实例字段存储对象数据,按声明顺序排列。填充用于对齐目的。值类型直接存储在对象头中,而引用类型存储对对象实例的引用并有实例字段部分。了解内存布局对于优化性能和解决问题至关重要,包括优化对齐、减少字段数量以及考虑特殊情况和不同类型之间的差异。
深入理解.NET对象的内存布局
2024-04-02

编程热搜

  • Python 学习之路 - Python
    一、安装Python34Windows在Python官网(https://www.python.org/downloads/)下载安装包并安装。Python的默认安装路径是:C:\Python34配置环境变量:【右键计算机】--》【属性】-
    Python 学习之路 - Python
  • chatgpt的中文全称是什么
    chatgpt的中文全称是生成型预训练变换模型。ChatGPT是什么ChatGPT是美国人工智能研究实验室OpenAI开发的一种全新聊天机器人模型,它能够通过学习和理解人类的语言来进行对话,还能根据聊天的上下文进行互动,并协助人类完成一系列
    chatgpt的中文全称是什么
  • C/C++中extern函数使用详解
  • C/C++可变参数的使用
    可变参数的使用方法远远不止以下几种,不过在C,C++中使用可变参数时要小心,在使用printf()等函数时传入的参数个数一定不能比前面的格式化字符串中的’%’符号个数少,否则会产生访问越界,运气不好的话还会导致程序崩溃
    C/C++可变参数的使用
  • css样式文件该放在哪里
  • php中数组下标必须是连续的吗
  • Python 3 教程
    Python 3 教程 Python 的 3.0 版本,常被称为 Python 3000,或简称 Py3k。相对于 Python 的早期版本,这是一个较大的升级。为了不带入过多的累赘,Python 3.0 在设计的时候没有考虑向下兼容。 Python
    Python 3 教程
  • Python pip包管理
    一、前言    在Python中, 安装第三方模块是通过 setuptools 这个工具完成的。 Python有两个封装了 setuptools的包管理工具: easy_install  和  pip , 目前官方推荐使用 pip。    
    Python pip包管理
  • ubuntu如何重新编译内核
  • 改善Java代码之慎用java动态编译

目录