ASP.Net Core异步编程

概念

什么是异步编程?

异步编程是可以让程序并行运行的一种手段,其可以让程序中的一个工作单元与主应用程序线程分开独立运行,并且在工作单元运行结束后,会通知主应用程序线程它的运行结果或者失败原因。使用异步编程可以提高应用程序的性能和响应能力。[^1]

应当注意的是,所谓的异步编程能提高效率这句话并不严谨,严格的来说它是利用了等待时间以优化整体的时间效率,而对于其中任意一项工作其本来的效率并没提高。

如果你对此概念的理解还是十分抽象,下面我们用一道小学数学题来举例。

小明的妈妈做饭要a分钟,烧水要b分钟,请问小明妈妈烧水并做饭一共要多长时间(a与b均大于0)?我们不妨记最终所用时间为T,则有如下情况:

  • 对于传统的同步编程来讲,小明妈妈要先烧水然后做饭或者先做饭然后去烧水,烧水或做饭的时间内是无法做其他事情的,这个等待的过程我们称为阻塞的。这样所用的总时间如下
\[T_1 = a + b
\]
  • 然而对于异步编程,就是让小明妈妈将烧水壶通电进行烧水,烧水由烧水壶负责,而小明妈妈可以一边做饭一边等水烧开。简单来讲就是在等待一件事情完成的同时,利用这段空闲时间去做其他事情,这个过程是非阻塞的。所以所用时间如下:
\[T_2 = \max (a,b)
\]

然而由简单的数学知识可知

\[x \le \max (a,b) < a+b,\space x\in \left \{ a,b \right \}
\]

所以问题来了:对于烧水或做饭的效率提高了吗?答案是没有。因为烧水仍然要b分钟,做饭仍然要a分钟。然而对于整体的效率却得到了提升,就如上方公式所表示的那样。

async\await关键字

异步方法的定义

在.net中所谓的异步方法,一般是指async关键字修饰的方法。该方法有如下特点:

  • 异步方法的返回值一般是Task<T>,T是真正的返回值类型,如Task<int>。即使方法没有返回值,也最好把返回值声明为非泛型的Task。(按钮等控件事件响应方法用void)
  • 异步方法名字以Async结尾。
  • 调用异步方法时,一般方法前面加上await关键字,这样返回值就是泛型指定的T类型。
  • 一个方法中如果有await调用的异步方法,那么该方法也必须是async修饰的异步方法。

下面我们利用C#自带的异步同步方法写入再读取txt文件

同步方法:

//同步
using System; namespace ConsoleApp1
{
class Program
{ static void Main(string[] args)
{
string filename = "test.txt"; //读取/写入文件名
File.WriteAllText(filename, "hello,world");
string str = File.ReadAllText(filename);
Console.WriteLine(str);
}
}
}

异步方法:

//异步
using System; namespace ConsoleApp1
{
class Program
{ static async Task Main(string[] args) //注意这里Main函数变化
{
string filename = "test.txt"; //读取/写入文件名
//如果此处不写await,此处不会等待就进行读取。当数据多的时候,一边写一边读,由于写入操作占用文件,当执行下方读取语句的时候程序会报错。
await File.WriteAllTextAsync(filename, "hello");
/*
* 对于ReadAllTextAsync返回值是Task<string>,添加await后会自动把string从Task拿出来
* 否则要这样写
* Task<string> t = File.ReadAllTextAsync(filename);
* string str = await t;
*/
string str = await File.ReadAllTextAsync(filename);
Console.WriteLine(str);
}
}
}

官方给的方法有了,那么接下来我们写一个自己的异步方法来获取百度的html

using System;

namespace ConsoleApp1
{
class Program
{ static async Task Main(string[] args)
{
int a = await DownloadHtmlAsync("https://www.baidu.com", @"test.txt");
Console.WriteLine("写入完毕!字符串长度为{0}",a);
} //获取百度htnl并写入文件中,返回html字符串长度
static async Task<int> DownloadHtmlAsync(string url, string filename)
{
HttpClient client = new HttpClient(); //.net5及以上
string html = await client.GetStringAsync(url);
await File.WriteAllTextAsync(filename, html);
return html.Length;
}
}
}

