整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

在 .NET 应用程序中运行 JavaScript,你会了吗?

者:liamwang

在这篇文章中,我展示了如何使用 JavaScriptEngineSwitcher NuGet 包来在 .NET 应用程序中运行 JavaScript。这个包为许多不同的 JavaScript 引擎提供了一个一致的接口。

1.你为什么要这样做?

尽管我很喜欢 .NET 生态系统,但有些事情,JavaScript 生态系统做得更好。其中之一就是任何事情都能找到一个库,特别是涉及到网络时。

以语法高亮为例。这可以直接用 C# 来做,但这不是一个特别流畅的体验。例如,TextMateSharp 项目为 TextMate 语法提供了一个解释器。这些文件是 VS Code 用来为一种语言添加基本语法高亮的。然而,如果你想部署应用程序,它包装了一个本地依赖,这就增加了一些复杂性。

相比之下,JavaScript 有大量成熟的语法高亮库。仅举几例,有 highlight.js、Prism.js(在本博客中使用)和 shiki.js。尤其是前两个,非常成熟,有多个插件和主题,而且有简单的 API。

作为一个 .NET 开发者,JavaScript 的明显问题是,你需要学习并选择进入一个完整的独立工具链,与 Node.js 和 NPM 一起工作。这似乎是一个很大的开销,只是为了使用一个小功能。

因此,我们陷入了一个困境。我们要么走 C#(+ Native)路线,要么就得转用 JavaScript。

或者......我们直接从我们的 .NET 应用程序中调用 JavaScript 。

2.在 .NET 中运行 JavaScript

一旦你决定在你的 .NET 代码中运行 JavaScript,你就会考虑几个选择。你可以借用 JavaScript 引擎,让它为你运行你的 JavaScript,但你并没有真正解决问题,你仍然需要安装 Node.js。

另一个选择是在你的库中直接捆绑 JavaScript 引擎。这并不像听起来那么疯狂,有几个 NuGet 包采用了这种方法,然后暴露出一个 C# 层来与引擎进行交互。

下面是你可以使用的一些包的列表。

Jering.Javascript.NodeJS

这个库采取了上述的第一种方法。它不包括包中的 Node.js。相反,它为执行 JavaScript 代码提供了一个 C# API,并调用了安装在你机器上的 Node.js。这在你知道两者都已安装的环境中可能很有用,但它并没有真正解决我想避免的问题。

ChakraCore

ChakraCore 是 Edge 转为基于 Chromium 引擎之前最初使用的 JavaScript 引擎。根据 GitHub 项目的介绍:

ChakraCore 是一个带有 C 语言 API 的 JavaScript 引擎,你可以用它来为任何 C 语言或 C 语言兼容项目添加对 JavaScript 的支持。它可以在 Linux macOS 和 Windows 上针对 x64 处理器进行编译。而 x86 和 ARM 只适用于 Windows。

因此,ChakraCore 包括一个本地依赖,但由于 C# 可以 P/Invoke 到本地库,这本身并不是一个问题。但它会带来一些部署方面的挑战。

ClearScript (V8)

Node.JS、Chromium、Chrome 和最新的 Edge 使用的都是 V8 JavaScript 引擎。Microsoft.ClearScript 包为该库提供了一个封装,为调用 V8 库提供了一个 C# 接口。就像 ChakraCore 一样,V8 引擎本身是一个本地依赖。ClearScript 库负责 P/Invoke 调用,提供了一个很好的 C# API,但你仍然要确保你在目标平台上部署了正确的本地库。

Jint

Jint 很有意思,因为它是一个完全在 .NET 中运行的 JavaScript 解释器,没有任何本地的依赖!它完全支持 ECMAScript 5.1 (ES5),并支持 .NET Standard 2.0,所以你可以在你的所有项目中使用它!

Jurassic

Jurassic 是另一个 JavaScript 引擎的 .NET 实现,类似于 Jint。也和 Jint 类似,它支持所有的 ES5,而且似乎也部分支持 ES6。与 Jint 不同的是,Jurassic 不是一个解释器,它将 JavaScript 编译成 IL,这使得它的速度非常快,而且它没有本地的依赖性。

那么,在所有这些选择中,你应该选择哪一个?

3.JavaScriptEngineSwitcher:当一个 JS 引擎不够用的时候

