This is the eleventh part of the Async Wandering series. For your convenience you can find other parts in the table of contents in Part 1 – Why creating Form from WinForms in unit tests breaks async?
We continue exploring async code.
Last time we saw how to use fibers to wait tasks. This effectively allows us to have async code without any function coloring.
We already know function coloring has big drawbacks and I also provided an example how to use monads to address that. However, monads were actually reverse-coloring everything, instead of having functions of two colors (one for sync and one for async) we had all functions of the same color with some monad type returned.
Today we are going to merge that approach. We’ll have reverse-colored functions with fibers. Let’s begin.
I start with the same glue code as the last time:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
public static ConcurrentDictionary readyToGo = new ConcurrentDictionary(); public static ConcurrentDictionary allJobs = new ConcurrentDictionary(); public static AsyncLoomCli.FiberHelper helper = new AsyncLoomCli.FiberHelper(); public static int current; public static bool done; public static int StartFiber(string arg) { int actionId = (int)arg[0]; allJobs[actionId](); if (actionId != 0) { MonadFiberAsync.done = true; MonadFiberAsync.helper.Switch(0); } return 0; } public static void Run() { helper.Convert(); allJobs.TryAdd(1, RunInternal); readyToGo.TryAdd(1, 0); helper.Create(1); allJobs.TryAdd(2, SideJob); readyToGo.TryAdd(2, 0); helper.Create(2); while (true) { done = false; var keys = readyToGo.Keys.GetEnumerator(); while (keys.MoveNext()) { current = keys.Current; helper.Switch(current); if (done) { helper.Delete(current); Action action; allJobs.TryRemove(current, out action); byte b; readyToGo.TryRemove(current, out b); } } if (allJobs.IsEmpty) { break; } Thread.Sleep(1); } } |
Next, I introduce monads:
1 2 3 4 5 6 |
public interface Monad { Monad<U> Map<U>(Func<T, Monad<U>> lambda); void Complete(T t); T Value(); } |
Super simple interface, not adhering to all monad laws. This is just for showing the idea, not a bullet proof implementation.
We go with this base class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public abstract class BaseMonad : Monad { public T Value() { T value = default(T); Map(t => { value = t; return (Monad)null; }); return value; } public abstract Monad<U> Map<U>(Func<T, Monad<U>> lambda); public abstract void Complete(T t); } |
We can see that Value
is a glue to just extract the value from the monad. It’s an unwrap operation.
Next, we have our identity monad:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class Id : BaseMonad { private T t; public override Monad<U> Map<U>(Func<T, Monad<U>> lambda) { lock (this) { while (t == null) { Monitor.Wait(this); } } return lambda(this.t); } public override void Complete(T t) { lock (this) { this.t = t; Monitor.PulseAll(this); } } } |
We have value, simple map operation waiting for the value to appear, and a callback to fill the monad. Notice how this is effectively a promise.
Now, async monad:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class Async : BaseMonad { private T t; private int current; public override Monad<U> Map<U>(Func<T, Monad<U>> lambda) { if (t == null) { this.current = MonadFiberAsync.current; byte b; MonadFiberAsync.readyToGo.TryRemove(this.current, out b); MonadFiberAsync.helper.Switch(0); } return lambda(this.t); } public override void Complete(T t) { this.t = t; MonadFiberAsync.readyToGo.TryAdd(this.current, 0); } } |
The only difference here is that we do the cooperative scheduling instead of just pausing the thread.
Now, we’d like to decide how to use these monads. For that we need to have builders which we’ll be able to replace in runtime (similar to synchronization context):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public abstract class Builder { public abstract Monad Build(); } public class IdBuilder : Builder { public override Monad Build() { return new Id(); } } public class AsyncBuilder : Builder { public override Monad Build() { return new Async(); } } |
No magic here. And the env:
1 2 3 4 5 |
public class ExecutionEnvironment { public Builder SyncBuilder; public Builder AsyncBuilder; } |
If you think about task builders in C# (which let you return any task type) then you’re right.
Okay, let’s go with some operation now. Again, I’ll have 2 jobs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private static void RunInternal() { WhereAmI("Before nesting"); var env = new ExecutionEnvironment { SyncBuilder = new IdBuilder(), AsyncBuilder = new AsyncBuilder() }; RunInternalNested(env); WhereAmI("After nesting"); } |
We create environment with builders and then continue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
private static void RunInternalNested(ExecutionEnvironment env) { var start = env.SyncBuilder.Build(); start.Complete(0); start.Map(i => { WhereAmI("Before creating task"); var delay = Delay(2000, env); WhereAmI("After creating delay"); return delay; }).Map(i => { WhereAmI("After sleeping"); var data = Data("Some string", env); WhereAmI("After creating data"); return data; }).Map(result => { WhereAmI($"After reading data {result}"); return env.SyncBuilder.Build(); }); } |
We glue some lambdas together thanks to mapping. Notice how we return delay from the first lambda and then call Map on it which makes the waiting. Also, notice how I pass environment explicitly. In some other language we could pass it via implicits, or we could utilize compiler to do the reverse-coloring for us (to avoid parameters and lambdas!).
Moving on:
1 2 3 4 5 6 7 |
private static Monad Delay(int timeout, ExecutionEnvironment env) { var a = env.AsyncBuilder.Build(); var timer = new Timer(_ => a.Complete(new object()), null, timeout, Timeout.Infinite); GC.KeepAlive(timer); return a; } |
Instead of blocking the thread with sleep, we create a timer which calls the callback and resolves the monad.
1 2 3 4 5 6 |
private static Monad Data(string d, ExecutionEnvironment env) { var monad = env.SyncBuilder.Build(); monad.Complete(d); return monad; } |
Here we just return the data.
And another side job:
1 2 3 4 |
private static void SideJob() { WhereAmI("Side job"); } |
Output:
1 2 3 4 5 6 7 8 9 10 |
Thread 1 Time 7/23/2020 10:56:48 PM: Start - MonadFiberAsync Thread 1 Time 7/23/2020 10:56:48 PM: Before nesting Thread 1 Time 7/23/2020 10:56:48 PM: Before creating task Thread 1 Time 7/23/2020 10:56:48 PM: After creating delay Thread 1 Time 7/23/2020 10:56:48 PM: Side job Thread 1 Time 7/23/2020 10:56:50 PM: After sleeping Thread 1 Time 7/23/2020 10:56:50 PM: After creating data Thread 1 Time 7/23/2020 10:56:50 PM: After reading data Some string Thread 1 Time 7/23/2020 10:56:50 PM: After nesting Thread 1 Time 7/23/2020 10:56:50 PM: End - MonadFiberAsync |
Okay, works like a charm. However, let’s now do some magic. Instead of using async builder for async methods lets do AsyncBuilder = new IdBuilder()
. Output:
1 2 3 4 5 6 7 8 9 10 |
Thread 1 Time 7/23/2020 10:57:23 PM: Start - MonadFiberAsync Thread 1 Time 7/23/2020 10:57:23 PM: Before nesting Thread 1 Time 7/23/2020 10:57:23 PM: Before creating task Thread 1 Time 7/23/2020 10:57:23 PM: After creating delay Thread 1 Time 7/23/2020 10:57:25 PM: After sleeping Thread 1 Time 7/23/2020 10:57:25 PM: After creating data Thread 1 Time 7/23/2020 10:57:25 PM: After reading data Some string Thread 1 Time 7/23/2020 10:57:25 PM: After nesting Thread 1 Time 7/23/2020 10:57:25 PM: Side job Thread 1 Time 7/23/2020 10:57:25 PM: End - MonadFiberAsync |
Notice that Side job
waited until the first job finished and wasn’t run in the middle. The thread was sleeping since we blocked it by using synchronous monad. One line of code and we disabled asynchronous code.
Is this approach better? In theory this has the best of two worlds, you can wait synchronously for async code, not block the thread, and change the logic any way you like. Allocation is much higher, but that’s obvious with this approach. Also, there is a lot of plumbing code so we’d probably need a clever compiler doing the magic behind the scenes but that could bring us back to the C# solution. Apart from different coloring, I pass environment explicitly but once I stop doing that (and it’s added by the compiler) then it isn’t much different from thread local variables for synchronization context.
However, the difference now is that nothing can escape the monad (well, at least when we have compiler checking that) so we can control how things behave deep down. If we better encapsulate the environment then it won’t be possible to change the async machinery by replacing some global state.
Ultimately these solutions may not necessarily differ that much.