Skip to content

Commit 3d6c652

Browse files
committed
cc docs: clarify task cancellation behavior
Clarified the behavior of @ref engine::TaskBase::State, @ref engine::TaskBase::IsValid and cancellation propagation. commit_hash:a3c45aee640d0d431e4c82bea088a113c4578c72
1 parent 2cd5a06 commit 3d6c652

2 files changed

Lines changed: 86 additions & 45 deletions

File tree

core/include/userver/engine/task/task_base.hpp

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,34 @@ class [[nodiscard]] TaskBase {
5151
kQueued, ///< awaits execution
5252
kRunning, ///< executing user code
5353
kSuspended, ///< suspended, e.g. waiting for blocking call to complete
54-
kCancelled, ///< exited user code because of external request
55-
kCompleted, ///< exited user code with return or throw
54+
55+
/// The task is cancelled and was finished without returning a value or throwing a user-provided exception.
56+
///
57+
/// This can happen for two reasons:
58+
///
59+
/// 1. When a non-critical task (see @ref Importance::kCritical, see @ref flavors_of_async) is cancelled before
60+
/// it starts running, the task functor is skipped, only destructor is executed. This can be interpreted
61+
/// as every non-critical task having an implicit cancellation point at its start.
62+
/// See more details and examples in @ref task_cancellation_before_start.
63+
///
64+
/// 2. When a task is cancelled because of a call to @ref engine::current_task::CancellationPoint.
65+
/// This cancellation is implemented using a non-`std::exception`-based exception.
66+
///
67+
/// In both cases, @ref engine::TaskWithResult::Get throws @ref engine::TaskCancelledException.
68+
///
69+
/// Unintuitively, this status is not set when the task was cancelled after it started running,
70+
/// which caused the user code to exit early.
71+
///
72+
/// Use @ref TaskBase::CancellationReason instead to check whether the task was cancelled.
73+
kCancelled,
74+
75+
/// Exited user code with return or throw.
76+
///
77+
/// This includes cases where the task was cancelled after it started running,
78+
/// which caused the user code to exit early.
79+
///
80+
/// Use @ref TaskBase::IsFinished instead to check whether the task finished execution.
81+
kCompleted,
5682
};
5783

5884
/// Task wait mode
@@ -63,15 +89,19 @@ class [[nodiscard]] TaskBase {
6389
kMultipleAwaiters
6490
};
6591

66-
/// @brief Checks whether this object owns
67-
/// an actual task (not `State::kInvalid`)
92+
/// @brief Checks whether this object owns an actual task (not @ref State::kInvalid)
6893
///
69-
/// An invalid task cannot be used. The task becomes invalid
70-
/// after each of the following calls:
94+
/// An invalid task cannot be used. The task becomes invalid after each of the following calls:
7195
///
7296
/// 1. the default constructor
73-
/// 2. `Detach()`
74-
/// 3. `Get()` (see `engine::TaskWithResult`)
97+
/// 2. moving from this task object
98+
/// 3. @ref engine::TaskWithResult::Get
99+
/// 4. @ref concurrent::BackgroundTaskStorageCore::Detach
100+
/// 5. @ref engine::DetachUnscopedUnsafe
101+
///
102+
/// Notably, the task does *not* become invalid immediately after it finishes execution.
103+
/// (That would always cause race conditions when trying to await a task.)
104+
/// It means that some of the task's resources are held onto until the task object is invalidated or destroyed.
75105
bool IsValid() const;
76106

77107
/// Gets the task State

scripts/docs/en/userver/intro.md

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ low-latency, then it may be fine to run all the code on the same task processor.
4949
______
5050
⚠️🐙❗ If you want to run code that uses standard synchronization primitives
5151
(for example, code from a third-party library), then this code should be run in
52-
a separate `engine::TaskProcessor` to avoid starvation of main task processors.
52+
a separate @ref engine::TaskProcessor to avoid starvation of main task processors.
5353
See @ref scripts/docs/en/userver/task_processors_guide.md for more info.
5454
______
5555

@@ -95,21 +95,21 @@ By engine::TaskProcessor:
9595

9696
By shared-ness:
9797

98-
* By default, functions return engine::TaskWithResult, which can be awaited
98+
* By default, functions return @ref engine::TaskWithResult, which can be awaited
9999
from 1 task at once. This is a reasonable choice for most cases.
100100
* Functions from `utils::Shared*Async*` and `engine::Shared*AsyncNoSpan`
101-
families return engine::SharedTaskWithResult, which can be awaited
101+
families return @ref engine::SharedTaskWithResult, which can be awaited
102102
from multiple tasks at the same time, at the cost of some overhead.
103103

