Updated, a follow-up blog post: Asynchronous coroutines with C# 8.0 and IAsyncEnumerable.
I use C# iterators as a replacement for coroutines, and it has been working great. I want to switch to async/await as I think the syntax is cleaner and it gives me type safety...
IMO, it's a very interesting question, although it took me awhile to fully understand it. Perhaps, you didn't provide enough sample code to illustrate the concept. A complete app would help, so I'll try to fill this gap first. The following code illustrates the usage pattern as I understood it, please correct me if I'm wrong:
using System;
using System.Collections;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApplication
{
// https://stackoverflow.com/q/22852251/1768303
public class Program
{
class Resource : IDisposable
{
public void Dispose()
{
Console.WriteLine("Resource.Dispose");
}
~Resource()
{
Console.WriteLine("~Resource");
}
}
private IEnumerator Sleep(int milliseconds)
{
using (var resource = new Resource())
{
Stopwatch timer = Stopwatch.StartNew();
do
{
yield return null;
}
while (timer.ElapsedMilliseconds < milliseconds);
}
}
void EnumeratorTest()
{
var enumerator = Sleep(100);
enumerator.MoveNext();
Thread.Sleep(500);
//while (e.MoveNext());
((IDisposable)enumerator).Dispose();
}
public static void Main(string[] args)
{
new Program().EnumeratorTest();
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
GC.WaitForPendingFinalizers();
Console.ReadLine();
}
}
}
Here, Resource.Dispose
gets called because of ((IDisposable)enumerator).Dispose()
. If we don't call enumerator.Dispose()
, then we'll have to uncomment //while (e.MoveNext());
and let the iterator finish gracefully, for proper unwinding.
Now, I think the best way to implement this with async/await
is to use a custom awaiter:
using System;
using System.Collections;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApplication
{
// https://stackoverflow.com/q/22852251/1768303
public class Program
{
class Resource : IDisposable
{
public void Dispose()
{
Console.WriteLine("Resource.Dispose");
}
~Resource()
{
Console.WriteLine("~Resource");
}
}
async Task SleepAsync(int milliseconds, Awaiter awaiter)
{
using (var resource = new Resource())
{
Stopwatch timer = Stopwatch.StartNew();
do
{
await awaiter;
}
while (timer.ElapsedMilliseconds < milliseconds);
}
Console.WriteLine("Exit SleepAsync");
}
void AwaiterTest()
{
var awaiter = new Awaiter();
var task = SleepAsync(100, awaiter);
awaiter.MoveNext();
Thread.Sleep(500);
//while (awaiter.MoveNext()) ;
awaiter.Dispose();
task.Dispose();
}
public static void Main(string[] args)
{
new Program().AwaiterTest();
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
GC.WaitForPendingFinalizers();
Console.ReadLine();
}
// custom awaiter
public class Awaiter :
System.Runtime.CompilerServices.INotifyCompletion,
IDisposable
{
Action _continuation;
readonly CancellationTokenSource _cts = new CancellationTokenSource();
public Awaiter()
{
Console.WriteLine("Awaiter()");
}
~Awaiter()
{
Console.WriteLine("~Awaiter()");
}
public void Cancel()
{
_cts.Cancel();
}
// let the client observe cancellation
public CancellationToken Token { get { return _cts.Token; } }
// resume after await, called upon external event
public bool MoveNext()
{
if (_continuation == null)
return false;
var continuation = _continuation;
_continuation = null;
continuation();
return _continuation != null;
}
// custom Awaiter methods
public Awaiter GetAwaiter()
{
return this;
}
public bool IsCompleted
{
get { return false; }
}
public void GetResult()
{
this.Token.ThrowIfCancellationRequested();
}
// INotifyCompletion
public void OnCompleted(Action continuation)
{
_continuation = continuation;
}
// IDispose
public void Dispose()
{
Console.WriteLine("Awaiter.Dispose()");
if (_continuation != null)
{
Cancel();
MoveNext();
}
}
}
}
}
When it's time to unwind, I request the cancellation inside Awaiter.Dispose
and drive the state machine to the next step (if there's a pending continuation). This leads to observing the cancellation inside Awaiter.GetResult
(which is called by the compiler-generated code). That throws TaskCanceledException
and further unwinds the using
statement. So, the Resource
gets properly disposed of. Finally, the task transitions to the cancelled state (task.IsCancelled == true
).
IMO, this is a more simple and direct approach than installing a custom synchronization context on the current thread. It can be easily adapted for multithreading (some more details here).
This should indeed give you more freedom than with IEnumerator
/yield
. You could use try/catch
inside your coroutine logic, and you can observe exceptions, cancellation and the result directly via the Task
object.
Updated, AFAIK there is no analogy for the iterator's generated IDispose
, when it comes to async
state machine. You really have to drive the state machine to an end when you want to cancel/unwind it. If you want to account for some negligent use of try/catch
preventing the cancellation, I think the best you could do is to check if _continuation
is non-null inside Awaiter.Cancel
(after MoveNext
) and throw a fatal exception out-of-the-band (using a helper async void
method).