输出结果为

写入完毕!字符串长度为9193

打开生成的可执行文件同一目录下的test.txt发现果然获取到了。Html代码太长,此处我就不放出来了。

那么还会有人问,如果某些地方不支持异步方法,那怎么办呢。其实我们只需要在异步方法后面加.Wait().Result就可以了(不推荐),代码如下:

//此处为了简洁仅给出了Main函数部分
static void Main(string args[]) //注意此处的Main我们并没用async关键字
{
File.WriteAllTextAsync("text.txt", "hello,world").Wait();
string str = File.ReadAllTextAsync(@"test.txt").Result;
Console.WriteLine(str);
}

尽管这种方式可以达成目的,但是还是不推荐,因为这种方式可能会面临死锁的风险。

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。[^2]

异步方法的线程委托

在一些情况下我们可能会将异步方法放到线程池来执行。

如果该方法是用正则表达式写的匿名方法的话,则只需要在前面使用async关键字即可。

ThreadPool.QueueUserWorkItem(async (obj) =>{
while(true)
{
await File.WriteAllTextAsync(@"test.txt", "hello,world");
}
});

async\await原理

我们编译如下代码获取百度Html,并向test.txt写入内容做示范

using System;
using System.Net.Http; namespace ConsoleApp1
{
class Program
{ static async Task Main(string []args) //注意此处的Main我们并没用async限定符
{
using (HttpClient client = new HttpClient())
{
string html = await client.GetStringAsync("https://www.baidu.com");
Console.WriteLine(html);
}
string txt = "hello,world";
string filename = "test.txt";
await File.WriteAllTextAsync(filename, txt);
string str = await File.ReadAllTextAsync(filename);
Console.WriteLine("文件内容: {0}",str);
} }
}

然后利用ILSpy反编译生成的Dll文件,查看编译器到底给我们做了什么工作来探究async与await背后的原理。

ILSpy可以从GitHub上下载 GitHub - icsharpcode/ILSpy

将ILSpy版本设置成C#4.0

然后发现有两个Main函数

通过查看代码我们便知道,真正的Main实际上是void类型的,这是编译器帮我们搞定的,这个Main中调用了写代码的时候被async修饰返回值为Task的Main函数。

而通过查看<Main>d_0的代码我们可以分析出async与await的底层原理:

  • async的方法会被C#编译器编译成一个类,会主要根据await调用切分成多个状态,对async方法的调用会被拆分为MoveNext的调用。
  • await看似是等待,实际上编译后没有等待。await调用的等待期间,.net会把当前的线程返回给线程池,等异步方法调用执行完毕后,框架会从线程池再取出来一个线程执行后续代码。此外这里还进行了优化,到要等待的时候如果发现已经执行结束了,那就没必要切换线程了,剩下的代码继续在之前的线程上执行。

我们为了验证上面await的过程可以去尝试打印线程的ID,只要出现线程ID不同即可证明。代码如下:

//利用异步写入一个很大的此字符串增加时间以防止线程ID相同(字符串较小ID可能会相同)
static async Task Main(string []args)
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
StringBuilder sb = new StringBuilder(); //StringBuilder需要using System.Text
for(int i = 0; i < 10000; i++)
{
sb.Append("XXXXXX");
} await File.WriteAllTextAsync(@"test.txt", sb.ToString());
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
}

在我的PC上运行结果如下,很显然进程ID不一样。

1
10

异步方法不等于多线程

我们来执行下一段代码

using System;
using System.Text; namespace ConsoleApp1
{
class Program
{ static async Task Main(string []args)
{
Console.WriteLine("之前ID: "+Thread.CurrentThread.ManagedThreadId);
await CalcAsync(5000);
Console.WriteLine("之后: "+Thread.CurrentThread.ManagedThreadId);
} //n个随机数相加
static async Task<double> CalcAsync(int n)
{
Console.WriteLine("CalcAsync: " + Thread.CurrentThread.ManagedThreadId);
double result = 0;
Random random = new Random();
for(var i = 0; i < n; i++)
result += random.NextDouble(); return result;
} }
}

