Skip to main content

lance_core/utils/
tokio.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::sync::atomic::Ordering;
5use std::sync::{LazyLock, atomic};
6use std::time::Duration;
7
8use futures::{Future, FutureExt};
9use tokio::runtime::{Builder, Runtime};
10use tracing::Span;
11
12/// We cache the call to num_cpus::get() because:
13///
14/// 1. It shouldn't change during the lifetime of the program
15/// 2. It's a relatively expensive call (requires opening several files and examining them)
16static NUM_COMPUTE_INTENSIVE_CPUS: LazyLock<usize> =
17    LazyLock::new(calculate_num_compute_intensive_cpus);
18
19pub fn get_num_compute_intensive_cpus() -> usize {
20    *NUM_COMPUTE_INTENSIVE_CPUS
21}
22
23fn calculate_num_compute_intensive_cpus() -> usize {
24    if let Ok(raw) = std::env::var("LANCE_CPU_THREADS") {
25        return parse_env_usize("LANCE_CPU_THREADS", &raw, 1).unwrap_or_else(|e| panic!("{e}"));
26    }
27
28    let cpus = num_cpus::get();
29
30    if cpus <= *IO_CORE_RESERVATION {
31        // If the user is not setting a custom value for LANCE_IO_CORE_RESERVATION then we don't emit
32        // a warning because they're just on a small machine and there isn't much they can do about it.
33        if cpus > 2 {
34            log::warn!(
35                "Number of CPUs is less than or equal to the number of IO core reservations. \
36                This is not a supported configuration. using 1 CPU for compute intensive tasks."
37            );
38        }
39        return 1;
40    }
41
42    num_cpus::get() - *IO_CORE_RESERVATION
43}
44
45/// Parse an integer environment variable, rejecting values below `min`.
46///
47/// The error names the variable, so a bad value is diagnosable instead of
48/// surfacing as a bare `ParseIntError` or, for `LANCE_CPU_THREADS=0`, a panic
49/// deep inside tokio's `max_blocking_threads`.
50fn parse_env_usize(name: &str, raw: &str, min: usize) -> Result<usize, String> {
51    let value: usize = raw
52        .trim()
53        .parse()
54        .map_err(|e| format!("environment variable {name} must be an integer, got {raw:?}: {e}"))?;
55    if value < min {
56        return Err(format!(
57            "environment variable {name} must be at least {min}, got {value}"
58        ));
59    }
60    Ok(value)
61}
62
63/// Number of CPU cores held back for I/O and control tasks.
64///
65/// Overridable via the `LANCE_IO_CORE_RESERVATION` environment variable;
66/// defaults to `2` when unset. `0` is allowed (reserve nothing);
67/// [`get_num_compute_intensive_cpus`] subtracts this from the core count to
68/// size the compute pool. A non-integer value panics on first access with an
69/// error naming the variable.
70pub static IO_CORE_RESERVATION: LazyLock<usize> =
71    LazyLock::new(|| match std::env::var("LANCE_IO_CORE_RESERVATION") {
72        Ok(raw) => {
73            parse_env_usize("LANCE_IO_CORE_RESERVATION", &raw, 0).unwrap_or_else(|e| panic!("{e}"))
74        }
75        Err(_) => 2,
76    });
77
78fn create_runtime() -> Runtime {
79    Builder::new_multi_thread()
80        .thread_name("lance-cpu")
81        .max_blocking_threads(get_num_compute_intensive_cpus())
82        .worker_threads(1)
83        // keep the thread alive "forever"
84        .thread_keep_alive(Duration::from_secs(u64::MAX))
85        .build()
86        .unwrap()
87}
88
89static CPU_RUNTIME: atomic::AtomicPtr<Runtime> = atomic::AtomicPtr::new(std::ptr::null_mut());
90
91static RUNTIME_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false);
92
93static ATFORK_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false);
94
95fn global_cpu_runtime() -> &'static Runtime {
96    loop {
97        let ptr = CPU_RUNTIME.load(Ordering::SeqCst);
98        if !ptr.is_null() {
99            // SAFETY: `ptr` was produced by `Box::into_raw` below and is only ever
100            // reset to null by `atfork_tokio_child` in the forked child (single-
101            // threaded, async-signal context). The `Box` is never reclaimed, so the
102            // `Runtime` lives for the rest of the process.
103            return unsafe { &*ptr };
104        }
105        if !RUNTIME_INSTALLED.fetch_or(true, Ordering::SeqCst) {
106            break;
107        }
108        std::thread::yield_now();
109    }
110    if !ATFORK_INSTALLED.fetch_or(true, Ordering::SeqCst) {
111        install_atfork();
112    }
113    let new_ptr = Box::into_raw(Box::new(create_runtime()));
114    CPU_RUNTIME.store(new_ptr, Ordering::SeqCst);
115    // SAFETY: `new_ptr` was just obtained from `Box::into_raw`, so it is non-null,
116    // aligned, and points to a live `Runtime` that is never reclaimed.
117    unsafe { &*new_ptr }
118}
119
120/// After a fork() operation, force re-creation of the BackgroundExecutor. Note: this function
121/// runs in "async-signal context" which means that we can't (safely) do much here.
122extern "C" fn atfork_tokio_child() {
123    CPU_RUNTIME.store(std::ptr::null_mut(), Ordering::SeqCst);
124    RUNTIME_INSTALLED.store(false, Ordering::SeqCst);
125}
126
127#[cfg(not(windows))]
128fn install_atfork() {
129    unsafe { libc::pthread_atfork(None, None, Some(atfork_tokio_child)) };
130}
131
132#[cfg(windows)]
133fn install_atfork() {}
134
135/// Spawn a CPU intensive task
136///
137/// This task will be put onto a thread pool dedicated for CPU-intensive work
138/// This keeps the tokio thread pool free so that we can always be ready to service
139/// cheap I/O & control requests.
140///
141/// This can also be used to convert a big chunk of synchronous work into a future
142/// so that it can be run in parallel with something like StreamExt::buffered()
143///
144/// # Only hand over substantial CPU work
145///
146/// Dispatching to the pool has real overhead (a `spawn_blocking` hop plus a oneshot
147/// channel round trip). As a rule of thumb the closure should be expected to do at
148/// least ~100µs of CPU work; below that the thread-pool overhead is likely to
149/// outweigh any parallelism benefit, and the work is better left inline.
150///
151/// # The task must never wait on anything
152///
153/// The CPU pool is sized to [`get_num_compute_intensive_cpus`], which is
154/// `max(1, num_cpus - LANCE_IO_CORE_RESERVATION)`. On a big host that is plenty of
155/// workers (e.g. 62 on a 64-core box), but in resource-constrained environments it can
156/// collapse to a **single blocking thread** — on machines with `<= 3` visible CPUs
157/// (1-vCPU VMs, CI runners, CPU-limited Kubernetes pods) the pool has exactly one
158/// worker. A closure passed to `spawn_cpu` occupies one of these threads for its entire
159/// lifetime, including any time it spends *parked*. So the closure must only consume
160/// CPU and return; it must
161/// **never** block, wait, or park. Concretely, the closure must not, directly or
162/// transitively:
163///
164/// * **No channels** — no blocking send/recv (`send_blocking`, blocking `recv`, etc.).
165///   A full/empty channel parks the thread, and whatever would drain/fill the channel
166///   may need the same pool to run.
167/// * **No I/O** — no file, network, or object-store reads/writes, and no disk spills.
168///   I/O parks the thread while making no progress on CPU work.
169/// * **No locks** — no acquiring a contended lock (or any lock that is held across an
170///   `.await` elsewhere). Waiting for the lock parks the thread.
171/// * **No `block_on` / `.blocking_*`** — never drive or wait on another async task
172///   from inside the closure.
173///
174/// If any of these hold, the parked thread can starve the exact work that would
175/// unblock it, deadlocking the whole pool with no timeout and no error — a silent
176/// hang at 0% CPU. (See <https://github.com/lancedb/lance/pull/7423>.) When work
177/// needs to wait on a channel/lock/I/O, keep the waiting in an async task and only
178/// hand the pure-CPU portion to `spawn_cpu`, e.g. build each batch with `spawn_cpu`
179/// and dispatch it with `tx.send(batch).await` in the surrounding async code.
180pub fn spawn_cpu<
181    E: std::error::Error + Send + 'static,
182    F: FnOnce() -> std::result::Result<R, E> + Send + 'static,
183    R: Send + 'static,
184>(
185    func: F,
186) -> impl Future<Output = std::result::Result<R, E>> {
187    // Propagate the current span into the task
188    let span = Span::current();
189    let handle = global_cpu_runtime().spawn_blocking(move || {
190        let _span_guard = span.enter();
191        func()
192    });
193    // Awaited through the join handle, not a result channel: a panic in `func`
194    // arrives as a `JoinError` still carrying its payload, so resuming it
195    // re-raises the original panic in the caller. Reporting the closure's
196    // outcome over a channel instead loses that -- the sender drops unsent and
197    // every panic in any `spawn_cpu` closure surfaces identically as an opaque
198    // `RecvError`, pointing here rather than at the fault.
199    handle.map(|res| match res {
200        Ok(result) => result,
201        Err(join_error) => match join_error.try_into_panic() {
202            Ok(panic) => std::panic::resume_unwind(panic),
203            // The CPU runtime outlives every caller, so its tasks are not
204            // cancelled out from under one.
205            Err(join_error) => panic!("spawn_cpu task failed: {join_error}"),
206        },
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    /// A panic in the closure must reach the caller intact.
215    ///
216    /// Reporting the closure's outcome over a channel loses it: the sender
217    /// drops unsent and the caller can only see an opaque receive error, so
218    /// every panic in every `spawn_cpu` closure looks the same.
219    #[tokio::test]
220    async fn spawn_cpu_reraises_the_closure_panic() {
221        let hook = std::panic::take_hook();
222        std::panic::set_hook(Box::new(|_| {}));
223        let joined = tokio::spawn(async {
224            spawn_cpu(|| -> std::result::Result<(), std::io::Error> {
225                panic!("the original message")
226            })
227            .await
228        })
229        .await;
230        std::panic::set_hook(hook);
231
232        let payload = joined
233            .expect_err("the closure's panic propagates to the caller")
234            .into_panic();
235        let message = payload
236            .downcast_ref::<&str>()
237            .copied()
238            .expect("the original payload survives");
239        assert_eq!(message, "the original message");
240    }
241
242    // The env vars feed process-global `LazyLock`s that read once and are read
243    // in parallel by other tests, so the pure parser is tested directly rather
244    // than by mutating the environment.
245
246    #[test]
247    fn parses_valid_value_and_trims_surrounding_whitespace() {
248        assert_eq!(parse_env_usize("VAR", "8", 1).unwrap(), 8);
249        assert_eq!(parse_env_usize("VAR", " 8 ", 1).unwrap(), 8);
250    }
251
252    #[test]
253    fn rejects_non_integer_naming_the_variable() {
254        let err = parse_env_usize("LANCE_CPU_THREADS", "abc", 1).unwrap_err();
255        assert!(err.contains("LANCE_CPU_THREADS"), "{err}");
256        assert!(err.contains("must be an integer"), "{err}");
257    }
258
259    #[test]
260    fn rejects_value_below_minimum() {
261        // LANCE_CPU_THREADS=0 parses fine but would panic in tokio's
262        // max_blocking_threads(0); the minimum stops it at the boundary.
263        let err = parse_env_usize("LANCE_CPU_THREADS", "0", 1).unwrap_err();
264        assert!(err.contains("at least 1"), "{err}");
265    }
266
267    #[test]
268    fn allows_zero_when_minimum_is_zero() {
269        // LANCE_IO_CORE_RESERVATION=0 is valid: no cores reserved for IO.
270        assert_eq!(parse_env_usize("VAR", "0", 0).unwrap(), 0);
271    }
272}