一直感觉异步的世界万物有序,繁华似锦。一如我们人类虚构出来的制度、社团、国家、世界、宇宙等等,是的,有了共识,就有了协作,有了协作就有了这个世界这般模样,这,就是人类的伟大之处。
@TOC
1、承诺(promise)
古有一诺千金,今有一诺异步,当下飞速更新迭代的各类编程语言,不管是霸榜的Java和JavaScript,抑或是老牌持重的C#、或者是苒苒的新秀明星Flutter,都不约而同的玩起了承诺。来看看下列代码:
//ooo,我来自java的亲儿子,javascript,好了,别闹,我爸今天不来
new Promise(
function (resolve, reject) {
// 一段耗时的异步操作
resolve('成功') // 数据处理完成
// reject('失败') // 数据处理出错
}
).then(
(res) => {console.log(res)}, // 成功
(err) => {console.log(err)} // 失败
)
//让让,让让,我就是那个新贵flutter
Future(()=> throw 'we have a problem')
.then((_)=> print('callback1'))
.then((_)=> print('callback2'))
.catchError((error)=>print('$error'));
//有人说我比较老,我老吗,我才4岁,因为我c#最近飞升仙界了~~
var page = await client.GetStringAsync("https://www.dotnetfoundation.org");
在c#中任务是用于实现称之为并发 Promise 模型的构造。 简单地说,它们“承诺”,会在稍后完成工作,让你使用干净的 API 与 promise 协作。
2、识别I/O密集型工作、CPU密集型工作
因为c#提供的任务在处理I/O密集型和CPU密集型时采用的是两种不同的方式,因为在异步编程的第一步就是需要识别出来你需要处理的工作是从属于哪一类型的工作,搞错了你就惨兮兮的等待着排查性能bug吧。
让我show给你看看,识别的方法超级简单。
你的工作是否会“等待”某些内容,例如数据库中的数据?网络中的数据
如果答案为“是”,则你的工作是 I/O 密集型工作。
你的代码是否要执行开销巨大的计算?
如果答案为“是”,则你的工作是 CPU 密集型工作。
3、不同工作怎么异步
- 如果你的工作为 I/O 密集型工作,请使用 async 和 await(而不使用 Task.Run)。 不应使用任务并行库。
- 如果你的工作属于 CPU 密集型工作,并且你重视响应能力,请使用 async 和 await,但在另一个线程上使用 Task.Run 去干工作。 如果该工作同时适用于并发和并行,还应考虑使用任务并行库。
要想理解它们的本质,得务必将任务理解为工作的异步抽象,而非 在线程之上的抽象。 默认情况下,任务在当前线程上执行,且在适当时会将工作委托给操作系统。 当然你也可选择性地通过 Task.Run API 显式请求任务在独立线程上运行。
看个最简单得访问网页的IO密集型的工作示例吧。
public async Task<string> GetFirstCharactersCountAsync(string url, int count)
{
var client = new HttpClient();
var page = await client.GetStringAsync("https://www.dotnetfoundation.org");
if (count > page.Length)
{
return page;
}
else
{
return page.Substring(0, count);
}
}
它调用的原理大致类似下图:
sequenceDiagram
当前线程 ->>网络Api库: 你好,帮我拉取下www.dotnetfoundation.org
网络Api库->>系统内核: 读取下套接字
系统内核->>网络Api库: 嗯嗯,你的任务已经排队处理中,给你个信号id
网络Api库->> 当前线程: 任务已经排队处理,给你个任务对象
当前线程 ->>当前线程:好无聊,没事干,我干别的了,有事再叫我
系统内核-->> 当前线程: 兄弟,活干完了,你来收庄稼吧
当前线程->>当前线程: 谁,谁,打扰了我的睡眠,什么,有活干了?
当前线程->>当前线程:干活,直至结束
4、有啥好处
此模型可很好地处理典型的服务器方案工作负荷。 由于没有用于监视未完成任务的线程,服务器线程池可服务更多的 Web 请求。
考虑使用两个服务器:一个运行异步代码,一个不运行异步代码。 鉴于本示例的目的,每个服务器只有 5 个可用于服务请求的线程。 请注意,这样小的数目仅可用于演示。
假设两个服务器都接收到 6 个并发请求。 每个请求执行一个 I/O 操作。 未 运行异步代码的服务器必须对第 6 个请求排队,直到 5 个线程中的一个完成了 I/O 的工作并编写了响应。 此时收到了第 20 个请求,由于队列过长,服务器可能会开始变慢。
运行有 异步代码的服务器也需对第 6 个请求排队,但由于使用了 async 和 await, I/O 的工作开始时,每个线程都会得到释放,无需等到工作结束。 收到第 20 个请求时,传入请求队列将变得很小(如果其中还有请求的话),且服务器不会变慢。 尽管这是一个人为想象的示例,但在现实世界中其工作方式与此类似。 事实上,相比服务器将线程专用于接收到的每个请求,使用 async 和 await 能够使服务器多处理一个数量级的请求。
5、最佳实践是啥呢
自从C#5中引入异步/等待以来,异步编程已成为主流。在ASP.NET Core中代码是完全异步的,并且在编写Web服务时很难避免使用async关键字。然而,对于异步的最佳实践以及如何正确使用它,存在很多困惑。是啊,虽然异步编程已经存在了数年之久,但是从历史上看,它很难很好地完成。 以下下节都是经验之谈,请使用异步编程的人,一定把它们奉为圣经。
5.1 异步必须整个调用栈的异步
异步方法使用之后,所有调用者都应该异步!
因为除非整个调用栈都是异步的,否则异步的努力无济于事。在许多情况下,部分异步可能比完全同步更糟。因此,最好全力以赴,并使所有内容立即异步。
public int DoSomethingAsync()
{
//错误的代码示例:异步转同步,努力都白费
var result = CallDependencyAsync().Result;
return result + 1;
}
// 老老实实,从头到尾的异步才是王道
public async Task<int> DoSomethingAsync()
{
var result = await CallDependencyAsync();
return result + 1;
}
5.2 异步无效
在ASP.NET Core应用程序中使用异步void总是很糟糕的。 避免它,永远不要这么做。通常,它在开发人员尝试实现触发并忘记由控制器操作触发的模式时使用。如果抛出异常,异步void方法将使进程崩溃。
public class MyController : Controller
{
[HttpPost("/start")]
public IActionResult Post()
{
//一旦函数有异常,整个进程都崩溃,这酸爽,谁用谁知道。
BackgroundOperationAsync();
return Accepted();
}
//得意的使用了void方式
public async void BackgroundOperationAsync()
{
var result = await CallDependencyAsync();
DoSomething(result);
}
}
//正确的示范来一波
[HttpPost("/start")]
public IActionResult Post()
{
//就算有异常,.net core框架也会兜底。 TaskScheduler.UnobservedTaskException.
Task.Run(BackgroundOperationAsync);
return Accepted();
}
public async Task BackgroundOperationAsync()
{
var result = await CallDependencyAsync();
DoSomething(result);
}
5.3 已知结果直接拿Task.FromResult而不是Task.Run封装
对于预先计算的结果,不需要调用Task.Run,这最终将使工作项排队到线程池中,该线程将立即以预先计算的值完成。而如果使用Task.FromResult来创建一个任务,将已计算的数据包装起来,则无需动用线程池线程。
public class MyLibrary
{
public Task<int> AddAsync(int a, int b)
{
//纯粹消耗资源~~~~
return Task.Run(() => a + b);
//直接修改为下面即可。
return Task.FromResult(a + b);
//使用Task.FromResult将导致Task分配。使用ValueTask<T>可以完全删除该分配。
//更优的方案
return new ValueTask<int>(a + b);
}
}
5.4 避免使用Task.Result和Task.Wait
Task.Result,Task.Wait正确使用的方法很少,因此一般建议是完全避免在代码中使用它们。
- 异步上的同步
使用Task.Result或Task.Wait去阻塞并等待异步操作的完成比调用真正的同步API更糟。这种现象称为“异步上的同步”。在底层它们的逻辑是这样的:
- 异步操作开始。
- 调用线程被阻塞, 等待该操作完成。
- 异步操作完成后,它将解除阻塞等待该操作的代码。这发生在另一个线程上。
结果是我们需要使用2个线程而不是1个线程来完成同步操作。这通常会导致线程池不足,并导致服务中断。
- 死锁 一般和SynchronizationContext相关,不过 ASP.NET Core没有SynchronizationContext且不容易出现死锁问题。
public string DoOperationBlocking()
{
//不好的地方-阻止进入的线程。
// DoAsyncOperation将在默认任务调度程序上进行调度,从而消除了死锁的风险。
//如果发生异常,则此方法将引发一个AggregateException,该异常包装原始异常。
return Task.Run(() => DoAsyncOperation()).Result;
}
public string DoOperationBlocking2()
{
// 不好的地方-阻止进入的线程
// DoAsyncOperation将在默认任务调度程序上进行调度,从而消除了死锁的风险。
return Task.Run(() => DoAsyncOperation()).GetAwaiter().GetResult();
}
public string DoOperationBlocking3()
{
// 错误-阻止进入的线程,并阻止其中的theadpool线程。
//如果发生异常,则此方法将引发一个AggregateException,其中包含另一个AggregateException,其中包含原始异常。
return Task.Run(() => DoAsyncOperation().Result).Result;
}
public string DoOperationBlocking4()
{
// 错误-阻止进入的线程,并阻止其中的theadpool线程
return Task.Run(() => DoAsyncOperation().GetAwaiter().GetResult()).GetAwaiter().GetResult();
}
public string DoOperationBlocking5()
{
//错误-阻止进入的线程。
//错误-尚未采取任何措施防止当前的SynchonizationContext陷入死锁。
//如果发生异常,则此方法将引发一个AggregateException,该异常包装原始异常。
return DoAsyncOperation().Result;
}
public string DoOperationBlocking6()
{
//错误-阻止进入的线程。
//错误-尚未采取任何措施防止当前的SynchonizationContext陷入死锁。
return DoAsyncOperation().GetAwaiter().GetResult();
}
public string DoOperationBlocking7()
{
//错误-阻止进入的线程。
//错误-尚未采取任何措施来防止当前的SynchonizationContext陷入死锁。
var task = DoAsyncOperation();
task.Wait();
return task.GetAwaiter().GetResult();
}
5.5、避免将Task.Run用于阻塞线程的长时间运行的工作
在这种情况下,长时间运行的工作是指在整个生命周期的后台工作的线程(例如,处理队列项目,或休眠和唤醒以处理某些数据)。Task.Run将工作项排队到线程池中。假定工作将很快完成(或足够快以允许在合理的时间范围内重用该线程)。窃取线程池线程以进行长时间运行是很糟糕的,你应该手动生成一个新线程来执行长时间运行的阻塞工作。
- 注意:如果您阻塞线程,则线程池会增加,但是这样做是不好的做法。
- 注意:Task.Factory.StartNew有一个选项TaskCreationOptions.LongRunning,可以在幕后创建一个新线程并返回一个代表执行的Task。正确使用此参数需要传递几个非显而易见的参数,以在所有平台上获得正确的行为。
- 注意:请勿把TaskCreationOptions.LongRunning与异步代码一起使用,因为这将创建一个新线程,该线程将在first之后销毁await。
public class QueueProcessor
{
private readonly BlockingCollection<Message> _messageQueue = new BlockingCollection<Message>();
public void StartProcessing()
{
//偷取线程池线程去长久执行队列处理,这样的性能是低下的。
Task.Run(ProcessQueue);
//好的做法是开启线程
var thread = new Thread(ProcessQueue)
{
// 这很重要,因为它允许进程在此线程运行时退出
IsBackground = true
};
thread.Start();
}
public void Enqueue(Message message)
{
_messageQueue.Add(message);
}
private void ProcessQueue()
{
foreach (var item in _messageQueue.GetConsumingEnumerable())
{
ProcessItem(item);
}
}
private void ProcessItem(Message message) { }
}
5.6 用await代替ContinueWith
Task在引入async / await关键字之前就已经存在,因此提供了无需依赖语言即可继续执行的方法。虽然这些方法仍然有效使用,我们一般建议你用async/await代替ContinueWith。ContinueWith不获取SynchronizationContext,因此其在语义上实际上不同于async/ await。
public Task<int> DoSomethingAsync()
{
//不好,不好,不好,不好的事情说三遍
return CallDependencyAsync().ContinueWith(task =>
{
return task.Result + 1;
});
//替换成await,看着也清爽
var result = await CallDependencyAsync();
return result + 1;
}
5.7、使用超时时,一定要释放 CancellationTokenSource(s)
CancellationTokenSource通常用于超时(使用计时器创建或使用CancelAfter方法),如果未释放,会对计时器队列造成压力。
public async Task<Stream> HttpClientAsyncWithCancellationBad()
{
//因为没有释放,每次发出请求后,计时器都会在队列中保留10秒钟
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using (var client = _httpClientFactory.CreateClient())
{
var response = await client.GetAsync("http://backend/api/1", cts.Token);
return await response.Content.ReadAsStreamAsync();
}
//好的方式,直接使用using包裹即可。
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
{
using (var client = _httpClientFactory.CreateClient())
{
var response = await client.GetAsync("http://backend/api/1", cts.Token);
return await response.Content.ReadAsStreamAsync();
}
}
}
5.8、使用CancellationToken时,记得向下传递
NET中的取消操作,调用链中的所有内容都必须明确传递CancellationToken,以使其正常工作。这意味着,如果您想最有效地取消操作,则需要将令牌显式传递到采用令牌的其他API。
public async Task<string> DoAsyncThing(CancellationToken cancellationToken = default)
{
byte[] buffer = new byte[1024];
// 我们忘记将cancelToken传递给ReadAsync ,则操作不能被有效的撤销
int read = await _stream.ReadAsync(buffer, 0, buffer.Length);
//好的语句是
int read = await _stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
return Encoding.UTF8.GetString(buffer, 0, read);
}
5.9、在流(StreamWriter(S)或Stream)释放前始终调用FlushAsync
因为:在写入Stream或时StreamWriter,即使使用异步重载进行写入,也可能会缓冲基础数据。 所以: 缓冲数据后,通过方法释放Stream将同步写入/刷新,这将导致线程阻塞并可能导致线程池不足。
app.Run(async context =>
{
// 隐式调用Dispose 将同步写入,此示例最终通过同步写入HTTP响应主体来阻止请求。
using (var streamWriter = new StreamWriter(context.Response.Body))
{
await streamWriter.WriteAsync("Hello World");
}
});
//改良1
app.Run(async context =>
{
// 隐式调用 AsyncDispose 将调用异步刷新
await using (var streamWriter = new StreamWriter(context.Response.Body))
{
await streamWriter.WriteAsync("Hello World");
}
});
//改良2
app.Run(async context =>
{
using (var streamWriter = new StreamWriter(context.Response.Body))
{
//在处置之前异步刷新所有缓冲的数据StreamWriter。
await streamWriter.WriteAsync("Hello World");
// 强制异步输出
await streamWriter.FlushAsync();
}
});
5.10、使用async/await而不是直接返回Task
使用async/await关键字而不是直接返回Task:有好处。
- 异步和同步异常被规范化为始终是异步的。
- 该代码更易于修改(例如using,)。
- 异步方法的诊断更容易(调试挂起等)。
- 抛出的异常将自动包装在返回的内容中,而不是实际异常。
//没有了框架提供的好处,这是.net 的坑~~~~~~
public Task<int> DoSomethingAsync()
{
return CallDependencyAsync();
}
//要这样,这样,这样~~~
public async Task<int> DoSomethingAsync()
{
return await CallDependencyAsync();
}
注意:使用异步状态机而不是直接返回时,需要考虑性能Task。 直接返回总是更快,因为它的工作量较少,但最终会改变行为,并有可能失去异步状态机的某些好处。
6、asp.net core的一些常规错误
public class MyController : Controller
{
[HttpGet("/pokemon")]
public ActionResult<PokemonData> Get()
{
// 异步中的同步错误
var json = new StreamReader(Request.Body).ReadToEnd();
//正确的 var json = await new StreamReader(Request.Body).ReadToEndAsync();
return JsonConvert.DeserializeObject<PokemonData>(json);
}
[HttpPost("/form-body")]
public IActionResult Post()
{
//异步中的同步错误
var form = HttpRequest.Form;
//正确的 var form = await HttpRequest.ReadAsFormAsync();
Process(form["id"], form["name"]);
return Accepted();
}
}
7、结语
看过这些异步的禁忌,有没有吓出一身白毛汗!由于.net 框架自身的问题,其引入async/await之前已经有2种以上的异步操作方案,因此混用不同的异步方案,非常容易掉进坑里。以上这些需要时刻温习哦。