使用Await、Async和线程控制C#的流程

4
Microsoft表示:“async和await关键字不会导致创建额外的线程。异步方法不需要多线程,因为异步方法不在自己的线程上运行。该方法在当前同步上下文中运行,并且只有在方法处于活动状态时才使用线程时间。您可以使用Task.Run将CPU密集型工作移动到后台线程,但后台线程对于仅等待结果可用的进程没有帮助。”
以下是Microsoft用于解释异步和await的网络请求示例 (https://msdn.microsoft.com/en-us/library/mt674880.aspx)。我在问题末尾粘贴了示例代码的相关部分。
我的问题是,在每个“var byteArray = await client.GetByteArrayAsync(url);”语句之后,控制返回CreateMultipleTasksAsync方法,然后调用另一个ProcessURLAsync方法。在调用三个下载之后,它开始等待第一个ProcessURLAsync方法完成。但是如果ProcessURLAsync不在单独的线程上运行,它如何继续进行DisplayResults方法呢?因为如果它不在不同的线程上,将控制返回给CreateMultipleTasksAsync后,它永远无法完成。你能提供一个简单的控制流程让我理解吗?
假设在Task download3 = ProcessURLAsync(..)之前第一个client.GetByteArrayAsync方法已经完成,那么第一个DisplayResults方法是在什么时候被调用的?
private async void startButton_Click(object sender, RoutedEventArgs e)
    {
        resultsTextBox.Clear();
        await CreateMultipleTasksAsync();
        resultsTextBox.Text += "\r\n\r\nControl returned to startButton_Click.\r\n";
    }


    private async Task CreateMultipleTasksAsync()
    {
        // Declare an HttpClient object, and increase the buffer size. The
        // default buffer size is 65,536.
        HttpClient client =
            new HttpClient() { MaxResponseContentBufferSize = 1000000 };

        // Create and start the tasks. As each task finishes, DisplayResults 
        // displays its length.
        Task<int> download1 = 
            ProcessURLAsync("http://msdn.microsoft.com", client);
        Task<int> download2 = 
            ProcessURLAsync("http://msdn.microsoft.com/en-us/library/hh156528(VS.110).aspx", client);
        Task<int> download3 = 
            ProcessURLAsync("http://msdn.microsoft.com/en-us/library/67w7t67f.aspx", client);

        // Await each task.
        int length1 = await download1;
        int length2 = await download2;
        int length3 = await download3;

        int total = length1 + length2 + length3;

        // Display the total count for the downloaded websites.
        resultsTextBox.Text +=
            string.Format("\r\n\r\nTotal bytes returned:  {0}\r\n", total);
    }


    async Task<int> ProcessURLAsync(string url, HttpClient client)
    {
        var byteArray = await client.GetByteArrayAsync(url);
        DisplayResults(url, byteArray);
        return byteArray.Length;
    }


    private void DisplayResults(string url, byte[] content)
    {
        // Display the length of each website. The string format 
        // is designed to be used with a monospaced font, such as
        // Lucida Console or Global Monospace.
        var bytes = content.Length;
        // Strip off the "http://".
        var displayURL = url.Replace("http://", "");
        resultsTextBox.Text += string.Format("\n{0,-58} {1,8}", displayURL, bytes);
    }
}

我认为你可能会发现我的async介绍很有帮助。 - Stephen Cleary
2个回答

