I received quite a bit of feedback related to my sequential asynchronous workflows post. Most people asked if I could simplify the example even more and/or show the inner workings of the coroutine engine itself. Because the source for the library I demonstrated in the last post is not available, I've put together a very simple solution to help you better understand and take advantage of coroutines.
First, the fine print. This framework is simply to help understand the concept and provide a starting point for building out coroutines yourself. I know some companies simple don't allow third-party code and it always helps to learn a framework by starting at the ground floor. Some things you won't find here that would belong in production code include thread-awareness (background or UI thread?), timeouts (i.e. if I call out and it never returns, does my workflow die?) and exception management. All very important but reasons why this framework is educational and not a "production-ready" implementation.
Download the source for this post
Remember the original interface we defined for illustration purposes, the ICoroutine
interface? Here it is:
public interface ICoroutine { void Yield(); Action Yielded { get; set; } }
Now, to drive a workflow, you simply need an engine. Let's build a quick and dirty engine to drive what we want. Last post I showed how to use an existing framework. This time we'll do it a little differently. Instead of queuing asynchronous calls using the asynchronous process model, we'll drive the process with the coroutine interface itself. This comes with some caveats. What I'm going to show you will work, but a production solution will have to take it a step further and deal with things like execution threads (background vs. UI) etc (this is all handled as part of the AsyncEnumerator
class I showed you before).
So, here is our "bare bones" coroutine manager, and again, this is closer to Rob Eisenberg's implementation in his MIX session on building your own MVVM framework than the version I showed you last time, but I've taken it and dumbed it down as much as possible to make it easy to use and understand.
public class CoroutineManager { private readonly IEnumerator<ICoroutine> _enumerator; public CoroutineManager(IEnumerable<ICoroutine> workflow) { _enumerator = workflow.GetEnumerator(); } private void Yielded() { if(!_enumerator.MoveNext()) return; var next = _enumerator.Current; next.Yielded = Yielded; next.Yield(); } public static void Begin(object workflow) { if (workflow is ICoroutine) { workflow = new[] {workflow as ICoroutine}; } if (workflow is IEnumerable<ICoroutine>) { new CoroutineManager(workflow as IEnumerable<ICoroutine>).Yielded(); } } }
Not much to it, is there? The class has an entry point that can take a single coroutine or group, instantiate the class, then kick off the workflow. The workflow simply takes the next coroutine from the stack, wires in the yielded event to call back into the coroutine manager, and then executes it. That's it!
So how does it look? Let's make two helper classes: one that is generic and can handle any type of action, and another that is specific to our random number service.
Here's what the generic action coroutine looks like:
public class ActionCoroutine : ICoroutine { private readonly bool _immediate; public Action Execute { get; set; } public ActionCoroutine() { } public ActionCoroutine(bool immediate) { _immediate = immediate; } public ActionCoroutine(Action action) { _immediate = false; Execute = action; } public ActionCoroutine(Action action, bool immediate) { _immediate = immediate; Execute = action; } public void Yield() { Execute(); if (_immediate) { Yielded(); } } public Action Yielded { get; set; } }
Again, not much to it. I can either pass an action to trigger immediately, or pass an action and set immediate to false. If I set immediate to false, then I need to wire in something to call the Yielded
method (remember, our coroutine manager wires this up for us to re-enter the iterator state machine). I'll show you usage in a second. Finally, here is my random number service helper:
public class RandomResultCoroutine : ICoroutine { private readonly RandomNumberService _service; public RandomResultCoroutine(RandomNumberService service) { _service = service; } public int Max { get; set; } public int Result { get; set; } public void Yield() { _service.GetRandomNumber(Max, result => { Result = result; Yielded(); }); } public Action Yielded { get; set; } }
Notice how this service "wires itself." It has a max setting, calls the random number service, and tells the random number service to call back into a lambda expression. The expression sets the return result, then fires the Yielded
message to re-enter the state machine flow.
This is an example where we can make it work, but a more robust solution will have to handle the exceptions. For example, what if the service never calls my action? Then I'm in a bad state because the Yielded
will never get executed. That's why having timeouts and other checks and balances are important for a production-ready solution.
OK, we've set up our simple helpers and coroutine manager, let's see it in action. I'm just going to do everything in the code-behind for the main page to keep it simple. I'll set up three workflows. Two will generate shapes (circles and squares) and then feed the shapes to the third workflow which animates colors. This means we'll actually have dozens of workflows running simultaneously, but they will still fire sequentially within the workflow.
Take a look at our color workflow (it has as many iterations as seconds are in a day, just to keep it going for you to watch):
private IEnumerable<ICoroutine> ColorWorkflow(Shape element) { for (int x = 0; x < 60 * 60 * 24; x++) { var randomAction = new RandomResultCoroutine(_service) { Max = 128 }; yield return randomAction; int a = randomAction.Result + 128; randomAction.Max = 255; yield return randomAction; int r = randomAction.Result; yield return randomAction; int g = randomAction.Result; yield return randomAction; int b = randomAction.Result; var color = new Color {A = (byte) a, R = (byte) r, G = (byte) g, B = (byte) b}; var fromColor = color; var storyboard = new Storyboard(); if (element.Fill != null && element.Fill is SolidColorBrush) { fromColor = ((SolidColorBrush) element.Fill).Color; } var colorAnimation = new ColorAnimation {Duration = TimeSpan.FromSeconds(2), From = fromColor, To = color}; Storyboard.SetTarget(colorAnimation, element); Storyboard.SetTargetProperty(colorAnimation, new PropertyPath("(Shape.Fill).(SolidColorBrush.Color)")); var storyboardAction = new ActionCoroutine(storyboard.Begin, false); storyboard.Completed += (o, e) => { element.Fill = new SolidColorBrush(color); ((Storyboard) o).Stop(); storyboardAction.Yielded(); }; yield return storyboardAction; } yield break; }
Notice how straightforward it is. With our direct random number service helper, we can keep yield returning results. We will only go past the yield statement when the service call actually returns, and we can inspect the result because of the field we added to the helper, which is only set when the result is received and before the state machine is re-entered by calling yielded
.
What's nice about implementing the ICoroutine
interface is that all you have to do to repeat the call and get a new result is simply yield
the same helper class. The implementation ensures that the manager will call into it, block until a result is received, then continue execution with the new value available.
For the storyboard, we use the generic action coroutine. The begin action is set to kick off the storyboard and when it ends we set the new color and stop the storyboard. In this case I also wire in a call to yielded and set the "immediate" flag to false because we're depending on the story board completion to continue the workflow.
The square and circle workflows are exactly the same, so I'll just show the square one here (probably means they could be refactored to something simpler, too, but it works for this demonstration).
private IEnumerableSquareWorkflow() { var randomAction = new RandomResultCoroutine(_service) {Max = 20}; randomAction.Yield(); yield return randomAction; int iterations = randomAction.Result + 5; for (int x = 0; x < iterations; x++) { var rectangle = new Rectangle(); rectangle.SetValue(NameProperty, Guid.NewGuid().ToString()); randomAction.Max = 100; yield return randomAction; var size = randomAction.Result + 10; rectangle.Width = size; rectangle.Height = size; yield return randomAction; int left = randomAction.Result; yield return randomAction; int top = randomAction.Result; rectangle.Margin = new Thickness(left, top, 0, 0); var loadedSquare = new ActionCoroutine(() => LayoutRoot.Children.Add(rectangle), false); rectangle.Loaded += (o, e) => loadedSquare.Yielded(); yield return loadedSquare; CoroutineManager.Begin(ColorWorkflow(rectangle)); } yield break; }
This time we get a random number of squares and begin setting them up. We have a random size for the squares. Note we use the generic action coroutine to fire adding the square and loading it, and only when it is loaded do we kick off the color workflow to begin animating the colors on the square. This same workflow is repeated for circles.
You see how easy it is to kick off the routine? In fact, to kick off the main workflows, we simply do this:
public MainPage() { InitializeComponent(); Loaded += MainPage_Loaded; } void MainPage_Loaded(object sender, RoutedEventArgs e) { CoroutineManager.Begin(SquareWorkflow()); CoroutineManager.Begin(CircleWorkflow()); }
With those kicked off, even though we have two coroutines, you'll see they run asynchronously. While the circles and squares are sequentially added and animated (as opposed to popping in immediately as would happen if they were asynchronous within the workflow), they do so in parallel with each other (and once loaded, all of the color workflows continue to animate the individual elements but don't kick off a new color until the old storyboard is complete).
Here it is to play with. Due to a bug (can you find it?) the storyboard animation runs and completes but we only see the color change when it's done. That's OK because you can pick any shape on the surface and count 2 seconds and you'll notice the color changes every 2 seconds ... for every shape, proof that we have simultaneous sequential workflows all running asynchronously with each other. We also may see some CPU and memory issues over time because I'm not unhooking events.
Hopefully this helped simplify it a bit for those of you who were having trouble with the last post or wanted to see the innards of a framework so you can begin to build your own infrastructure.