C#图解教程之异步编程

作者 Zhendong Ho 日期 2019-09-01
C#
C#图解教程之异步编程

什么是异步

启动程序时,系统会在内存中创建一个新的进程。进程是构成运行程序的资源的集合。这些资源包括虚地址空间文件句柄和许多其他程序运行所需的东西。

在进程内部,系统创建了一个称为线程的内核(kernel)对象,它代表了真正执行的程序。一旦进程建立,系统会在Main方法的第一行语句处就开始线程的执行。

有关线程的知识

  • 默认情况下,一个进程只包含一个线程,从程序的开始一直执行到结束。
  • 线程可以派生其他线程,因此在任何时刻,一个进程都可能包含不同状态的多个线程,来执行程序的不同部分
  • 如果一个进程有多个线程,它们将共享进程的资源
  • 系统为处理器执行所规划的单元是线程

在异步程序中,程序代码不需要按照编写时的顺序严格执行。C#5.0引入了构建异步方法的async和await关键字

使用异步和不使用异步的区别

例:不使用异步的示例。

using System;
using System.Diagnostics;
using System.Net;

public class MyDownloadString
{
Stopwatch sw = new Stopwatch();

public void DoRun()
{
const int LargeNumber = 6_000_000;
sw.Start();

int t1 = CountCharacters(1, "http://www.microsoft.com");
int t2 = CountCharacters(2, "http://www.illustratedcsharp.com");

CountToALargeNumber(1, LargeNumber);
CountToALargeNumber(2, LargeNumber);
CountToALargeNumber(3, LargeNumber);
CountToALargeNumber(4, LargeNumber);

Console.WriteLine("Chars in http://www.microsoft.com : {0}", t1);
Console.WriteLine("Chars in http://www.illustratedcsharp.com: {0}", t2);
}

/// <summary>
/// 下载某网站的内容,返回该网站包含的字符数
/// </summary>
/// <param name="id"></param>
/// <param name="uriString"></param>
/// <returns></returns>
private int CountCharacters(int id, string uriString)
{
WebClient wc1 = new WebClient();
Console.WriteLine("Starting call {0} : {1, 4:N0} ms", id, sw.Elapsed.TotalMilliseconds);
string result = wc1.DownloadString(new Uri(uriString));
Console.WriteLine(" Call {0} completed: {1, 4:N0} ms", id, sw.Elapsed.TotalMilliseconds);
return result.Length;
}

/// <summary>
/// 执行一个消耗一定时间的任务
/// </summary>
/// <param name="id"></param>
/// <param name="value"></param>
private void CountToALargeNumber(int id, int value)
{
for (long i = 0; i < value; i++) ;
Console.WriteLine(" End counting {0} : {1, 4:N0} ms", id, sw.Elapsed.TotalMilliseconds);
}
}

class Program
{
static void Main(string[] args)
{
MyDownloadString ds = new MyDownloadString();
ds.DoRun();
Console.ReadKey();
}
}

输出结果如下所示,计时以毫秒为单位。每次运行的结果可能不同:

下图总结了输出结果。Call1和Call2占用了大部分时间,而且都浪费在等待网站的响应上。

如果我们能初始化两个CountCharacter调用,无需等待结果,而是直接执行4个CountToALargeNumber调用,然后在两个CountCharacter方法调用结束时再获取结果,就可以显著地提高性能。这里可以使用C#的async/await特性

  • 当DoRun调用CountCharactersAsync时,CountCharactersAsync将立即返回,然后才真正开始下载字符。它向调用方法返回的是一个Task<int>类型的占位符对象,表示它计划进行的工作。这个占位符最终将“返回”一个int
  • 这使得DoRun不用等待实际工作完成就可以继续执行。下一条语句是再次调用CountCharactersAsync,同样会返回一个Task<int>对象
  • 接着,DoRun可以继续执行,调用4次CountToALargeNumber,同时CountCharactersAsync的两次调用继续它们的工作——基本上是等待。
  • DoRun的最后两行CountCharactersAsync调用返回的Tasks中获取结果。如果还没有结果,将阻塞并等待。

