urig urig - 2 months ago 4
C# Question

How does C# Task.WaitAll() combine object states into one?

Given a simple Hotel entity as an example:

class Hotel
{
public int NumberOfRooms { get; set; }
public int StarRating { get; set; }
}


Please consider the following code in C# 5.0:

public void Run()
{
var hotel = new Hotel();
var tasks = new List<Task> { SetRooms(hotel), SetStars(hotel) };
Task.WaitAll(tasks.ToArray());
Debug.Assert(hotel.NumberOfRooms.Equals(200));
Debug.Assert(hotel.StarRating.Equals(5));
}

public async Task SetRooms(Hotel hotel)
{
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
hotel.NumberOfRooms = 200;
}

public async Task SetStars(Hotel hotel)
{
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
hotel.StarRating = 5;
}


Both calls to Debug.Assert() pass successfully. I don't understand how after both tasks have completed, the instance of Hotel contains the assignment from both the methods that run in parallel.

I thought that when
await
is called (in both
SetRooms()
and
SetStars()
), a "snapshot" of the hotel instance is created (having both
NumberOfRooms
and
StarRating
set to 0). So my expectation was that there will be a race condition between the two tasks and the last one to run will be the one copied back to
hotel
yielding a 0 in one of the two properties.

Obviously I am wrong. Can you explain where I'm misunderstanding how await works?

Answer

I thought that when await is called (in both SetRooms() and SetStars()), a "snapshot" of the hotel instance is created

Your Hotel class is a reference type. When you use async-await, your method is transformed into a state-machine, and that state-machine hoists the reference to your variable onto it. This means that both state-machines created are pointing at the same Hotel instance. There is no "snapshot" or deep copy of your Hotel, the compiler doesn't do that.

If you want to see what actually goes on, you can have a look at what the compiler emits once it transforms your async methods:

[AsyncStateMachine(typeof(C.<SetRooms>d__1))]
public Task SetRooms(Hotel hotel)
{
    C.<SetRooms>d__1 <SetRooms>d__;
    <SetRooms>d__.hotel = hotel;
    <SetRooms>d__.<>t__builder = AsyncTaskMethodBuilder.Create();
    <SetRooms>d__.<>1__state = -1;
    AsyncTaskMethodBuilder <>t__builder = <SetRooms>d__.<>t__builder;
    <>t__builder.Start<C.<SetRooms>d__1>(ref <SetRooms>d__);
    return <SetRooms>d__.<>t__builder.Task;
}
[AsyncStateMachine(typeof(C.<SetStars>d__2))]
public Task SetStars(Hotel hotel)
{
    C.<SetStars>d__2 <SetStars>d__;
    <SetStars>d__.hotel = hotel;
    <SetStars>d__.<>t__builder = AsyncTaskMethodBuilder.Create();
    <SetStars>d__.<>1__state = -1;
    AsyncTaskMethodBuilder <>t__builder = <SetStars>d__.<>t__builder;
    <>t__builder.Start<C.<SetStars>d__2>(ref <SetStars>d__);
    return <SetStars>d__.<>t__builder.Task;
}

You can see that both methods hoist the hotel variable into their state-machine.

So my expectation was that there will be a race condition between the two tasks and the last one to run will be the one copied back to hotel yielding a 0 in one of the two properties.

Now that you see what the compiler actually does, you can understand that there really isn't a race condition. It's the same instance of Hotel which is being modified, each method setting the different variable.


Side note

Perhaps you wrote this code just as an example to explain your question, but if you're already creating async methods, I'd recommend using Task.WhenAll instead of the blocking Task.WaitAll. This means changing the signature of Run to async Task instead of void:

public async Task RunAsync()
{
    var hotel = new Hotel();
    await Task.WhenAll(SetRooms(hotel), SetStars(hotel));
    Debug.Assert(hotel.NumberOfRooms.Equals(200));
    Debug.Assert(hotel.StarRating.Equals(5));
}