Question

I'm trying to implement coroutines using async/await, and for that I want to ensure my coroutines are only executing on one thread (the thread that resumes them).

I am currently using a custom awaiter which simply queues the continuation on the coroutine object. When a coroutine wants to yield, it awaits this custom awaiter. When a coroutine is resumed, it simply calls the continuation.

I can guarantee that only one continatuion is queued per resume, ie. that we don't create multiple tasks without awaiting them. I can also guarantee that we will only be awaiting tasks that ultimately await the custom awaiter or other tasks that await the custom awaiter. That is, we won't be awating any "external" tasks.

An example would be something like this:

private static async Task Sleep(int ms)
{
  Stopwatch timer = Stopwatch.StartNew();
  do
  {
    await Coroutine.Yield();
  }
  while (timer.ElapsedMilliseconds < ms);
}

private static async Task Test()
{
    // Second resume
    await Sleep(1000);
    // Unknown how many resumes
}

private static async Task Main()
{
    // First resume
    await Coroutine.Yield();
    // Second resume
    await Test();
}

It all seems to work and it seems like the continuations to the tasks are indeed executed inline on the same thread. I just want to be sure that this behavior is consistent and that I can rely on it. I was able to check the reference source, and have found what I think is the place continuations are executed. The path to this function seems pretty complex though, and I cannot determine just what call exactly leads to this function being called (but I assume it is some compiler generated call).

Now, from this function, it seems like the continuation is not inlined if:

  • The current thread is aborting

This should not be a problem, as the current thread is willingly executing the coroutine, and we shouldn't be executing a coroutine if we're aborting.

  • IsValidLocationForInlining is false

This property is false if the current synchronization context is non default, or the current task scheduler is non default. As a precaution I am doing SynchronizationContext.SetSynchronizationContext(null) for the duration of the continuation, when resuming a coroutine. I will also be ensuring that the task scheduler is the default.

Now, my actual question is if I can rely on this behavior. Is this something that is likely to change in .NET versions? Would it be better to implement a custom synchronization context which ensured that all continuations were run by the coroutine?

Furthermore, I know the task libraries have changed a lot from .NET 4 to .NET 4.5. The reference source is for .NET 4.5, as far as I know, so I want to know if someone knows if this behavior has changed. I will be using the coroutines library on .NET 4.0 with Microsoft.Bcl.Async mainly, and it also seems to work fine here.

Was it helpful?

Solution

I'm trying to implement coroutines using async/await, and for that I want to ensure my coroutines are only executing on one thread (the thread that resumes them).

I think you can safely rely upon this behavior. This should be true as long as you do not use any of the following features:

  • Custom TPL task schedulers;
  • Custom synchronization contexts;
  • ConfiguredTaskAwaitable or Task.ConfigureAwait();
  • YieldAwaitable or Task.Yield();
  • Task.ContinueWith();
  • Anything which may lead to a thread switch, like an async I/O API, Task.Delay(), Task.Run(), Task.Factory.StartNew(), ThreadPool.QueueUserWorkItem() etc.

The only thing that you use here is TaskAwaiter, more about it below.

First of all, you should not be worried about a thread switch inside the task, where you do await Coroutine.Yield(). The code will be resumed exactly on the same thread where you explicitly call the continuation callback, you have complete control over this.

Secondly, the only Task object you have here is that generated by the state machine logic (specifically, by AsyncTaskMethodBuilder). This is the task returned by Test(). As mentioned above, a thread switch inside this task may not take place, unless you do it explicitly before calling the continuation callback via your custom awaiter.

So, your only remaining concerned is about a thread switch which may happen at the point where you're awaiting the result of the task returned by Test(). That's where TaskAwaiter comes into play.

The behavior of TaskAwaiter is undocumented. As far as I can tell from the Reference Sources, IsValidLocationForInlining is not observed for the TaskContinuation kind of continuation (created by TaskAwaiter). The present behavior is the following: the continuation will be not inlined if the current thread was aborted or if the current thread's synchronization context is different from that captured by TaskAwaiter.

If you don't want to rely upon this, you can create another custom awaiter to replace TaskAwaiter for your coroutine tasks. You could implement it using Task.ContinueWith( TaskContinuationOptions.ExecuteSynchronously), which behavior is unofficially documented by Stephen Toub in his "When "“ExecuteSynchronously” doesn't execute synchronously" blog post. To sum up, the ExecuteSynchronously continuation won't be inlined under the following severe conditions:

  • the current thread was aborted;
  • the current thread has stack overflows;
  • the target task scheduler rejects inlining (but you're not using custom task schedulers; the default one always promotes inlining where possible).
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top