C# 异步简述
我自己对 C# Task / 异步 的理解.
引入
你想同时下载 3 个 string, 然后输出这三个 string, 该怎么办呢?
学过多线程但是没学过 Task
的你, 可能会写:(错误写法)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var thread1 = new Thread(() =>
{
var s = new HttpClient().GetStringAsync("https://speed.hetzner.de/1GB.bin").Result;
Console.WriteLine($"t1: {s}");
});
var thread2 = new Thread(() =>
{
var s = new HttpClient().GetStringAsync("https://speed.hetzner.de/1GB.bin").Result;
Console.WriteLine($"t2: {s}");
});
var thread3 = new Thread(() =>
{
var s = new HttpClient().GetStringAsync("https://speed.hetzner.de/1GB.bin").Result;
Console.WriteLine($"t3: {s}");
});
thread1.Start();
thread2.Start();
thread3.Start();
thread1.Join();
thread2.Join();
thread3.Join();
这里用到了
.Result
来模拟新手阻塞线程, 现实中没有必要调用.Result
都 2021 年了, 别再用WebClient
了
看起来可能没啥问题? 多线程很快. 然而这是线程占用: 一个主线程 三个工作线程.
想象你要同时下载几百个..就会有几百个线程傻乎乎地等着下载.
于是, 异步解决的第一个问题: 减少线程使用.
使用异步后, 在进行异步操作时, 并不会让傻乎乎地线程等, 而是直接不管后面的代码, 把后面的代码等到这个操作执行完之后再执行(即回调).
那这就得从一切的先祖 Task
说起了.
Task / Task<TResult>
来自微软官方文档 – Task
表示一个异步操作.
也就是 Task
封装了一个 “操作” 或者 “过程”.
这样的操作可以被 “等待” 或者获取这个操作的结果.
Task 包含的常用(?)属性/方法:
.Result
: 获取Task
的结果, 如果Task
还未完成, 则先等待完成.IsCompleted
: 获取Task
是否已经完成.ConfiureAwait(bool)
: 指定 await 后的内容需不需要在当前上下文执行, 后文会说到.ContinueWith(Action<Task...>)
: 设置一个在这个Task
执行完后的操作.Wait()
: 同步等待操作执行完成
ContinueWith
你发现 HttpClient.GetStringAsync
返回的就是一个 Task<string>
.
你决定使用 Task.ContinueWith()
来告诉 Task
在执行完后执行其传入的内容.
1
2
3
4
5
6
7
8
var thread1 = new Thread(() =>
{
new HttpClient().GetStringAsync("https://speed.hetzner.de/1GB.bin").ContinueWith(t =>
{
Console.WriteLine($"t1: {t.Result}");
});
});
...
这很好. 但好像… 没有必要新建线程了?
1
2
3
4
5
6
7
8
9
10
11
12
13
new HttpClient().GetStringAsync("https://speed.hetzner.de/1GB.bin").ContinueWith(t =>
{
Console.WriteLine($"t1: {t.Result}");
});
new HttpClient().GetStringAsync("https://speed.hetzner.de/1GB.bin").ContinueWith(t =>
{
Console.WriteLine($"t2: {t.Result}");
});
new HttpClient().GetStringAsync("https://speed.hetzner.de/1GB.bin").ContinueWith(t =>
{
Console.WriteLine($"t3: {t.Result}");
});
Console.ReadKey(); // 让程序不结束
其实
HttpClient
可以复用, 并不需要多次new
但是这里…是谁来下载的文件呢? 是谁来执行 ContinueWith
的代码呢? 是线程池线程.
另外, 还可以这么写: 这里先得到了 3 个 Task, 再等待
1
2
3
4
5
6
var httpClient = new HttpClient();
Task s1 = httpClient.GetStringAsync("https://speed.hetzner.de/1GB.bin");
Task s2 = httpClient.GetStringAsync("https://speed.hetzner.de/1GB.bin");
Task s3 = httpClient.GetStringAsync("https://speed.hetzner.de/1GB.bin");
s1.Wait(); s2.Wait(); s3.Wait();
Console.WriteLine(s1.Result...);
但是不要使用.Wait()
下一个问题
假设你在写一个 WPF 软件, 按下按钮后下载一个数字, 把这个数字分解因数, 并显示出来.
这个问题分为两部分: I/O 和 CPU
于是你尝试这么写:
1
2
3
4
5
6
7
void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
var httpClient = new HttpClient();
string result = httpClient.GetStringAsync("https://cyan.cafe/number.txt").Result;
for (int i = 0; i < 10000; i++) int.TryParse(result, out _); // 模拟计算
TextBlock1.Text = result; // 假装显示
}
然而..按下按钮的瞬间窗口卡死咧.
因为上面的代码都在 UI 线程执行.
你想到了之前的 ContinueWith
1
2
3
4
5
6
7
var httpClient = new HttpClient();
httpClient.GetStringAsync("https://cyan.cafenumber.txt").ContinueWith((t) =>
{
var result = t.Result;
for (int i = 0; i < 10000; i++) int.TryParse(result, out _); // 模拟计算
TextBlock1.Text = result; // 假装显示
});
你按下按钮…
报错了! 因为只有 UI 线程才能修改控件.
你百度到使用 Dispatcher.Invoke()
可以从第三方线程告诉 UI 线程执行代码, 于是:
1
2
3
4
5
6
7
8
9
10
var httpClient = new HttpClient();
httpClient.GetStringAsync("https://cyan.cafe/number.txt").ContinueWith((t) =>
{
var result = t.Result;
for (int i = 0; i < 10000; i++) int.TryParse(result, out _); // 模拟计算
Dispatcher.Invoke(() =>
{
TextBlock1.Text = result; // 假装显示
});
});
嘿, 会不会太累了一点? 又用了 ContinueWith
又用了 Dispatcher.Invoke
.
await
隆重介绍 await 运算符:
1
2
3
4
5
6
7
8
async void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
var httpClient = new HttpClient();
var result = await httpClient.GetStringAsync("https://cyan.cafe/number.txt");
for (int i = 0; i < 10000; i++) int.TryParse(result, out _); // 模拟计算
TextBlock1.Text = result; // 假装显示
}
是不是简洁了很多? 不用管复杂的线程切换了? 这就是我认为异步的第二个好处: 上下文. 那 await 到底干了啥? 反编译一下看看:
看不懂, 没事, 简单来说, await 把这个方法拆成了两个(或者更多)的部分. 当执行到 await 时, 这个方法立即返回一个 Task
. 当被 await 的 Task
的操作执行完时, 便会从 UI 上下文执行 await 后面的代码.
总结一下:
- 当遇到 IO 操作时, 使用
await
- 当遇到 CPU 操作时, 使用
Task.Run()
(一般不会遇到)