C#异步编程的深入探究
在六月份的线上问题总结中,我们业务线的API站点连续发生了数次上游请求量激增,导致某些业务接口严重超时的问题。这在以往还从未发生过一个月内出现数次线上问题的情况,我们初步将问题定位在机器配置上,考虑请求量激增导致机器无法应对过大的流量,于是通过增加新机器来得到短暂解决,但是发现数天后我们发现问题在高峰时段仍然没有得到解决,于是我们进一步排查发现,导致出现问题的最大原因也是我们Team负责项目的历史遗留问题。
---------接口内部依赖外部接口过多,同步方法中大量调用异步方法发送HTTP请求。当QPS有一定量之后,在接口内部处理时会一直阻塞调用线程然后去异步发送HTTP请求外部接口,随着请求不断到达服务器,从而导致线程池线程数量激增,但是大量的线程并没有实际工作而是处于阻塞状态导致线程饥饿,线程数量直至突破机器最大负载,导致整个站点的所有接口严重超时。
1. 问题探究
- 同步方法中调用异步方法有几种方式?
- 为什么同步方法中调用异步方法有可能会引发严重的问题?
- Wait()和GetAwaiter().GetResult()有什么区别?
- 小结
2. 问题一:同步方法中调用异步方法有几种方式?
首先我们先定义一个异步方法TestMethodAsync
static async Task TestMethodAsync()
{
var result = await new HttpClient().GetStringAsync("https://www.stackoverflow.com/");
Console.WriteLine($ "{result[..9]}----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
}
2.1 方法一: 不阻塞/不等待
Main方法:
static void Main1(string[] args)
{
Console.WriteLine($ "MainStart!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
TestMethodAsync(); // 不阻塞,不等待
Console.WriteLine($ "MainOver!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
}
输出:
我们发现我们明明调用了异步方法但是好像看起来并没有执行啊!
实则不然,TestMethodAsync实际上在输出MainOver之前是已经开始在线程池分配的线程中开始执行了,只是没有执行完程序就已经Exit了,因为在.NET中ThreadPool中的线程默认为后台线程,new Thread()创建的线程默认为前台线程, 主线程就是一个前台线程 .NET中只有前台线程会阻止进程的结束,也就是说一个程序中所有的前台线程全部结束程序才会结束自然而然后台线程中执行的任务也会随之结束,只要有一个前台线程未结束整个应用程序就没有结束。
所以我们将代码稍加改造刻意让主线程休眠,再次观察结果。
static void Main(string[] args)
{
Console.WriteLine($ "MainStart!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
TestMethodAsync(); // 不阻塞,不等待
Thread.Sleep(10000);
Console.WriteLine($ "MainOver!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
}
2.2 方法二: 阻塞/等待 Wait()
Main方法:
static void Main1(string[] args)
{
Console.WriteLine($ "MainStart!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
TestMethodAsync().Wait(); // 阻塞,等待
Console.WriteLine($ "MainOver!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
}
使用 TaskInstance.Wait()调用异步方法会阻塞主线程,主线程会一直等待异步任务执行结束才会继续进行工作,假若在Winform中使用Wait()调用异步方法可以直观地看到UI线程将会被阻塞,页面卡死。
输出:
2.3 方法三: 阻塞/等待 GetAwaiter().GetResult()
Main方法:
static void Main(string[] args)
{
Console.WriteLine($ "MainStart!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
TestMethodAsync().GetAwaiter().GetResult(); // 同步方法中调用异步方法 阻塞,等待
Console.WriteLine($ "MainOver!----ThreadId:{Thread.CurrentThread.ManagedThreadId}");
}
首先要明确的使用GetAwaiter().GetResult()同样是阻塞主(调用)线程的,主线程会一直等待线程池分配线程去执行完异步任务后才会继续工作,执行后续代码,在Winform中使用时也会阻塞UI线程。
输出:
我们后续会讨论Wait()和GetAwaiter().GetResult()两者的区别
3.问题二:为什么同步方法中调用异步方法有可能会引发严重的问题?
为了真实的还原开发环境,我们创建一个WebAPI应用,通过Jmeter来模拟并发。
API Code:
[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
private readonly IHttpClientFactory _httpClientFactory;
public TestController(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
[HttpGet("MethodSync")]
public string MethodSync()
{
var url = "http://www.{0}.com";
var baiDu = _httpClientFactory.CreateClient().GetStringAsync(string.Format(url, "baidu")).GetAwaiter().GetResult();
Task.Run(() =>
{
Thread.Sleep(1000);
}).Wait();
return $"baidu:{baiDu[..49]}";
}
[HttpGet("MethodAsync")]
public async Task<string> MethodAsync()
{
var url = "http://www.{0}.com";
var baiDu = await _httpClientFactory.CreateClient().GetStringAsync(string.Format(url, "baidu"));
await Task.Run(() =>
{
Thread.Sleep(1000);
});
return $"baidu:{baiDu[..49]}";
}
}
使用Postman对两个接口分别进行简单的性能测试,串行请求100次记录平均响应时间:
同步接口MethodSync:
测试结果:
平均响应时间:1319ms
异步接口MethodAsync:
测试结果:
平均响应时间:1365ms
从测试结果看来两者在性能效率上没有明显的差别。
下面我们使用Jmeter模拟并发来观察在有一定并发量的情况同步方法中调用异步方法会带来哪些问题,同时我们观察API服务的线程和CPU情况。
Jmeter并发设置:
我们来调用同步接口观察CPU及线程情况:
可以发现,请求并没有立即收到响应,线程数量一直在不断攀升,直到线程数量突破200,大量的请求依旧没有收到服务端的响应。
此时我们再去访问其他接口,已经无法正常访问,整个站点已经无法正常工作。
直到线程数突破275,部分请求收到响应,大量请求严重超时,服务器内部抛出异常。
我们以同样的并发来调用异步接口来观察CPU及线程数量的变化情况:
可以看出,最开始的时候线程数量同样在不断攀升,当线程数量到达110左右时,整个服务的线程数量并没有太大的变动,只是CPU在不断地变化,因为此时110个线程已经可以满足应用中任务的工作和调度,CPU在不断地调度线程去执行任务,整个过程请求都可以正常收到响应。
我们来分析一下第一种情况也就是同步方法中调用异步方法在一定并发下为什么会产生如此严重的问题。
当第一次200个请求到达服务器后,我们假定分配200个线程去处理,调用线程执行到Wait()或者GetAwaiter().GetResult()等待的Task时,会从线程池中分配线程去执行,同时大量的调用线程并没有去做其他的事情而是在一直等待异步任务执行结束,此时线程池中的线程远远不够,此时CLR不会不断的生产线程来保证整个服务的正常运行,然而CLR生产线程的速度和能力是有限的,有空闲线程后就立马去执行异步任务,但是异步任务一般都是高耗时操作,比如本例中我们一个接口中模拟的两个高耗时异步任务至少是需要1000ms的,所以异步任务在执行的过程中,新一轮的请求到达服务器,此时有的请求没有多余的线程去执行,有的请求开始执行内部遇到异步方法又要产生Task等待空闲线程去执行,循环往复,入不敷出。就会造成CLR在不停生产线程、调度线程去工作,一堆的请求和Task需要线程,但是大量的调用线程并没有投入到工作中,而是在被阻塞苦苦等待,最终线程数量突破服务器的最大负载,整个站点失效。
实际上,导致我们整个站点被拉垮的接口内部都是有很多第三方接口依赖,然而我们去发送Http请求时使用的是我们内部自己封装的client,实际底层还是基于HttpClient来构建的。HttpClient是.NET4.5引入的一个HTTP客户端库,相比于HttpWebRequest和WebClient能够更好地使用TAP风格的异步编程模型,所以对于同步调用可能最多的还是使用Async方法进行Wait()/Result/GetAwaiter().GetResult()所以当并发量在某高峰时段远高于平时时就引发了上述的那种情况,导致站点的其他接口也严重超时。
4.问题三:Wait()和GetAwaiter().GetResult()有什么区别?
首先需要明确的是两者都是阻塞主线程的同步等待,其次两者在异常抛出时有细微的不同。
class Program
{
static void Main(string[] args)
{
// 都是阻塞调用线程的
try
{
//TestWait(); // AggregateException
TestGetAwaiter(); // ArgumentNullException
}
catch (Exception e)
{
Console.WriteLine(e.GetType().Name);
}
}
static void TestWait()
{
TestAsync(null).Wait();
}
static void TestGetAwaiter()
{
TestAsync(null).GetAwaiter().GetResult();
}
static async Task TestAsync(string key)
{
if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException();
await Task.Delay(1000);
}
}
我们先来看Stack Overflow中一位答友对此的解释:
核心的就是说,两者在处理异常时是有所不同的,使用Wait()异常堆栈跟踪不会改变,并且表示异常发生时的实际堆栈,内部发生异常会被包装成AggregateException抛出,而GetAwaiter().GetResult()将会重新处理堆栈跟踪以考虑所有异步上下文,整个代码逻辑就像完全同步执行一样,引发异常时会直接抛出内部异常,如上述的ArgumentNullException。
5.小结
在初期接触TAP异步编程时,就知道尽量不要在同步方法中调用异步方法,要不全同步、要不全异步,在生产环境出现问题后经过一番探究后才明白这样的代码可能会引发非常严重的问题,所以我们将”从Action起所有同步代码改为全异步“作为下一步的重构目标,实际上我们在从Framework迁移到Core时就应该提早去做这件事,因为异步编程模型逐渐已成为编程规范,越来越多的第三方包/库提供出来的API大多已不再支持同步调用。
如果我们必须在同步方法中调用异步方法,应当重点关注此类接口特别是内部异步方法较多的接口,在上线前进行压测,关注监控流量和接口调用情况。如果在条件允许的情况下,我们还是应当将后续新接口采用异步进行编写,如遇耦合度较高的代码我们可以在Service层中提供Sync和Async的两种实现,这样不但不会影响我们原有代码结构同时也利于后续整个站点的全盘重构,只需要将interface中的Sync实现相关的代码下掉即可。