Freigeben über


Tasks are (still) not threads and async is not parallel

I talk to a lot of developers who are either new to .NET or are moving from an older version to the newer platform and tools. As such I’m always trying to think of new ways to describe the nature of Tasks vs Threads and async vs parallel. Modern .NET development is steeped in the async/await model and async/await is built on the Task model. So understanding these concepts is key to long term success building apps in .NET.

In order to help visualize this I built a simple WPF application that displays a chart of an application’s activity. I want to display some of the potential variations in behavior of what appear to be a simple set of async tasks.

Take the following method

start code

This is a simple event handler which is going to call 3 asynchronous methods and then wait for all three to complete and then print out how long it took to do the whole operation. How many threads are there? Some people will assume only 1, some will assume 3. Like everything in software, it depends.

The DoWorkAsync() method just runs a loop. Each time around the loop, it will do some sort of asynchronous task and then do some busy “work” for some amount of time, then it will draw a rectangle on the screen representing the time that it spent doing that “work.” (This is analogous to making a web service call and then doing some local processing on the data returned from the service.) In this way we can easily see (a) when the work is being performed, and (b) whether the work overlaps with other task’s work. If it does, then we are running on multiple threads. The work we are concerned with is our code (e.g. the local data processing) – not the thing being waited on (the async web service), so each bar in the app will represent the local processing.

no-async code

The first case is the simplest, the method is marked as async but we really don’t have any asynchronous work going on inside it. In this case, the method is actually going to actually run to completion synchronously, meaning that the tasks above (in the event handler) will all be complete immediately upon creation and there wont be any new threads. They will each just run one right after the other.

no-async

In this image, the vertical axis is time. So first the red task ran, then the blue task ran, then the green task ran. It took 5 seconds total because 5 loops * (200ms + 300ms+ 500ms) = 5 seconds. You can also just make out the faint white lines of each individual iteration of the loops. But only 1 thread was used to run all three tasks.

Now lets make one change. Add an asynchronous operation where the //TODO is. Typically this might be a web service call or reading from a file, but for our purposes we will just use Task.Delay()

regular-async code

The only change here is the addition of the Task.Delay to the beginning of the loop. This will cause the method to not complete synchronously because it is going to do an asynchronous wait at the start of every iteration of the loop (simulating our async service call wait). Now look at the result.

regular-async

It still took about the same amount of time, but the iterations are interleaved. They are not overlapping each other though because we are still on the same thread. When you “await” an asynchronous task, the rest of your method will continue running in the same context that it started on. In WPF, that context is the UI thread. So while the red task is doing its delay, the blue and green tasks are running on the UI thread. When the red task is done delaying and wants to continue, it cant until the UI thread becomes available so it just queues up until its turn comes back around.

(Also notice that we didn’t add 1.5 seconds (100ms * 5 iterations * 3 tasks) to the total operation time. That’s the async benefit, we were able to overlap the waiting time of one task with the work time of other tasks by sharing the UI thread while we were waiting.)

But sometimes, this interleaving doesn’t happen. What if you have an asynchronous task that finishes so fast, it might as well be synchronous?

async-completed synchronously code

When the async plumbing goes to work, it first checks to see if the async operation is completed.  Task.Delay(0) will be completed immediately, and as such will be treated like a synchronous call.

async-completed synchronously

Well that puts us back to where we started. All 5 red iterations happen first because there is no asynchronous work to wait on.

Everything happens on the same thread context unless you tell it not to. Enter ConfigureAwait().

async-config await false code

ConfigureAwait(false) tells the async plumbing to ignore the thread context, and just continue on any old thread it wants. This means that as soon as the red task is done with its delay, it doesn’t have to wait for the UI thread to be available. It runs on a random threadpool thread.

async-config await false

Two things to notice here. First, since they are not bound to only running on the UI thread, they are running overlapped at the same time on multiple threads. Second, it only took 3 seconds to complete all three because they were able to fan out across multiple threads. (You can explicitly see the Task.Delays here because they are the white gaps between each bar)

Now what happens if we combine Task.Delay(0) with ConfigAwait(false)?

async-completed synchronously config await false code

Now we have a async task that will actually complete synchronously, but we are telling it not to bother with affinity for the threading context.

async-completed synchronously config await false

Completed synchronously wins. If the task completes synchronously already, then the async plumbing doesn’t come into play.

Summary

Look at this from the perspective of the original event handler above. The event handler has absolutely no idea whether its tasks are going to run on one thread or multiple threads. All it knows is that it has requested 3 potentially asynchronous tasks to be completed. The underlying implementation will determine whether additional threads come in to play or not. And whether you have multiple threads determines whether you run in parallel or not. So you need to be prepared for either behavior when writing and debugging your app because in the end, it just depends.

