
I have long wanted to study “TPL” (Task Parallel Library) and “DLR” (Dynamic Languages Runtime). For this, I needed a specific and, preferably, quite relevant task. In
one of my translations it was told about the so-called "game cycles". The topic reviewed there is quite interesting for me in itself, and besides, the TPL + DLR combination is suitable for that task in the best way possible, in my opinion. So I came to the idea of implementing a lightweight asynchronous script engine, which could be relatively easily screwed to different applications (including games). I decided to implement the engine core in C #. The choice between dynamic languages in my case did not even stand. I have chosen Ruby for this purpose for a long time. For a while, I hatched an idea, from time to time thinking about it at my leisure.
Formulation of the problem
So, I want to have the opportunity to run asynchronously or on some specific stream several scripts. To do this, I will need a service that will be responsible for the following tasks:
- storing references to running tasks with scripts
- request for stopping a specific script task
- task status tracking
- logging output to the console (Stdout and Stderr)
- notification of text output to the console by a broadcast event
In general, something like this

What is there depicted? The root node, "
IRE.Instance ", is a singleton that will serve as the service responsible for coordinating the work of the scripts. For this, a singleton instance will store a Dictionary with entries for each task added via the "
RunScriptWithScheduler " and "
RunScriptAsync "
functions . As the name suggests, these functions will be different by the scheduler, under which the task will be launched. With the help of “RunScriptWithScheduler” you can run a task, for example, on a GUI thread. The methods "
WriteMessage " and "
WriteError " will be accessible from everywhere (including from scripts) and are intended for outputting messages to the log. On the side of the scripts, it will be possible to override the standard output methods in the console to redirect the text to the service log.
What will be the entry in the dictionary of service tasks? The key will be a unique GUID that will identify the running script. This GUID can be easily transferred to both the party requesting the launch of the script and embedded in the context of the script itself. The value of the record should at least keep references to
CancelationToukenSource and
Task .
CancelingToukenSource , with which the
Task script is created, will allow at any time to signal the task that it is necessary to round out. And the link to Task itself will allow us to hang a Continuation on it (I will not describe TPL in detail here).
IRE service
Perhaps I'll start with a description of the engine core, namely the service, made in the form of a singleton. Singleton implementation taken from
MSDN . There is also a link to a rather detailed analysis of different implementation of the singleton on the YaP JAVA. I highly recommend reading if someone has not read it yet.
So, I implemented the service in the form of a multithreaded "Double-Checked Locking" singleton. The base implementation code would be something like this:
Singleton code commentedusing System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using IronRuby; using Microsoft.Scripting; using Microsoft.Scripting.Hosting; using System.IO; using System.Threading.Tasks; using System.Diagnostics; using Microsoft.Scripting.Runtime; namespace IREngine { public sealed class IRE { #region Singleton private static volatile IRE _instance; private static readonly object SyncRoot = new Object(); private IRE() { // // IRE.Instance // , - ( , ) // var instance = IRE.Instance; } public static IRE Instance { get { if (_instance == null) { lock (SyncRoot) { if (_instance == null) _instance = new IRE(); } } return _instance; } } #endregion #region Consts // #endregion #region Fields // #endregion #region Properties // #endregion #region Private Methods // #endregion #region Public Methods // #endregion } }
I think the commentary structure is clear. There is nothing special about the code. I will give only a couple of comments on the essence of a singleton. Instead of a singleton, you could use just a static helper. But the life cycle of a singleton is easier to control. For example, the moment a static constructor is called is nondetermined, and the call to a non-static constructor will occur only at the first call to the instance. Thanks to this, if before starting to work with a singleton in the future we need to initialize something, we will be able to feel relatively safe. Another interesting thing is double null checking. Two threads can be inside the first condition at the same time, but “lock” will be executed only on one of them. The second will wait for the first thread to exit the critical section. After releasing the critical section, the second thread should check the instance again for "null". Thus, we will not allow the situation of creating two instances of a singleton in parallel with running threads.
')
Briefly about using TPL
Now briefly describe the basics of TPL. You can read more on
CodeProject .
The first asynchronous tasks that we create will monitor console output buffers. As I wrote at the beginning, one and the functions of the service will be logging the output of messages and errors in the console. So what do we need?
- Action checks for changes in the Output buffer
- Error Handling Action for Checking the Output Buffer
- Action checks for changes to the Error Buffer
- Error Error Handling Action
- The public method that will create Task instances for performing actions
Let's place the initialization of the Actions in the private constructor of the singleton
Action initialization private IRE() { // Ruby _outStringBuilder = new StringBuilder(); _errStringBuilder = new StringBuilder(); _outWatchAction = () => { int i = 0; while (IsConsoleOutputWatchingEnabled) { string msg = string.Format("***\t_outWatchTask >> tick ({0})\t***", i++); Debug.WriteLine(msg); WriteMessage(msg); Task.Factory.CancellationToken.ThrowIfCancellationRequested(); int currentLength = OutputBuilder.Length; if (OutputUpdated != null && currentLength != _lastOutSize) { OutputUpdated.Invoke(_outWatchTask, new StringEventArgs(OutputBuilder. ToString(_lastOutSize, currentLength - _lastOutSize))); _lastOutSize = currentLength; } Thread.Sleep(TIME_BETWEEN_CONSOLE_OUTPUT_UPDATES); } }; _outWatchExcHandler = (t) => { if (t.Exception == null) return; Instance.WriteError( string.Format( "!!!\tException raised in Output Watch Task\t!!!\n{0}", t.Exception.InnerException.Message)); }; _errWatchAction = () => { int i = 0; while (IsConsoleErrorWatchingEnabled) { string msg = string.Format("***\t_errWatchTask >> tick ({0})\t***", i++); Debug.WriteLine(msg); WriteError(msg); Task.Factory.CancellationToken.ThrowIfCancellationRequested(); int currentLength = ErrorBuilder.Length; if (ErrorUpdated != null && currentLength != _lastErrSize) { ErrorUpdated.Invoke(_errWatchTask, new StringEventArgs(ErrorBuilder. ToString(_lastErrSize, currentLength - _lastErrSize))); _lastErrSize = currentLength; } Thread.Sleep(TIME_BETWEEN_CONSOLE_OUTPUT_UPDATES); } }; _errWatchExcHandler = (t) => { if (t.Exception == null) return; Instance.WriteError( string.Format( "!!!\tException raised in Error Watch Task\t!!!{0}", t.Exception.InnerException.Message)); }; }
Below are the ads of the desired properties, constants and private fields.
Fields, properties, constants initialization #region Consts public readonly int TIME_BETWEEN_CONSOLE_OUTPUT_UPDATES = 1000; #endregion #region Fields private bool _outWatchEnabled; private bool _errWatchEnabled; private Task _outWatchTask; private readonly CancellationTokenSource _outWatchTaskToken = new CancellationTokenSource(); private int _lastOutSize = 0; private Task _errWatchTask; private readonly CancellationTokenSource _errWatchTaskToken = new CancellationTokenSource(); private int _lastErrSize = 0; private readonly StringBuilder _outStringBuilder; private readonly StringBuilder _errStringBuilder; private readonly Action _outWatchAction; private readonly Action<Task> _outWatchExcHandler; private readonly Action _errWatchAction; private readonly Action<Task> _errWatchExcHandler; private readonly CancellationTokenSource _scriptsToken = new CancellationTokenSource(); #endregion #region Properties public StringBuilder OutputBuilder { get { lock (SyncRoot) { return _outStringBuilder; } } } public StringBuilder ErrorBuilder { get { lock (SyncRoot) { return _errStringBuilder; } } } public bool IsConsoleOutputWatchingEnabled { get { lock (SyncRoot) { return _outWatchEnabled; } } set { lock (SyncRoot) { _outWatchEnabled = value; } } } public bool IsConsoleErrorWatchingEnabled { get { lock (SyncRoot) { return _errWatchEnabled; } } set { lock (SyncRoot) { _errWatchEnabled = value; } } } public event EventHandler<StringEventArgs> OutputUpdated; public event EventHandler<StringEventArgs> ErrorUpdated; #endregion
What does this code do? The lengths of the StringBuilders are compared periodically, and if the lengths have changed, the corresponding events are jerking. In the event handlers (on the GUI side), the added message text is displayed on the screen. It should be noted that on the side of the graphical interface, the code in the handler will also be executed inside Task, but with an explicit indication for the scheduler task received from the graphical interface. Because an event is jerked in an asynchronous task executed NOT in a GUI thread, then the code in the handler will not have direct access to the interface elements. We have to create an Action and run it on the interface stream, explicitly specifying the Scheduler of the main application window. Below is the event handler code on the GUI side.
Event handler code IRE.Instance.OutputUpdated += (s, args) => { string msg = string.Format("***\tIRE >> Out Updated callback\t***\nResult: {0}\n***\tEND\t***\n",args.Data); Debug.WriteLine(msg); var uiUpdateTask = Task.Factory.StartNew(() => { OutputLogList.Items.Add(msg); }, Task.Factory.CancellationToken, TaskCreationOptions.None, _uiScheduler); uiUpdateTask.Wait(); }; IRE.Instance.ErrorUpdated += (s, args) => { string msg = string.Format("!!!\tIRE >> Err Updated callback\t!!!\nResult: {0}\n!!!\tEND\t!!!\n", args.Data); Debug.WriteLine(msg); var uiUpdateTask = Task.Factory.StartNew(() => { ErrorLogList.Items.Add(msg); }, Task.Factory.CancellationToken, TaskCreationOptions.None, _uiScheduler); uiUpdateTask.Wait(); }; IRE.Instance.StartWatching();
This code adds a subscription to the console output buffer update event. The main thing you should pay attention to in it is "
_uiScheduler ". This is a link created in the constructor of the main window to its (windows) scheduler. This link is created as follows.
public MainWindow() { InitializeComponent(); _uiScheduler = TaskScheduler.FromCurrentSynchronizationContext(); }
All Task'i created with the indication of this scheduler will be running on the flow of the graphical interface, regardless of which thread they instantiated. Closures on graphical elements of the interface will not cause exceptions of cross-flow access.
Now about the line "
uiUpdateTask.Wait (); ". It is desirable to frame this line in "
try - catch ". All exceptions that occur within a Task are not immediately transferred to the thread that created the task. One way to get access to exceptions in the calling thread is to call the “Wait ()” function. It is also possible to add “Continuation” to the TASK and already in it to poke the exceptions. It does not matter how, but you
must handle exceptions. Otherwise, exceptions will be passed to the calling thread when the GarbageCollector gets to the task. At what point it happens - is unknown. Therefore, for the application it can be fatal.
In this case, for simplicity, I did not add "
try-catch ", but I would probably add it later. Now the code here is simple enough, and in the future everything can change.
Now it remains to give the code for creating tasks for monitoring console output.
public void StartWatching() { StopWatching(); if (_outWatchTask != null) _outWatchTask.Wait(); if (_errWatchTask != null) _errWatchTask.Wait(); IsConsoleOutputWatchingEnabled = IsConsoleErrorWatchingEnabled = true; _outWatchTask = Task.Factory.StartNew(_outWatchAction, _outWatchTaskToken.Token); _outWatchTask.ContinueWith(_outWatchExcHandler, TaskContinuationOptions.OnlyOnFaulted); _errWatchTask = Task.Factory.StartNew(_errWatchAction, _errWatchTaskToken.Token); _errWatchTask.ContinueWith(_errWatchExcHandler, TaskContinuationOptions.OnlyOnFaulted); }
Everything is simple here too. Just in case, we stop the tracking tasks ("
StopWatching (); "). Calling this function simply sets the flags "
IsConsoleOutputWatchingEnabled " and "
IsConsoleErrorWatchingEnabled " to
false , and also stops the
request through "
CancelingToken ". I could confine myself to a token. But in general, a request to stop through a token is considered an emergency stop. There is even a special function "
Task.Factory.CancellationToken.ThrowIfCancellationRequested (); ", the call of which causes the task to throw an exception if during the execution of the task a cancellation request is received through a token. Here it is worth making a reservation that calling this function and generally checking the status of the CancelationToken is quite an expensive procedure. Therefore, it is desirable to do it as rarely as possible.
The next thing I want to draw attention to is the construction of the form "
_outWatchTask.ContinueWith (_outWatchExcHandler, TaskContinuationOptions.OnlyOnFaulted); ". This code puts a so-called “continuation” on the task (Continuation). And the continuation is created with the option "
TaskContinuationOptions.OnlyOnFaulted ". Such a continuation will be called only in the case when the “AggregatedException” of the task contains at least one exception. Simply put, if the task is completed normally, then this continuation will be ignored. It should also be noted that there may be several exceptions. After all, we can create nested sub-tasks within this task. When nested tasks are collected by "
GC ", all unhandled exceptions will pop up to the parent in the form of the same "AggregatedException". Thus, you can get a whole tree of nested exceptions. To turn this exception tree into a flat list, there is a special method "
AggregatedException.Flatten () ".
Script Embedding Basics
Now let's talk about the scripting itself. First we need to create a “ScriptEngine” and load the necessary .Net assemblies of our application into it. This is done simply:
_defaultEngine = Ruby.CreateEngine((setup) => { setup.ExceptionDetail = true; }); _defaultEngine.Runtime.LoadAssembly(typeof(IRE).Assembly);
I added this code to the beginning of the private singleton constructor. The first part of the code, in fact, creates an instance of “ScriptEngine”. The lambda transmitted as a parameter allows you to configure the engine. It is not necessary to do this. But using this approach you can prevent ScriptEngine from compiling Ruby code so that the scripts are interpreted each time. This is useful, for example, on the WindowsPhone 7 platform, since save memory. I just included a detailed display of information about exceptions.
The second part simply loads the build with our service into the Ruby runtime engine. Without this, we will not be able to communicate with our application from scripts. In the same way you can download other necessary assemblies. It is desirable to generally take out this loading of assemblies in a separate method, so that it is easier to add new assemblies and at the same time the sheet of the same type of code is not messed with eyes.
It remains to provide the function code for adding an asynchronous task of the script and several service methods.
Add asynchronous task script script public Guid RunScriptAsync(string code) { var scriptScope = _defaultEngine.CreateScope(); CompiledCode compiledCode = null; try { ScriptSource scriptSource = _defaultEngine.CreateScriptSourceFromString(code, SourceCodeKind.AutoDetect); var errListner = new ErrorSinkProxyListener(ErrorSink.Default); compiledCode = scriptSource.Compile(errListner); } catch (Exception ex) { WriteError(ex.Message); return Guid.Empty; } var action = new Action(() => compiledCode.Execute(scriptScope)); var tokenSource = new CancellationTokenSource(); var task = new Task(action, tokenSource.Token, TaskCreationOptions.LongRunning); var guid = Guid.NewGuid(); AddTask(guid, new TaskRecord { TokenSource = tokenSource, Task = task }); task.Start(); task.ContinueWith((t) => {
What do we have here? First create a ScriptScope. We will use the default Ruby engine, but each script will have its own ScriptScope. The scope of all variables and script execution results will be limited to these scopes.
Then ScriptSource and CompiledCode are created inside “try-catch”. “Try - catch” is needed because when creating a compiled code, exceptions may occur. In this case, we return an empty Guid, and the caller must adequately handle this situation. If the script compiles normally, then you can start creating the task of executing the script.
So, we create an Action that executes the script, we create a CancelationTokenSource, a new Task with the “TaskCreationOptions.LongRunning” option (after all, the script will potentially run for a long time). Then we create a new Guid for the task and, finally, we pack it all into a new entry in the Dictionary of scripting tasks. As I planned at the beginning.
After creation, we run the task and hang on it with the continuation of exception handling. In the continuation of the task, we produce a “flattening” AggregatedException and process all exceptions (
return true; ), having previously written an error message to the log.
In addition to the continuation indicated above, we hang another one that will be called upon any completion of the task. In this continuation, we delete the spent Task from the dictionary, since it is no longer useful to us.
With service functions like everything is simple.
Usage example
To demonstrate the operation of this scripting framework, I created a window application project. The window layout is simple.
<Window x:Class="IREngineTestBed.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="350" Width="525"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition /> <ColumnDefinition /> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition /> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <Button x:Name="StartButton" Content="Start" Grid.Row="1" Margin="5" Height="25" Click="StartButton_Click"/> <Button x:Name="StopButton" Content="Stop" Grid.Row="1" Grid.Column="1" Margin="5" Height="25" Click="StopButton_Click"/> <ListBox x:Name="OutputLogList" Grid.Column="0" Margin="5"/> <ListBox x:Name="ErrorLogList" Grid.Column="1" Margin="5"/> </Grid> </Window>
Conclusion
The window contains two ListBoxes and two buttons. I did not use TextBlock to display logs because of the poor performance of adding lines (TextBlock.Text + = "some string" - there is a great evil, as you probably know). But ListBoxes are actually a bad idea. Just because the text is not copied. In general, ListBox'y just for the demonstration put (in haste). In the future I will replace, probably, with RichTextBox. In them, string virtualization seems to be there, and the text, it seems, is appended to StringBuilder.
So, the “StartButton” button performs (re) the start of tasks for monitoring the console output buffers and the script execution task. Button "StopButton" - all stops.
Here is the test script code.
In this code, helper methods for outputting messages to the log are added to the service class. Moreover, additional methods are added to the instance singleton. But if I put the prefix “self.” Or “IRE” in front of the method names, they would be created as static methods. Probably later I will do it (it will be necessary to separately protest this case).
In the body of the script, we see a fivefold line output to the log and forwarding exceptions. This exception will be handled correctly on the service side. This is what the interface looks like after running the script

Conclusion
In this article, I presented a basic implementation of the engine that allows you to run several scripts asynchronously at the same time. At the time of this writing, not all our plans have been implemented. For example, you still need to check the correctness of handling the situation of syntax errors in scripts. Also, the method of launching the script with a specific scheduler is not implemented yet.
You can follow the development of code in the repository on
github . I would be glad if someone takes part in the development of the project.
Thanks for attention!