104-
By engine::TaskBase::Importance ("critical-ness"):
104+
By @ref engine::TaskBase::Importance ("critical-ness"):
105105

106-
* By default, functions can be cancelled due to engine::TaskProcessor
106+
* By default, functions can be cancelled due to @ref engine::TaskProcessor
107107
overload. Also, if the task is cancelled before being started, it will not
108108
run at all.
109109
* If the whole service's health (not just one request) depends on the task
110110
being run, then functions from `utils::*CriticalAsync*` and
111111
`engine::*CriticalAsyncNoSpan*` families can be used. There, execution of
112-
the function is guaranteed to start regardless of engine::TaskProcessor
112+
the function is guaranteed to start regardless of @ref engine::TaskProcessor
113113
load limits
114114

115115
By tracing::Span:
@@ -128,7 +128,7 @@ By tracing::Span:
128128
But beware! Using tracing::Span::CurrentSpan() will trigger asserts
129129
and lead to UB in production.
130130

131-
By the propagation of engine::TaskInheritedVariable instances:
131+
By the propagation of @ref engine::TaskInheritedVariable instances:
132132

133133
* Functions from `utils::*Async*` family (which you should use by default)
134134
inherit all task-inherited variables from the parent task.
@@ -270,76 +270,87 @@ Cancellation can occur:
270270

271271
* by an explicit request;
272272
* due to the end of the task object lifetime;
273-
* at coroutine engine shutdown (affects tasks launched via `engine::Task::Detach`);
273+
* at coroutine engine shutdown (affects tasks launched via @ref engine::DetachUnscopedUnsafe);
274274
* due to the lack of resources.
275275

276-
To cancel a task explicitly, call the `engine::TaskBase::RequestCancel()` or `engine::TaskBase::SyncCancel()` method. It cancels only a single task and does not directly affect the subtasks that were created by the canceled task.
276+
To cancel a task explicitly, call the @ref engine::TaskBase::RequestCancel or @ref engine::TaskBase::SyncCancel method.
277+
For details on what happens with subtasks when a task is cancelled, see @ref task_cancellation_outside_world.
277278

278-
Another way to cancel a task it to drop the `engine::TaskWithResult` without awaiting it, e.g. by returning from the function that stored it in a local variable or by letting an exception fly out.
279+
Another way to cancel a task it to drop the @ref engine::TaskWithResult without awaiting it, e.g. by returning from the function that stored it in a local variable or by letting an exception fly out.
279280

280281
@snippet core/src/engine/task/cancel_test.cpp stack unwinding destroys task
281282

282-
Tasks can be cancelled due to `engine::TaskProcessor` overload, if configured. This is a last-ditch effort to avoid OOM due to a spam of tasks. Read more in `utils::Async` and `engine::TaskBase::Importance`. Tasks started with `engine::CriticalAsync` are excepted from cancellations due to `TaskProcessor` overload.
283+
In the example above, `child_task` is cancelled and awaited due to stack unwinding. In any case, the child task's
284+
execution is guaranteed to be finished once its @ref engine::TaskWithResult handle is destroyed.
285+
286+
Tasks can be cancelled due to @ref engine::TaskProcessor overload, if configured. This is a last-ditch effort to avoid OOM due to a spam of tasks. Read more in @ref utils::Async and @ref engine::TaskBase::Importance. Tasks started with @ref engine::CriticalAsync are excepted from cancellations due to `TaskProcessor` overload.
283287

284288
### How the task sees its cancellation
285289

286290
Unlike C++20 coroutines, userver does not have a magical way to kill a task. The cancellation will somehow be signaled to the synchronization primitive being waited on, then it will go through the logic of the user's function, then the function will somehow complete.
287291

288292
How some synchronization primitives react to cancellations:
289293

