首页 文章

C#控制流程等待异步和线程

提问于
浏览
3

微软表示:“async和await关键字不会导致创建额外的线程 . 异步方法不需要多线程,因为异步方法不能在自己的线程上运行 . 该方法在当前同步上下文上运行,并仅在方法处于活动状态时在线程上使用时间 . 您可以使用Task.Run将CPU绑定的工作移动到后台线程,但后台线程对于只等待结果可用的进程没有帮助 . “

以下是Microsoft用于解释async和await使用的Web请求示例 . (https://msdn.microsoft.com/en-us/library/mt674880.aspx) . 我在问题的最后粘贴了示例代码的相关部分 .

我的问题是,在每个“var byteArray = await client.GetByteArrayAsync(url);”语句之后,控制返回到CreateMultipleTasksAsync方法,然后调用另一个ProcessURLAsync方法 . 在调用三次下载后,它会在完成第一个ProcessURLAsync方法后开始等待完成 . 但是,如果ProcessURLAsync没有在单独的线程中运行,它如何进入DisplayResults方法呢?因为如果它不在另一个线程上,在将控制权返回给CreateMultipleTasksAsync后,它永远无法完成 . 你能提供一个简单的控制流程,以便我能理解吗?

让我们假设第一个client.GetByteArrayAsync方法在Task download3 = ProcessURLAsync(..)之前完成,当时第一个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);
    }
}