8
它调用函数而不创建新线程的方式是主“UI”线程不断地遍历待处理工作队列,并逐一处理队列中的项目。您可能会听到的一个常见术语是“消息泵”。
当您执行await并从UI线程运行时,一旦调用完成GetByteArrayAsync,新的任务将被放入队列中,在轮到该任务时,它将继续方法的其余代码。 GetByteArrayAsync也没有使用线程来完成其工作,它要求操作系统执行工作并将数据加载到缓冲区中,然后等待操作系统告诉它操作系统已完成缓冲区的加载。当来自操作系统的消息到达时,队列中将出现新项(稍后我会详细介绍),一旦轮到该项,它将把从操作系统获得的小缓冲区复制到更大的内部缓冲区中,并重复此过程。一旦获取了文件的所有字节,它将向您的代码发出完成信号,导致您的代码将其继续执行加入队列(上段解释的内容)。
我在谈论GetByteArrayAsync时所说的“有点”之所以是因为您的程序实际上不止一个队列。有一个是用于UI的,一个是用于“线程池”的,还有一个是用于“I/O完成端口”的(IOCP)。线程池和IOCP队列将在池中生成或重新使用短期使用的线程,因此技术上可能被称为创建线程,但如果可用的线程空闲在池中,则不会创建线程。
您的代码现在将使用“UI队列”,而代码GetByteArrayAsync很可能使用线程池队列来完成其工作,操作系统用于告诉GetByteArrayAsync缓冲区中有数据可用的消息,将使用IOCP队列。
您可以通过在执行等待的行上添加.ConfigureAwait(false)来更改代码以从UI队列切换到线程池队列。
var byteArray = await client.GetByteArrayAsync(url).ConfigureAwait(false);

这个设置告诉 await "不要试图使用 SynchronizationContext.Current 来排队工作(如果您在 UI 线程上,则为 UI 队列),请使用 “默认” 的 SynchronizationContext(即线程池队列)。

假设第一个 "client.GetByteArrayAsync" 方法在 "Task download3 = ProcessURLAsync(..)" 之前完成,那么将被调用的是 "Task download3 = ProcessURLAsync(..)" 还是 "DisplayResults"?因为据我所知,它们都在您提到的队列中。

我将尝试明确地列出从鼠标单击到完成的所有事件序列:

  1. 您在屏幕上单击鼠标
  2. 操作系统使用 IOCP 池的一个线程将 WM_LBUTTONDOWN 消息放入 UI 消息队列中。
  3. UI 消息队列最终到达该消息,并让所有控件都知道这一点。
  4. 名为 startButtonButton 控件接收到该消息,看到当事件触发时鼠标位于其上方,并调用其单击事件处理程序
  5. 单击事件处理程序调用 startButton_Click
  6. startButton_Click 调用 CreateMultipleTasksAsync
  7. CreateMultipleTasksAsync 调用 ProcessURLAsync
  8. ProcessURLAsync 调用 client.GetByteArrayAsync(url)
  9. GetByteArrayAsync 最终在内部执行 base.SendAsync(request, linkedCts.Token),
  10. SendAsync 在内部执行了一堆操作,最终导致它从本地 DLL 发送请求下载文件。

到目前为止,还没有发生任何 “async” 操作,这只是普通的同步代码。如果是同步或异步,直到此时为止,所有内容都完全相同。

  1. 一旦向操作系统发出请求,SendAsync 将返回一个目前处于“Running”状态的Task
  2. 在文件的后面,它会达到response = await sendTask.ConfigureAwait(false);
  3. await 检查任务的状态,看到它仍在运行,并导致函数返回一个新的处于“Running”状态的Task,它还要求该任务在完成时运行一些额外的代码,但使用线程池来执行该额外的代码(因为它使用了.ConfigureAwait(false))。
  4. 这个过程重复进行,直到最终GetByteArrayAsync返回一个处于“Running”状态的Task<byte[]>
  5. 您的await看到返回的Task<byte[]>处于“Running”状态,并导致函数返回一个新的处于“Running”状态的Task<int>,它还要求Task<byte[]>使用SynchronizationContext.Current运行一些额外的代码(因为您没有指定.ConfigureAwait(false)),这将导致运行该额外的代码时将其放入我们在步骤3中看到的队列中。
  6. ProcessURLAsync返回一个处于“Running”状态的Task<int>,并将该任务存储到变量download1中。
  7. 步骤7-15会分别为变量download2download3重复执行

注意:在整个过程中,我们仍然在UI线程上,并且尚未将控制权交还给消息泵。

  1. await download1,它发现任务处于“Running”状态,并要求该任务使用SynchronizationContext.Current运行一些额外的代码,然后创建一个新的处于“Running”状态的Task并返回它。
  2. await来自CreateMultipleTasksAsync的结果,它看到任务处于“Running”状态,并要求该任务使用SynchronizationContext.Current运行一些额外的代码。因为这个函数是async void,所以它只是返回控制权给消息泵。
  3. 消息泵处理队列中的下一条消息。

