P2-2 异步编程

异步编程


为什么要用异步编程?

场景:餐馆点餐
客人上桌后,服务员站在旁边等待客人点餐,点餐完成后给后厨。 这叫:“同步点餐”
客户上桌后,服务员把菜单和点菜单给客人留下,然后就招待别的客人去了。让客人自己翻看菜单和写点菜单,写完之后呼叫服务员,让服务员把菜单给后厨。 这叫“异步点餐”

  • 优点
    • 让服务员可以同时服务更多的客人
  • 缺点
    • 单个客人点餐的时间变长
  • 例子:同步美化图片
    • 例子
    • 异步编程不会让单个请求处理效率变高,甚至有可能略有降低。
    • 异步编程不是提高了web服务器的运行效率,只是提高了web服务器能够同时处理的这个请求的数量而已

轻松上手async、await

  • 传统多线程开发太麻烦
    • C#关键字:async、await
    • async、await 并不等于“多线程”,只是减低了编写难度而已

异步方法

“异步方法”:用async关键字修饰的方法

  • 异步方法的返回值一般是Task,T就是真正的返回值类型.
    • 比如:Task 这个的返回值类型就是int
    • 惯例:异步方法名字以Async结尾(程序员的个人修养,建议这个做规范一点)
      • 当然了,你不写Async结尾也可以,只是建议这样做,一般只要是Async结尾的,大部分都是异步方法
  • 即使方法没有返回值,也最好把返回值声明为非泛型的Task。
    • 比如:static async Task Main(string[] args)
    • 不要写成 static async void Main(string[] args),要不然会出问题
  • 调用泛型方法时,一般在方法前加上await,这样拿到的返回值就是泛型指定的T类型;
    • 比如:string s = await File.ReadAllTextAsync(fileName);
  • 异步方法的“传染性”:一个方法中如果有await调用,则这个方法也必须修饰为async
    • 只要方法里面有一条await调用,那就必须修饰为async
  • 特点
    • 不等待
    • 等结果出来了,我在输出它
      1
      2
      3
      4
      5
      6
      7
      8
      static async Task Main(string[] args)
      {
      string fileName = "d:/1.txt";
      File.Delete(fileName);
      File.WriteAllTextAsync(fileName, "hello async");
      string s = await File.ReadAllTextAsync(fileName);
      Console.WriteLine(s);
      }

练习例子:await

  • 1.问题:用了await,方法就必须用async去修饰,修饰完还是报错

    • 程序不包含适合于入口点的静态“Main”方法
      • 把Void修改为:Task
    • 不适合Main
    • 没问题
  • 2.报错:System.IO.IOException:“The process cannot access the file ‘D:.NET Core Test\1.txt’ because it is being used by another process.”

    • 写入方法前面不加 await。代码是可以被编译成功并且运行的。
    • 原因:WriteAllTextAsync方法默认是独占式写入。当这个方法还没写入完成,就没办法运行下一行代码
    • 所以前面加上await即可,不等待这个方法写完,就运行下一个代码;写入完,就开始输出
  • 3.注意:调用异步方法一般前面都加await


如何编写异步方法?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static async Task Main(string[] args)
{
int i = await DownloadHtmlAsync("https://www.youzack.com", @"D:\.NET Core Test\1.txt");
Console.WriteLine("ok" + i);
}
/* 不带返回值的
* static async Task DownloadHtmlAsync(string url,String filename)
{
//因为HttpClient里面实现了IDisposable这个接口,所以用using来进行资源的回收
//HttpClientFactory 一般都是搭配这个使用
using (HttpClient httpClient=new HttpClient())
{
String html = await httpClient.GetStringAsync(url);
await File.WriteAllTextAsync(filename, html);
}
}*/