例:使用异步示例。

using System;
using System.Diagnostics;
using System.Net;
using System.Threading.Tasks;

public class MyDownloadString
{
Stopwatch sw = new Stopwatch();

public void DoRun()
{
const int LargeNumber = 6_000_000;
sw.Start();

Task<int> t1 = CountCharactersAsync(1, "http://www.microsoft.com"); // 保存结果的对象
Task<int> t2 = CountCharactersAsync(2, "http://www.illustratedcsharp.com"); // 保存结果的对象

CountToALargeNumber(1, LargeNumber);
CountToALargeNumber(2, LargeNumber);
CountToALargeNumber(3, LargeNumber);
CountToALargeNumber(4, LargeNumber);

Console.WriteLine("Chars in http://www.microsoft.com : {0}", t1.Result); // 获取结果
Console.WriteLine("Chars in http://www.illustratedcsharp.com: {0}", t2.Result); // 获取结果
}

private async Task<int> CountCharactersAsync(int id, string site) // Task<int>表示正在执行的工作,最终将返回int
{
WebClient wc = new WebClient();
Console.WriteLine("Starting call {0} : {1, 4:N0} ms", id, sw.Elapsed.TotalMilliseconds);
string result = await wc.DownloadStringTaskAsync(new Uri(site)); // 该操作需要等待
Console.WriteLine(" Call {0} completed: {1, 4:N0} ms", id, sw.Elapsed.TotalMilliseconds);
return result.Length;
}

private void CountToALargeNumber(int id, int value)
{
for (long i = 0; i < value; i++) ;
Console.WriteLine(" End counting {0} : {1, 4:N0} ms", id, sw.Elapsed.TotalMilliseconds);
}
}

输出结果:

下图总结了输出结果,展示了修改后的程序的时间轴。新版程序比旧版快了32%!这是因为CountToALargeNumber的4次调用是在CountCharactersAsync方法调用等待网站响应的时候进行的

async/await特性的结构

如果一个程序调用某个方法,等待其执行所有处理后才继续执行,我们就称这一的方法是同步的,这是默认的

相反,异步的方法在处理完成之前就返回到调用方法。

C#的async/await特性可以创建并使用异步方法。该特性由三个部分组成:

  • 调用方法(calling method):该方法调用异步方法,然后在异步方法执行其任务的时候继续执行。
  • 异步(async)方法:该方法异步执行其工作,然后立即返回到调用方法。
  • await表达式:用于异步方法内部,指明需要异步执行的任务。一个异步方法可以包含任意多个await表达式,不过如果一个都不包含的话编译器会发出警告。

异步方法的语法

static class DoAsyncStuff
{
// 异步方法
public static async Task<int> CalculateSumAsync(int i1, int i2)
{
int sum = await TaskEx.Run(() = > GetSum(i1, i2)); // await表达式
return sum;
}
}

class Program
{
// 调用方法
static void Main()
{
Task<int> value = DoAsyncStuff.CalculateSumAsync(5, 6);
}
}

什么是异步方法

异步方法在完成其工作之前即返回到调用方法,然后在调用方法继续执行的时候完成其工作。

异步方法语法特点

  • 方法头中包含async方法修饰符
  • 包含一个或多个await表达式,表示可以异步完成的任务。
  • 必须具有三种返回类型,voidTaskTask<T>。Task和Task<T>的返回对象表示将在未来完成的工作。
  • 异步方法的参数可以为任何类型任意数量,但不能为out或ref参数
  • 异步方法的名称应该以Async为后缀命名
  • 除了方法以外,Lambda表达式和匿名方法也可以作为异步对象

例:异步方法示例。

