前言
在文章《async和await》中,我们观察到了一下客观的规律,但是没有讲到本质,而且还遗留了一个问题:
这篇文章中,我们继续看看这个问题如何解决!
我们再看看之前写的代码:
static public void TestWait2()
{
var t = Task.Factory.StartNew(async () =>
{
Console.WriteLine("Start");
await Task.Delay(3000);
Console.WriteLine("Done");
});
t.Wait();
Console.WriteLine("All done");
}
static public void TestWait3()
{
var t = Task.Run(async () =>
{
Console.WriteLine("Start");
await Task.Delay(3000);
Console.WriteLine("Done");
});
t.Wait();
Console.WriteLine("All done");
}
当时问题是,为啥 Task.Factory.StartNew 可以看到异步效果,而Task.Run中却是同步效果。
那其实是因为,Task.Factory.StartNew 返回的 t.Wait(); 它没卡住主线程,而Task.Run的 t.Wait();它卡住了。
那为啥,Task.Factory.StartNew没卡住呢?
这是应为 Task.Factory.StartNew 返回的变量 t 他是Task< Task >类型!
如果,Task.Run 返回的是Task类型,如果我们改成Task.Factory.StartNew,那么它 返回的类型就是Task<Task< int >>
在.Net4.0中提供一个Unwrap方法,用于将Task<Task< int>>解为Task< int>类型,所以如果代码改为:
static public async void Factory嵌套死等()
{
Console.WriteLine($"Factory不嵌套死等{getID()}");
var t = Task.Factory.StartNew(async() =>
{
Console.WriteLine($"Start{getID()}");
await Task.Delay (1000);
Console.WriteLine($"Done{getID()}");
}).Unwrap();
t.Wait();
Console.WriteLine($"All done{getID()}");
}
那么此时 t.Wait(); 也能卡死主线程。
其实Task.Run(.net4.5引入) 是在 Task.Factory.StartNew(.net4.0引入) 之后出现的,Task.Run是为了简化Task.Factory.StartNew的使用。
t.Wait() 和 await t;
现在我从另一个角度分析问题。
使用 Task.Run,能不能达到异步的效果? 答案是肯定的!
不过,我们此时不应该使用 t.Wait(); 而是应该是 await t;
static public async void Run嵌套Await()
{
Console.WriteLine($"Run嵌套Await{getID()}");
var t = Task.Run(async () =>
{
Console.WriteLine($"Start{getID()}");
await Task.Delay(1000);
Console.WriteLine($"Done{getID()}");
});
await t;
Console.WriteLine($"All done{getID()}");
}
这样的话就实现了异步效果。
await 是如何实现异步的
这里我们可以进一步分析一下。
“1” 是主线程的ID “5” 是 task 启的子线程 ID。
我发现All done 在 Done 后面执行的,这是应为 await t; 把主线程"遣返了"
而await t; 之后的代码(也就是All done 这句话的打印)是由子线程5接着完成的。
整个流程是这样的,当编译时,编译器看到了函数使用了 async 关键字,那么整个函数将被转换为一个带有状态机的函数,反编译后发现函数名称变为MoveNext。
当主线程执行到子函数时,遇到 await 那么此时 主线程就会返回(跳出整个子函数,去执行下一个函数),MoveNext呢就会切换状态机(由于状态机已经切换,下次MoveNext在被调用时,就会从await 处向下执行)。
不过,从现象看await 之后的代码,不是主线程调用了,而是Task的子线程。子线程会再次调用MoveNext,并且进入一个新的状态机。
这里就有一个结论,当主线程进入一个子函数,遇到await机会从函数直接返回,函数中以下的代码交给新的子线程执行。
为了证明一这一点,我又写了一个程序:
static public async Task AsyncInvoke()
{
await Task.Run(() =>
{
Console.WriteLine($"This is 1 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
});
Console.WriteLine($"1{getID()}");
await Task.Run(() =>
{
Console.WriteLine($"This is 2 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
});
Console.WriteLine($"2{getID()}");
await Task.Run(() =>
{
Console.WriteLine($"This is 3 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
});
Console.WriteLine($"3{getID()}");
await Task.Run(() =>
{
Console.WriteLine($"This is 4 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
});
Console.WriteLine($"4{getID()}");
}
执行效果如下:
你会发现不过一个函数里面有多少个await ,主线程遇到一个await就返回了,就跳出这个函数去执行其他的函数了。
函数剩下的await 后面的都是由子线程完成的!多个await 只是多个几个状态机而已。
所以在一个函数中,如果有个多个await ,除了第一个后面的都和主线程无关。
这里又出现了一个新的问题,为啥后面的线程ID都是5?这个其实不一定的,我重新跑了一次:
这次发现出现了两个子线程号 3 和 5,这是应为 Task 背后有个 线程池。Task 被翻译为任务,单纯的线程是指的Thread。
Task 启动后,使用哪个线程是由背后的线程池提供,而这个线程池是由.net进行维护。包括回调什么时候发生都是由线程池中的一个线程通知Task对象!await 操作符 其实是 调用 Task对象的 ContinueWith,所以上面这段代码也可以这么写:
/// <summary>
/// 回调式写法
/// </summary>
public void TaskInvokeContinue()
{
Task.Run(() =>
{
Console.WriteLine($"This is 1 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
}).ContinueWith(t =>
{
Console.WriteLine($"This is 2 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
}).ContinueWith(t =>
{
Console.WriteLine($"This is 3 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
}).ContinueWith(t =>
{
Console.WriteLine($"This is 4 ManagedThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}");
Thread.Sleep(1000);
})
;
//不太爽---nodejs---回调式写法,嵌套是要疯掉
}
这就进一步体现了 await 用同步的方式,写异步的代码。
能实现这个的原因就是,函数已经被改造成一个状态机了。
到这里,我就把上次坑给填上了!下次我们在一起掰扯掰扯Task的一些有意思的用法。
小结
我觉得最重要的一点就是:
主线程遇到一个await就返回了,如何理解这个返回?
返回就是跳出这个函数,和这个函数没有半毛钱关系了,去执行其他下面的函数了。
该函数剩下的await后面的部分 都是由线程池中的子线程完成的!
理解这一点,有助于我们对异步代码的编写!
2023年7月29日 更新 (一次Debug的分享)
昨天才写完这篇文章,今天就发现之前的写的一段代码有问题。没想到这么快就用上了~~(笑哭)
程序大概是这样的。我有一个主线程,里面有两个函数A和B,A和B实现了 async await 。
A和B里有一句 await tcpcli.SendAsync(str) 这句异步代码, 大致代码如下:
while(true)
{
await A(){
....
bool b = await tcpcli.SendAsync(str);
}
await B(){
....
await tcpcli.SendAsync(str);
}
}
正常情况下,这样没啥问题。程序都是正常跑。但是当Tcp服务那边反应延时的时候,就会出问题。
运行到 bool b = await tcpcli.SendAsync(str); 时,按照之前的结论,主线程都是直接返回的,就会直接执行B
然后再接着执行A,但是如果bool b = await tcpcli.SendAsync(str); 依然还在等待,之线程还是会返回的,
此时会再次开一个新的线程,导致多个线程并发,但是我这里其他逻辑并发的话是会有问题的(比如写Modbus的一个寄存器)。
所以,一旦 tcpcli.SendAsync(str)卡住了,逻辑就出问题了!
既然逻辑不能并发,我当时为啥不直接用同步的方式呢?其实原因是当时我不知道如何用同步的方式获取返回值。
我当时 调用 tcpcli.SendAsync(str).Wait(); 时发现这个Wait()返回值是空,但是我又需要返回值,所以就用了
bool b = await tcpcli.SendAsync(str); 那其实如果想用同步的方式获取返回值,应该使用:
bool b = tcpcli.SendAsync(str).GetAwaiter().GetResult();
所以,最后改程序为:
while(true)
{
await A(){
....
bool b = tcpcli.SendAsync(str).GetAwaiter().GetResult();
}
await B(){
....
tcpcli.SendAsync(str).Wait();
}
}