Specify multiple workers that manage their own state and abstract out the stepper method and execute the work step using an enumerator.
Introduction
This article was inspired by honey the codewitch's article, What is a Coroutine, so if you're going to vote, I'd suggest you actually vote on her article!
What struck me about what codewitch presented was two things:
- The work is defined in the
IEnumerable
coroutine itself. - The implementation with the
yield
doesn't handle general purpose cooperative multitasking of multiple work items.
Abstraction, Iteration 1
I decided to look at how the concept could be abstracted to address the two issues above.
Separate the Work
My first iteration was an attempt to separate out the work. This required three changes:
- The worker state needs to be specified in its own container.
- The work itself is a separate method.
- The
Coroutine
method is changed to take a Func
, which it executes and returns the result.
To manage the state, we have a formal state class now:
public class UpDownCounterState
{
public bool IsDone => Value == 1 && CountDirection == Direction.Down;
public int StateValue => Value;
public int Value { get; set; } = 0;
public Direction CountDirection { get; set; } = Direction.Up;
}
And the worker method is defined separately (for brevity, I changed to this to count from 1
to 7
and back to 1
):
static (int ret, bool done) Counter(UpDownCounterState currentState)
{
currentState.Value += 1 * (int)currentState.CountDirection;
bool done = currentState.IsDone;
currentState.CountDirection = currentState.Value == 7 ?
Direction.Down : currentState.CountDirection;
return (currentState.Value, done);
}
Which yields the following implementation for the Coroutine:
static IEnumerable<R> Coroutine<R, Q>(Q state, Func<Q, (R ret, bool done)> fnc)
{
bool done = false;
while (!done)
{
var result = fnc(state);
yield return result.ret;
done = result.done;
}
}
Using this is straightforward - we pass in the initial state and worker method and let the Coroutine do the work of calling and returning the result of each step:
using (var cr = Coroutine(new UpDownCounterState(), Counter).GetEnumerator())
{
while (cr.MoveNext())
{
Console.Write(cr.Current + " ");
}
}
And we see:
1 2 3 4 5 6 7 6 5 4 3 2 1
Note that the code>Coroutine
has been "generified" so that the return can be whatever the worker method returns.
Multiple Workers
The above achieves a certain degree of abstraction and can be expanded to support multiple workers, let's say we want a multiplier as well. So we have a class to handle the multiplier state:
static (int ret, bool done) Multiplier(UpDownMultiplierState currentState)
{
currentState.StateValue += 1 * (int)currentState.CountDirection;
bool done = currentState.IsDone;
currentState.CountDirection = currentState.StateValue == 5 ?
Direction.Down : currentState.CountDirection;
return (currentState.StateValue * currentState.Multiplier, done);
}
And a Coroutine
implementation that takes a list of workers which loops until all the work is done:
static IEnumerable<R> Coroutiners<Q, R>(List<(Q state, Func<Q, (R ret, bool done)> fnc)> fncs)
{
List<bool> doners = Enumerable.Repeat(false, fncs.Count).ToList();
int n = 0;
while (!doners.All(done => done))
{
if (!doners[n])
{
var result = fncs[n].fnc(fncs[n].state);
doners[n] = result.done;
yield return result.ret;
}
n = (n == fncs.Count - 1) ? 0 : n + 1;
}
}
And we can initiate the cooperative coroutine workers like this:
using (var cr = Coroutiners(
new List<(IState, Func<IState, (int, bool)>)>()
{
(new UpDownCounterState(), Counter),
(new UpDownMultiplierState(), Multiplier)
}).GetEnumerator())
{
while (cr.MoveNext())
{
Console.Write(cr.Current + " ");
}
}
Given that the multiplier worker goes from 1 to 5 and back to 1 (and the counter goes from 1 to 7 and back to 1), we see:
1 10 2 20 3 30 4 40 5 50 6 40 7 30 6 20 5 10 4 3 2 1
which illustrates the cooperative "multitasking" of each worker.
Problems
There are three major problems with this implementation:
- All workers must return the same type.
- The abstraction
IState
is required and forces each worker to cast to the concrete state type. - The state and the worker is decoupled:
(new UpDownCounterState(), Counter)
such that the programmer could easily apply the wrong state to the worker function.
To illustrate #2, the state containers must be derived from IState
:
public interface IState { }
public class UpDownCounterState : IState
{
public bool IsDone => Value == 1 && CountDirection == Direction.Down;
public int StateValue => Value;
public int Value { get; set; } = 0;
public Direction CountDirection { get; set; } = Direction.Up;
}
public class UpDownMultiplierState : IState
{
public bool IsDone => Counter == 1 && CountDirection == Direction.Down;
public int Counter { get; set; } = 0;
public int Multiplier { get; set; } = 10;
public Direction CountDirection { get; set; } = Direction.Up;
}
To illustrate #3, the workers must cast IState
to the expected state container:
static (int ret, bool done) Counter(IState state)
{
UpDownCounterState currentState = state as UpDownCounterState;
currentState.Value += 1 * (int)currentState.CountDirection;
bool done = currentState.IsDone;
currentState.CountDirection = currentState.Value == 7 ?
Direction.Down : currentState.CountDirection;
return (currentState.Value, done);
}
static (int ret, bool done) Multiplier(IState state)
{
UpDownMultiplierState currentState = state as UpDownMultiplierState;
currentState.Counter += 1 * (int)currentState.CountDirection;
bool done = currentState.IsDone;
currentState.CountDirection = currentState.Counter == 5 ?
Direction.Down : currentState.CountDirection;
return (currentState.Counter * currentState.Multiplier, done);
}
Abstraction, Iteration 2
The solution to address the problems described above is that each worker must be abstracted out into its own container class which manages its own state:
public interface ICoroutine
{
bool IsDone { get; }
void Step();
}
And the worker containers look like this:
public class UpDownCounter : ICoroutine
{
public bool IsDone => State.IsDone;
protected UpDownCounterState State { get; set; }
public UpDownCounter()
{
State = new UpDownCounterState();
}
public void Step()
{
State.Value += 1 * (int)State.CountDirection;
State.CountDirection = State.Value == 7 ? Direction.Down : State.CountDirection;
}
public override string ToString()
{
return State.Value.ToString();
}
}
public class UpDownMultiplier : ICoroutine
{
public bool IsDone => State.IsDone;
protected UpDownMultiplierState State { get; set; }
public UpDownMultiplier()
{
State = new UpDownMultiplierState();
}
public void Step()
{
State.Counter += 1 * (int)State.CountDirection;
State.Value = State.Counter * State.Multiplier;
State.CountDirection = State.Counter == 5 ? Direction.Down : State.CountDirection;
}
public override string ToString()
{
return State.Value.ToString();
}
}
Note that the worker initializes its own state! Yes, the programmer can still mess that up, but it's less likely, in my opinion.
Next, the Coroutines
method signature is actually simpler:
static IEnumerable<Q> Coroutines<Q>(List<Q> fncs) where Q : ICoroutine
{
int n = 0;
while (!fncs.All(f => f.IsDone))
{
if (!fncs[n].IsDone)
{
fncs[n].Step();
yield return fncs[n];
}
n = n == (fncs.Count - 1) ? 0 : n + 1;
}
}
But look! We are no longer returning the value of the worker step, we are returning the worker itself! We've also eliminated the need for the IState
interface.
Its usage is easier to define:
using (var cr2 = Coroutines(new List<ICoroutine>()
{
new UpDownCounter(),
new UpDownMultiplier()
}).GetEnumerator())
{
while (cr2.MoveNext())
{
Console.Write(cr2.Current.ToString() + " ");
}
}
And again we see:
1 10 2 20 3 30 4 40 5 50 6 40 7 30 6 20 5 10 4 3 2 1
Here, ToString
is overridden in the worker so that it returns the worker's current step value, but it should be pointed out that most likely, workers will just do something and we don't care about their internal state, so we can write the cooperative multitasking work simply as:
foreach (var _ in Coroutines(new List<ICoroutine>()
{
new UpDownCounter(),
new UpDownMultiplier()
}));
This is an unusual syntax as the foreach
doesn't have a body! One would hope that the compiler doesn't optimize this into a "this loop does nothing" and throws out the code!
Conclusion
Inspired by codewitch, we've abstracted the concept of coroutines to support multiple workers and in the process solved a variety of problems. Some ideas: the code here can now be extended to:
- Handle exceptions that a worker might throw without necessarily disrupting the other workers.
- Implement a "stop all" option that any worker can set.
- Include a worker that simply has a "stop all" flag that could be set asynchronously.
Have fun!
History
- 25th March, 2020: Initial version