关键字  返回类型
↓ ↓
async Task<int> CountCharactersAsync(int id, string site)
{
Console.WriteLine("Starting CountCharacters");
WebClient wc = new WebClient();

string result = await wc.DownloadStringTaskAsync(new Uri(site)); // await表达式

Console.WriteLine("CountCharacters Completed");
return result.Length; // 返回语句
}

异步方法的组成

  • 异步方法在方法头中必须包含async关键字,而且必须出现在返回类型之前
  • 该修饰符只是标识该方法包含一个或多个await表达式。本身并不能创建任何异步操作。
  • async关键字是一个上下文关键字,即除了作为方法修饰符之外,async还可用作标识符

三种返回类型

Task<T>:如果调用方法要从调用中获取一个T类型的值,异步方法的返回类型就必须是Task<T>。调用方法将通过读取Task的Result属性来获取这个T类型的值。

Task<int> value = DoStuff.CalculateSumAsync(5, 6);
Console.WriteLine("Value: {0}", value.Result);

Task:如果调用方法不需要从异步方法中返回某个值,但需要检查异步方法的状态,那么异步方法可以返回一个Task类型的对象。这时,即使异步方法中出现了return语句也不会返回任何东西

Task someTask = DoStuff.CalculateSumAsync(5, 6);
someTask.Wait();

void:如果调用方法仅仅想执行异步方法,而不需要与它做任何进一步的交互时调用并忘记[fire and forget]),异步方法可以返回void类型。这时,即使异步方法中出现return语句也不会返回任何东西

注意:任何返回Task<T>类型的异步方法,其返回值必须为T类型或可以隐式转换为T的类型。

例:使用返回Task<int>对象的异步方法。

using System;
using System.Threading.Tasks;

class Program
{
static void Main()
{
Task<int> value = DoAsyncStuff.CalculateSumAsync(5, 6);
// 处理其他事情
Console.WriteLine("Value: {0}", value.Result);
}
}

static class DoAsyncStuff
{
public static async Task<int> CalculateSumAsync(int i1, int i2)
{
int sum = await Task.Run(() => GetSum(i1, i2));
return sum;
}

private static int GetSum(int i1, int i2)
{
return i1 + i2;
}
}

例:使用返回Task对象的异步方法。

using System;
using System.Threading.Tasks;

class Program
{
static void Main()
{
Task someTask = DoAsyncStuff.CalculateSumAsync(5, 6);
// 处理其他事情
someTask.wait();
Console.WriteLine("Async stuff is done");
}
}

static class DoAsyncStuff
{
public static async Task CalculateSumAsync(int i1, int i2)
{
int value = await Task.Run(() => GetSum(i1, i2));
Console.WriteLine("Value: {0}", value);
}

private static int GetSum(int i1, int i2)
{
return i1 + i2;
}
}

例:使用“调用并忘记”的异步方法。

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
static void Main()
{
DoAsyncStuff.CalculateSumAsync(5, 6);
// 处理其他事情
Thread.Sleep(200); // 由于使用了Thread.Sleep方法来暂停当前线程,所以异步方法完成时它还没完成
Console.WriteLine("Program Exiting");
}
}

static class DoAsyncStuff
{
public static async void CalculateSumAsync(int i1, int i2)
{
int value = await Task.Run(() => GetSum(i1, i2));
Console.WriteLine("Value: {0}", value);
}

private static int GetSum(int i1, int i2)
{
return i1 + i2;
}
}

异步方法的控制流

异步方法的结构包含三个不同的区域

  • await表达式之前的部分:从方法开头到第一个await表达式之间的所有代码。这部分应该只包含少量且无需长时间处理的代码。
  • await表达式:表示将被异步执行的任务。
  • 后续部分:在await表达式之后出现的方法中的其余代码。包括其执行环境,如所在线程信息、目前作用域内的变量值,以及当await表达式完成后要重新执行所需的其他信息。