290-
* `engine::TaskWithResult::Get` and `engine::TaskBase::Wait` throw `engine::WaitInterruptedException`, which typically leads to the destruction of the child task during stack unwinding, cancelling and awaiting it;
291-
* `engine::ConditionVariable::Wait` and `engine::Future::wait` return a status code;
292-
* `engine::SingleConsumerEvent::WaitForEvent` returns `false`;
293-
* `engine::SingleConsumerEvent::WaitForEventFor` returns `false` and needs an additional `engine::current_task::ShouldCancel()` check;
294-
* `engine::InterruptibleSleepFor` needs an additional `engine::current_task::ShouldCancel()` check;
295-
* `engine::CancellableSemaphore` returns `false` or throws `engine::SemaphoreLockCancelledError`.
294+
* @ref engine::TaskWithResult::Get and @ref engine::TaskBase::Wait throw @ref engine::WaitInterruptedException, which typically leads to the destruction of the child task during stack unwinding, cancelling and awaiting it;
295+
* @ref engine::ConditionVariable::Wait and @ref engine::Future::wait return a status code;
296+
* @ref engine::SingleConsumerEvent::WaitForEvent returns `false`;
297+
* @ref engine::SingleConsumerEvent::WaitForEventFor returns `false` and needs an additional @ref engine::current_task::ShouldCancel check;
298+
* @ref engine::InterruptibleSleepFor needs an additional @ref engine::current_task::ShouldCancel check;
299+
* @ref engine::CancellableSemaphore returns `false` or throws engine::SemaphoreLockCancelledError.
296300

297301
Some synchronization primitives deliberately ignore cancellations, notably:
298302

299-
* `engine::Mutex`;
300-
* `engine::Semaphore` (use `engine::CancellableSemaphore` to support cancellations);
301-
* `engine::SleepFor` (use `engine::InterruptibleSleepFor` to support cancellations).
303+
* @ref engine::Mutex;
304+
* @ref engine::Semaphore (use @ref engine::CancellableSemaphore to support cancellations);
305+
* @ref engine::SleepFor (use @ref engine::InterruptibleSleepFor to support cancellations).
302306

303-
Most clients throw a client-specific exception on cancellation. Please explore the docs of the client you are using to find out how it reacts to cancellations. Typically, there is a special exception type thrown in case of cancellations, e.g. `clients::http::CancelException`.
307+
Most clients throw a client-specific exception on cancellation. Please explore the docs of the client you are using to find out how it reacts to cancellations. Typically, there is a special exception type thrown in case of cancellations, e.g. @ref clients::http::CancelException.
304308

309+
@anchor task_cancellation_outside_world
305310
### How the outside world sees the task's cancellation
306311

307-
The general theme is that a task's completion upon cancellation is still a completion. The task's function will ultimately return or throw something, and that is what the parent task will receive in `engine::TaskWithResult::Get` or `engine::TaskBase::Wait`.
312+
The general theme is that a task's completion upon cancellation is still a completion. The task's function will ultimately return or throw something, and that is what the parent task will receive in @ref engine::TaskWithResult::Get or @ref engine::TaskBase::Wait.
308313

309-
If the cancellation is due to the parent task being cancelled, then its `engine::TaskWithResult::Get` or `engine::TaskBase::Wait` will throw an `engine::WaitInterruptedException`, leaving the child task running (for now), so the parent task will likely not have a chance to observe the child task's completion status. Usually the stack unwinding in the parent task then destroys the `engine::Task` handle, which causes it to be cancelled and awaited.
314+
If the cancellation is due to the parent task being cancelled, then its @ref engine::TaskWithResult::Get or @ref engine::TaskBase::Wait will throw an @ref engine::WaitInterruptedException, leaving the child task running (for now), so the parent task will likely not have a chance to observe the child task's completion status. Usually the stack unwinding in the parent task then destroys the @ref engine::TaskWithResult handle, which causes it to be cancelled and awaited.
310315

311316
@snippet core/src/engine/task/cancel_test.cpp parent cancelled
312317

318+
In the example above, `child_task` is cancelled and awaited due to stack unwinding. In any case, the child task's
319+
execution is guaranteed to be finished once its @ref engine::TaskWithResult handle is destroyed.
320+
313321
If the child task got cancelled without the parent being cancelled, then:
314322

315-
* `engine::TaskWithResult::Get` will return or throw whatever the child task has returned or thrown, which is practically meaningless (because why else would someone cancel a task?);
316-
* `engine::TaskBase::Wait` will return upon completion;
317-
* `engine::TaskBase::IsFinished` will return `true` upon completion;
318-
* `engine::TaskBase::GetStatus` will return `engine::TaskBase::Status::kCancelled` upon completion.
323+
* @ref engine::TaskWithResult::Get will return or throw whatever the child task has returned or thrown, which is practically meaningless (because why else would someone cancel a task?);
324+
* @ref engine::TaskBase::Wait will return upon completion;
325+
* @ref engine::TaskBase::IsFinished will return `true` upon completion;
326+
* @ref engine::TaskBase::GetStatus will return @ref engine::TaskBase::Status::kCancelled upon completion.
327+
328+
@anchor task_cancellation_before_start
329+
### What happens to tasks that are cancelled before they start running
319330