//带返回值的
static async Task<int> DownloadHtmlAsync(string url, String filename)
{
//因为HttpClient里面实现了IDisposable这个接口,所以用using来进行资源的回收
//HttpClientFactory 一般都是搭配这个使用
using (HttpClient httpClient = new HttpClient())
{
String html = await httpClient.GetStringAsync(url);
await File.WriteAllTextAsync(filename, html);
return html.Length;//返回长度
}
}
  • 如果同样的功能,既有同步方法,又有异步方法,选择用哪个呢?

    • 选择1
    • 这个情况首先使用异步方法,这样能提高系统并发量
  • 对于不支持的异步方法(或者逼不得已不能写async方法时)怎么办?

    • Result(有返回值)
      • string s= File.ReadAllTextAsync(@”D:.NET Core Test\1.txt”).Result;
      • 这一句分解开来,就是下面两句。
        • Task t = File.ReadAllTextAsync(@”D:.NET Core Test\1.txt”);
          string s = t.Result;
    • Wait()(无返回值)
      • File.WriteAllTextAsync(@”D:.NET Core Test\1.txt”,”aaaaaaaaa”).Wait();
  • 注意点:尽量不要使用。因为他有死锁的风险。

    • 如果你使用了,未来运行过程中卡了,及有可能是这边出了问题

异步委托

lambda
1
2
3
4
5
6
7
8
9
10
ThreadPool.QueueUserWorkItem(async(obj) =>
{

while (true)
{
await File.WriteAllTextAsync(@"D:\.NET Core Test\1.txt", "aaaaaaaaa");
}
});
Console.Read();

async、await原理揭秘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static async Task Main(string[] args)
{
using (HttpClient httpClient = new HttpClient())
{
string html = await httpClient.GetStringAsync("https://www.baidu.com");
Console.WriteLine(html);
}
string txt = "hello cool";
string filename = @"D:\.NET Core Test\1.txt";
await File.WriteAllTextAsync(filename, txt);
Console.WriteLine("写入成功");
string s = await File.ReadAllTextAsync(filename);
Console.WriteLine("文件内容"+s);
}
  • 1.用ILSpy反编译dll(.exe只是windows下的启动器),切换成C# 4.0版本,就能看到容易理解的底层IL代码。
    • 反编译
  • 2.用dnSpy反编译
    • dnSpy1
    • dnSpy2
    • dnSpy3
  • await、async是“语法糖”,最终编译成“状态机调用”
    • 语法糖:背后很苦,表面很甜

总结

  • async的方法会被C#编译器编译成一个类,并根据await调用把方法切分为多个状态,对async方法的调用就会被拆分为若干次对MoveNext的调用。
  • 用await看似是“等待”,经过编译后,其实没有“wait”
    • 只不过是把菜单给你让你自己点餐,然后先去接待其他客人,等你点好了,帮你把菜单给后厨而已

async背后的线程切换

问题

  • 问题1:为什么编译器要把一个async方法拆分为多个状态然后分为多次调用?
  • 问题2:“异步的可以避免线程等待耗时操作” 但是使用await还是要等待。反正都是等待,有什么区别呢?
    • 在对异步方法进行await调用的等待期间,.NET会把当前的线程返回给线程池,等异步方法调用执行完毕后,框架会从线程池再取出来一个线程,执行后续的代码。
    • 我们把这种由不同线程执行不同代码段的行为称为“线程切换”

线程切换的演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static  async Task Main(string[] args)
{
Console.WriteLine("线程1:"+Thread.CurrentThread.ManagedThreadId);//获取线程ID
/* StringBuilder txt = new StringBuilder();
for (int i = 0; i < 100000; i++)
{
txt.Append("aaaaaa");
}*/
string str = new string('a', 1000000);
await File.WriteAllTextAsync("D:/.NET Core Test/1.txt", str);
Console.WriteLine("线程2:" + Thread.CurrentThread.ManagedThreadId);//获取线程ID
await File.WriteAllTextAsync("D:/.NET Core Test/2.txt", str);
Console.WriteLine("线程3:" + Thread.CurrentThread.ManagedThreadId);//获取线程ID
}

切换线程1