(Side note: The parallel behavior above is a side effect of the async/await thread context affinity in the WPF task scheduler. It is not guaranteed and the behavior may vary depending with different task schedulers. It should not be relied upon as a method to create other threads. If you require something to run in parallel, use Task.Run())

The example project used here is available in my GitHub repository.

Comments

  • Anonymous
    August 25, 2016
    The comment has been removed
    • Anonymous
      August 25, 2016
      Thanks!
  • Anonymous
    September 07, 2016
    I'm struggling to understand why Green was drawn first in the example where you introduced "await Task.Delay(100);".
    • Anonymous
      September 07, 2016
      The comment has been removed
      • Anonymous
        September 07, 2016
        I do get the same result on my machine as well. My first assumption would be LIFO behavior. However that doesn't support the code flow.1. I tried to rearrange the the task creation - still yields the same result. It doesn't seem to make sense why G gets drawn first. I specifically put in a debug statement to see which Task hit the await statement first - the order is dependent on the Task creation order.2. Here are the debug statements from "AddTimeRectangle". Adding Rect 116 616 Green => Green gets drawn first and also the first to hit second iteration (i=1) and requeued.Adding Rect 618 918 Blue => Blue gets drawn second and also moved to 2nd iteration and requeued. At 918 miliseconds, G should also be done - roughly 300 miliseconds has pass since G requeued on second iteration. If LIFO, then Green should be executed next.Adding Rect 919 1119 Red => Red gets drawn last, moved to next iteration and requeued. At this stage, G and B are both done and G gets drawn next which supported LIFO behavior.Adding Rect 1120 1620 GreenAdding Rect 1620 1920 BlueAdding Rect 1921 2121 Red
        • Anonymous
          September 07, 2016
          Looking into the code for Task.Delay (http://referencesource.microsoft.com/#mscorlib/system/threading/Tasks/Task.cs,34b191a243434f6a) The delay Tasks are just wrappers around System.Threading.Timer with callbacks. So its also going to depend on how Timer class orders them in its internal queue, and then subsequently on how the ThreadPool executes the callbacks, and then how the OS schedules those threads to which post back to the WPF dispatcher and finally run our code. So there are a lot of moving parts, several of which can be affected by outside influences.
  • Anonymous
    October 13, 2016
    A great read, but I am completely lost on the last change, "but we are telling it not to bother with affinity for the threading context." Could you please help me understand why adding a 0 delay brings the whole example full "circle" back to the original graph?
    • Anonymous
      October 14, 2016
      The comment has been removed
      • Anonymous
        February 28, 2017
        That's really helpful explanation. Thanks!
  • Anonymous
    April 14, 2017
    just want to say thank you for this excellent example.
  • Anonymous
    April 24, 2017
    Best explanation so far...
  • Anonymous
    November 08, 2017
    Nov 2017..this is still so relevant. It is not an easy concept to grasp. great explanation!
  • Anonymous
    January 24, 2018
    Nice article Ben, I ran your github code and tried logged the threadID in the AddTimeRectangle method, and here is what I get:For this case: await Task.Delay(100).ConfigureAwait(false); // continues asynchronously on any threadAdding Rect 213 413 #32FF0000 on thread: 1Adding Rect 317 2476 #3200FF00 on thread: 1Adding Rect 2503 4035 #320000FF on thread: 1Adding Rect 2477 4040 #32FF0000 on thread: 1Adding Rect 4036 4941 #3200FF00 on thread: 1Adding Rect 4921 5667 #320000FF on thread: 1Adding Rect 4921 5667 #32FF0000 on thread: 1Adding Rect 5667 9562 #3200FF00 on thread: 1Adding Rect 9560 10358 #32FF0000 on thread: 1Adding Rect 9565 10367 #320000FF on thread: 1Adding Rect 10967 11194 #32FF0000 on thread: 1Adding Rect 11197 11553 #3200FF00 on thread: 1Adding Rect 11379 11921 #320000FF on thread: 1Adding Rect 11921 12221 #3200FF00 on thread: 1Adding Rect 12433 12933 #320000FF on thread: 1They're all running on the same thread it looks like, any idea why?
    • Anonymous
      January 26, 2018
      You should capture the thread inside DoWorkAsync instead and and pass it into AddTimeRectangle. AddTimeRectangle must run on the UI thread because it is updating the UI directly. DoWorkAsync performs its work asynchronously on the threads we discussed here and then calls Dispatcher.BeginInvoke to ensure that AddTimeRectangle executes on the UI thread. I've updated the source to output the thread ID on which the work is happening.
  • Anonymous
    December 01, 2018
    thank you Benit was one of the best article that I've read about difference between Task and Thread in C#
  • Anonymous
    February 13, 2019
    Ben, very well explained.I've been struggling for the last few days to understand what really goes on behind the scenes on this matter. My goal is to master all about threads and paralelism since it is so important and impactfull on our applications.Thank you for the explanation.