Skip to content

Commit 01866fa

Browse files
committed
Document task shutdown protocol and justify its concurrency safety. Close #2696. Close bblum's internship.
1 parent 5ebea76 commit 01866fa

File tree

1 file changed

+76
-7
lines changed

1 file changed

+76
-7
lines changed

src/rt/rust_task.h

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,86 @@
1212
* Switching between running Rust code on the Rust segmented stack and
1313
foreign C code on large stacks owned by the scheduler
1414
15+
# Lifetime
16+
1517
The lifetime of a rust_task object closely mirrors that of a running Rust
1618
task object, but they are not identical. In particular, the rust_task is an
1719
atomically reference counted object that might be accessed from arbitrary
1820
threads at any time. This may keep the task from being destroyed even after
19-
the task is dead from a Rust task lifecycle perspective.
20-
21-
FIXME (#2696): The task and the scheduler have an over-complicated,
22-
undocumented protocol for shutting down the task, hopefully without
23-
races. It would be easier to reason about if other runtime objects could
24-
not access the task from arbitrary threads, and didn't need to be
25-
atomically refcounted.
21+
the task is dead from a Rust task lifecycle perspective. The rust_tasks are
22+
reference counted in the following places:
23+
24+
* By the task's lifetime (i.e., running tasks hold a reference to themself)
25+
26+
* In the rust_task_kill_all -> rust_kernel::fail ->
27+
rust_sched_loop::kill_all_tasks path. When a task brings down the whole
28+
runtime, each sched_loop must use refcounts to take a 'snapshot' of all
29+
existing tasks so it can be sure to kill all of them.
30+
31+
* In core::pipes, tasks that use select() use reference counts to avoid
32+
use-after-free races with multiple different signallers.
33+
34+
# Death
35+
36+
All task death goes through a single central path: The task invokes
37+
rust_task::die(), which invokes transition(task_state_dead), which pumps
38+
the scheduler loop, which switches to rust_sched_loop::run_single_turn(),
39+
which calls reap_dead_tasks(), which cleans up the task's stack segments
40+
and drops the reference count.
41+
42+
When a task's reference count hits zero, rust_sched_loop::release_task()
43+
is called. This frees the memory and deregisters the task from the kernel,
44+
which may trigger the sched_loop, the scheduler, and/or the kernel to exit
45+
completely in the case it was the last task alive.
46+
47+
die() is called from two places: the successful exit path, in cleanup_task,
48+
and on failure (on linux, this is also in cleanup_task, after unwinding
49+
completes; on windows, it is in begin_failure).
50+
51+
Tasks do not force-quit other tasks; a task die()s only itself. However...
52+
53+
# Killing
54+
55+
Tasks may kill each other. This happens when propagating failure between
56+
tasks (see the task::spawn options interface). The code path for this is
57+
rust_task_kill_other() -> rust_task::kill().
58+
59+
It also happens when the main ("root") task (or any task in that task's
60+
linked-failure-group) fails: this brings down the whole runtime, and kills
61+
all tasks in all groups. The code path for this is rust_task_kill_all() ->
62+
rust_kernel::fail() -> rust_scheduler::kill_all_tasks() ->
63+
rust_sched_loop::kill_all_tasks() -> rust_task::kill().
64+
65+
In either case, killing a task involves, under the protection of its
66+
lifecycle_lock, (a) setting the 'killed' flag, and (b) checking if it is
67+
'blocked'* and if so punting it awake.
68+
(* and also isn't unkillable, which may happen via task::unkillable()
69+
or via calling an extern rust function from C.)
70+
71+
The killed task will then (wake up if it was asleep, and) eventually call
72+
yield() (or wait_event()), which will check the killed flag, see that it is
73+
true, and then invoke 'fail', which begins the death process described
74+
above.
75+
76+
Three things guarantee concurrency safety in this whole affair:
77+
78+
* The lifecycle_lock protects tasks accessing each other's state: it makes
79+
killing-and-waking up atomic with respect to a task in block() deciding
80+
whether it's allowed to go to sleep, so tasks can't 'escape' being woken.
81+
82+
* In the case of linked failure propagation, we ensure (in task.rs) that
83+
tasks can only see another task's rust_task pointer if that task is
84+
already alive. Even before entering the runtime failure path, a task will
85+
access (locked) the linked-failure data structures to remove its task
86+
pointer so that no subsequently-failing tasks will do a use-after-free.
87+
88+
* In the case of bringing down the whole runtime, each sched_loop takes an
89+
"atomic snapshot" of all its tasks, protected by the sched_loop's lock,
90+
and also sets a 'failing' flag so that any subsequently-failing task will
91+
know that it must fail immediately upon creation (which is also checked
92+
under the same lock). A similar process exists at the one-step-higher
93+
level of the kernel killing all the schedulers (the kernel snapshots all
94+
the schedulers and sets a 'failing' flag in the scheduler table).
2695
*/
2796

2897
#ifndef RUST_TASK_H

0 commit comments

Comments
 (0)