还有一个伟大的项目可以让你简单地尝试上面其中的任何一个库。虽然所有的库都允许你运行 JavaScript,但它们都有略微不同的 C# API 来与之交互。这可能会使比较它们变得有点痛苦,因为你必须为每个库学习不同的 API。

JavaScriptEngineSwitcher 这个库为我提到的所有库和更多的库提供了封装:

  • Jering.Javascript.NodeJS
  • ChakraCore
  • Microsoft ClearScript.V8
  • Jint
  • Jurassic
  • MSIE JavaScript Engine for .NET
  • NiL.JS
  • VroomJs

每个库都在一个单独的包中(有本地依赖关系的引擎需要一个额外的本地包),还有一个 Core 包,它提供通用的 API。即使你不打算切换 JS 引擎,我也倾向于尽可能地使用 JavaScriptEngineSwitcher 封装库,这样你就不必在以后需要切换引擎时弄清楚一个新的 API 了。

在 .NET 项目中改变使用的 JavaScript 引擎在我看来是完全可能的。例如,我开始使用 Jint,但当我需要执行更大的脚本时,我遇到了性能问题,于是换成了 Jurassic。JavaScriptEngineSwitcher 让这一切变得很简单,只需在我的项目中添加一个新的包并改变一些初始化代码即可。

我最近才发现 JavaScriptEngineSwitcher 这个库,但最新版本的下载量已接近一百万,它被用于 .NET 静态网站建设者 Statiq 中。在这篇文章的最后部分,我将举一个最基本用法的例子。

4.案例:用 JavaScriptEngineSwitcher 在控制台应用中运行 prism.js

在这篇文章的开头,我讨论了一个特定的场景--代码块的语法高亮。在本节中,我将展示如何使用 prism.js 高亮一小段代码,并在一个控制台应用程序中运行。

开始之前请添加 JavaScriptEngineSwitcher.Jurassic NuGet 包的引用。

dotnet add package JavaScriptEngineSwitcher.Jurassic1.

接下来,下载你想运行的 JavaScript 文件。例如,我从 Prism.js 的官网下载了 prism.js 文件,并将 C# 添加到默认支持高亮的语言集。在把文件放到项目文件夹的根目录后,我把文件更新为嵌入资源。你可以在你的 IDE 中操作,也可以手动编辑项目文件:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="JavaScriptEngineSwitcher.Jurassic" Version="3.17.4" />
  </ItemGroup>

  <!--  Make prism.js an embedded resource -->
  <ItemGroup>
    <None Remove="prism.js" />
    <EmbeddedResource Include="prism.js" />
  </ItemGroup>

</Project>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.

剩下的就是编写代码,在我们的程序中运行脚本。下面的代码段设置了 JavaScript 引擎,从程序集中加载嵌入的 prism.js 库,并执行它。

using JavaScriptEngineSwitcher.Jurassic;

// Create an instance of the JavaScript engine
IJsEngine engine = new JurassicJsEngine();

// Execute the embedded resource called JsInDotnet.prism.js from the provided assembly
engine.ExecuteResource("JsInDotnet.prism.js", typeof(Program).Assembly);1.2.3.4.5.6.7.

现在我们可以在同一个上下文中运行我们自己的 JavaScript 命令。我们可以通过使用 SetVariableName、Execute 和 Evaluate 从 C# 向 JavaScript 引擎传递数值:

// This is the code we want to highlight
string code = @"
using System;

public class Test : ITest
{
    public int ID { get; set; }
    public string Name { get; set; }
}";

// set the JavaScript variable called "input" to the value of the c# variable "code"
engine.SetVariableValue("input", code);

// set the JavaScript variable called "lang" to the string "csharp"
engine.SetVariableValue("lang", "csharp");

// run the Prism.highlight() function, and set the result to the "highlighed" variable
engine.Execute($"highlighted = Prism.highlight(input, Prism.languages.csharp, lang)");

// "extract the value of "highlighted" from JavaScript to C#
string result = engine.Evaluate<string>("highlighted");

Console.WriteLine(result);1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.

当你把它们放在一起运行时,高亮的代码会被打印到控制台:

<span class="token keyword">using</span> 
<span class="token namespace">System</span>
<span class="token punctuation">;</span>

<span class="token keyword">public</span> 
<span class="token keyword">class</span> 
                                  <span class="token class-name">Test</span> 
                                  <span class="token punctuation">:</span> 
                                  <span class="token type-list">
                                  		<span class="token class-name">ITest</span>
                                  </span>