注意:当达到await表达式时,异步方法将控制返回到调用方法。如果方法的返回类型为Task或Task<T>类型,将创建一个Task对象,表示需异步完成的任务和后续,然后将该Task返回到调用方法

目前有两个控制流异步方法内的调用方法内的。异步方法内的代码完成以下工作:

  • 异步执行await表达式的空闲任务。
  • 当await表达式完成时,执行后续部分。
  • 当后续部分遇到return语句或达到方法末尾时,根据返回类型void、Task、Task<T>设置对应的属性并退出控制流

同时,调用方法中的代码将继续其进程从异步方法获取Task对象。当需要其实际值时,就引用Task对象的Result属性。届时,如果异步方法设置了该属性,调用方法就能获得该值并继续。否则,将暂停并等待该属性被设置,然后再继续执行

await表达式

await表达式指定了一个异步执行的任务。

语法:由await关键字和一个空闲对象(称为任务)组成。

这个任务可能是一个Task类型的对象,也可能不是。默认情况下,这个任务在当前线程异步运行。

await task

一个空闲对象即是一个awaitable类型的实例。awaitable类型是指包含GetAwaiter方法的类型,该方法没有参数,返回一个称为awaiter类型的对象。awaiter类型包含以下成员。

  • bool isCompleted { get; }
  • void OnCompleted(Action);

它还包含以下成员之一:

  • void GetResult();
  • T GetResult(); // T为任意类型

然而实际上,并不需要构建awaitable。应该使用Task类,它是awaitable类型。

Task.Run方法

尽管目前BCL中存在许多返回Task<T>类型对象的方法,但是也可以编写自己的方法

最简单的方式是在方法中使用Task.Run方法来创建一个Task。Task.Run是在不同的线程上运行方法的

例:Task.Run方法接受一个Func<TReturn>委托作为参数。

class MyClass
{
public int Get10() // 与Func<int>兼容
{
return 10;
}

public async Task DoWorkAsync()
{
Func<int> ten = new Func<int>(Get10);
int a = await Task.Run(ten);

int b = await Task.Run(new Func<int>(Get10)); // 在参数列表创建委托

int c = await Task.Run(() => { return 10; }); // Lambda表达式隐式转换为Func<int>委托

Console.WriteLine("{0} {1} {2}", a, b, c);
}
}

class Program
{
static void Main()
{
Task t = (new MyClass()).DoWorkAsync();
t.Wait(); // 10 10 10
}
}

上面的代码中,使用的Task.Run的签名Func<TResult>为参数。该方法共8个重载如下所示:

可能用到的4个委托类型的签名:

例:使用Task.Run方法来运行4种不同的委托类型。

static class MyClass
{
public static async Task DoWorkAsync()
{
await Task.Run(() => Console.WriteLine(5.ToString())); // Action

Console.WriteLine((await Task.Run(() => 6)).ToString()); // TResult Func()

await Task.Run(() => Task.Run(() => Console.WriteLine(7.ToString()))); // Task Func()

int value = await Task.Run(() => Task.Run(() => 8)); // Task<TResult> Func()
Console.WriteLine(value.ToString());
}
}

class Program
{
static void Main()
{
Task t = MyClass.DoWorkAsync();
t.Wait(); // 5 6 7 8
}
}

任何可以使用表达式的地方,都可以使用await表达式,只要位于异步方法内。上面的代码中:

  • 第一个和第三个实例将await表达式用作语句
  • 第二个实例将await表达式用作WriteLine方法的参数
  • 第四个实例将await表达式用作赋值语句的右端

例:用可接受的Func委托的形式创建一个Lambda函数。

static class MyClass
{
private static int GetSum(int i1, int i2)
{
return i1 + i2;
}

public static async Task DoWorkAsync()
{
int value = await Task.Run(() => GetSum(5, 6)); // TResult Func()
Console.WriteLine(value.ToString());
}
}

class Program
{
static void Main()
{
Task t = MyClass.DoWorkAsync();
t.Wait(); // 11
}
}