异步
异步的意思是一会执行这个一会执行那个。
比较术语一点的说法就是,当一个方法没有被完全执行时,就交出控制权,让其他方法执行。
和异步相反的叫同步,这就是说等待方法完全执行完毕。
异步的意义就是“不等待完全执行”,这样做就可以对一些东西立刻发出响应。
例如一个压缩或解压操作。一般的软件上面都会有一个暂停,继续,取消按钮。
这三个命令是必须立刻执行的,不能等待压缩完全执行完毕后才执行。
所以这里就是一个异步操作,一会执行压缩任务,一会看看暂停有没有被点击。
又比如,你的CPU一般不超过16个核心。
但打开你的任务管理器,闲置状态也会有几千个线程任务。
一个核心只能当一个线程(或者两个)使用,
一个核心处理这几千个线程的方式也可以叫一种异步,
用几毫秒执行这个,再用几毫秒执行那个,让你以为是同时执行的。
这个过程也是没有完全执行完毕一个程序,就去执行另一个程序了。
异步的实现
异步有很多种不同的实现方式,他的核心理念就是能在执行完之前去执行别的事情。
最简单的办法是使用基于委托的注册。一旦任务完成后就执行委托。
执行注册委托的地方确实不是执行委托的地方,这的确是一种异步。
在unity中,使用基于迭代器的协程来实现异步。
迭代器的yield return
的确能实现没有完全执行完方法,就去执行别的方法。
所以迭代器也能实现异步。
而当前的c#,使用一种基于Task
,async
,await
的异步机制。
一会执行这个,一会执行那个。并不是以语句或方法为单位。而是以Task
为单位。
直接构造的Task
通常是传入委托来包含需要执行的语句的。
async
是方法的修饰符,在任何返回Task
的方法或匿名委托中都可以使用。
被修饰的方法会根据内容自动切分出多个Task
,然后把最后一个Task
作为返回值给你。
await
类似于yield return
,是切分的依据。异步方法会根据await
的位置来切分出多个Task
。
当代码执行到这里的时候,就可能暂停这个方法的执行。
异步方法
async
异步方法是使用async
修饰的,返回值为void
,Task
,或Task<T>
类型的方法。
在异步方法中,你需要返回的类型是Task<T>
中T的类型。
例如如果你的返回类型是void
或Task
,这都是无返回值的方法,
在方法内部的编写和返回void
的方法一样,你不能return
后面跟一个值。
如果你的返回类型为Task<int>
,那么方法内部的编写就像返回int
的方法一样,
你必须return
后面接一个int
来结束方法。
和迭代器不同,它不是根据你是否写了await
来决定是否自动合成,
只要你使用了async
修饰,就会合成,无论你是否使用await
。
async Task<int> GetInt32Async(int i)
{
return i;
}
Task<int> GetInt32(int i)
{
return Task.FromResult(i);
}
因为异步方法和迭代器的特性,可能会在一个方法没有完全执行完毕时就执行别的方法,
所以你不能在编译器合成的异步方法或迭代器方法中,使用引用参数ref
,out
,in
否则你发现:“我这个明明刚才还是1的,怎么现在就变成2了,我又没给他赋值”,的奇特景象。
可await的类型
和迭代器一样,只要满足一定条件,就能使用await
语法。
而这些条件同时也暗示了async
如何合成Task
的原理。
当一个类型具有可以访问的无参GetAwaiter()
方法(包括扩展方法),
并且这个方法的返回类型具有以下成员
IsCompleted {get; }
:bool
类型的属性,表示异步操作是否已经完成。GetResult()
:这个方法返回异步操作的结果,它可以是任何类型包括void
OnCompleted(Action continuation)
:传入一个委托,表示完成以后要继续做的事情。
await
在异步方法中遇到一个await
的东西的时候,就会像迭代器一样暂停当前的方法。
Console.WriteLine("准备调用异步方法");
var taskHello = HelloAsync();
Console.WriteLine("回到调用方法处了");
await taskHello;
async Task HelloAsync()
{
Console.WriteLine("异步方法开始了");
await Task.Yield();
Console.WriteLine("异步方法结束了");
}
准备调用异步方法
异步方法开始了
回到调用方法处了
异步方法结束了
如果是普通的方法,那么一定要执行完方法,
也就是先输出异步方法结束了,才会输出回到调用方法处了。
await的重回时机
迭代器是通过foreach
或人手动调用他的NextMove
才继续执行的。
而异步方法中,遇到await
时,首先会判断这个东西是否需要等待。
也就是上述可await条件中的IsCompleted。也就是说如果先前等待过了,
或者你执行别的东西花太久,它已经执行完毕了,那就不用再等了。
这样的话异步方法也就不会暂停了。
例如将上述示例中的Task.Yield()
换成Task.CompletedTask
就不会暂停。
如果一个异步方法被暂停了,之后的所有内容会转化成委托传入到OnCompleted方法进行注册。
所以当等待完成的时候,他能找到从哪里继续方法。
在等待完成后,原来的线程会接到通知,当这个线程离开接到通知时的方法时,
就会继续之前被暂停的任务。
离开是指通过return
结束当前方法或是await
暂停当前方法,
但是不包括在方法里调用新的方法。
刚才的示例是因为他在主线程调用,主线程没了那程序就结束了。
如果把这些东西都放在一个方法里,那就不需要await
了。
Start();
void Start()
{
Console.WriteLine("准备调用异步方法");
HelloAsync();
Console.WriteLine("回到调用方法处了");
}
具体由哪个线程执行取决于环境配置。如果配置要求是原线程执行,
那么就一定会等待原线程结束他的方法。
如果这个方法很长,那就真的会卡住那些等待完成的异步方法。
如果这个时候接到多个等待完成的信号,那么会随机抽取一个异步方法继续。
如果配置允许多线程执行,那就会由空闲线程立刻接收。
有返回值的await
如果GetResult的返回类型不是void
,那么在使用await
的时候,还能把这个返回值解构出来。
async Task HelloInt64Async()
{
Console.WriteLine("开始执行一个异步方法");
long l = await GetInt64Async(40);
Console.WriteLine($"获取到了异步返回值{l}");
}
async Task<long> GetInt64Async(long l)
{
await Task.Yield();
return l;
}
在你完成异步的时候,你当然已经有了这个返回值了。
防止阻塞
CPU和内存的运算和交互速度非常快。
仅靠CPU和内存就能完成的工作称为CPU密集型任务。
反过来其他的例如需要硬盘资源,或网络资源进行交互的称为IO密集型任务。
这种时间对CPU来说是非常漫长的,而且真就啥事也没干。
并且如果使用同步阻塞的方式执行,那就要一个网页获取到的时候才开始访问下一个网页。
既然我只需要等,那为什么不先把这些任务都启动一起等?
Task.Yield
是最快的异步任务,他的作用真的只是让出执行权,
可以说一旦await
就会立刻完成。那我们可以把这个等待改长一点。
例如Task.Delay(100)
,100毫秒对CPU来说有点长了。
async Task HelloInt16Async()
{
Console.WriteLine("同时启动3个异步任务");
var t1 = GetInt16Async(6);
var t2 = GetInt16Async(18);
var t3 = GetInt16Async(60);
Console.WriteLine("开始等待着3个异步任务");
Console.WriteLine($"第1个任务等待完毕,他的值是{await t1}");
Console.WriteLine($"第2个任务等待完毕,他的值是{await t2}");
Console.WriteLine($"第3个任务等待完毕,他的值是{await t3}");
}
async Task<short> GetInt16Async(short l)
{
await Task.Delay(100);
return l;
}
调用异步方法是创建任务,而await
是等待这个任务完成。
所以如果要分开创建和等待,那就需要保存这个Task
,在需要的时候才await
。
异步方法作用域
异步方法的整个方法内都是同一个作用域。
如果在里面声明了using
变量,那么也会await
完毕后才会释放他们。
一些文件读取操作和网络访问操作是返回Task
的。
但如果对他们使用了using
声明,那就一定要完成等待再返回值,
如果直接把从他们身上获取的Task
作为返回值,那他们就会被立刻释放,
这些异步任务也无法完成。
Task<string> Get4399()
{
using HttpClient client = new HttpClient();
return client.GetStringAsync("www.4399.com");
//这个方法在刚刚访问4399的时候,网络类就会被释放。
}
async Task<string> Get4399Async()
{
using HttpClient client = new HttpClient();
return await client.GetStringAsync("www.4399.com");
}
异常处理
在异步方法内如果需要使用try-catch
,只需要像普通的方法一样对他进行包围。
通过await
解析出来的值和普通变量具有相同的地位,不需要特别处理。
如果等待的东西发生了错误,那么GetResult方法在调用(也就是await
解析)的时候,
就会把这个异常抛出。
相反,以前的基于注册委托事件的异步,会构成一个回调地狱。
每个注册的委托都在不同的作用域内,需要写很多的try-catch
分别包围。
这里只演示一下以前版本的事件回调造成的嵌套地狱。
void GetHtml(string url, Action<string> callback)
{
// 模拟从网页获取源码
Console.WriteLine("获取网页源码...");
var client = new WebClient();
client.DownloadStringCompleted += (sender, e) =>
{
if (e.Error == null)
{
var html = e.Result;
callback(html);
}
else
{
throw e.Error;
}
};
client.DownloadStringAsync(new Uri(url));
}
void WriteFile(string html, Action<bool> callback)
{
// 模拟将源码写入到磁盘
Console.WriteLine("写入到磁盘...");
var path = "html.txt";
using (var stream = new FileStream(path, FileMode.Create))
{
var bytes = Encoding.UTF8.GetBytes(html);
stream.BeginWrite(bytes, 0, bytes.Length, ar =>
{
try
{
stream.EndWrite(ar);
callback(true);
}
catch (Exception ex)
{
throw ex;
}
}, null);
}
}
void Process()
{
try
{
GetHtml("www.4399.com", html =>
{
try
{
WriteFile(html, success =>
{
try
{
Console.WriteLine("完成");
}
catch (Exception ex)
{
Console.WriteLine("发生错误:" + ex.Message);
}
});
}
catch (Exception ex)
{
Console.WriteLine("发生错误:" + ex.Message);
}
});
}
catch (Exception ex)
{
Console.WriteLine("发生错误:" + ex.Message);
}
}
手动拼接Task
异步通常在异步方法使用await
等待和使用。
但是一些情况下,无法使用await
,例如属性的访问器不能被async
修饰。
又或者,重写基类的返回Task
的方法,但是不需要使用await
。
async
是每个方法前指示需要编译器合成的修饰,不属于方法签名。
假任务
Task.CompletedTask
会给你一个已经完成的任务Task.FromResult
方法或创建一个已经完成的任务,并且具有你给定的完成值Task.FromCanceled
方法从指定的取消令牌创建一个已经取消的任务。Task.FromException
方法从指定的异常创建一个发生异常的任务。
这也对应了一个Task
的4种结束情况。CompletedTask
和FromResult
表示任务成功完成,
分别是无返回值和有返回值的Task
。
FromCanceled
和FromException
分别表示取消和异常而终止失败的任务。
一个完整的方法应该具有返回值,异步方法是还没有执行完毕,他的返回值会被Task
保存。
那如果要让一个应该有返回值的方法没有返回值,异常中断是唯一的办法。
实际上取消也会引发一种异常。
纯等待任务
Task.Delay
方法可以接收一个表示毫秒的数字,或一个正规的事件间隔类TimeSpan
。
这个方法会创建一个指定时间完成后的任务,内容只有等待计时器响应。
不过,任务调度本身有时间误差,在时间结束后还可能有大概最多20毫秒的等待。
如果指定的时间是0,那么他是立刻完成的,不会被await
暂停。
Task.Yield()
的语义是仅让出当前执行权。和等待类似,但是不指定具体的时间,
而是当你访问他的时候就同时设置为完成。所以理论上可以当作等待一个相当小的时间。
但实际上任务调度的误差依然有影响,因此跟直接用Task.Delay(1)
也没什么差别。
构造任务
你可以使用构造器传入委托的方式来手动构造一个Task
。
但是这样创建的任务需要手动激活启用。
Task taskCw = new Task(() => { Console.WriteLine("hello"); });
taskCw.Start();
或者用他的静态方法Task.Run
来构造并直接启动一个任务。
但是这个任务是运行在线程池上的,有些地方不能跨线程访问数据。
var taskCw2 = Task.Run(() => { Console.WriteLine("hello"); });
拼接任务
延续任务
Task
实例的ContinueWith
方法会创建一个在目标执行完毕后才开始执行的任务。
using HttpClient client = new HttpClient();
var httpTask = client.GetStringAsync("http://www.4399.com");
var fileTask = httpTask.ContinueWith(http =>
{
File.WriteAllText(@"D:\4399.html", http.Result);
});
fileTask
就是一个从httpTask
中延续出来的任务。他会决定好当目标完成时要做的事情。
他使用的委托是有一个参数的,就是完成的任务的Task
。
所以在委托内使用这个Task
返回值的时候还需要亲自访问他的返回值。
不携带配置的情况下,延续任务默认是不会执行的。当源任务成功完成时,延续任务会自动激活。
但是如果源任务被取消或异常,那么延续任务会立刻设置为相同的取消或异常而不会执行。
时机任务
Task.WhenAll
可以接收多个Task
作为参数,这个任务会在所有参数完成的时候完成。
他自己没有实际执行的操作,只是等而已。
如果所有参数都是相同类型的Task<T>
,那么返回值为Task<T[]>
,
也就是把所有的参数打成数组作为完成值。如果任何一个参数取消或异常,
那么整个任务就会取消或异常。
Task.WhenAny
可以接收多个Task
作为参数,在任何一个参数完成的时候,这个任务就会完成。
并且他的返回值是Task<Task<T>>
,内层Task
就是完成最先完成的任务的整个数据。
async Task SelectHero(int player)
{
await Task.Delay(Random.Shared.Next(100, 2000));
Console.WriteLine($"玩家{player}选择英雄完毕");
}
async Task Countdown(int seconds)
{
await Task.Delay(seconds);
Console.WriteLine($"倒计时结束,为没有选择英雄的玩家随机选择英雄");
}
Task[] selectTasks = new Task[5];
for (int i = 0; i < 5; i++)
{
selectTasks[i] = SelectHero(i + 1);
}
//所有玩家都选择完毕英雄
var allSelectHero=Task.WhenAll(selectTasks);
//倒计时
Task countdown = Countdown(1600);
//选择英雄或倒计时任何一个完成
Task startGame = Task.WhenAny(allSelectHero,countdown);
await startGame;
Console.WriteLine("游戏开始");
同步阻塞等待
在获取到一个Task
后,可以调用他的Wait
方法来同步阻塞的进行等待。
这种情况下并不会切换方法执行,而是会空等这个任务执行完毕。
对于有返回值的Task
,还可以使用Result
获取他的返回值。
这个属性也会先调用Wait
,也会等待他执行完毕。
并且如果期间发生了异常或取消,会在Wait
或Result
调用或等待的时候抛出。
所以如果有可能有异常,需要把Wait
或Result
放在try
中。
var taskResult=Task.FromResult(12);
taskResult.Wait();
int Result=taskResult.Result;
对于多个任务一起等待的方法,也有Task.WaitAll
和Task.WaitAny
。
他们的使用方式和Task.WhenAll
,Task.WhenAny
差不多,只不过不返回Task
而是干等。
任务的完成情况
在一个任务实例的属性中,有以下4个bool
属性
IsCanceled
:任务被取消了IsFaulted
:任务出现异常了IsCompletedSuccessfully
:成功运行到结束IsCompleted
:任务已经运行完毕,无论成功,取消,异常。
除了这些最终状态的属性,还有一个Status
属性会返回一个枚举。这个枚举有以下状态
- Created:使用构造器创建任务时的初始状态,需要调用Start方法来启动。
- WaitingForActivation:使用
Task.Run
或TaskFactory.StartNew
方法创建任务时的初始状态,.Net对他的创建和安排到任务调度器中有些许延迟。 - WaitingToRun:在任务调度器里等待被分配到一个线程上执行。
- Running:开始执行委托时的状态,正在运行,但尚未完成。
- RanToCompletion:执行完成且没有异常或取消时的最终状态。
- Canceled:被取消时的最终状态,可能是在执行前或执行中。
- Faulted:抛出异常时的最终状态。
- WaitingForChildrenToComplete:使用TaskCreationOptions.AttachedToParent选项创建子任务时的状态,已经完成执行,正在等待子任务完成。
取消
取消操作和两个类有关,取消令牌源CancellationTokenSource
和取消令牌CancellationToken
。
接收者会获得一个取消令牌,取消令牌只能被动接受取消信息。
取消令牌全部由取消令牌源生成,但取消令牌源身上有取消方法,所以不能把这个给接收者。
一个异步方法通常以Async
结尾,并有一个接受取消令牌的可选参数。
取消令牌是值类型,因此直接使用default
就能生成可以使用的默认值。
如果是引用类型还需要对他进行非空判断。
取消令牌源
取消令牌源是一个类,它负责生成和管理取消令牌。它有以下主要成员:
Token
属性:返回一个与该源关联的取消令牌,可以传递给需要支持取消的异步方法。Cancel
方法:通知该源及其关联的所有取消令牌,已请求取消操作。这会导致所有注册的回调函数被执行,并且所有观察该令牌的异步方法都会抛出OperationCanceledException
异常。CancelAfter
方法:在指定的时间间隔后,自动调用Cancel
方法。这可以用于实现超时功能。CreateLinkedTokenSource
静态方法:创建一个新的取消令牌源,它与指定的一个或多个其他源链接在一起。这意味着当任何一个链接的源被取消时,新创建的源也会被取消。
取消令牌
取消令牌是一个结构,它表示是否已请求取消操作。它有以下主要成员:
IsCancellationRequested
属性:返回一个布尔值,表示是否已请求取消操作。如果为true,则表示已请求取消操作,否则为false。CanBeCanceled
属性:返回一个布尔值,表示该令牌是否能够被取消。取消令牌中储存着生成自己的取消令牌源,由默认值创建的取消令牌这个字段是null
。这个属性判断记录的取消令牌源是否为null
,如果为null
当然不可能被取消。Register
方法:注册一个回调函数,当该令牌被取消时,该函数会被执行。可以注册多个回调函数,它们会按照注册的顺序被执行。也可以使用一个布尔参数来指定是否在同步方式下执行回调函数。
示例
// 创建一个取消令牌源
var cts = new CancellationTokenSource();
_ = DoSomethingAsync(cts.Token);
await Task.Delay(Random.Shared.Next(120, 300));
cts.Cancel();
async Task DoSomethingAsync(CancellationToken token=default)
{
token.Register(() => Console.WriteLine("被取消了"));
while (!token.IsCancellationRequested)
{
Console.WriteLine("没有被取消");
await Task.Delay(20, token);
}
}
取消令牌不仅可以用于异步方法中,他的取消回调和是否被取消,
在很多场景中足够实现取消判断。
异步流
异步流,其特点是数据的产生和处理可能不同步,需要等待或者并行进行。
异步迭代器是实现异步流的一种方式。可以在内部同时使用await
和yield
异步迭代器需要返回IAsyncEnumerable
或IAsyncEnumerator
接口,
IAsyncEnumerable
的获取迭代器添加了一个可选参数的取消令牌。
IAsyncEnumerator
的获取下一个值变成了异步方法MoveNextAsync
。
async IAsyncEnumerable<int> GetHtmlAsync()
{
// 创建一个HttpClient对象
var client = new HttpClient();
// 定义一个字符串数组,存储要访问的网址
var urls = new[] { "www.4399.com", "www.bilibili.com", "www.baidu.com" };
// 遍历字符串数组,对每个网址发起异步请求,并返回网页源码
foreach (var url in urls)
{
// 使用await关键字来等待异步请求完成,并获取响应内容
var response = await client.GetStringAsync("http://" + url);
// 使用yield return关键字来按需返回数据
yield return response.Length;
}
}
对异步流进行迭代需要使用await foreach
。
同样的在Main方法中使用是看不出来效果的。所以需要构造一个方法,并和他同时运行。
var showTask = ShowHtmlAsync();
while (!showTask.IsCompleted)
{
Console.WriteLine("主线程等待中");
await Task.Delay(1000);
}
Console.WriteLine("主线程结束");
async Task ShowHtmlAsync()
{
Console.WriteLine("开始异步方法");
await foreach (var item in GetHtmlAsync())
{
Console.WriteLine(item);
}
Console.WriteLine("异步方法结束");
}
每当异步方法等待完毕,就会获取执行foreach
。
在等待期间一直输出主线程等待中。
事件驱动
c#库中没有为异步流提供相应的Linq方法,异步流接口原本是扩展包的内容。
在c#库中添加这个接口是为了统一不同扩展包中使用的异步流让他们可以兼容。
异步流的Linq通常由扩展包提供,例如.Net Rx
库的响应性编程。
在游戏中,
- 当xx受到伤害时
- 当400米以内的队友被附加异常状态时
- 回合开始前,回合开始时,回合内,回合结束时,回合结束后
- 当你造成伤害时,造成伤害前,选择目标时,释放技能时。
- 当按钮被点击时,当用户开始发送请求时。
所有这种在时机发生时做出响应的称为事件驱动模型。
在开始之初就通过事件注册,观察者模式订阅,异步流监听的方式决定好要做什么,
然后一直等待事件发生,不发生就不执行。发生了就按照计划执行。