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(user_specified) = std::env::var("LANCE_CPU_THREADS") {
25 return user_specified.parse().unwrap();
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
45pub static IO_CORE_RESERVATION: LazyLock<usize> = LazyLock::new(|| {
46 std::env::var("LANCE_IO_CORE_RESERVATION")
47 .unwrap_or("2".to_string())
48 .parse()
49 .unwrap()
50});
51
52fn create_runtime() -> Runtime {
53 Builder::new_multi_thread()
54 .thread_name("lance-cpu")
55 .max_blocking_threads(get_num_compute_intensive_cpus())
56 .worker_threads(1)
57 // keep the thread alive "forever"
58 .thread_keep_alive(Duration::from_secs(u64::MAX))
59 .build()
60 .unwrap()
61}
62
63static CPU_RUNTIME: atomic::AtomicPtr<Runtime> = atomic::AtomicPtr::new(std::ptr::null_mut());
64
65static RUNTIME_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false);
66
67static ATFORK_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false);
68
69fn global_cpu_runtime() -> &'static Runtime {
70 loop {
71 let ptr = CPU_RUNTIME.load(Ordering::SeqCst);
72 if !ptr.is_null() {
73 // SAFETY: `ptr` was produced by `Box::into_raw` below and is only ever
74 // reset to null by `atfork_tokio_child` in the forked child (single-
75 // threaded, async-signal context). The `Box` is never reclaimed, so the
76 // `Runtime` lives for the rest of the process.
77 return unsafe { &*ptr };
78 }
79 if !RUNTIME_INSTALLED.fetch_or(true, Ordering::SeqCst) {
80 break;
81 }
82 std::thread::yield_now();
83 }
84 if !ATFORK_INSTALLED.fetch_or(true, Ordering::SeqCst) {
85 install_atfork();
86 }
87 let new_ptr = Box::into_raw(Box::new(create_runtime()));
88 CPU_RUNTIME.store(new_ptr, Ordering::SeqCst);
89 // SAFETY: `new_ptr` was just obtained from `Box::into_raw`, so it is non-null,
90 // aligned, and points to a live `Runtime` that is never reclaimed.
91 unsafe { &*new_ptr }
92}
93
94/// After a fork() operation, force re-creation of the BackgroundExecutor. Note: this function
95/// runs in "async-signal context" which means that we can't (safely) do much here.
96extern "C" fn atfork_tokio_child() {
97 CPU_RUNTIME.store(std::ptr::null_mut(), Ordering::SeqCst);
98 RUNTIME_INSTALLED.store(false, Ordering::SeqCst);
99}
100
101#[cfg(not(windows))]
102fn install_atfork() {
103 unsafe { libc::pthread_atfork(None, None, Some(atfork_tokio_child)) };
104}
105
106#[cfg(windows)]
107fn install_atfork() {}
108
109/// Spawn a CPU intensive task
110///
111/// This task will be put onto a thread pool dedicated for CPU-intensive work
112/// This keeps the tokio thread pool free so that we can always be ready to service
113/// cheap I/O & control requests.
114///
115/// This can also be used to convert a big chunk of synchronous work into a future
116/// so that it can be run in parallel with something like StreamExt::buffered()
117///
118/// # Only hand over substantial CPU work
119///
120/// Dispatching to the pool has real overhead (a `spawn_blocking` hop plus a oneshot
121/// channel round trip). As a rule of thumb the closure should be expected to do at
122/// least ~100µs of CPU work; below that the thread-pool overhead is likely to
123/// outweigh any parallelism benefit, and the work is better left inline.
124///
125/// # The task must never wait on anything
126///
127/// The CPU pool is sized to [`get_num_compute_intensive_cpus`], which is
128/// `max(1, num_cpus - LANCE_IO_CORE_RESERVATION)`. On a big host that is plenty of
129/// workers (e.g. 62 on a 64-core box), but in resource-constrained environments it can
130/// collapse to a **single blocking thread** — on machines with `<= 3` visible CPUs
131/// (1-vCPU VMs, CI runners, CPU-limited Kubernetes pods) the pool has exactly one
132/// worker. A closure passed to `spawn_cpu` occupies one of these threads for its entire
133/// lifetime, including any time it spends *parked*. So the closure must only consume
134/// CPU and return; it must
135/// **never** block, wait, or park. Concretely, the closure must not, directly or
136/// transitively:
137///
138/// * **No channels** — no blocking send/recv (`send_blocking`, blocking `recv`, etc.).
139/// A full/empty channel parks the thread, and whatever would drain/fill the channel
140/// may need the same pool to run.
141/// * **No I/O** — no file, network, or object-store reads/writes, and no disk spills.
142/// I/O parks the thread while making no progress on CPU work.
143/// * **No locks** — no acquiring a contended lock (or any lock that is held across an
144/// `.await` elsewhere). Waiting for the lock parks the thread.
145/// * **No `block_on` / `.blocking_*`** — never drive or wait on another async task
146/// from inside the closure.
147///
148/// If any of these hold, the parked thread can starve the exact work that would
149/// unblock it, deadlocking the whole pool with no timeout and no error — a silent
150/// hang at 0% CPU. (See <https://github.com/lancedb/lance/pull/7423>.) When work
151/// needs to wait on a channel/lock/I/O, keep the waiting in an async task and only
152/// hand the pure-CPU portion to `spawn_cpu`, e.g. build each batch with `spawn_cpu`
153/// and dispatch it with `tx.send(batch).await` in the surrounding async code.
154pub fn spawn_cpu<
155 E: std::error::Error + Send + 'static,
156 F: FnOnce() -> std::result::Result<R, E> + Send + 'static,
157 R: Send + 'static,
158>(
159 func: F,
160) -> impl Future<Output = std::result::Result<R, E>> {
161 let (send, recv) = tokio::sync::oneshot::channel();
162 // Propagate the current span into the task
163 let span = Span::current();
164 global_cpu_runtime().spawn_blocking(move || {
165 let _span_guard = span.enter();
166 let result = func();
167 let _ = send.send(result);
168 });
169 recv.map(|res| res.unwrap())
170}