Skip to main content

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}