vlc/doc/executor.md

8.9 KiB

Executor

Overview

The executor API allows to submit runnables to be executed from background threads.

The runnable instances are allocated and freed by the caller. It is the responsibility of the caller to guarantee that the runnable is valid until the task is cancelled or completed.

The caller is expected to (but not forced to) embed the runnable into its custom task structure:

struct my_task
{
    /* custom data */
    int num;
    char *str;

    /* embedded runnable */
    struct vlc_runnable runnable;
};

void Run(void *userdata)
{
    // ...
}

and submit it as follow:

    struct my_task *task = ...;

    task->runnable.run = Run;
    task->runnable.userdata = task; /* userdata passed to Run() */

    vlc_executor_Submit(executor, &task->runnable);

Since the task is allocated by the caller, vlc_executor_Submit() may not fail (it returns void).

The cancellation of a submitted runnable may be requested. It succeeds only if the runnable has not started yet.

VLC_API void
vlc_executor_Submit(vlc_executor_t *executor, struct vlc_runnable *runnable);

VLC_API bool
vlc_executor_Cancel(vlc_executor_t *executor, struct vlc_runnable *runnable);

Design discussions

The design of this API is the result of many discussions, especially on the mailing-list:

This documentation aims to explain the rationale for each decision.

Runnable allocation

The struct vlc_runnable must be provided by the client, and the executor uses only this instance to represent its task (it does not allocate per-task private data).

Firstly, note that this allows vlc_executor_Submit() to return void, so no error handling is necessary. It's minor, but convenient.

An attractive alternative would be to return a new allocated runnable on submission, which could be used for cancellation:

struct vlc_runnable *
vlc_executor_Submit(vlc_executor_t *, void (*run)(void *), void *userdata);

void vlc_executor_Cancel(vlc_executor_t *, struct vlc_runnable *);

But this is inherently racy: a naive implementation could return a pointer to a vlc_runnable already freed. Indeed, vlc_executor_Submit() must post the runnable to some pending queue, and in theory it may be executed by a background thread even before the function returns.

For example, if the executor frees the runnable as soon as its execution is complete, this simple code is incorrect:

struct my_task
{
    char *some_custom_data;
    struct vlc_runnable *runnable; /* keep a pointer to cancel it */
};

void Run(void *userdata)
{
    struct my_task *task = userdata;
    /* (task->runnable may be uninitialized here) */
}
    struct my_task *task = ...;
    task->runnable = vlc_executor_Submit(executor, Run, task);
    /* task->runnable may be already freed here */
    vlc_executor_Cancel(executor, task->runnable); /* boom, use-after-free */

To avoid use-after-free, runnable ownership must be shared. This makes the API more complex:

struct vlc_runnable_t *runnable = vlc_executor_Submit(executor, Run, task);
vlc_runnable_Release(runnable); /* don't need the runnable anymore */

However, this would still be insufficient: the task resources must typically be released at the end of the run() callback, but at this point vlc_executor_Submit() may not have returned yet, so the runnable instance would not be known by the user.

Since in practice, the client needs its own task structure (to store the parameters, state and result of the execution), so it must allocate it and pass it to the executor (as userdata) anyway. Therefore, it's simpler if the task already embeds the runnable.

Note that it is not mandatory to embed the runnable into the client task structure (it's just convenient). The runnable could be anywhere, for example, on the stack (provided that it lives long enough).

This design also has disadvantages. In particular, it forces the runnable queue to be implemented as an intrusive list. As a consequence, the public struct vlc_runnable must include an executor-private field (struct vlc_list node), and the same runnable may not be queued twice (it is not possible to put the same item twice in the same intrusive list).

Therefore, a client must always explicitly create a new runnable for each execution. In practice, it should not be a problem, since it must often create a new custom task for the execution state anyway.

In addition, further extensions of the executor API are constrained by the fact that it can not allocate and store per-task data (other than the intrusive list node).

Cancellation

It is important to be able to cancel and interrupt a running task.

However, in C, the interruption mechanism is very specific to the concrete task implementation, so it can not be provided by the executor: it has to be provided by the user. And since the user is also at the origin of the cancellation request (which could lead to the interruption), the executor just lets the user handle the interruption manually.

Since it does not handle interruptions, it could not provide a generic timeout mechanism either (see below). The user has to handle timeouts manually.

It just provides a way to cancel a queued task that has not started yet:

VLC_API bool
vlc_executor_Cancel(vlc_executor_t *executor, struct vlc_runnable *runnable);

The runnable instance is created and destroyed by the user, so there is no possible use-after-free race condition.

Since the runnable is queued via an intrusive list, it can be removed in O(1).

The tricky part is that it must indicate to the user if the runnable has actually been dequeued or not (i.e., if it has already been taken by a thread to be run). Indeed, the user must be able to know if the run() callback will be executed or not, in order to release the task resources correctly in all cases, without race conditions. This adds some complexity for the user.

For the implementation, the problem is that even if we can remove an item from an intrusive list in O(1), there is a priori no way to know immediately if the item was in a specific list or not. To circumvent this problem, when an item is dequeued, the executor resets the list nodes to NULL. This allows to store 1 bit of information indicating whether the item has been dequeued or not.

Note that the use-after-free race condition on cancel could alternatively be solved by passing an optional user-provided void *id:

struct vlc_runnable
{
    void (*run)(void *userdata)
    void *userdata;
    struct vlc_list node;

    /* add a user-provided id (may be NULL) used for cancellation */
    void *id;
}

void vlc_executor_Submit(vlc_executor_t *executor, struct vlc_runnable *);

void vlc_executor_Cancel(vlc_executor_t *executor, void *id);

This would solve the problem because the user owns the id, so it is guaranteed to exist on cancel. This is also practical, because the user could just pass a pointer to its custom task structure.

An additional advantage is that a higher-level API (like the preparser) could directly benefit from the cancellation API, without tracking its submitted tasks to find the matching runnable.

But it would only make sense if the user was not already forced to track its submitted tasks, to be able to cancel them on deletion. Indeed, since the executor must report for each task if it has been dequeued or not, it is not possible to provide a function to cancel all tasks at once. Therefore, the user has to keep track of submitted tasks, which is inconvenient.

Timeout

A timeout implementation is often very specific to the concrete task implementation (for example, calling vlc_cond_timedwait() instead of vlc_cond_wait()), so it makes sense to let the user implement it.

However, we could see the timeout as a cancellation where the deadline is known in advance. Since the user must already implement the interruption on its own, it could have been used to provide a timeout mechanism "for free" (for the user).

We decided against it, for several reasons:

  • In theory, the user may want to do something different on timeout and cancellation (it may for example consider that one is an error and the other is not);
  • This would require more callbacks and complicate the executor API;
  • If it's necessary, the user could just use a separate component (timer) to trigger the interruption.

Conclusion

The resulting executor is a "minimal" API, simple and general: it just handles execution.

As a drawback, it does not help with interruption, cancellation, or timeout, so the user has more work to do on its own. For example, it must always track its submitted tasks, and implement boilerplate to correctly handle cancellation and interruption on deletion.

But I can't think of an executor API design in C with all the desirable properties. Any design I considered sucked one way or another. :/