线程切换2

  • 用餐馆点餐的例子阐述一下线程切换的过程
    • 1.服务员给客人安排好位置(放了菜单)后,就被放回了“空闲服务员池”
    • 2.等客人完成看菜单和点菜的操作后,喊“服务员,菜点好了”后,餐馆会从空闲服务员池中取一个空闲的服务员出来完成帮客人把点菜单交给后厨的操作。
  • 图2,10次都是同一个线程,当到了10000次时,线程就不一样了。(硬件的不同,对比也不同)
    • 注意细节:如果写入内容少,会发现线程ID不变
    • 优化:到要等待的时候,如果发现已经执行结束了,那就没必要再切换线程了,剩下的代码就继续在之前的线程上继续执行了。
    • 用餐馆点餐来举例子
    • 1.服务员给客人安排好位置后,刚准备回头到“空闲服务员池”的时候,客人已经点好了菜,喊了“服务员,菜点好了”,这个时候服务员还没走出几步呢,所以直接同一个服务员去服务不就可以了吗~ 所以线程是同一个

异步方法不等于多线程

误解:很多开发人员觉得异步方法中的代码一定是在新线程中执行的。觉得异步方法等于多线程

  • 结论:异步方法中的代码并不会自动在新线程中执行,除非把代码放到新线程中执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static async Task Main(string[] args)
{
Console.WriteLine("1-Main: "+ Thread.CurrentThread.ManagedThreadId);
Console.WriteLine(await CalcAsync(1000));
Console.WriteLine("2-Main: " + Thread.CurrentThread.ManagedThreadId);
}
public static async Task<double> CalcAsync(int n)
{
/*Console.WriteLine("CalcAsync: " + Thread.CurrentThread.ManagedThreadId);
double result = 0;
Random rand = new Random();
for (var i = 0; i < n*n; i++)
{
result = result + (double)rand.NextDouble();
}
return result;*/
return await Task.Run(() => //Task.Run 新线程
{
Console.WriteLine("CalcAsync: " + Thread.CurrentThread.ManagedThreadId);
double result = 0;
Random rand = new Random();
for (var i = 0; i < n * n; i++)
{
result = result + (double)rand.NextDouble();
}
return result;
});
}
  • 1.没有用Task.Run的时候,都是同一个线程,不论次数。
    同线程
  • 2.调用Task.Run方法后,代码就可以在新线程中执行了。所以可以证明结论时正确的。
    • Task.Run就是对 Task.Factory.StartNew 方法的封装而已。
    • Task.Factory.StartNew 方法提供的参数更多,可以对执行的线程做更精细化的控制。
      不同线程

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

看.NET 的File.ReadAllTextAsync()代码

  • 方法名字上面没有 async 去修饰,方法里面也没有await
    File

async方法缺点

  • 1、异步方法会被编译成对应的类,这不仅会加大程序集的尺寸,而且程序的运行效率没有普通方法高
    • 普通异步方法还需要先接收Task,然后用await取出来,在装到Task里面。这就是“拆开了再组装上”。
  • 2、可能会占用非常多的线程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static async Task Main(string[] args)
{
string s = await ReadFileAsync(2);
Console.WriteLine(s);
}
static async Task<string> ReadFileAsync(int num)//普通异步方法
{
if (num==1)
{
return await File.ReadAllTextAsync(@"D:\.NET Core Test\1.txt");
}
else if(num==2)
{
return await File.ReadAllTextAsync(@"D:\.NET Core Test\2.txt");
}
else
{
throw new ArgumentException("num invalid");
}
}

未用async修饰的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static async Task Main(string[] args)
{
string s = await ReadFileAsync(2);
Console.WriteLine(s);
}
static Task<string> ReadFileAsync(int num)//未用async修饰的异步方法
{
if (num == 1)
{
return File.ReadAllTextAsync(@"D:\.NET Core Test\1.txt");//返回值就是Task<string>类型,所以直接return
}
else if (num == 2)
{
return File.ReadAllTextAsync(@"D:\.NET Core Test\2.txt");
}
else
{
throw new ArgumentException("num invalid");
}
}
  • 只甩手Task的数据,不“拆完了再装”。

    • 变成了普通的方法调用
  • 优点:运行效率更高,不会造成线程浪费

  • 返回值为Task的不一定都要标注async,标注async只是让我们更方便的await而已。

