-
Notifications
You must be signed in to change notification settings - Fork 0
AboutTasks
Javascript code is made of statements (and expressions). One key characteristic of the language is the fact that all these statements are "non blocking". This means that a statement cannot "block". It is executed with no delay, it cannot "wait" for something to happen.
As a result there is only one "thread" of execution and any activity that cannot complete immediately needs to register code to execute later when some "event" occurs. This single thread runs a tight loop that consumes events and run code registered to handle them. This is "the event loop".
while( true ){
event = get_next_event();
dispatch( event);
}
Code that is executed when an event happens is often named "callback". This is because it is the "event loop" (though "dispatch()") that "calls back" that code.
function process_mouse_over(){
obj.onmouseover = call_me_back;
function call_me_back(){
// called when mouse runs over that obj
}
}
That event_loop/callback style is simple and efficient. However, it has some notorious drawbacks. Things can get fairly complex to handle when some activity involves many sub-activities that must be run in some specific order.
Multiple solutions exist to care with such cases. The most basic one is to start a new activity from within the callback that gets called when the previous activity is completed.
ajax_get_user( name, function user_found( user ){
ajax_check_credential( user, function credential_checked( is_ok ){
if( is_ok ){
ajax_do_action( user, "delete", function delete_result( err ){
if( err ) signal( err );
}
}
}
}
This code is not very readable because of the "nesting" of the different parts that obscures it. That is the so called "Callback Hell" issue.
ajax_get_user( name, user_found );
function user_found( user ){
ajax_check_credential( user, credential_checked );
}
function credential_checked( is_ok ){
if( !is_ok )return;
ajax_do_action( user, "delete", delete_result );
}
function delete_result( err ){
if( err ) signal( err );
}
This slightly different style is barely more readable. What would be readable is something like this:
var user = ajax_get_user( name );
if( !ajax_check_credential( user ) ) return;
if( err = ajax_do_action( user, "delete" ) ) signal( err );
However, this cannot exist in javascript because no function can "block". The function "ajax_get_user()" cannot "block" until it receives an answer.
This is where l8 helps.
Steps are to Tasks what statements are to functions: a way to describe what they do.
var user;
l8.step( function( ){ ajax_get_user( name );
}).step( function( result ){ ajax_check_credentials( user = result );
}).step( function( result ){ if( !result ) l8.return();
ajax_do_action( user, "delete" );
}).step( function( result ){ if( result ) signal( result ); })
This is be less verbose with CoffeeScript:
user = null
@step -> ajax_get_user name
@step (r) -> ajax_check_credentials (user = r)
@step (r) -> if !r then @return() else
ajax_do_action user, "delete"
@step (r) -> if err = r then signal err
By "breaking" a function into multiple "steps", code become almost as readable as it would be if statements in javascript could block, minus the "step" noise.
This example is a fairly simple one. Execution goes from step to step in a sequential way. Sometimes the flow of control is much more sophisticated. There can be multiple "threads" of control, with actions initiated concurrently and various styles of collaboration between these actions.
Please note that the ajax_xxx() functions of the example are not regular functions, they are "task constructors". When you invoke such a function, a new task is created.
If they were usual ajax_xxx( p1, p2, cb) style of functions, one would need to use l8.walk or l8.proceed() as the callback in order to ask l8 to move to the next step:
var user = null, err
l8.step( function(){
ajax_get_user_async( name, this.walk );
}).step( function( r ){
ajax_check_credential_async( (user = r), this.walk );
}).step( function( r ){
if( !r ) this->return();
ajax_do_action_async( user, "delete", this.walk );
}).step( function( r ){
if( err = r ) signal( err );
})
Tasks execute queued steps. A Task is a l8 object that consolidates the result of multiple threads of control (aka sub-tasks) that all participate in the completion of a task.
Tasks are to steps what functions are to statements: a way to group them.
To perform a task, the simplest way is to invoke a "task constructor". It will schedule the new task and return a Task object. Such an object is also a "Promise". This means that it is fairly easy to get notified of the task's completion, either it's success or it's failure.
var new_task = do_something_task();
new_task.then( on_success, on_failure );
function on_success( result ){ ... }
function on_failure( reason ){ ... }
A "task constructor" is to a "task" what a "function" is to a "function call": both define (statically) what happens when they are invoked (dynamically).
Tasks queue steps that the l8 scheduler will execute much like function calls queue statements that the Javascript interpretor executes. With functions, statements queueing is implicit. With tasks, it becomes explicit. As a result, defining what a task does is of course less syntaxically easy at first.
do_something_task = l8.Task( do_something_as_task );
function do_something_as_task(){
l8.step( function(){
this.sleep( 1000);
}).fork( function(){
do_some_other_task();
}).fork( function(){
do_another_task();
}).step( function(){
...
})
}
This is the "procedural" style. A "declarative" style is also available where what is usually a function can be a list of steps:
do_something_task = l8.Task(
function(){ this.sleep( 1000) },
{fork: function(){ do_some_other_task() }},
{fork: function(){ do_another_task() }},
[
{step: function(){...}},
{failure: function(){...}}
],
{repeat:[
function(){ do_something },
function(r){ if( !r ) this.break }
{failure: function(){ ... }}
]}
{success: function(){ ... }},
{final: function(){ .... }}
)
There is also a transpiler option that takes a funny looking function and turns it into a task constructor. It's compact but you lose the ability to set break-points in a debugger.
do_something_task = l8.compile( do_something_as_task );
function do_something_as_task(){
step; this.sleep( 1000 );
fork; do_some_other_task_xx();
fork; another_task_xx();
step( a, b ); use_xx( a ); use_xx( b );
begin
...
step; ...
failure; ...
end
repeat; begin
...
step; act_xx()
step( r ); if( !r ) this.break
end
success( r ); done_xx( r );
failure( e ); problem_xx( e );
final( r, e); always_xx();
}
Note that when do_something_task() is called, it does not do the actual work, it only registers steps in que task's queue. These steps, and steps later added to the task, are executed later, in the appropriate order, until the task is fully done. It is then, and only then, that the on_success/on_failure callbacks of the task's promise will be called.
In a function, statements are executed in a purely sequentiel order. That restriction does not apply with steps in a task. While the sequential order is still the "normal", steps that run in parallel paths can also exist. Such steps can be the result of "forks". When all forks are done, the forks "join" and execution continues with the next normal step. When using a generator, the steps of the producer and those of the consumer are executed alternatively when .yield() and .next() are called to handle a new generated result.
Execution goes "step by step" until task completion. Steps to execute are queued. To queue a new step to execute after the currently executing step, use .step(). Such steps are run once the current step is completed, FIFO order.
The output of a step is the input of the next one. The output of forked steps are concatenated and the array becomes the input of the next step.
To insert a new step on a new parallel task/path, use l8.fork(). Such tasks block the current task until they are completed. When multiple such forked tasks are created, the next non forked step will execute when all the forked steps are done. The result of such multiple steps is accumulated into an array that will be the parameter of the next non forked step. This is a "join". When only one forked step is inserted, this is similar to calling a function, ie the next step receives the result of the task that ran the forked step. There is a shortcut for that special frequent case: l8.task().
var rrr = l8.Task( function( max ){
var delay = Math.floor( max * Math.random() );
l8.step( function(){
l8.delay( delay );
}).step( function(){
console.log( "hi!" );
return delay;
})
})
l8.task( function(){
l8.fork( function(){
rrr( 100 );
}).fork( function(){
rrr( 200 );
}).step( function( delays ){
delays.forEach( function( d ){
console.log( "hi was issued after " + d + " ms." );
})
})
})
To queue a step that won't block the current step, use spawn() instead. Such steps are also run in a new task but the current step is not blocked until the new task is completed. If the parent task terminates before the spawn tasks are completed, the spawn tasks are re-attached to the parent task of the task that created them, ie. spawn tasks are "inherited" by the parent of their creator (Unix processes are similar).
Note that is it possible to cancel tasks and/or their sub tasks. That cancel action can be either "gentle" (using .stop() & .stopping) or "brutal" using .cancel().
var task = l8.task( function(){
l8.repeat( function(){
l8.step( function(){ console.log( "hi" );
}).step( function(){ l8.delay( 1000 ); })
}).failure( function( err ){ console.log( "failure", err ); })
})
l8.task( function(){
l8.step( function(){ l8.sleep( 10 * 1000 );
}).step( function(){ task.cancel() })
})
a_task.return( x) also cancels a task (and it's sub tasks) but provides the result of that task (whereas .cancel() makes the task fail).
.final( block) provides a solution that mimics the finally clause of the "try" javascript construct. The final block typically performs "clean up" work associated with the task or subtask it is attached too.
var file
l8
.step( function(){ file = file_open( file_name) })
.step( function(){ xxxx work with that file })
.final( function(){ file_close( file) })
There is only one final clause per task. That clause is attached to the task when the .final() method is executed. When multiple clauses are needed, one needs to create nested tasks. The final block is executed once the task is done. As a result additional steps are attached to the "parent" task, not to the current task (this may change in a future version).
var file1
var file2
l8
.step( function(){ file2 = file_open( file1_name) })
.step( function(){ xxxx work with file1 xxx })
.step( function(){
if( some_thing ){ l8.begin
.step( function(){ file = file_open( file2_name) })
.step( function(){ xxx work with file2 xxx })
.final( function(){ file_close( file2) })
.end })
.final( function(){ file_close( file1) })
.defer( block) is inspired by the Go language "defer" keyword. It is itself a variation around the C++ notion of "destructors". There can be multiple deferred blocks for a task. Because deferred steps are executed just before the task reach its end, they can register additional steps to handle async activities. As a result, the task is not fully done until all the deferred work is done too. Deferred blocks are executed in a LIFO order, ie the last deferred step is run first.
var resourceA
var resourceB
l8
.step( function(){ acquireResource( xxx) })
.step( function( r ){
ressourceA = r
l8.defer( function(){ releaseResource( resourceA) })
})
.step( function(){ xxx work with resourceA xxx })
.step( function(){ acquireResource( yyy) })
.step( function( r ){
resourceB = r
l8.defer( function(){ releaseResource( resourceB) })
})
.step( function(){ xxx work with resourceB xxx })
Because multiple deferred blocks are possible, .defer() is more modular. For example, it makes it possible to implement the "Resource Acquisition is Initialization" pattern. See http://en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization
var with_file = l8.Task( function( file_name ){
var file
l8
.step(){ function(){ file_open( file_name) })
.step( r ){
file = r
l8.parent.defer( function(){ file_close( file) })
}
})
Usage:
var file
l8
.step( function(){ with_file( file_name) })
.step( function( r ){ xxx work with file r xxx })
xxx don't worry about closing that file xxx
The general "rule of thumb" is to use .final() for quick & simple stuff and use .defer() for more elaborated async stuff.
Tasks can define variables much like functions can. There are some differences. Contary to function local variables, task local variables are "fluid", as per Scheme jargon, ie they are dynamically scoped (whereas javascript variables use lexical scoping). See also http://en.wikipedia.org/wiki/Thread_local_storage
A nice property of task local variables is the fact that a variable defined by a parent task is accessible from a child subtask. As a result, task local variables are "global" to a subset of all tasks, based on the task hierarchy.
When a subtask needs to override an inherited variables, it uses ".var()" to set a new value that it's own subtasks will share. When a subtask, on the contrary, wants to share an inherited variables, it uses ".set()" to set a new value that it's parent task can query using ".get()".
Please note that tasks can also use regular lexically scoped variables, as long as such a variable is part of a function's closure. This is the most convenient and fast use case. When more global variables are required, l8 fluid variables are handy.
var trace = function(){
l8.trace( l8.get( "message") + " from " + l8.binding( "message").task)
}
var subtask = function(){
l8.label = "sub"
l8.step( function(){ trace() })
l8.step( function(){ l8.var( "message", "deeper") })
l8.step( function(){ l8.delay( 10) })
l8.step( function(){ trace() })
}
l8.task( function(){
l8.label = "main"
l8.var( "message", "top")
l8.spawn( subtask )
l8.step( function(){ l8.var( "message", "deeper") })
l8.step( function(){ trace() })
})
displays: top from Task/x[main], top from Task/x[main], deeper from Task/x[sub]