Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

C# Control Flow With Await Async And Threading

Microsoft says: “The async and await keywords don't cause additional threads to be created. Async methods don't require multithreading because an async method doesn't run on its own thread. The method runs on the current synchronization context and uses time on the thread only when the method is active. You can use Task.Run to move CPU-bound work to a background thread, but a background thread doesn't help with a process that's just waiting for results to become available.”

Here is the web request example Microsoft uses for explaining the use of async and await. (https://msdn.microsoft.com/en-us/library/mt674880.aspx). I pasted the relevant part of the sample code at the end of the question.

My question is, after each “var byteArray = await client.GetByteArrayAsync(url);”statement, control returns to CreateMultipleTasksAsync method, then invoking another ProcessURLAsync method. And after three downloads are invoked, then it starts to wait on the completion of the first ProcessURLAsync method to finish. But how can it proceed to the DisplayResults method if ProcessURLAsync is not running in a seperate thread? Because if it is not on a different thread, after returning control to CreateMultipleTasksAsync, it can never complete. Can you provide a simple control flow so that I can understand?

Let's assume the first client.GetByteArrayAsync method finished before Task download3 = ProcessURLAsync(..), when exactly is the first DisplayResults called?

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);
    }
}
like image 569
John L. Avatar asked Feb 06 '23 08:02

John L.


1 Answers

The way it calls the function without creating a new thread is the main "UI" thread is constantly going through a queue of work to do and processing items in the queue one after another. A common term you might hear for this is the "Message Pump".

When you do a await and you are running from the UI thread, once the call completes to GetByteArrayAsync a new job will be put on the queue and when it becomes that job's turn it will continue with the rest of the code of the method.

GetByteArrayAsync does not use a thread to do it'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's turn it will copy the small buffer it got from the OS to a bigger internal buffer and repeats the process. Once it gets all bytes of the file it will signal it is done to your code causing your code to put it's continuation on to the queue (the stuff I explained last paragraph).

The reason I said "kinda" when talking about GetByteArrayAsync putting items in to the queue is there is actually more than one queue in your program. There is one for the UI, one for the "thread pool", and one for "I/O Completion ports" (IOCP). The thread pool and IOCP ones will generate or reuse short lived threads in the pool, so this technicaly could be called creating a thread, but a available thread was sitting idle in the pool no thread would be created.

Your code as-is will use the "UI queue", the code GetByteArrayAsync is most likely using the thread pool queue to do it's work, the message the OS uses to tell GetByteArrayAsync that data is available in the buffer uses the IOCP queue.

You can change your code to switch from using the UI queue to the thread pool queue by adding .ConfigureAwait(false) on the line you perform the await.

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

This setting tells the await "Instead of trying to use SynchronizationContext.Current to queue up the work (The UI queue if you are on the UI thread) use the "default" SynchronizationContext (which is the thread pool queue)

Let's assume the first "client.GetByteArray‌​Async" method finished before "Task download3 = ProcessURLAsync(..)" then, will it be "Task download3 = ProcessURLAsync(..)" or "DisplayResults" that will be invoked? Because as far as I understand, they will both be in the queue you mention.

I will try to make a explicit sequence of events of everything that happens from mouse click to completion

  1. You click your mouse on the screen
  2. The OS uses a thread from the IOCP pool to put a WM_LBUTTONDOWN message in the UI message queue.
  3. The UI Message queue eventually gets to that message, and lets all the controls know about it.
  4. The Button control named startButton receives the message the message, sees that the mouse was positioned over itself when the event was fired and calls its click event handler
  5. The click event handler calls startButton_Click
  6. startButton_Click calls CreateMultipleTasksAsync
  7. CreateMultipleTasksAsync calls ProcessURLAsync
  8. ProcessURLAsync calls client.GetByteArrayAsync(url)
  9. GetByteArrayAsync eventually internally does a base.SendAsync(request, linkedCts.Token),
  10. that SendAsync does a bunch of stuff internally that eventually leads it to send a request from the OS to download a file from native DLLs.

So far, nothing "async" has happened, this is just all normal synchronous code. everything up to this point behaves exactly the same if it was sync or async.

  1. Once the request to the OS has been made, SendAsync returns a Task that is currently in the "Running" state.
  2. Later on in the file it reaches a response = await sendTask.ConfigureAwait(false);
  3. The await checks the status of the task, sees that it is still running and causes the function to return with a new Task in the "Running" state, it also asks the task to run some additional code once it finishes, but use the thread pool to do that additional code (because it used .ConfigureAwait(false)).
  4. This process repeats till eventually GetByteArrayAsync returns a Task<byte[]> that is in the "Running".
  5. Your await sees that the returned Task<byte[]> is in the "Running" state and causes the function to return with a new Task<int> in the "Running" state, it also asks the Task<byte[]> to run some additional code using SynchronizationContext.Current (because you did not specify .ConfigureAwait(false)), this will cause the additional code when ran to be put in to the queue we last saw in step 3.
  6. ProcessURLAsync returns a Task<int> that is in the "Running" state and that task is stored in to the variable download1.
  7. Steps 7-15 get repeated again for variables download2 and download3

NOTE: We are still on the UI thread and have yet to yield control back to the message pump during this entire process.

  1. You await download1 it sees that the task is in the "Running" state and it asks the task to run some additional code using SynchronizationContext.Current it then creates a new Task that is in the "Running" state and returns it.
  2. You await the result from CreateMultipleTasksAsync it sees that the task is in the "Running" state and it asks the task to run some additional code using SynchronizationContext.Current. Because the function is async void it just returns control back to the message pump.
  3. The message pump processes the next message in the queue.

Ok, got all that? Now we move on to what happens when "work gets done"

Once you do step 10 at any time the OS may send a message using IOCP to tell the code it has finished filing a buffer, that IOCP thread may copy the data or it mask ask a thread pool thread to do it (I did not look deep enough to see which).

This process keeps repeating till all the data is downloaded, once it is fully downloaded the "extra code" (a delegate) step 12 asked the task to do gets sent to SynchronizationContext.Post, because it used the the default context that delegate will get executed by the thread pool. At the end of that delegate it signals the original Task that was returned that had the "Running" state to the completed state.

Once the Task<byte[]> returned in step 13, awaited in step 14 it does it's SynchronizationContext.Post, this delegate will contain code similar to

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

Because the context you passed in was the UI context this delegate gets put in the queue of messages to be processed by the UI, the UI thread will get to it when it gets a chance.

Once ProcessURLAsync for download1 completes that will cause the a delegate that looks kinda like

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

Because the context you passed in was the UI context this delegate gets put in the queue of messages to be processed by the UI, the UI thread will get to it when it gets a chance. Once that one is done it does queues up a delegate that looks kinda like

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

Because the context you passed in was the UI context this delegate gets put in the queue of messages to be processed by the UI, the UI thread will get to it when it gets a chance. Once that is done it queues up a delegate that looks kinda like

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();
}

Because the context you passed in was the UI context this delegate gets put in the queue of messages to be processed by the UI, the UI thread will get to it when it gets a chance. Once the "SetTaskForCreateMultipleTasksAsyncDone" gets called it queues up a delegate that looks like

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

And your work is finally complete.

I have made some major simplifactions, and did a few white lies to make it a little easier to understand, but this is the basic jist of what happens. When a Task finishes it's work it will use the thread it was already working on to do the SynchronizationContext.Post, that post will put it in to whatever queue the context is for and will get processed by the "pump" that handles the queue.

like image 146
Scott Chamberlain Avatar answered Feb 12 '23 12:02

Scott Chamberlain