什么样的方法是可以直接不写async呢

  • 如果一个异步方法只是对别的异步方法调用的转发,并没有太多复杂的逻辑(比如等待A的结果,在调用B;把A调用的返回值拿到内部做一些处理再返回),那么就可以去掉async关键字。
  • 如果需要把获取异步方法的返回值后再进一步的处理,比如后面+字符串cool,或者其他逻辑时,最好的选择就是用async和await。
    例子

异步编程的几个重要问题

异步暂停

  • 异步暂停的方法
    • 如果想在异步方法中暂停一段时间,不要用Thread.Sleep(),因为它会阻塞调用线程
    • 换成 await Task.Delay() 即可。
  • 在控制台中看不到这两个方法的区别,但是放在WinForm程序中就难呢过看到区别了。
    • ASP.NET Core中也看不到区别,但是Sleep()会降低并发
    • 用点餐举例子
      • Sleep()的时候,是服务员都卡着动不了
      • Delay()的时候,是客户卡着不动,休息了

暂停

作业

  • 封装一个异步方法,下载给定的网址,如果下载失败,则稍等500ms在重试,如果重试三次仍然失败,则抛异常“下载失败”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
static async Task Main(string[] args)
{

string url = "https://www.baiu.com/"; // 这里替换成你想要下载的URL
try
{
var content = await DownloadAsync(url);
Console.WriteLine("下载成功:");
Console.WriteLine(content);
}
catch (Exception ex)
{
Console.WriteLine($"下载失败: {ex.Message}");
}

}

public static async Task<string> DownloadAsync(string url)
{
using(HttpClient httpClient =new HttpClient())
{
int retryCount = 0;
while(retryCount<4)//3次重试
{
try
{
string s = await httpClient.GetStringAsync(url);//获取网址信息
return s;
}
catch (HttpRequestException)//抓取异常
{
retryCount++;
if (retryCount >= 4)
{
throw new Exception("下载失败");
}

Console.WriteLine($"尝试下载失败,‌正在重试...({retryCount}/3)");
await Task.Delay(500); // 等待500ms
}
}
throw new Exception("下载失败");
}
}

提前终止执行

  • 有时需要提前终止任务,比如:请求超时、用户取消请求

    • 很多异步方法都有CancellationToken参数,用于获得提前终止执行的信号
  • CancellationToken结构体

    • None:空
    • bool isCancellationRequested 是否取消
    • (*)Register(Action callback) 注册取消监听 这个了解即可~不常用
    • ThrowifCancellationRequested() 如果任务被取消,执行到这句话就抛异常
    • CancellationTokenSource
    • CancelAfter()超时后发出取消信号
    • Cancel() 发出取消信号
    • CancellationToken Token
  • 建议用第一种写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static async Task DownloadAsync(string url,int n,CancellationToken cancellationToken)//第一种写法
{
using (HttpClient httpClient = new HttpClient())
{
for (int i = 0; i < n; i++)
{
string html = await httpClient.GetStringAsync(url);
Console.WriteLine($"{DateTime.Now}:{html}");
if (cancellationToken.IsCancellationRequested)//自己可以控制以及如何来处理这个请求的细节
{
Console.WriteLine("请求被取消");
break;
}

}
}
}

请求异常1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static async Task Download2Async(string url, int n, CancellationToken cancellationToken)//第二种写法
{
using (HttpClient httpClient = new HttpClient())
{
for (int i = 0; i < n; i++)
{
string html = await httpClient.GetStringAsync(url);//请求一分钟到了后返回,然后在处理它
Console.WriteLine($"{DateTime.Now}:{html}");
/*if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("请求被取消");
break;
}*/
cancellationToken.ThrowIfCancellationRequested();//发现请求取消了,就把异常抛出去

}
}
}

请求异常2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static async Task Download3Async(string url, int n, CancellationToken cancellationToken)//第三种写法
{
using (HttpClient httpClient = new HttpClient())
{
for (int i = 0; i < n; i++)
{
//GetAsync的实现,微软内置的。有可能请求还没到1分钟,就被请求取消5s自动处理了.这个可以在后续在手动处理
//缺点:不能自己控制cancellationToken响应的处理
var resp = await httpClient.GetAsync(url, cancellationToken);

string html = await resp.Content.ReadAsStringAsync();
Console.WriteLine($"{DateTime.Now}:{html}");


}
}
}

