接上篇:《C#异步的世界【上】》
上篇主要分析了async\await之前的一些异步模式,今天说异步的主要是指C#5的async\await异步。在此为了方便的表述,我们称async\await之前的异步为“旧异步”,async\await为“新异步”。
新异步的使用
只能说新异步的使用太简单(如果仅仅只是说使用)
方法加上async修饰符,然后使用await关键字执行异步方法,即可。对就是如此简单。像使用同步方法逻辑一样使用异步。
public async Task<int> Test()
{
var num1 = await GetNumber(1);
var num2 = await GetNumber(num1);
var task = GetNumber(num2);
//或者
var num3 = await task;
return num1 + num2 + num3;
}
新异步的优势
在此之前已经有了多种异步模式,为什么还要引入和学习新的async\await异步呢?当然它肯定是有其独特的优势。
我们分两个方面来分析:WinForm、WPF等单线程UI程序和Web后台服务程序。
对于WinForm、WPF等单线程UI程序
代码1(旧异步)
private void button1_Click(object sender, EventArgs e)
{
var request = WebRequest.Create("https://github.com/");
request.BeginGetResponse(new AsyncCallback(t =>
{
//(1)处理请求结果的逻辑必须写这里
label1.Invoke((Action)(() => { label1.Text = "[旧异步]执行完毕!"; }));//(2)这里跨线程访问UI需要做处理
}), null);
}
代码2(同步)
private void button3_Click(object sender, EventArgs e)
{
HttpClient http = new HttpClient();
var htmlStr = http.GetStringAsync("https://github.com/").Result;
//(1)处理请求结果的逻辑可以写这里
label1.Text = "[同步]执行完毕!";//(2)不在需要做跨线程UI处理了
}
代码3(新异步)
private async void button2_Click(object sender, EventArgs e)
{
HttpClient http = new HttpClient();
var htmlStr = await http.GetStringAsync("https://github.com/");
//(1)处理请求结果的逻辑可以写这里
label1.Text = "[新异步]执行完毕!";//(2)不在需要做跨线程UI处理了
}
新异步的优势:
-
没有了烦人的回调处理
-
不会像同步代码一样阻塞UI界面(造成假死)
-
不在像旧异步处理后访问UI不在需要做跨线程处理
-
像使用同步代码一样使用异步(超清晰的逻辑)
是的,说得再多还不如看看实际效果图来得实际:(新旧异步UI线程没有阻塞,同步阻塞了UI线程)
【思考】:旧的异步模式是开启了一个新的线程去执行,不会阻塞UI线程。这点很好理解。可是,新的异步看上去和同步区别不大,为什么也不会阻塞界面呢?
【原因】:新异步,在执行await表达式前都是使用UI线程,await表达式后会启用新的线程去执行异步,直到异步执行完成并返回结果,然后再回到UI线程(据说使用了SynchronizationContext)。所以,await是没有阻塞UI线程的,也就不会造成界面的假死。
【注意】:我们在演示同步代码的时候使用了Result。然,在UI单线程程序中使用Result来使异步代码当同步代码使用是一件很危险的事(起码对于不太了解新异步的同学来说是这样)。至于具体原因稍候再分析(哎呀,别跑啊)。
对于Web后台服务程序
也许对于后台程序的影响没有单线程程序那么直观,但其价值也是非常大的。且很多人对新异步存在误解。
【误解】:新异步可以提升Web程序的性能。
【正解】:异步不会提升单次请求结果的时间,但是可以提高Web程序的吞吐量。
1、为什么不会提升单次请求结果的时间?
其实我们从上面示例代码(虽然是UI程序的代码)也可以看出。
2、为什么可以提高Web程序的吞吐量?
那什么是吞吐量呢,也就是本来只能十个人同时访问的网站现在可以二十个人同时访问了。也就是常说的并发量。
还是用上面的代码来解释。[代码2] 阻塞了UI线程等待请求结果,所以UI线程被占用,而[代码3]使用了新的线程请求,所以UI线程没有被占用,而可以继续响应UI界面。
那问题来了,我们的Web程序天生就是多线程的,且web线程都是跑的线程池线程(使用线程池线程是为了避免不断创建、销毁线程所造成的资源成本浪费),而线程池线程可使用线程数量是一定的,尽管可以设置,但它还是会在一定范围内。如此一来,我们web线程是珍贵的(物以稀为贵),不能滥用。用完了,那么其他用户请求的时候就无法处理直接503了。
那什么算是滥用呢?比如:文件读取、URL请求、数据库访问等IO请求。如果用web线程来做这个耗时的IO操作那么就会阻塞web线程,而web线程阻塞得多了web线程池线程就不够用了。也就达到了web程序最大访问数。
此时我们的新异步横空出世,解放了那些原本处理IO请求而阻塞的web线程(想偷懒?没门,干活了。)。通过异步方式使用相对廉价的线程(非web线程池线程)来处理IO操作,这样web线程池线程就可以解放出来处理更多的请求了。
不信?下面我们来测试下:
【测试步骤】:
1、新建一个web api项目
2、新建一个数据访问类,分别提供同步、异步方法(在方法逻辑执行前后读取时间、线程id、web线程池线程使用数)
public class GetDataHelper
{
/// <summary>
/// 同步方法获取数据
/// </summary>
/// <returns></returns>
public string GetData()
{
var beginInfo = GetBeginThreadInfo();
using (HttpClient http = new HttpClient())
{
http.GetStringAsync("https://github.com/").Wait();//注意:这里是同步阻塞
}
return beginInfo + GetEndThreadInfo();
}
/// <summary>
/// 异步方法获取数据
/// </summary>
/// <returns></returns>
public async Task<string> GetDataAsync()
{
var beginInfo = GetBeginThreadInfo();
using (HttpClient http = new HttpClient())
{
await http.GetStringAsync("https://github.com/");//注意:这里是异步等待
}
return beginInfo + GetEndThreadInfo();
}
public string GetBeginThreadInfo()
{
int t1, t2, t3;
ThreadPool.GetAvailableThreads(out t1, out t3);
ThreadPool.GetMaxThreads(out t2, out t3);
return string.Format("开始:{0:mm:ss,ffff} 线程Id:{1} Web线程数:{2}",
DateTime.Now,
Thread.CurrentThread.ManagedThreadId,
t2 - t1);
}
public string GetEndThreadInfo()
{
int t1, t2, t3;
ThreadPool.GetAvailableThreads(out t1, out t3);
ThreadPool.GetMaxThreads(out t2, out t3);
return string.Format(" 结束:{0:mm:ss,ffff} 线程Id:{1} Web线程数:{2}",
DateTime.Now,
Thread.CurrentThread.ManagedThreadId,
t2 - t1);
}
}
3、新建一个web api控制器
[HttpGet]
public async Task<string> Get(string str)
{
GetDataHelper sqlHelper = new GetDataHelper();
switch (str)
{
case "异步处理"://
return await sqlHelper.GetDataAsync();
case "同步处理"://
return sqlHelper.GetData();
}
return "参数不正确";
}
4、发布web api程序,部署到本地iis(同步链接:http://localhost:803/api/Home?str=同步处理 异步链接:http://localhost:803/api/Home?str=异步处理)
5、接着上面的winform程序里面测试请求:(同时发起10个请求)
View Code
6、重启iis,并用浏览器访问一次要请求的链接地址(预热)
7、启动winform程序,点击“访问同步实现的Web”:
8、重复6,然后重新启动winform程序点击“访问异步实现的Web”
看到这些数据有什么感想?
数据和我们前面的【正解】完全吻合。仔细观察,每个单次请求用时基本上相差不大。 但是步骤7"同步实现"最高投入web线程数是10,而步骤8“异步实现”最高投入web线程数是3。
也就是说“异步实现”使用更少的web线程完成了同样的请求数量,如此一来我们就有更多剩余的web线程去处理更多用户发起的请求。
接着我们还发现同步实现请求前后的线程ID是一致的,而异步实现前后线程ID不一定一致。再次证明执行await异步前释放了主线程。
【结论】:
-
使用新异步可以提升Web服务程序的吞吐量
-
对于客户端来说,web服务的异步并不会提高客户端的单次访问速度。
-
执行新异步前会释放web线程,而等待异步执行完成后又回到了web线程上。从而提高web线程的利用率。
【图解】:
Result的死锁陷阱
我们在分析UI单线程程序的时候说过,要慎用异步的Result属性。下面我们来分析:
private void button4_Click(object sender, EventArgs e)
{
label1.Text = GetUlrString("https://github.com/").Result;
}
public async Task<string> GetUlrString(string url)
{
using (HttpClient http = new HttpClient())
{
return await http.GetStringAsync(url);
}
}
代码 GetUlrString("https://github.com/").Result 的Result属性会阻塞(占用)UI线程,而执行到GetUlrString方法的 await异步的时候又要释放UI线程。此时矛盾就来了,由于线程资源的抢占导致死锁。
且Result属性和.Wait()方法一样会阻塞线程。此等问题在Web服务程序里面一样存在。(区别:UI单次线程程序和web服务程序都会释放主线程,不同的是Web服务线程不一定会回到原来的主线程,而UI程序一定会回到原来的UI线程)
我们前面说过,.net为什么会这么智能的自动释放主线程然后等待异步执行完毕后又回到主线程是因为SynchronizationContext的功劳。
但这里有个例外,那就是控制台程序里面是没有SynchronizationContext的。所以这段代码放在控制台里面运行是没有问题的。
static void Main(string[] args)
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
GetUlrString("https://github.com/").Wait();
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
Console.ReadKey();
}
public async static Task<string> GetUlrString(string url)
{
using (HttpClient http = new HttpClient())
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
return await http.GetStringAsync(url);
}
}
打印出来的都是同一个线程ID
使用AsyncHelper在同步代码里面调用异步
但可是,可但是,我们必须在同步方法里面执行异步怎办?办法肯定是有的
我们首先定义一个AsyncHelper静态类:
static class AsyncHelper
{
private static readonly TaskFactory _myTaskFactory = new TaskFactory(CancellationToken.None,
TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default);
public static TResult RunSync<TResult>(Func<Task<TResult>> func)
{
return _myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult();
}
public static void RunSync(Func<Task> func)
{
_myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult();
}
}
然后调用异步:
private void button7_Click(object sender, EventArgs e)
{
label1.Text = AsyncHelper.RunSync(() => GetUlrString("https://github.com/"));
}
这样就不会死锁了。
ConfigureAwait
除了AsyncHelper我们还可以使用Task的ConfigureAwait方法来避免死锁
private void button7_Click(object sender, EventArgs e)
{
label1.Text = GetUlrString("https://github.com/").Result;
}
public async Task<string> GetUlrString(string url)
{
using (HttpClient http = new HttpClient())
{
return await http.GetStringAsync(url).ConfigureAwait(false);
}
}
ConfigureAwait的作用:使当前async方法的await后续操作不需要恢复到主线程(不需要保存线程上下文)。
异常处理
关于新异步里面抛出异常的正确姿势。我们先来看下面一段代码:
private async void button8_Click(object sender, EventArgs e)
{
Task<string> task = GetUlrStringErr(null);
Thread.Sleep(1000);//一段逻辑。。。。
textBox1.Text = await task;
}
public async Task<string> GetUlrStringErr(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
throw new Exception("url不能为空");
}
using (HttpClient http = new HttpClient())
{
return await http.GetStringAsync(url);
}
}
调试执行执行流程:
在执行完118行的时候竟然没有把异常抛出来?这不是逆天了吗。非得在等待await执行的时候才报错,显然119行的逻辑执行是没有什么意义的。让我们把异常提前抛出:
提取一个方法来做验证,这样就能及时的抛出异常了。有朋友会说这样的太坑爹了吧,一个验证还非得另外写个方法。接下来我们提供一个没有这么坑爹的方式:
在异步函数里面用匿名异步函数进行包装,同样可以实现及时验证。
感觉也不比前种方式好多少...可是能怎么办呢。
异步的实现
上面简单分析了新异步能力和属性。接下来让我们继续揭秘异步的本质,神秘的外套下面究竟是怎么实现的。
首先我们编写一个用来反编译的示例:
class MyAsyncTest
{
public async Task<string> GetUrlStringAsync(HttpClient http, string url, int time)
{
await Task.Delay(time);
return await http.GetStringAsync(url);
}
}
反编译代码:
点击看大图
为了方便阅读,我们把编译器自动命名的类型重命名。
GetUrlStringAsync 方法变成了如此模样:
public Task<string> GetUrlStringAsync(HttpClient http, string url, int time)
{
GetUrlStringAsyncdStateMachine stateMachine = new GetUrlStringAsyncdStateMachine()
{
_this = this