Posts Async_introduction
Post
Cancel

Async_introduction

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() (一般不会遇到)
This post is licensed under CC BY 4.0 by the author.
Contents

Comments powered by Disqus.

-

使用 OpenWrt 来用 Wi-Fi 中继 PPPoE

Trending Tags