<span class="token punctuation">{</span>
    <span class="token keyword">public</span>
                                  <span class="token return-type class-name">
                                  		<span class="token keyword">int</span>
                                  </span> 
                                  ID 
                                  <span class="token punctuation">{</span>
                                  <span class="token keyword">get</span>
                                  <span class="token punctuation">;</span>
                                  <span class="token keyword">set</span>
                                  <span class="token punctuation">;</span>
                                  <span class="token punctuation">}</span>
    <span class="token keyword">public</span>
<span class="token return-type class-name">
  	<span class="token keyword">string</span>
</span> 
Name 
  <span class="token punctuation">{</span> 
                                   <span class="token keyword">get</span>
                                   <span class="token punctuation">;</span>
                                   <span class="token keyword">set</span>
                                   <span class="token punctuation">;</span> 
                                   <span class="token punctuation">}</span>
<span class="token punctuation">}</span>

渲染后,看起来像这样:

我对整个过程的简单程度感到惊讶。启动一个 JavaScript 引擎,加载 prism.js 文件,并执行我们的自定义代码是如此顺利。这是我面临问题的完美解决方案。

我显然不建议所有的应用程序都这样做。如果你需要运行大量的 JavaScript,那么直接使用 Node.js 生态系统及工具可能更容易。但如果你只是想利用一个小型的、独立的工具(如 prims.js),那么这是一个不错的选择。

5.总结

在这篇文章中,我展示了如何使用 JavaScriptEngineSwitcher NuGet 包来在 .NET 应用程序中运行 JavaScript。这个包为许多不同的 JavaScript 引擎提供了一个一致的接口。其中一些引擎(如 Chakra Core 和 V8)需依赖一个本地组件,而其他引擎(如 Jint 和 Jurassic)只使用托管代码。最后,我展示了你如何使用 JavaScriptEngineSwitcher 在 .NET 应用程序内部运行 Prims.js 代码高亮库。

原文:bit.ly/38awq7W

作者:Andrew Lock

翻译:精致码农

来源: 精致码农

述:本文将讨论如何用最简单的术语在网站上运行 C# 代码。半技术讲座我使用了 wasm-tools-net7,这是一个基于 wasm-tools 的工作负载,没有包含任何额外的包。我的重点是简单性和主要主题。彻底了解该主题可提供完成所有其他任务所需的信息。如何工作?WebAssembly 工作原理:序列图创建演示创建项目我用的是net7,但这取决于你。Dotnet new console -o WASM_Demo cd WASM_Demo Dotnet workload install wasm-tools-net7此时,需要对 csproj 文件进行修改。Project Sdk=Mi

本文将讨论如何用最简单的术语在网站上运行 C# 代码。

半技术讲座

我使用了 wasm-tools-net7,这是一个基于 wasm-tools 的工作负载,没有包含任何额外的包。我的重点是简单性和主要主题。彻底了解该主题可提供完成所有其他任务所需的信息。

如何工作?

WebAssembly 工作原理:序列图

创建演示

创建项目

  • 我用的是net7,但这取决于你。
Dotnet new console -o WASM_Demo  
  
cd WASM_Demo  
  
Dotnet workload install wasm-tools-net7

此时,需要对 csproj 文件进行修改。

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net7.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>

        <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
        <WasmMainJSPath>main.js</WasmMainJSPath>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>

    <ItemGroup>
        <WasmExtraFilesToDeploy Include="index.html" />
        <WasmExtraFilesToDeploy Include="main.js" />
    </ItemGroup>

</Project>

我们添加了什么:

  • RuntimeIdentifier (wasm-tools 需要)
  • WasmMainJSPath (wasm-tools 需要)
  • AllowUnsafeBlocks(JSExportAttribute 需要不安全的代码)
  • ItemGroup (Include as resource)导入 index.html导入main.js

返回到program.cs文件,需要考虑某些规则。

  • 类必须是公共的和部分的。
  • 函数必须是公共的和静态的,并且必须使用 [JSExport] 进行属性化。

让我们举个例子。

using System.Runtime.InteropServices.JavaScript;

namespace WASM_Demo;

public partial class Program
{
    static void Main(string[] args) { }

