初见
虽然写了不少的C#代码,但却还么有使用过带有async关键字的异步方法。平常遇到的需求,使用普通的多线程都可以完美解决,感觉异步方法只是微软想当然开发出来的,虽然华丽但是毫无用处的垃圾功能。直到有一天,我要修改一个几乎全是异步方法的代码。
这里介绍一下本次修改遇到的全部的坑。可以通过这些坑纠正我们的认知偏差。
坑1 何时异步执行
请查看为何下面两个代码输出内容不同:
internal class Program
{
public static void Main()
{
Program p = new Program();
_ = p.AsyncTask();
Console.WriteLine("主线程退出 id:" + Environment.CurrentManagedThreadId);
}
private async Task AsyncTask()
{
Console.WriteLine("执行异步方法 id:" + Environment.CurrentManagedThreadId);
await Task.Delay(1);
}
}
输出内容:
执行异步方法 id:1
主线程退出 id:1
第二段代码:
internal class Program
{
public static void Main()
{
Program p = new Program();
_ = p.AsyncTask();
Console.WriteLine("主线程退出 id:" + Environment.CurrentManagedThreadId);
}
private async Task AsyncTask()
{
await Task.Delay(1);
Console.WriteLine("执行异步方法 id:" + Environment.CurrentManagedThreadId);
}
}
输出内容:
主线程退出 id:1
执行异步方法 id:6
接下来将上面第二段代码转换一下,就能看出来为什么了。
第二段代码等价于:
internal class Program
{
public static void Main()
{
Program p = new Program();
Task task = p.AsyncTask();
task.ContinueWith(t =>
{
Console.WriteLine("执行异步方法 id:" + Environment.CurrentManagedThreadId);
});
Console.WriteLine("主线程退出 id:" + Environment.CurrentManagedThreadId);
}
private Task AsyncTask()
{
return Task.Delay(1);
}
}
可以看出,C#的关键字async与await太误导人了。
同时也可以看出异步方法的本质:
在遇到需要等待执行的Task任务,让线程直接执行异步方法的后续代码,同时从线程池中取出新线程执行任务。
坑2 如何在非异步方法中调用异步方法
internal class Program
{
public static void Main()
{
Program p = new Program();
_thread = new Thread(p.CheckThreadId);
_thread.Start();
// 方法结束 交还ui线程
}
private static Thread? _thread;
private async Task CheckThreadId()
{
for (int i = 0; i < 10; i++)
{
// 执行任务
await Task.Delay(1000);
}
}
}
}
C#设计异步方法的主要目标,就是使用Task自己的线程池,免去建立新线程的开销,以及自己搭建线程池的麻烦。可是结合上面的代码看着就很奇怪,既然使用了异步方法,为何还要建立新的线程?最重要的是,无法通过判断_thread
的运行状态来判断任务是否还在执行。
于是,我将线程改为Task对象,这样就可以根据Task对象的信息,判断任务是否执行完成。不过之后就出问题了,主线程,也就是winform的ui线程被占用,窗体卡死。
为何会卡死主线程,会在坑三中介绍。
internal class Program
{
public static void Main() // 按键点击后执行的方法
{
Program p = new Program();
_task = p.CheckThreadId();
// 方法结束 交还ui线程
}
private static Task? _task;
private async Task CheckThreadId()
{
for (int i = 0; i < 10; i++)
{
// 执行任务
await Task.Delay(1000);
}
}
}
正确的方式应该是这样的,交给Task来执行异步方法,而不要直接调用异步方法。
internal class Program
{
public static void Main() // 按键点击后执行的方法
{
Program p = new Program();
_task = Task.Run(p.CheckThreadId);
// 方法结束 交还ui线程
}
private static Task? _task;
private async Task CheckThreadId()
{
for (int i = 0; i < 10; i++)
{
// 执行任务
await Task.Delay(1000);
}
}
}
坑3 被卡死的ui线程
从该代码中,可以看出循环线程有问题。虽然每次循环都有await,但是任务只能被执行一次。不过由于未知的原因,在第一次await等待结束后,继续接受执行的百分百为ui线程,可能是因为该线程在闲置状态吧。之后的循环,因为await不再生效,所以ui线程就被卡死在其中了,winfrom窗口也被卡死。
internal class Program
{
public static void Main() // 按键点击后执行的方法
{
Program p = new Program();
_task = p.Work();
Console.WriteLine("主线程结束");
// 方法结束 交还ui线程
}
private static Task? _task;
private async Task Work()
{
Task delayTask = Task.Delay(1000);
for (int i = 0; i < 10; i++)
{
// 执行任务
Console.WriteLine("执行任务" + DateTime.Now);
await delayTask;
}
}
}
坑4 异步方法的异常处理
在普通的异步操作中,新建立线程中的异常不会被抛到其他线程中。若在新线程中没有catch异常,则异常信息可能会丢失。
异步方法中的异常不会锁定在一个线程中,它会通过等待操作互相转移。通过下面的测试代码可以看出,执行异步方法的主线程不受异常影响,而执行等待的两个子线程同时catch到了异步方法的异常。
public static void Main()
{
_testTask = Task.Run(Test);
new Thread(AwaitF1).Start();
AwaitF2();
Thread.Sleep(10 * 1000);
}
private static Task _testTask = null!;
private static void AwaitF1()
{
Thread.Sleep(1000);
try
{
Console.WriteLine("等待方法1");
_testTask.Wait();
}
catch(Exception e)
{
Console.WriteLine("等待方法1 " + e.Message);
}
}
private static async void AwaitF2()
{
try
{
Console.WriteLine("等待方法2");
await _testTask;
}
catch(Exception e)
{
Console.WriteLine("等待方法2 " + e.Message);
}
}
private static async Task Test()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine(i);
await Task.Delay(1000);
if (i == 8)
throw new ApplicationException("异步方法异常");
}
}
输出信息
等待方法2
0
等待方法1
1
2
3
4
5
6
7
8
等待方法2 异步方法异常
等待方法1 One or more errors occurred. (异步方法异常)