frust_reactive/thread_policy.rs
1//! Scheduling classes for the framework's own threads.
2//!
3//! Two kinds of thread the framework spawns compete with the UI thread for the
4//! same cores: the render thread, which owns each frame's encode → acquire →
5//! submit tail, and this crate's background runtime (its async workers and the
6//! [`spawn_blocking`](crate::spawn_blocking) pool that JSON parsing and image
7//! decoding land on). Left alone, both run at whatever priority they inherit
8//! from the thread that spawned them. On Android that is the UI thread's boosted
9//! top-app nice value, so a background decode competes with the frame on equal
10//! terms, while a fixed render-thread nice value written over the inherited one
11//! can *lower* the frame's own submit path below its producers.
12//!
13//! [`set_current_thread_class`] states which side of the frame a thread is on
14//! and lets the platform shim translate that:
15//!
16//! | Platform | [`ThreadClass::Frame`] | [`ThreadClass::Background`] |
17//! |---|---|---|
18//! | Android, Linux | nice `min(inherited, -4)` (`THREAD_PRIORITY_DISPLAY`); an inherited better value is kept | nice `reference + 10`, never below the inherited value, at most `19` |
19//! | iOS, macOS | `QOS_CLASS_USER_INTERACTIVE` | `QOS_CLASS_UTILITY` |
20//! | everything else | nothing ([`ThreadClassOutcome::Unsupported`]) | nothing |
21//!
22//! The `reference` for the background class is the nice value of the thread
23//! that built the background runtime — the UI thread (see
24//! [`ReactiveRuntime::init`](crate::ReactiveRuntime::init)) — recorded once, so
25//! every lowered thread lands ten steps below the UI thread whichever thread
26//! happened to spawn it (a blocking thread spawned from an already-lowered
27//! worker would otherwise drift a further ten steps down). Only blocking-pool
28//! threads are lowered, by [`spawn_blocking`](crate::spawn_blocking) on the
29//! first job each thread runs; the async workers keep the class they inherit,
30//! and the pool keeps tokio's default size (it carries parked plugin calls
31//! beside decode and DNS).
32//!
33//! The shim only ever asks for a priority the platform hands out without
34//! privilege, and never takes one away from a thread that inherited better:
35//! the frame class asks for `-4` only when the inherited value is worse, and
36//! keeps the inherited value when the kernel refuses (`EACCES` outside an
37//! Android app's rlimit); the background class only ever raises the nice value
38//! (lower priority), which a thread may always do to itself.
39//!
40//! [`NO_THREAD_TUNING_VAR`] (`FRUST_NO_THREAD_TUNING`, compile-time define or
41//! runtime env, any non-`"0"` value) turns the policy off: the background
42//! runtime is built with tokio's defaults and nothing calls this module. The
43//! shells consult the same variable through
44//! `frust_shell_common::render_split::thread_tuning_enabled` for the render
45//! thread and keep their previous fixed priority under it.
46//!
47//! The `unsafe` here is three plain libc calls on the calling thread
48//! (`gettid`/`getpriority`/`setpriority` on Linux kernels,
49//! `pthread_set_qos_class_self_np` on Apple), each in its own `SAFETY`-noted
50//! block inside the private `sys` module.
51
52#[cfg(any(target_os = "android", target_os = "linux"))]
53use std::sync::atomic::{AtomicI32, Ordering};
54
55/// Which side of the frame a thread works on.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ThreadClass {
58 /// On the frame's critical path: the render thread's encode → acquire →
59 /// submit tail.
60 Frame,
61 /// Off the frame's critical path: the background runtime's blocking-pool
62 /// threads.
63 Background,
64}
65
66/// What [`set_current_thread_class`] did to the calling thread.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ThreadClassOutcome {
69 /// Linux kernels: the nice value moved from `from` to `to`.
70 Changed {
71 /// The nice value the thread inherited.
72 from: i32,
73 /// The nice value it runs at now.
74 to: i32,
75 },
76 /// Linux kernels: the inherited nice value already satisfies the class,
77 /// so nothing was written.
78 Kept {
79 /// The inherited (and unchanged) nice value.
80 nice: i32,
81 },
82 /// Apple: the QoS class was applied.
83 QosApplied,
84 /// The OS refused the change; the thread keeps what it inherited. `error`
85 /// is the raw OS error code (`errno` on Linux kernels, the returned code
86 /// on Apple).
87 Refused {
88 /// Raw OS error code.
89 error: i32,
90 },
91 /// No policy on this platform; nothing was attempted.
92 Unsupported,
93}
94
95/// The thread-tuning kill-switch environment/compile-time variable: when set
96/// to any non-`"0"` value, [`thread_tuning_enabled`] is `false` and the
97/// background runtime keeps tokio's default pool. Parsed exactly like the
98/// shells' `FRUST_NO_RENDER_THREAD` (`frust_shell_common::render_split`), which
99/// reads this same variable for the render thread.
100pub const NO_THREAD_TUNING_VAR: &str = "FRUST_NO_THREAD_TUNING";
101
102/// Whether the thread-scheduling policy is on: `true` unless the
103/// [`NO_THREAD_TUNING_VAR`] kill switch is engaged by the compile-time define
104/// or the runtime environment.
105pub fn thread_tuning_enabled() -> bool {
106 !kill_switch(
107 option_env!("FRUST_NO_THREAD_TUNING"),
108 std::env::var(NO_THREAD_TUNING_VAR).ok().as_deref(),
109 )
110}
111
112/// The pure decision [`thread_tuning_enabled`] negates: a non-`"0"` value from
113/// either source engages the kill switch.
114fn kill_switch(compile_time: Option<&str>, runtime: Option<&str>) -> bool {
115 fn is_set_non_zero(value: Option<&str>) -> bool {
116 matches!(value, Some(v) if v != "0")
117 }
118 is_set_non_zero(compile_time) || is_set_non_zero(runtime)
119}
120
121/// `android.os.Process.THREAD_PRIORITY_DISPLAY`, the published nice value of
122/// Android's own display-pipeline threads: the floor the frame class asks for
123/// when the inherited value is worse.
124pub const FRAME_NICE: i32 = -4;
125
126/// How many nice steps the background class sits below the reference (UI)
127/// thread. Android's own `THREAD_PRIORITY_BACKGROUND` is `10`; relative to a
128/// top-app UI thread at `-10` this lands the pool at the default `0`.
129pub const BACKGROUND_NICE_OFFSET: i32 = 10;
130
131/// The weakest nice value Linux accepts.
132#[cfg_attr(not(any(target_os = "android", target_os = "linux")), allow(dead_code))]
133const MAX_NICE: i32 = 19;
134
135/// The frame class's target nice for a thread that inherited `inherited`:
136/// `None` when the inherited value is already at least as good as
137/// [`FRAME_NICE`] (keep it), otherwise `Some(FRAME_NICE)`.
138#[cfg_attr(not(any(target_os = "android", target_os = "linux")), allow(dead_code))]
139pub(crate) fn frame_target(inherited: i32) -> Option<i32> {
140 (inherited > FRAME_NICE).then_some(FRAME_NICE)
141}
142
143/// The background class's target nice for a thread that inherited `inherited`,
144/// relative to the recorded `reference` (the inherited value itself when none
145/// was recorded): `reference + BACKGROUND_NICE_OFFSET`, never below
146/// `inherited` (the class only ever lowers priority) and never above
147/// [`MAX_NICE`]. `None` when that is the inherited value already.
148#[cfg_attr(not(any(target_os = "android", target_os = "linux")), allow(dead_code))]
149pub(crate) fn background_target(inherited: i32, reference: Option<i32>) -> Option<i32> {
150 let base = reference.unwrap_or(inherited);
151 let target = base
152 .saturating_add(BACKGROUND_NICE_OFFSET)
153 .max(inherited)
154 .min(MAX_NICE);
155 (target != inherited).then_some(target)
156}
157
158/// How long an idle blocking-pool thread lingers before it exits under the
159/// thread policy: tokio's own default, set explicitly so the policy's pool
160/// shape is stated in one place.
161pub const BLOCKING_KEEP_ALIVE: std::time::Duration = std::time::Duration::from_secs(10);
162
163/// One line describing the background runtime's thread policy for a shell to
164/// log: the recorded reference (UI thread) nice, the blocking keep-alive and
165/// whether tuning is on. This crate has no logger, so the shell logs it.
166pub fn runtime_policy_summary() -> String {
167 let reference = current_thread_nice_reference();
168 format!(
169 "background runtime thread policy: reference nice {}, blocking keep-alive {}s, tuning {}",
170 reference.map_or_else(|| "unknown".to_owned(), |n| n.to_string()),
171 BLOCKING_KEEP_ALIVE.as_secs(),
172 if thread_tuning_enabled() { "on" } else { "off" },
173 )
174}
175
176fn current_thread_nice_reference() -> Option<i32> {
177 #[cfg(any(target_os = "android", target_os = "linux"))]
178 {
179 recorded_reference()
180 }
181 #[cfg(not(any(target_os = "android", target_os = "linux")))]
182 {
183 None
184 }
185}
186
187/// Sentinel for "no reference recorded yet".
188#[cfg(any(target_os = "android", target_os = "linux"))]
189const NO_REFERENCE: i32 = i32::MIN;
190
191/// The nice value of the thread that built the background runtime (the UI
192/// thread), read by the background class. [`NO_REFERENCE`] until recorded.
193#[cfg(any(target_os = "android", target_os = "linux"))]
194static REFERENCE_NICE: AtomicI32 = AtomicI32::new(NO_REFERENCE);
195
196/// Records the calling thread's priority as the reference the background class
197/// is measured from. Called once on the UI thread while the background runtime
198/// is built; a no-op where the platform has no nice values. The wasm runtime
199/// starts no threads, so nothing calls it there.
200#[cfg_attr(target_family = "wasm", allow(dead_code))]
201pub(crate) fn record_reference_thread() {
202 #[cfg(any(target_os = "android", target_os = "linux"))]
203 if let Ok(nice) = sys::current_nice() {
204 REFERENCE_NICE.store(nice, Ordering::Relaxed);
205 }
206}
207
208#[cfg(any(target_os = "android", target_os = "linux"))]
209fn recorded_reference() -> Option<i32> {
210 let nice = REFERENCE_NICE.load(Ordering::Relaxed);
211 (nice != NO_REFERENCE).then_some(nice)
212}
213
214/// Applies `class` to the calling thread (see the module docs for what each
215/// platform does). Best-effort: a refusal leaves the thread at the priority it
216/// inherited and is reported, never raised as a panic. Shells log the
217/// outcome; this crate has no logger.
218pub fn set_current_thread_class(class: ThreadClass) -> ThreadClassOutcome {
219 #[cfg(any(target_os = "android", target_os = "linux"))]
220 {
221 let inherited = match sys::current_nice() {
222 Ok(nice) => nice,
223 Err(error) => return ThreadClassOutcome::Refused { error },
224 };
225 let target = match class {
226 ThreadClass::Frame => frame_target(inherited),
227 ThreadClass::Background => background_target(inherited, recorded_reference()),
228 };
229 match target {
230 None => ThreadClassOutcome::Kept { nice: inherited },
231 Some(to) => match sys::set_nice(to) {
232 Ok(()) => ThreadClassOutcome::Changed {
233 from: inherited,
234 to,
235 },
236 Err(error) => ThreadClassOutcome::Refused { error },
237 },
238 }
239 }
240
241 #[cfg(target_vendor = "apple")]
242 {
243 let qos = match class {
244 ThreadClass::Frame => libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE,
245 ThreadClass::Background => libc::qos_class_t::QOS_CLASS_UTILITY,
246 };
247 match sys::set_qos(qos) {
248 Ok(()) => ThreadClassOutcome::QosApplied,
249 Err(error) => ThreadClassOutcome::Refused { error },
250 }
251 }
252
253 #[cfg(not(any(target_os = "android", target_os = "linux", target_vendor = "apple")))]
254 {
255 let _ = class;
256 ThreadClassOutcome::Unsupported
257 }
258}
259
260/// The calling thread's nice value, where the platform has one. Exposed for
261/// tests and diagnostics that compare a thread with its spawner.
262pub fn current_thread_nice() -> Option<i32> {
263 #[cfg(any(target_os = "android", target_os = "linux"))]
264 {
265 sys::current_nice().ok()
266 }
267 #[cfg(not(any(target_os = "android", target_os = "linux")))]
268 {
269 None
270 }
271}
272
273/// The platform calls. Every function acts on the calling thread only.
274#[cfg(any(target_os = "android", target_os = "linux"))]
275mod sys {
276 /// The calling thread's kernel id, the `who` that scopes `PRIO_PROCESS`
277 /// to one thread (Linux nice values are per thread).
278 fn tid() -> libc::id_t {
279 // SAFETY: `gettid` takes no arguments, cannot fail and touches no
280 // memory; it returns the calling thread's kernel id.
281 let tid = unsafe { libc::gettid() };
282 tid as libc::id_t
283 }
284
285 /// The calling thread's nice value, or the raw `errno`.
286 ///
287 /// Read through the raw syscall rather than libc's `getpriority`, whose
288 /// `-1` return is ambiguous (a legitimate nice of `-1` versus an error):
289 /// the kernel returns `20 - nice`, always in `1..=40`, and a negative
290 /// value only on error.
291 pub(super) fn current_nice() -> Result<i32, i32> {
292 // SAFETY: `SYS_getpriority` with `PRIO_PROCESS` and this thread's own
293 // id only reads the thread's scheduling attribute; no pointer crosses
294 // the call.
295 let raw = unsafe {
296 libc::syscall(
297 libc::SYS_getpriority,
298 libc::PRIO_PROCESS as libc::c_long,
299 tid() as libc::c_long,
300 )
301 };
302 if raw < 0 {
303 Err(last_errno())
304 } else {
305 Ok(20 - raw as i32)
306 }
307 }
308
309 /// Sets the calling thread's nice value to `nice`, or returns the raw
310 /// `errno` (`EACCES` when lowering below what the rlimit allows).
311 pub(super) fn set_nice(nice: i32) -> Result<(), i32> {
312 // SAFETY: `setpriority` with `PRIO_PROCESS` and this thread's own id
313 // changes only this thread's nice value; no pointer crosses the call.
314 let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, tid(), nice) };
315 if rc == 0 { Ok(()) } else { Err(last_errno()) }
316 }
317
318 fn last_errno() -> i32 {
319 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
320 }
321}
322
323/// The platform calls. Every function acts on the calling thread only.
324#[cfg(target_vendor = "apple")]
325mod sys {
326 /// Tags the calling thread with `qos`, or returns the call's error code.
327 pub(super) fn set_qos(qos: libc::qos_class_t) -> Result<(), i32> {
328 // SAFETY: `pthread_set_qos_class_self_np` operates on the calling
329 // thread only and takes no pointer; a relative priority of `0` is the
330 // documented default within the class.
331 let rc = unsafe { libc::pthread_set_qos_class_self_np(qos, 0) };
332 if rc == 0 { Ok(()) } else { Err(rc) }
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn frame_target_keeps_an_inherited_value_at_or_above_display() {
342 assert_eq!(frame_target(-10), None, "a top-app UI thread's -10 is kept");
343 assert_eq!(frame_target(FRAME_NICE), None);
344 }
345
346 #[test]
347 fn frame_target_asks_for_display_when_the_inherited_value_is_worse() {
348 assert_eq!(frame_target(0), Some(FRAME_NICE));
349 assert_eq!(frame_target(10), Some(FRAME_NICE));
350 }
351
352 #[test]
353 fn background_target_sits_ten_steps_below_the_reference() {
354 // A pool thread spawned from a -10 UI thread lands at 0.
355 assert_eq!(background_target(-10, Some(-10)), Some(0));
356 // One spawned from an already-lowered worker stays where it is
357 // instead of drifting a further ten steps down.
358 assert_eq!(background_target(0, Some(-10)), None);
359 }
360
361 #[test]
362 fn background_target_never_raises_priority() {
363 // Inherited weaker than reference + offset: kept, never lowered.
364 assert_eq!(background_target(15, Some(-10)), None);
365 }
366
367 #[test]
368 fn background_target_without_a_reference_is_relative_to_the_inherited_value() {
369 assert_eq!(background_target(0, None), Some(10));
370 assert_eq!(background_target(-4, None), Some(6));
371 }
372
373 #[test]
374 fn background_target_clamps_at_the_weakest_nice() {
375 assert_eq!(background_target(15, None), Some(MAX_NICE));
376 assert_eq!(background_target(MAX_NICE, None), None);
377 }
378
379 #[test]
380 fn kill_switch_parses_like_the_render_thread_switch() {
381 assert!(!kill_switch(None, None));
382 assert!(kill_switch(Some("1"), None));
383 assert!(kill_switch(None, Some("1")));
384 assert!(!kill_switch(Some("0"), None));
385 assert!(!kill_switch(None, Some("0")));
386 assert!(kill_switch(Some("0"), Some("1")));
387 }
388
389 /// Runs `f` on a fresh thread spawned from the calling one and returns its
390 /// result, so a class change never leaks into the test-runner thread.
391 #[cfg(any(target_os = "android", target_os = "linux"))]
392 fn on_spawned_thread<R: Send + 'static>(f: impl FnOnce() -> R + Send + 'static) -> R {
393 std::thread::spawn(f).join().expect("probe thread panicked")
394 }
395
396 #[cfg(any(target_os = "android", target_os = "linux"))]
397 #[test]
398 fn a_background_thread_runs_at_a_higher_nice_than_its_spawner() {
399 let spawner = current_thread_nice().expect("nice is readable on Linux");
400 let (outcome, after) = on_spawned_thread(|| {
401 let outcome = set_current_thread_class(ThreadClass::Background);
402 (outcome, current_thread_nice().expect("nice is readable"))
403 });
404 if spawner >= MAX_NICE {
405 assert_eq!(after, MAX_NICE, "nothing weaker than {MAX_NICE} exists");
406 return;
407 }
408 assert!(
409 after > spawner,
410 "background thread nice {after} must be higher than its spawner's {spawner} \
411 (outcome {outcome:?})"
412 );
413 }
414
415 #[cfg(any(target_os = "android", target_os = "linux"))]
416 #[test]
417 fn a_frame_thread_never_runs_below_its_inherited_priority() {
418 let spawner = current_thread_nice().expect("nice is readable on Linux");
419 let (outcome, after) = on_spawned_thread(|| {
420 let outcome = set_current_thread_class(ThreadClass::Frame);
421 (outcome, current_thread_nice().expect("nice is readable"))
422 });
423 assert!(
424 after <= spawner,
425 "frame thread nice {after} must not be weaker than the inherited {spawner} \
426 (outcome {outcome:?})"
427 );
428 match outcome {
429 ThreadClassOutcome::Kept { nice } => assert_eq!(nice, spawner),
430 ThreadClassOutcome::Changed { from, to } => {
431 assert_eq!(from, spawner);
432 assert_eq!(to, FRAME_NICE);
433 }
434 // An unprivileged host refuses the move to -4; the thread keeps
435 // what it inherited, which the assertion above already pinned.
436 ThreadClassOutcome::Refused { .. } => assert_eq!(after, spawner),
437 other => panic!("unexpected outcome on Linux: {other:?}"),
438 }
439 }
440}