    [JSExport]
    public static string Response()
    {
        return """
               <h1>
                   Hello World
               </h1>
               """;
    }
}

没关系,但是我们如何在浏览器中运行此代码?

运行这个程序的代码是dotnet.js的,它自带了wasm-tools,所以没有必要担心它。要使用此dotnet.js,我们只需使用一个名为 main.js 的文件。

import { dotnet } from './dotnet.js'

const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);

const { setModuleImports, getAssemblyExports, getConfig, runMainAndExit } = await dotnet
    .withDiagnosticTracing(false)
    .withApplicationArgumentsFromQuery()
    .create();

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);

const html = 
    exports
        .WASM_Demo    // Namespace
        .Program      // Class Name
        .Response();  // Function Name

// Regular javascript code
document.getElementById("app").innerHTML = `${html}`;

await runMainAndExit(config.mainAssemblyName, [] /* Console App Args */);

index.html页面的模板已经准备完毕。

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>WASM Demo</title>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <link rel="modulepreload" href="./dotnet.js" />
    </head>
    
    <body>
        <main id="app"></main>
        <script type="module" src="./main.js"></script>
    </body>
</html>

现在,让我们再看一遍这个过程,

  • HTTP 请求传入
  • WASM-Tools 处理此问题并发送index.html文件。
  • index.html文件请求dotnet.js和main.js文件,dotnet.js由 WASM-Tools 发送,main.js是我们的自定义代码。
  • 通过在 main.js 中使用 dotnet.js,我们可以进入 C# 代码中的 Program 类,调用 Response 函数并在 main.js 中进行我们想要的任何客户端更改。

我们还有一件事要做,你需要打开一个名为 runtimeconfig.template.json 的文件,并将以下 JSON 数据放入其中。

{
  "wasmHostProperties": {
    "perHostConfig": [
      {
        "name": "browser",
        "html-path": "index.html",
        "Host": "browser"
      }
    ]
  }
}

我们已经到了尽头,程序现在可以运行了。唯一需要的命令是:

Dotnet run -c Release

常见问题

我可以托管所有文件而不是 wasm-tools 吗?又是如何做到的呢?

当然,但它可能会变得有点复杂,你用 wasm-tools 制作的项目不能用于任何其他目的,即控制台应用程序不起作用,wasm-tools 可以工作。因为我们选择 browser-wasm 作为 RuntimeIdentifier,并且多个 RuntimeIdentifiers 在 .NET 中不可用。作为替代方法,您可以打开两个项目,将第一个项目设置为 WASM 项目,然后在第二个项目中将其设置为控制台应用程序,然后生成第一个项目并托管输出文件夹,所有 DLL 和文件都将在那里。

这个演示只是索引文件,我可以做多页吗?又是如何做到的呢?

当然,但这比你想象的要难得多,因为这样做的方法是一种叫做SPA(单页应用程序)的方法,用户总是在同一页面上,只是内容发生了变化。有多种方法可以做到这一点。所以它可以用你的创造力来完成。

我可以像计数器一样做动态代码吗?又是如何做到的呢?

_是的,我也这样做了,你可以一遍又一遍地调用 C# 函数,如果你只是将导出绑定到 window 对象,你可以从每个 JavaScript 代码中调用它。

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

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

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



初看异步

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

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

语言调度单位关键字/方法



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

多线程编程

在该异步编程模型诞生之前,多线程编程模型是很多人所熟知的。一般来说,开发者会使用 Thread 、 std::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() 执行后,该线程即被阻塞,即使阻塞期间该线程没有任何的操作,该用户线程也不会被释放,并被操作系统不断轮转调度,这显然浪费了资源。

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

异步编程

因此我们在 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 操作完成之后才会被重新调度并恢复先前让出控制权时的上下文,使得后面的代码继续执行。

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 等地方,因为利用了 SynchronizedContext 对上下文行为有所设置,所以可以得到和上述不同的结论。

但是上面和经典的多线程编程的那一套有任何的关系吗?完全没有。

至于 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 这套几乎差不多了(虽然实现上还是有差异)。

自己封装异步逻辑

了解了上面的东西之后,相信对 .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");
    await tsc.Task;
    Console.WriteLine("end");
}

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

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

同步方式调用异步代码

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

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 中的异步有一个更清晰的理解。

作者:博客园-hez2010

原文地址:https://www.cnblogs.com/hez2010/p/async-in-dotnet.html