2 回答

  • 0

    它在不创建新线程的情况下调用函数的方式是主要的"UI"线程经常通过一个工作队列来一个接一个地处理队列中的项目 . 您可能听到的一个常见术语是"Message Pump" .

    当您执行 await 并且您正在从UI线程运行时,一旦调用完成 GetByteArrayAsync ,新作业将被放入队列,当它成为该作业时,它将继续该方法的其余代码 .

    GetByteArrayAsync 不使用线程来执行此操作's work either, it asks the OS to do the work and load the data in to a buffer and then it waits for the OS to tell it that the OS has finished loading the buffer. When that message comes in from the OS a new item goes in to that queue I was talking about earlier (kinda, i get in to that later), once it becomes that item'转而将它从操作系统获得的小缓冲区复制到更大的内部缓冲区并重复该过程 . 一旦它获得了文件的所有字节,它就会发出信号告知你的代码已经完成,导致你的代码将它继续放到队列中(我在上一段解释的内容) .

    当我说 GetByteArrayAsync 将项目放入队列时我说"kinda"的原因是你的程序中实际上有多个队列 . UI有一个,一个用于"thread pool",另一个用于"I/O Completion ports"(IOCP) . 线程池和IOCP将生成或重用池中的短期线程,因此可以将此技术称为创建线程,但是可用线程在池中空闲,不会创建线程 .

    您的代码按原样使用"UI queue",代码 GetByteArrayAsync 很可能使用线程池队列来完成它的工作,操作系统用来告诉 GetByteArrayAsync 缓冲区中的数据可用的消息使用IOCP队列 .

    您可以通过在执行等待的行上添加 .ConfigureAwait(false) 来更改代码以从使用UI队列切换到线程池队列 .

    var byteArray = await client.GetByteArrayAsync(url).ConfigureAwait(false);
    

    此设置告诉 await “而不是尝试使用SynchronizationContext.Current排队工作(如果您在UI线程上的UI队列)使用"default" SynchronizationContext (这是线程池队列)

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

    我将尝试对从鼠标单击到完成发生的所有事件做出明确的事件序列

    • 您在屏幕上单击鼠标

    • 操作系统使用IOCP池中的线程将WM_LBUTTONDOWN消息放入UI消息队列中 .

    • UI消息队列最终到达该消息,并让所有控件知道它 .

    • 名为 startButtonButton 控件收到消息消息,发现事件被触发时鼠标位于自身上并调用其click事件处理程序

    • click事件处理程序调用 startButton_Click

    • startButton_Click 来电 CreateMultipleTasksAsync

    • CreateMultipleTasksAsync 来电 ProcessURLAsync

    • ProcessURLAsync 来电 client.GetByteArrayAsync(url)

    • GetByteArrayAsync 最终内部做了base.SendAsync(request, linkedCts.Token),

    • SendAsync 在内部做了一堆最终导致它的东西从操作系统发送请求以从本机DLL下载文件 .

    到目前为止,没有发生任何“异步”,这只是所有正常的同步代码 . 到目前为止,如果它是同步或异步,则表现完全相同 .

    • 一旦对操作系统发出请求, SendAsync 将返回当前处于"Running"状态的 Task .

    • 稍后在文件中它到达 response = await sendTask.ConfigureAwait(false);

    • await 检查任务的状态,发现它仍在运行并导致函数返回"Running"状态的新任务,它还要求任务在完成后运行一些额外的代码,但使用线程池做那个额外的代码(因为它使用 .ConfigureAwait(false) ) .

    • 此过程重复,直到最终 GetByteArrayAsync 返回"Running"中的 Task<byte[]> .

    • 你的 await 看到返回的 Task<byte[]> 处于"Running"状态并导致函数返回"Running"状态的新 Task<int> ,它还要求 Task<byte[]> 使用 SynchronizationContext.Current 运行一些额外的代码(因为你没有指定 .ConfigureAwait(false) ),这个将在运行时将附加代码放入我们上次在步骤3中看到的队列中 .

    • ProcessURLAsync 返回"Running"状态的 Task<int> ,该任务存储在变量 download1 中 .

    • Steps 7-15 get repeated again for variables download2 and download3

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

    • await download1 它发现任务处于"Running"状态,它要求任务使用 SynchronizationContext.Current 运行一些额外的代码,然后创建一个处于"Running"状态的新 Task 并返回它 .

    • await 来自 CreateMultipleTasksAsync 的结果它发现任务处于"Running"状态,它要求任务使用 SynchronizationContext.Current 运行一些额外的代码 . 因为函数是 async void ,它只是将控制权返回给消息泵 .

    • 消息泵处理队列中的下一条消息 .


    好的,得到了所有这些?现在我们继续讨论当“工作完成”时会发生什么

    一旦你在任何时候执行步骤10操作系统可以使用IOCP发送消息告诉代码它已经完成归档缓冲区,那IOCP线程可以复制数据或者掩码请求线程池线程执行它(我没看足够深,看哪个) .

    这个过程不断重复,直到所有数据都被下载,一旦完全下载,"extra code"(一个委托)步骤12要求任务发送到SynchronizationContext.Post,因为它使用了委托将由线程池执行的默认上下文 . 在该委托结束时,它将原来的 Task 信号发送给已完成状态,该原始 Task 具有"Running"状态 .

    一旦 Task<byte[]> 在步骤13中返回,等待在步骤14中它执行 SynchronizationContext.Post ,该委托将包含类似于

    Delegate someDelegate () =>
    {
        DisplayResults(url, byteArray);
        SetResultOfProcessURLAsyncTask(byteArray.Length);
    }
    

    因为您传入的上下文是UI上下文,所以此委托被放入要由UI处理的消息队列中,UI线程将在有机会时获取它 .

    一旦 ProcessURLAsyncdownload1 完成,将导致看起来有点像的委托

    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 ,该帖子将把它放入上下文所用的任何队列中,并将由处理队列的"pump"处理 .

  • 7

    是什么帮助我理解async-await工作的方式很多this restaurant metaphor by Eric Lippert . 在面试过程中的某个地方搜索async等待 .

    只有当你的线程有时需要等待一些冗长的事情来完成时,异步等待才有意义,例如将文件写入磁盘,从数据库查询数据,从互联网获取信息 . 在等待完成这些操作时,您的线程可以自由地执行其他操作 .

    不使用async-await,在漫长的处理之后做其他事情并继续原始代码将是麻烦且难以理解和维护的 .

    那是async-await来救援的时候 . 使用async-await你的线程不会等到冗长的进程完成 . 事实上,它记得在Task对象中进行长度处理后仍然必须完成某些操作,并开始执行其他操作,直到需要冗长进程的结果 .

    在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的情况下编写异步函数,编译器会发出警告 . 您的线程执行ReadAsync中的所有代码,直到它到达等待 . 而不是真正等待你的线程在其调用堆栈中上升,看看它是否可以做其他事情 . 在上面的示例中,它启动DoSomethingElse() .

    过了一会儿,你的线程看到await readTextTask . 再次,而不是真正等待它上升它的堆栈,看看是否有一些代码没有等待 .

    它继续这样做,直到每个人都在等待 . 然后,只有这时你的线程才真的不能做任何事情,它开始等待,直到等待ReadAsync,如果完成 .

    这种方法的优点是您的线程可以减少等待,因此您的过程将提前完成 . 此外,它将使您的呼叫者(包括UI)响应,而不会有多线程的开销和困难 .

    您的代码看起来是顺序的,实际上它不是按顺序执行的 . 每次满足等待时,将执行未等待的调用堆栈中的某些代码 . 请注意,尽管它不是顺序的,但它仍然由一个线程完成 .

    请注意,这一切仍然是单线程的 . 一个线程一次只能做一件事,所以当你的线程忙于做一些繁重的计算时,你的调用者不能做任何事情,并且你的程序在你的线程完成计算之前仍然不会响应 . Async-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 . 用户界面可能没有等待,因此可以自由地做其他事情 .

    完成所有下载后,您的主题将继续显示结果 .

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

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

    另一个帮助我理解async-await的文件是Async And Await by the ever so helpful Stephen Cleary

相关问题