查看输出结果中的线程ID发现并没有变。

实际上,异步方法并不会自动在新的线程中执行,除非把代码放到新线程中去。看到这里你可能会问,为什么上文中的线程ID变了呢?

那是因为我们在上文中用当都是内部方法,其实现本就带了Task。

如果我们将上面的CalcAsync方法改成下面的,就能看到进程ID改变了。

static async Task<double> CalcAsync(int n)
{
return await Task.Run(() =>
{
Console.WriteLine("CalcAsync: " + Thread.CurrentThread.ManagedThreadId);
double result = 0;
Random random = new Random();
for (var i = 0; i < n; i++)
result += random.NextDouble();
return result;
});
}

为什么有的异步方法没标async

下面来看这两种方法:

static async Task<string> ReadFileAsync(int num)
{
if (num == 0)
return await File.ReadAllTextAsync("test1.txt");
else if (num == 1)
return await File.ReadAllTextAsync("test2.txt");
else
throw new ArgumentException("num invalid");
}
static Task<string> ReadFileAsync(int num)
{
if (num == 0)
return File.ReadAllTextAsync("test1.txt");
else if (num == 1)
return File.ReadAllTextAsync("test2.txt");
else
throw new ArgumentException("num invalid");
}

首先要说明的是,这两种方法都是符合语法规范的并且结果一致。第一种方法,把Task的string拆出来然后返回时又封装了回去,是一个异步方法;而第二种在调用时相当于直接访问的返回的Task,是一个普通的方法,但使用起来是异步的(相当于直接用File.ReadAllTextAsync)。

然而对于异步方法来讲,通过上文中的反编译可知,异步方法会生成一个类,占用更多的线程,运行效率没有普通方法高。第二种方法很好的避免了这个现象,因此必要时我们可以采用第二种方法。

但如果不是简单地将内部的Task返回出来,而是要对值进行某些操作然后再返回的话(比如将string字符串后面再加上一段),那么只能老实地去使用async与await关键字了。

static async Task<string> ReadFileAsync(int num)
{
if (num == 0)
{
string s = await File.ReadAllTextAsync("test1.txt");
s += "123";
}
else if (num == 1)
{
string s = await File.ReadAllTextAsync("test2.txt");
s += "123";
}
else
throw new ArgumentException("num invalid");
}

异步方法不要使用Sleep

如果想要在异步方法中暂停一段时间,不要用Sleep方法,因为它会阻塞调用线程,降低并发。

Thread.Sleep(3000);		// 暂停3000ms,会阻塞线程调用

如果要实现类似效果的话,可以用await关键字加Delay方法 ,用法如下

await Task.Delay();		//异步暂停3000ms

CancellationToken参数

在以前版本的.net给出了Thread.Abort()方法,但这种方法是强制结束线程的,可能产生一些问题,尽量不要去使用它。

有时需要提前终止任务,如请求超时,用户取消请求。很多异步方法都有CancellationToken参数用于提请终止执行的信号。

CancellationToken是一个结构体,它有如下几个成员

  • None
  • bool IsCancellationRequested 是否取消
  • (*)Register(Action callback)注册取消监听
  • ThrowIfCancellationRequested()如果任务被取消执行到这句话就抛出异常

在创建CancellationToken结构体时,一般不是通过new关键字去完成的,而是通过CancellationTokenSource类去创建的。

CancellationTokenSource有几个重要的方法:

  • CancelAfter()超时后发出取消信号
  • Cancel()发出取消信号

我们利用上面的知识编写一个方法,要求下载一个网页Html n次,但是在一秒钟后会被取消,当然这个过程是异步的。