好的,都明白了吗?现在我们继续讨论“工作完成时会发生什么”

在任何时候,执行第10步后,操作系统都可以使用IOCP发送消息来告诉代码已经完成了缓冲区的填充,该IOCP线程可以复制数据,或者它可以要求线程池线程来执行它(我没有深入研究看哪个)。

这个过程会一直重复,直到所有数据都被下载完毕。一旦数据完全下载,步骤12中要求任务执行的“额外代码”(一个委托)将被发送到SynchronizationContext.Post,因为它使用了默认上下文,该委托将由线程池执行。在该委托结束时,它会将原始返回的Task从“运行”状态变为“已完成”状态。
一旦步骤13中返回的Task<byte[]>被await于步骤14中,它就会执行SynchronizationContext.Post,而这个委托将包含类似以下代码:
Delegate someDelegate () =>
{
    DisplayResults(url, byteArray);
    SetResultOfProcessURLAsyncTask(byteArray.Length);
}

因为您传递给它的上下文是UI上下文,所以该委托将被放入消息队列中,等待UI处理。当UI线程有机会时,它将进行处理。

一旦download1ProcessURLAsync 完成,就会触发一个类似于以下代码的委托:

Delegate someDelegate () =>
{
    int length2 = await download2;
}

因为你传入的上下文是UI上下文,所以这个代理被放入消息队列中等待UI处理。当UI有机会时,它将处理该代理。一旦处理完成,它会排队一个类似于以下内容的代理:

Delegate someDelegate () =>
{
    int length3 = await download3;
}

因为您传递的上下文是UI上下文,所以该委托被放入消息队列中等待UI处理。当UI有机会时,它将处理该委托。一旦完成,它会排队一个类似于以下内容的委托:

Delegate someDelegate () =>
{
    int total = length1 + length2 + length3;

    // Display the total count for the downloaded websites.
    resultsTextBox.Text +=
        string.Format("\r\n\r\nTotal bytes returned:  {0}\r\n", total);
    SetTaskForCreateMultipleTasksAsyncDone();
}

因为您传递的上下文是UI上下文,所以此委托将被放入消息队列中,以等待UI处理。UI线程在有机会时将处理它。一旦调用了“SetTaskForCreateMultipleTasksAsyncDone”,它就会排队一个类似于委托的内容。

Delegate someDelegate () =>
{
    resultsTextBox.Text += "\r\n\r\nControl returned to startButton_Click.\r\n";
}

你的工作终于完成了。

我进行了一些重要的简化,并说了几个小谎话,以便更容易理解,但这是发生的基本情况。当一个Task完成它的工作后,它将使用已经在工作的线程来执行SynchronizationContext.Post,该post将把它放入上下文所在的任何队列中,并由处理队列的“pump”进行处理。


假设第一个“client.GetByteArrayAsync”方法在“Task download3 = ProcessURLAsync(..)”之前完成,那么将调用哪个方法?是“Task download3 = ProcessURLAsync(..)”还是“DisplayResults”?因为据我所知,它们都将在您提到的队列中。 - John L.
@JohnL。我尝试逐步说明发生了什么,请告诉我是否还有任何你不理解的地方。 - Scott Chamberlain
感谢您提供如此详细和宝贵的解释。所以,既然您说线程在“有机会时”处理消息,那么我可以理解为:如果GetByteArrayAsync(或任何异步方法)在调用函数到达等待点之前完成(在本例中是“await download1”),则异步方法的其余部分(即DisplayResults)将在调用方法到达“await download1”后执行,因此线程将处于空闲等待状态。我理解得对吗? - John L.
是的,那就是我提到的“善意的谎言”之一。它并不总是在SyncContext上排队继续执行。如果任务已经处于完成状态,它将同步运行继续执行,而不是将其放入队列中。 - Scott Chamberlain

0

对我理解异步等待工作方式有很大帮助的是Eric Lippert提出的这个餐厅比喻。在访谈中间搜索异步等待。