320-
There is one extra quirk: if the task is cancelled before being started, then only the functor's destructor will be run by default. See details in `utils::Async`. In this case `engine::TaskWithResult::Get` will throw `engine::TaskCancelledException`.
331+
There is one extra quirk: if the task is cancelled before being started, then only the functor's destructor will be run by default. See details in @ref utils::Async. In this case @ref engine::TaskWithResult::Get will throw @ref engine::TaskCancelledException.
321332

322-
Tasks launched via `utils::CriticalAsync` are always started, even if cancelled before entering the function. The cancellation will take effect immediately after the function starts:
333+
Tasks launched via @ref utils::CriticalAsync are always started, even if cancelled before entering the function. The cancellation will take effect immediately after the function starts:
323334

324335
@snippet core/src/engine/task/cancel_test.cpp critical cancel
325336

326337
### Lifetime of a cancelled task
327338

328-
Note that the destructor of `engine::Task` cancels and waits for task to finish if the task has not finished yet. Use `concurrent::BackgroundTaskStorage` to continue task execution out of scope.
339+
Note that the destructor of @ref engine::Task cancels and waits for task to finish if the task has not finished yet. Use @ref concurrent::BackgroundTaskStorage to continue task execution out of scope.
329340

330-
The invariant that the task only runs within the lifetime of the `engine::Task` handle or `concurrent::BackgroundTaskStorage` is the backbone of structured concurrency in userver, see `utils::Async` and `concurrent::BackgroundTaskStorage` for details.
341+
The invariant that the task only runs within the lifetime of the @ref engine::Task handle or @ref concurrent::BackgroundTaskStorage is the backbone of structured concurrency in userver, see @ref utils::Async and @ref concurrent::BackgroundTaskStorage for details.
331342

332343
### Utilities that interact with cancellations
333344

334345
The user is provided with several mechanisms to control the behavior of the application in case of cancellation:
335346

336-
* `engine::current_task::CancellationPoint()` -- if the task is canceled, calling this function throws an exception that is not caught during normal exception handling (not inherited from `std::exception`). This will result in stack unwinding with normal destructor calls for all local objects. The parent task will receive `engine::TaskCancelledException` from `engine::TaskWithResult::Get`.
347+
* @ref engine::current_task::CancellationPoint -- if the task is canceled, calling this function throws an exception that is not caught during normal exception handling (not inherited from `std::exception`). This will result in stack unwinding with normal destructor calls for all local objects. The parent task will receive @ref engine::TaskCancelledException from @ref engine::TaskWithResult::Get.
337348
**⚠️🐙❗ Catching this exception results in UB, your code should not have `catch (...)` without `throw;` in the handler body**!
338-
* `engine::current_task::ShouldCancel()` and `engine::current_task::IsCancelRequested()` -- predicates that return `true` if the task is canceled:
339-
* by default, use `engine::current_task::ShouldCancel()`. It reports that a cancellation was requested for the task and the cancellation was not blocked (see below);
340-
* `engine::current_task::IsCancelRequested()` notifies that the task was canceled even if cancellation was blocked; effectively ignoring caller's requests to complete the task regardless of cancellation.
341-
* `engine::TaskCancellationBlocker` -- scope guard, preventing cancellation in the current task. As long as it is alive all the blocking calls are not interrupted, `engine::current_task::CancellationPoint` throws no exceptions, `engine::current_task::ShouldCancel` returns `false`.
342-
**⚠️🐙❗ Disabling cancellation does not affect the return value of `engine::current_task::IsCancelRequested()`.**
349+
* @ref engine::current_task::ShouldCancel and @ref engine::current_task::IsCancelRequested -- predicates that return `true` if the task is canceled:
350+
* by default, use @ref engine::current_task::ShouldCancel. It reports that a cancellation was requested for the task and the cancellation was not blocked (see below);
351+
* @ref engine::current_task::IsCancelRequested notifies that the task was canceled even if cancellation was blocked; effectively ignoring caller's requests to complete the task regardless of cancellation.
352+
* @ref engine::TaskCancellationBlocker -- scope guard, preventing cancellation in the current task. As long as it is alive all the blocking calls are not interrupted, @ref engine::current_task::CancellationPoint throws no exceptions, @ref engine::current_task::ShouldCancel returns `false`.
353+
**⚠️🐙❗ Disabling cancellation does not affect the return value of @ref engine::current_task::IsCancelRequested.**
343354

344355

345356
----------

0 commit comments

Comments
 (0)