-
Notifications
You must be signed in to change notification settings - Fork 19
Tasks
SA-MP programming is essentially event-based, meaning that responses to all events are handled by user-defined callbacks in the script. However, in many cases, the event is a response to a single action in the script, and having to use a public function in these localized conditions bloats the script with unnecessary public methods, spreading the relevant code among them.
This plugin introduces task-based programming into Pawn, inspired by C# in syntax and Lua in semantics.
- Task
- Timers
- Event-based to task-based conversion
- Task lifetime
- Combining tasks
- Binding tasks
- Advanced configuration
- Asynchronous pattern
- Task errors and cancellation
- Combining synchronous and asynchronous code
- Examples
A task, compared to an event, has a beginning and an end. It starts with some action, then does some "work", and then goes to the "finished" state.
The work itself doesn't have to done by a script, or it may not be an active work at all. The only important thing is that once this process is completed, the task is finished (optionally with a result).
A script can "wait" for any task to be finished. The waiting is non-blocking, meaning that the server will not freeze when such a wait occurs, and will instead continue as normal:
new Task:end_task;
public OnFilterScriptInit()
{
end_task = task_new();
await end_task;
print("Goodbye");
}
public OnFilterScriptExit()
{
task_set_result(end_task, 0);
}
Here, a new task is created and its ID assigned to end_task
. Then, the new pseudo-statement await
is used to pause the execution of the script and await the completion of the task. The keyword is actually an alias to native task_await
.
When the awaiting starts, the execution of the current public function is paused, and the function is forced to return. The state of the abstract machine is saved and associated with the task. Once the task is completed (via task_set_result
or externally), all saved states sequentially temporarily replace the old states in their respective AMX machines, and their execution is resumed.
The public function has to return, so in some cases ignoring this fact could lead to incorrect code:
public OnPlayerUpdate()
{
await task_ms(1000); // task_ms is a task that automatically completes after the specified number of milliseconds
//...
return true;
}
Since the public function has to return, and since the intended return value is not yet produced at this time, the function returns 0 (false), which leads to desynchronisation of all players. There are two ways to work around this.
The yield
pseudo-statement (alias to task_yield
) stores the result value of the current public function in a special location, which is then read from when the public function has to return.
public OnPlayerUpdate(playerid)
{
yield true;
await task_ms(1000);
//...
return false;
}
Here, yield
does not perform any waiting at all, and the public function returns the last value it was used with.
Another way is to indirectly call another public function:
public OnPlayerUpdate(playerid)
{
CallLocalFunction(#PostPlayerUpdate, "d", playerid);
return true;
}
forward PostPlayerUpdate(playerid);
public PostPlayerUpdate(playerid)
{
await task_ms(1000);
//...
}
The awaiting pauses the closest externally executed public function, so it stops at PostPlayerUpdate
and doesn't pause OnPlayerUpdate
. This use case is similar to the "fire and forget" technique, since an asynchronous process is started but its completion is not tracked.
There are two ways a script can wait for a given amount of time. You can wait for a specific number of milliseconds, or a specific number of server ticks (the rate of these ticks can be configured via the sleep server variable). The latter is especially useful for delaying messages or actions by the smallest amount of time possible:
public OnPlayerConnect(playerid)
{
SendClientMessage(playerid, -1, "Connected");
wait_ticks(1);
SendClientMessage(playerid, -1, "Hello!");
}
SendClientMessage(playerid, -1, "Goodbye!");
wait_ticks(1);
Kick(playerid);
The first example displays the second message just after the "Connected to ..." client message appears. In the second example, the message is displayed to the player before they are kicked (without the wait, the message won't appear).
Note that in contrast to the SetTimer function, an interval of 0 does not skip any ticks. To wait 1 tick (i.e. until the next tick), the value of 1 is necessary.
Functions wait_ms
and wait_ticks
are equivalent to await task_ms
and await task_ticks
semantically, but may not create the actual task on some occasions, so don't rely on their return value. If you pass a negative number, the task is never completed.
The script is always entered from the main thread, so no race conditions occur. This also means that the granularity of the actual delay in milliseconds depends on the server tick rate, and may take longer by a small amount if the execution of the code goes past the interval. However, it is still more accurate than SetTimer and resumes the execution as early as possible.
Waiting for a specific amount of time is useful, but even more useful is waiting for a specific action to happen. Let's say we want to wait until any player connects to the server. The initial take on this could look like this:
new Task:player_connect;
stock Task:WhenPlayerConnect()
{
player_connect = task_new();
}
public OnPlayerConnect(playerid)
{
task_set_result(player_connect, playerid);
}
You can then await WhenPlayerConnect();
(or use new playerid = await(WhenPlayerConnect());
if you want the player ID). However, there are a couple of issues with this code. Mainly, a new task is created every time you call WhenPlayerConnect
, but only one task is completed in OnPlayerConnect
. You could create a table of these tasks, but having to create it for every task-wrapped event you want to use is impractical.
For this problem, a technique known as additional callback handlers can be used. From an abstract point of view, a callback is a way of interaction between the host and the script, but the same way a callback can have a handler in each script, each script could have more than one handler for a callback:
public OnFilterScriptInit()
{
pawn_register_callback(#OnPlayerConnect), #MyPlayerConnect);
}
forward MyPlayerConnect(playerid);
public MyPlayerConnect(playerid)
{
//...
}
MyPlayerConnect
is called every time OnPlayerConnect
is to be called in the script. This in itself is not impressive, but additional parameters may be specified to appear in the handler, effectively creating what is known as a delegate (but bound to several values instead of one).
public OnFilterScriptInit()
{
pawn_register_callback(#OnPlayerConnect, #MyPlayerConnect, _, "d", 12);
}
forward MyPlayerConnect(param, playerid);
public MyPlayerConnect(param, playerid)
{
//...
}
param
could identify the instance of the handler, but this is still a step from perfection – using the e
specifier is much more better. It is used to refer to the instance of the event–delegate pair, and can be used to unregister it:
public OnFilterScriptInit()
{
pawn_register_callback(#OnPlayerConnect, #MyPlayerConnect, _, "e");
}
forward MyPlayerConnect(CallbackHandler:id, playerid);
public MyPlayerConnect(CallbackHandler:id, playerid)
{
pawn_unregister_callback(id);
//...
}
This is a single-fire handler – it is triggered only once and then unregisters itself. Let's use this technique to create something more useful: waiting for an event when a player returns:
stock Task:WhenPlayerReturns(playerid)
{
new name[MAX_PLAYER_NAME];
GetPlayerName(playerid, name, sizeof name);
new Task:t = task_new();
pawn_register_callback(#OnPlayerConnect, #SingleFireNameTaskHandler, _, "eds", t, name); //"e" does not need an argument
return t;
}
// A handler that checks the name of a player and compares it to the one it is bound to.
forward SingleFireNameTaskHandler(CallbackHandler:id, Task:task, name[], playerid);
public SingleFireNameTaskHandler(CallbackHandler:id, Task:task, name[], playerid)
{
new name2[16];
GetPlayerName(playerid, name2, sizeof name2);
if(!strcmp(name, name2))
{
pawn_unregister_callback(id);
task_set_result(task, playerid);
}
}
public OnPlayerDisconnect(playerid, reason)
{
printf("%d disconnected!", playerid);
playerid = await(WhenPlayerReturns(playerid));
printf("%d is back!", playerid);
}
The SingleFire…
function encapsulates a simple pattern – check for the source object, unregistering itself and completing a task if the check is successful. In this case, it is bound to a name, but it can be bound to anything that can be used to identify the object, like its ID.
Tasks reside in a single pool, but are collected aggressively. Since they are primarily made for notifying other pieces of code of a completion of an event, there is no need for a task to live longer after it is completed. When a call to task_set_result
happens, its handlers are executed but the task is destroyed immediately after the call. There are two options to prolong the lifetime of a task – task_keep
and task_reset
. The first function specifies that after the completion, the task shouldn't be destroyed, while the second function resets the task to its initial uncompleted state, making it reusable. Using these functions is not recommended, as there are ways to achieve similar goals without affecting the standard lifetime of a task.
Sometimes, it is useful to combine tasks to create a new compound task that is completed based on the input tasks. Standard operations are task_any
and task_all
. A task produced from the first function is completed as soon as one of the input tasks is completed, using it as its result, while a task produced from task_all
requires all its input tasks to be completed at the same time, returning the last task that was completed. The actual task object is returned from these functions, so their return value should be properly retagged to Task:
.
Binding is a mechanism that effectivelly makes it possible to wait for the completion of any public function, even if it is paused or raises an error.
When a new context is created via task_bind
, the task is assigned to the context. When the execution inside the context ends, the effect is analogous to calling task_set_result
/task_set_error
on the task.
Binding a task is simple, but ensuring that it can handle any execution is a little bit more complicated:
forward taskfunc(time);
public taskfunc(time)
{
wait_ms(time); // simulating an asynchronous operation
return 1;
}
stock test()
{
new Task:t = task_new();
task_keep(t, true); // t will not be collected after it is completed, because taskfunc might end immediately and the return value is lost
task_bind(t, "taskfunc", "d", 100);
task_wait(t); // wait until the task is completed, but do not raise an error if it faults
// process the result
task_delete(t);
}
Instead of deleting the task, it is possible to use pawn_guard
to ensure the task will be deleted in any case.
If task_yield
is called inside a context with a bound task, it is equivalent to calling task_set_completed
on the bound task in addition to its usual function.
To change how asynchronous functions work, it is possible to use the task_config
function. When the script is paused, all its memory is moved to a storage inside the awaited task. This might turn out to be inefficient for deeply nested scripts with a large part of irrelevant memory, but by default, it needs to be enabled, since the script might access variables from outside its current context or frame.
task_config
allows you to specify which parts of the stack and heap will be saved and restored when the execution is resumed.
task_restore_none
specifies that the memory section will not be saved. For heap, it discards all by-ref callback arguments, and temporary by-ref arguments to Pawn functions. For stack, it discards all local variables, parameters, and all information that make executing functions possible. The only way to exit the function is to all amx_yield
or raise an error afterwards.
task_restore_frame
refers to the memory allocated by the current stack frame (it behaves like task_restore_context
when used for the heap). It saves all variables and by-value parameters used in the current function, but nothing else. Since the function can no longer return to the previously executed code, the stack will be modified to exit the execution when the function returns.
task_restore_context
stores the memory used by the current context (created by an external call to a public function). All stack frames are preserved, as well as all parameters, with the single exception of by-ref parameters to the public function (since they are allocated before the context is created).
task_restore_full
is the default behaviour which saves the complete stack and heap sections of the AMX instance.
Like in other languages, the asynchronous capabilities of tasks can be used to write fully asynchronous functions, albeit a bit more explicitly in Pawn:
stock Task:DoSomethingAsync()
{
new Task:this_task = task_new();
new Handle:task_handle = handle_new(this_task, .weak=true);
pawn_guard(task_handle);
task_detach();
yield this_task;
wait_ms(1000); // a pausing call
if(handle_alive(task_handle))
{
task_set_result(this_task, true); // result of the function
}
return Task:0; // this value will be never exposed, but is needed for Pawn
}
task_detach
is used to detach the current context from the parent one, effectively turning a direct call to an indirect call. Therefore, the inner call to wait_ms
will pause the execution of the function, but not the code that called it. The code inside the function has to be asynchronous, or the created task will be destroyed by task_set_result
without any code being able to observe its result.
Creating a handle from the task is not necessary, but it helps when the resulting task will be used by other code, potentially being deleted or finished before the function can end (see below). The call to pawn_guard
ensures the handle stays alive for the duration of the function (can be replaced by pair of handle_acquire
/handle_release
). Alternatively, the handle can be yielded instead of the task to give greater control over its lifetime, in which case it shouldn't be created as weak.
In addition to the standard completed state of a task, it can also store an AMX error in case the action it represents ended in a failure. Using this faulted state is not necessary, but PawnPlus ensures the error gets propagated where relevant.
new Task:t = task_new();
task_set_error_ms(t, amx_err_exit, 1000);
await t;
The call to task_await
(via the macro await
) internally calls task_wait
and task_get_result
. The first function just waits for the task to change its state, be it completed or faulted, but calling task_get_result
on a faulted task raises the AMX error stored inside. amx_err_exit
and amx_err_assert
are common error codes that can be used, but it's up to the script to choose which codes to use and how to handle them.
If you don't want to handle AMX errors but still want to cancel a task (for example make player disconnection cancel an open dialog), it is usually safe to simply delete the task, unless you have explicit resource creation and destruction in a function that awaits a soon-to-be-deleted task (in which case pawn_guard
can be used instead). Deleting the task will terminate all executions that wait for its completion, causing no memory leaks whatsoever.
In general, it is not recommended to have a function that is conditionally asynchronous, i.e. only pauses the script in a specific case (task_detach
is fine, as the pause doesn't affect external code in that case) because the user of the function might expect it to always be asynchronous and design the code to accommodate for its asynchronous effects.
stock AsyncFunction(arg)
{
if(arg < 0) return;
wait_ms(1000);
}
Here, the function can end without any asynchronous effects, making previous calls to task_yield
redundant. To end the function asynchronously in both cases, wait_ms(0)
can be used:
stock AsyncFunction(arg)
{
if(arg < 0) return wait_ms(0), 0;
wait_ms(1000);
}
wait_ms
used with a zero-length interval does not skip any ticks, but still postpones the rest of the execution after everything else in the current callback.
Another example is a function using the asynchronous pattern or simply returning a task:
stock Task:FunctionAsync(arg)
{
new Task:t = task_new();
if(arg >= 0)
{
Something(t);
}else{
task_set_error(t, amx_err_exit);
}
return t;
}
This function is not asynchronous but it also serves as an example, because the task it returns should be either completed asynchronously in the future, or already finished. However, task_set_error
deletes the task before it can be returned from the function, so the code that follows will still fail but not in the intended way. Like above, a zero-length wait fixes easily this function:
stock Task:FunctionAsync(arg)
{
new Task:t = task_new();
if(arg >= 0)
{
Something(t);
}else{
task_set_error_ms(t, amx_err_exit, 0);
}
return t;
}
The task will exist in both cases when the function ends, and will be given a chance to be awaited before its result (or error) is set.