只有在线程有时需要等待某些耗时完成的事情时,如将文件写入磁盘、从数据库查询数据、获取来自互联网的信息,异步等待才有意义。在等待这些操作完成时,线程可以自由地做其他事情。

如果不使用异步等待,在进行其他操作并在长时间处理后继续原始代码将会很麻烦和难以理解和维护。

这就是异步等待的用处。使用异步等待,您的线程不会等待长时间的处理过程完成。实际上,它会记住在任务对象中仍然需要完成某些操作,并开始做其他事情,直到需要长时间处理的结果。

按照Eric Lippert的比喻:在开始烤面包后,厨师不会等到线程启动。相反,他开始煮鸡蛋。

在代码中,这看起来像:

private async Task MyFunction(...)
{
    // start reading some text
    var readTextTask = myTextReader.ReadAsync(...)
    // don't wait until the text is read, I can do other things:
    DoSomethingElse();
    // now I need the result of the reading, so await for it:
    int nrOfBytesRead = await readTextTask;
    // use the read bytes
    ....
 }

发生的情况是您的线程进入了ReadAsync函数。由于该函数是异步的,我们知道其中某处有一个await。实际上,如果您编写了一个没有await的异步函数,编译器会发出警告。您的线程执行ReadAsync中的所有代码,直到它达到await。而不是真正等待,您的线程会在其调用堆栈中向上查找,看看是否可以做其他事情。在上面的示例中,它开始执行DoSomethingElse()。
过了一会儿,您的线程看到了await readTextTask。同样,它不是真正等待,而是在其堆栈中向上查找,看看是否有一些未等待的代码。
它继续这样做,直到每个人都在等待。只有这样,您的线程才真正无法再做任何事情,并开始等待,直到ReadAsync中的await完成。
这种方法的优点是您的线程将少等待,因此您的进程将更早地完成。此外,它将保持您的调用者(包括UI)响应,而不必担心多个线程的开销和困难。
您的代码看起来是顺序的,但实际上并非按顺序执行。每次遇到await时,都会执行调用堆栈中未等待的某些代码。请注意,尽管它不是顺序的,但仍由一个线程完成。
请注意,所有内容仍然是单线程的。一个线程一次只能做一件事情,因此当您的线程忙于进行一些繁重的计算时,您的调用方无法做任何其他事情,直到您的线程完成计算之前,您的程序仍然无响应。异步等待对线程没有帮助。
这就是为什么您会看到耗时的过程被启动在一个单独的线程中作为一个可等待的任务,使用Task.Run。这将释放您的线程去做其他事情。当然,如果您的线程确实有其他事情要做而等待计算完成,并且启动新线程的开销比自己计算要小,那么这种方法才有意义。
private async Task<string> ProcessFileAsync()
{
    var calculationTask = Task.Run( () => HeavyCalcuations(...));
    var downloadTask = downloadAsync(...);

    // await until both are finished:
    await Task.WhenAll(new Task[] {calculationTask, downloadTak});
    double calculationResult = calculationTask.Result;
    string downloadedText = downloadTask.Result;

    return downloadedText + calculationResult.ToString();
}

现在回到你的问题。

在第一个ProcessUrlAsync中,有一个await。线程将控制权返回给你的过程,并记住它仍然需要在Task对象downLoad1中进行一些处理。它开始再次调用ProcessUrlAsync。不等待结果并开始第三次下载。每次都记住它仍然需要在Task对象downLoad2和downLoad3中做一些事情。

现在你的进程真的没有什么可做的了,所以它等待第一个downLoad完成。

这并不意味着你的线程真的什么都没做,它会沿着调用堆栈向上查看是否有任何调用者没有等待并开始处理。在你的例子中,Start_Button_Click正在等待,所以它转到调用者,可能是UI。UI可能没有等待,因此可以自由地做其他事情。

在所有下载完成后,你的线程继续显示结果。

顺便说一句,你可以使用Task.WhenAll等待所有任务完成,而不是等待三次。

await Task.WhenAll(new Task[] {downLoad1, download2, download3});

另一个帮助我理解异步等待的文档是Stephen Cleary非常有帮助的《异步和等待》


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接