关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

.NET 异步详解

发布时间:2020-08-06 10:46:31

初看异步#

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

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

语言

调度单位

关键字/方法

C#

Task<>ValueTask<>

asyncawait

C++

std::future<>

co_await

Rust

std::future::Future<>

.await

JavaScript、TypeScript

Promise<>

asyncawait

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

多线程编程#

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

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

Copy

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 归结为多线程模型,这是完全错误的。

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

Copy

static async Task Main(){

    while (true)

    {

        Console.WriteLine(Environment.CurrentManagedThreadId);

        await Task.Delay(1000);

    }

}

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

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

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

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

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



/template/Home/Zkeys/PC/Static