static async Task Main(string []args)
{
CancellationTokenSource cts = new CancellationTokenSource();
cts.CancelAfter(1000); //1秒后取消操作
CancellationToken cToken = cts.Token;
await DownloadHtml("https://www.baidu.com", 100, cToken);
} static async Task DownloadHtml(string url, int n, CancellationToken cancellationToken)
{
using(HttpClient client = new HttpClient())
{
for(var i = 0; i < n; i++)
{
string html = await client.GetStringAsync(url);
Console.WriteLine($"{DateTime.Now}:{html}");
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("请求被取消!");
break;
}
}
}
}

接着我们改变下需求,要求一秒钟后抛出异常,代码可以这样写

static async Task DownloadHtml(string url, int n, CancellationToken cancellationToken)
{
using(HttpClient client = new HttpClient())
{
for(var i = 0; i < n; i++)
{
string html = await client.GetStringAsync(url);
Console.WriteLine($"{DateTime.Now}:{html}");
cancellationToken.ThrowIfCancellationRequested();
}
}
}

当然还有一种方式是利用了GetAsync()方法的

static async Task DownloadHtml(string url, int n, CancellationToken cancellationToken)
{
using (HttpClient client = new HttpClient())
{
for (var i = 0; i < n; i++)
{
var resp = await client.GetAsync(url, cancellationToken);
string html = await resp.Content.ReadAsStringAsync();
Console.WriteLine($"{DateTime.Now}:{html}");
}
}
}

那么这两种方式有什么区别呢?如果网站下载特别慢,对于第一种方式要下载完网页才会执行抛出异常的语句,实际上一秒钟后不一定会抛出异常;而第二种方式,则一秒钟后一定会抛出异常。

Task类与WhenAll

Task类的重要方法:

  • Task<Task> WhenAny(IEnumerable<Task> tasks)等,任何一个Task完成,Task就完成。
  • Task<TResult[]> WhenAll<TResult>(params Task<TResult>[] tasks)等,所有Task完成,Task才完成。用于等待多个任务执行结束但是不在乎它们的执行顺序。
  • FromResult()创建普通数值的Task对象

在这里我们主要来看WhenAll的使用

static async Task Main(string[] args)
{
Task<string> t1 = File.ReadAllTextAsync(@"test1.txt");
Task<string> t2 = File.ReadAllTextAsync(@"test2.txt");
Task<string> t3 = File.ReadAllTextAsync(@"test3.txt"); string[] strs = await Task.WhenAll(t1, t2, t3);
for (var i = 0; i < 3; i++)
Console.WriteLine(strs[i]);
}

异步的其他问题

接口中的异步方法

async是编译器为异步方法中await代码进行分段处理的,而一个异步方法是否修饰了async对于调用者来讲没有区别,因此对于接口中的方法或者抽象方法不能修饰为async。

interface ITest
{
Task<int> GetCharCount(string file); //正确
async Task<int> GetCharCountAsync(string file); //错误,接口中的方法不能用async修饰
}
class Test : ITest
{
public async Task<int> GetCharCount(string file)
{
string s = await File.ReadAllTextAsync(file);
return s.Length;
}
}

异步与yield

yield不仅能简化数据的返回,而且还可以让数据处理“流水线化”提升性能。

关于yield的使用可以参考yield 上下文关键字 - C# 参考 | Microsoft DocsC#中yield用法 - 大西瓜3721 - 博客园

在旧版C#中,async方法中不能用yield。从C#8.0开始,把返回值声明为async IAsyncEnumerable(不要带Task)然后遍历的时候用await foreach()即可。

static async Task Main(string[] args)
{
await foreach (var s in Test()) //注意await
Console.WriteLine(s);
}
static async IAsyncEnumerable<string> Test() //注意没Task
{
yield return "hello";
yield return "world";
}

SynchronizationContext问题

在ASP.Net Core和控制台项目中没有SynchronizationContext因此不用去管ConfigureAwait(false)等。

注意事项

在开发时不要把同步方法和异步方法混用。

结束

异步方法的基本内容大概就这些了,本文章可以看作教程同时也是笔者的学习笔记,感谢 杨中科老师提供的.Net Core课程

参考