请求异常3

  • 例子
    为“下载一个网址N次”的方法增加取消功能。
    分别用GetStringAsync + IsCancellationRequested、 GetStringAsync + ThrowIfCancellationRequested()、带CancellationToken的GetAsync()分别实现。取消分别用超时、用户敲按键(不能await)实现。

  • ASP.NET Core开发中,一般不需要自己处理CancellationToken、CancellationTokenSource这些,只要做到“能转发CancellationToken就转发”即可。

    • ASP.NET Core会对于用户请求中断进行处理
    • (*)演示一下ASP.NET Core中的使用:写一个方法,Delay1000次,用Debug.WriteLine()输出,访问中间跳到放到其他网站。(等有ASP.NET Core的基础了再看)

WhenAll

  • Task类的重要方法
    • 1、Task WhenAny(IEnumerable tasks)等,任何一个Task完成,Task就完成
      • 这个用的比较少
    • 2、Task<TResult[]> WhenAll(params Task[] tasks)等,所有Task完成,Task才完成。用于等待多个任务执行结束,但是不在乎它们的执行顺序。
      • 一般都用用这个
    • 3、FromResult() 创建普通数值的Task对象。
1
2
3
4
5
6
7
8
9
10
11
static async Task Main(string[] args)
{
Task<string> t1 = File.ReadAllTextAsync(@"D:\.NET Core Test\1.txt");//读取文件
Task<string> t2 = File.ReadAllTextAsync(@"D:\.NET Core Test\2.txt");
Task<string> t3 = File.ReadAllTextAsync(@"D:\.NET Core Test\3.txt");
string[] results = await Task.WhenAll(t1, t2, t3);//然后await去取,等都读取完成后,再返回值
string s1 = results[0];
string s2 = results[1];
string s3 = results[2];
Console.WriteLine(s1+s2+s3);
}

案例:计算一个文件夹下,所有文本文件的单词个数汇总

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static async Task Main(string[] args)
{

string[] files = Directory.GetFiles(@"D:\.NET Core Test");
Task<int>[] countTasks = new Task<int>[files.Length];
for (int i = 0; i < files.Length; i++)
{
string filename = files[i];
Task<int> t = ReadCharsCount(filename);
countTasks[i] = t;
}
int[] counts = await Task.WhenAll(countTasks);
int c = counts.Sum();//计算数组的和
Console.WriteLine(c);
}
static async Task<int> ReadCharsCount(string filename)
{
string s = await File.ReadAllTextAsync(filename);
return s.Length;
}

接口中的异步方法

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

  • 接口中不能加async,但是正文中可以
    其他1

  • 异步与yield

    • 复习:yield return不仅能够简化数据的返回,而且可以让数据处理“流水线化”,提升性能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void Main(string[] args)
{
foreach(var s in Test())
{
Console.WriteLine(s);
}

}
static IEnumerable<string> Test()
{
List<string> list = new List<string>();
list.Add("hello");
list.Add("cool");
list.Add("youzack");
return list;//这是上述代码都执行完成后再一起返回。
}
static IEnumerable<string> Test1()
{
yield return "hello";//这种是执行完后,就返回.这样的执行效率就会更高
yield return "cool";
yield return "youzack";
}
  • 在旧版C#中,async方法中不能用yield。

  • 从C# 8.0 开始,把返回值声明为IAsyncEnumerable(不要带Task),然后遍历的时候用await foreach()即可。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    static async Task Main(string[] args)
    {
    await foreach(var s in Test2())//循环前面加上await
    {
    Console.WriteLine(s);
    }

    }

    static async IAsyncEnumerable<string> Test2()
    {
    yield return "hello";
    yield return "yzk";
    yield return "youzack";
    }
  • ASP.NET Core和控制台项目中没有SynchronizationContext,因此不用管ConfigureAwait(false)等。

  • 注意:不要同步、异步方法混用。


P2-2 异步编程
http://example.com/2024/08/27/Net Core2022教程/第2章:.NET Core 重难点知识/P2-2 异步编程/
Author
John Doe
Posted on
August 27, 2024
Licensed under