[^1]迷彩风情.认识异步编程[Z].知乎,2020-03-28

[^2]死锁[Z].百度百科,2022-08-20

ASP.Net Core异步编程的更多相关文章

  1. 学习ASP.NET Core Razor 编程系列二——添加一个实体

    在Razor页面应用程序中添加一个实体 在本篇文章中,学习添加用于管理数据库中的书籍的实体类.通过实体框架(EF Core)使用这些类来处理数据库.EF Core是一个对象关系映射(ORM)框架,它简 ...

  2. 学习ASP.NET Core Razor 编程系列四——Asp.Net Core Razor列表模板页面

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

  3. 学习ASP.NET Core Razor 编程系列五——Asp.Net Core Razor新建模板页面

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

  4. 学习ASP.NET Core Razor 编程系列六——数据库初始化

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

  5. 学习ASP.NET Core Razor 编程系列七——修改列表页面

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

  6. 学习ASP.NET Core Razor 编程系列八——并发处理

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

  7. 学习ASP.NET Core Razor 编程系列九——增加查询功能

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

  8. 学习ASP.NET Core Razor 编程系列十——添加新字段

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

  9. 学习ASP.NET Core Razor 编程系列十九——分页

    学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...

随机推荐

  1. linux运维基础2

    内容概要 虚拟机关键配置名词解释 远程链接工具 xshell基本使用 linux命令准则 系统运⾏命令 常用快捷方式 文件命令操作 文件编辑命令 内容详情 虚拟机关键配置名词解释 # 虚拟网络编辑器说 ...

  2. 如何使用Superset可无缝对接MRS进行自助分析

    摘要:本文主要介绍如何在MRS之上使用Superset进行数据分析. 本文分享自华为云社区<使用商业智能软件Superset分析MRS数据之最佳实践>,作者: 啊喔YeYe . 1. 概要 ...

  3. Linux(Centos7)静默安装Oracle19C

    Oracle数据库服务器一般都是Linux,Linux服务器一般都是在非图形界面的操作,本文章手把手教你如何在非图形界面安装Oracle19C. ORACLE 19C 的安装包自行在官网下载,下载免费 ...

  4. 国外卡组织的 交换费-interchangefee(发卡行服务费) 和 银联对比

    本文地址:https://www.cnblogs.com/hchengmx/p/15170391.html 1. 交换费(interchangefee)介绍 2. MasterCard 万事达卡 &a ...

  5. Camunda定时器事件示例Demo(Timer Events)

    ​Camunda定时器事件(Timer Events)是由定义的计时器触发的事件.它们可以用作启动事件.中间事件或边界事件.边界事件可以中断,也可以不中断. Camunda定时器事件包括:Timer ...

  6. UiPath鼠标操作文本的介绍和使用

    一.鼠标(mouse)操作的介绍 模拟用户使用鼠标操作的一种行为,例如单击,双击,悬浮.根据作用对象的不同我们可以分为对元素的操作.对文本的操作和对图像的操作 二.鼠标对文本的操作在UiPath中的使 ...

  7. 排名前三——python 开源 IDE

    写在前面的一些P话: Python无处不在 ,似乎它支持从主要网站到桌面实用程序到企业软件的所有功能. Python已经被用来编写流行的软件项目,如dnf / yum,OpenStack,OpenSh ...

  8. TypeScript let与var的区别

    1.作用域不同 用var声明的变量,只有函数作用域和全局作用域,没有块级作用域.而let可以实现块级作用域,只能在代码块{}内有效,在{}之外不能访问,如下代码所示: { let a = 0; var ...

  9. JetBrains系列软件激活

    1.将以下记录加入hosts文件 0.0.0.0 account.jetbrains.com0.0.0.0 www.jetbrains.com 2.激活方式选择licence server,填写以下激 ...

  10. C++基本数据类型范围和区别(详细)

    一.基本类型的大小及范围的总结(以下所讲都是默认在32位操作系统下):字节:byte:位:bit.1.短整型short:所占内存大小:2byte=16bit:所能表示范围:-32768~32767:( ...