use std::collections::VecDeque;
use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, LazyLock, Mutex};
use std::thread::JoinHandle;
use super::facility::{recover, run_facility_loop, run_isolated};
use crate::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
pub type Callback = Box<dyn FnOnce() + Send + 'static>;
pub const NUM_CALLBACK_PRIORITIES: usize = 3;
pub const DEFAULT_QUEUE_SIZE: usize = 2000;
pub const DEFAULT_THREADS_PER_PRIORITY: usize = 1;
static CONFIGURED_QUEUE_SIZE: AtomicUsize = AtomicUsize::new(DEFAULT_QUEUE_SIZE);
static CONFIGURED_THREADS: [AtomicUsize; NUM_CALLBACK_PRIORITIES] = [
AtomicUsize::new(DEFAULT_THREADS_PER_PRIORITY),
AtomicUsize::new(DEFAULT_THREADS_PER_PRIORITY),
AtomicUsize::new(DEFAULT_THREADS_PER_PRIORITY),
];
pub fn set_queue_size(size: usize) {
CONFIGURED_QUEUE_SIZE.store(size.max(1), Ordering::Relaxed);
}
pub fn set_parallel_threads(count: usize, priority: Option<CallbackPriority>) {
let count = count.max(1);
match priority {
Some(p) => CONFIGURED_THREADS[p.index()].store(count, Ordering::Relaxed),
None => {
for slot in &CONFIGURED_THREADS {
slot.store(count, Ordering::Relaxed);
}
}
}
}
pub fn cpu_count() -> i32 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1) as i32
}
static PARALLEL_THREADS_DEFAULT: LazyLock<AtomicI32> =
LazyLock::new(|| AtomicI32::new(cpu_count()));
pub fn parallel_threads_default() -> i32 {
PARALLEL_THREADS_DEFAULT.load(Ordering::Relaxed)
}
pub fn set_parallel_threads_default(value: i32) {
PARALLEL_THREADS_DEFAULT.store(value, Ordering::Relaxed);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CallbackPriority {
Low,
Medium,
High,
}
impl CallbackPriority {
pub const ALL: [CallbackPriority; NUM_CALLBACK_PRIORITIES] = [
CallbackPriority::Low,
CallbackPriority::Medium,
CallbackPriority::High,
];
pub fn index(self) -> usize {
match self {
CallbackPriority::Low => 0,
CallbackPriority::Medium => 1,
CallbackPriority::High => 2,
}
}
pub fn from_record_prio(prio: i16) -> CallbackPriority {
match prio {
1 => CallbackPriority::Medium,
2 => CallbackPriority::High,
_ => CallbackPriority::Low,
}
}
pub fn name_prefix(self) -> &'static str {
match self {
CallbackPriority::Low => "cbLow",
CallbackPriority::Medium => "cbMedium",
CallbackPriority::High => "cbHigh",
}
}
pub fn os_priority(self) -> ThreadPriority {
let scan_low = ThreadPriority::ScanLow.value(); let scan_high = ThreadPriority::ScanHigh.value(); match self {
CallbackPriority::Low => ThreadPriority::Custom(scan_low - 1),
CallbackPriority::Medium => ThreadPriority::Custom(scan_low + 4),
CallbackPriority::High => ThreadPriority::Custom(scan_high + 1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallbackError {
QueueFull,
}
enum Queued {
Ring(Callback),
Task(Callback),
}
struct QueueState {
queue: VecDeque<Queued>,
ring_used: usize,
high_water: usize,
overflow: bool,
overflows: u64,
shutdown: bool,
}
struct PriorityQueue {
capacity: usize,
state: Mutex<QueueState>,
wake: Condvar,
}
impl PriorityQueue {
fn new(capacity: usize) -> Self {
PriorityQueue {
capacity,
state: Mutex::new(QueueState {
queue: VecDeque::with_capacity(capacity.min(1024)),
ring_used: 0,
high_water: 0,
overflow: false,
overflows: 0,
shutdown: false,
}),
wake: Condvar::new(),
}
}
fn request(&self, name: &str, cb: Callback) -> Result<(), CallbackError> {
let mut st = recover(FACILITY, self.state.lock());
if st.shutdown {
drop(st);
tracing::trace!(
target: "epics_base_rs::runtime::callback",
band = name,
"callbackRequest after shutdown dropped"
);
return Ok(());
}
if st.overflow {
return Err(CallbackError::QueueFull);
}
if st.ring_used >= self.capacity {
st.overflow = true;
st.overflows += 1;
tracing::error!(
target: "epics_base_rs::runtime::callback",
band = name,
"callbackRequest: ERROR {} ring buffer full",
name
);
return Err(CallbackError::QueueFull);
}
st.queue.push_back(Queued::Ring(cb));
st.ring_used += 1;
st.high_water = st.high_water.max(st.ring_used);
drop(st);
self.wake.notify_one();
Ok(())
}
fn schedule_task(&self, cb: Callback) {
let mut st = recover(FACILITY, self.state.lock());
if st.shutdown {
return;
}
st.queue.push_back(Queued::Task(cb));
drop(st);
self.wake.notify_one();
}
fn stats(&self, reset: bool) -> CallbackQueueStats {
let mut st = recover(FACILITY, self.state.lock());
let out = CallbackQueueStats {
size: self.capacity,
num_used: st.ring_used,
max_used: st.high_water,
num_overflow: st.overflows,
};
if reset {
st.high_water = 0;
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CallbackQueueStats {
pub size: usize,
pub num_used: usize,
pub max_used: usize,
pub num_overflow: u64,
}
const FACILITY: &str = "callback band";
fn worker_loop(pq: &PriorityQueue) {
loop {
let mut st = recover(FACILITY, pq.state.lock());
while st.queue.is_empty() && !st.shutdown {
st = recover(FACILITY, pq.wake.wait(st));
}
if st.queue.is_empty() {
return;
}
let cb = match st.queue.pop_front().unwrap() {
Queued::Ring(cb) => {
st.ring_used -= 1;
st.overflow = false;
cb
}
Queued::Task(cb) => cb,
};
drop(st);
run_isolated(FACILITY, cb);
}
}
#[derive(Clone)]
pub struct CallbackHandle {
queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
}
impl CallbackHandle {
pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
let pq = &self.queues[priority.index()];
pq.request(priority.name_prefix(), cb)
}
pub(super) fn schedule_task(&self, priority: CallbackPriority, cb: Callback) {
self.queues[priority.index()].schedule_task(cb);
}
pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
}
pub fn stats(&self, priority: CallbackPriority, reset: bool) -> CallbackQueueStats {
self.queues[priority.index()].stats(reset)
}
}
pub struct CallbackPool {
queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
workers: Vec<JoinHandle<()>>,
}
impl CallbackPool {
pub fn new() -> Self {
Self::with_per_priority_config(
CONFIGURED_QUEUE_SIZE.load(Ordering::Relaxed),
CallbackPriority::ALL.map(|p| CONFIGURED_THREADS[p.index()].load(Ordering::Relaxed)),
)
}
pub fn with_config(queue_size: usize, threads_per_priority: usize) -> Self {
Self::with_per_priority_config(queue_size, [threads_per_priority; NUM_CALLBACK_PRIORITIES])
}
pub fn with_per_priority_config(
queue_size: usize,
threads_per_priority: [usize; NUM_CALLBACK_PRIORITIES],
) -> Self {
let capacity = queue_size.max(1);
let threads_per_priority = threads_per_priority.map(|n| n.max(1));
let queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES] = [
Arc::new(PriorityQueue::new(capacity)),
Arc::new(PriorityQueue::new(capacity)),
Arc::new(PriorityQueue::new(capacity)),
];
let mut workers = Vec::with_capacity(threads_per_priority.iter().sum::<usize>());
for prio in CallbackPriority::ALL {
let pq = &queues[prio.index()];
let threads = threads_per_priority[prio.index()];
for j in 0..threads {
let name = if threads > 1 {
format!("{}-{}", prio.name_prefix(), j)
} else {
prio.name_prefix().to_string()
};
let pq = Arc::clone(pq);
let watched_name = name.clone();
let handle = MandatoryThread::new(
name,
prio.os_priority(),
StackSizeClass::Big,
)
.spawn(move || {
let _watched = crate::runtime::taskwd::taskwd_insert(
watched_name,
crate::runtime::taskwd::CheckIn::Unbounded,
None,
);
run_facility_loop(
FACILITY,
|| worker_loop(&pq),
|| recover(FACILITY, pq.state.lock()).shutdown = true,
);
});
workers.push(handle);
}
}
CallbackPool { queues, workers }
}
pub fn handle(&self) -> CallbackHandle {
CallbackHandle {
queues: self.queues.clone(),
}
}
pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
self.queues[priority.index()].request(priority.name_prefix(), cb)
}
pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
}
pub fn stats(&self, priority: CallbackPriority, reset: bool) -> CallbackQueueStats {
self.queues[priority.index()].stats(reset)
}
pub fn shutdown(&mut self) {
for pq in &self.queues {
recover(FACILITY, pq.state.lock()).shutdown = true;
pq.wake.notify_all();
}
for w in self.workers.drain(..) {
let _ = w.join();
}
}
}
impl Default for CallbackPool {
fn default() -> Self {
Self::new()
}
}
pub struct DedicatedExecutor {
queue: Arc<PriorityQueue>,
workers: Vec<JoinHandle<()>>,
}
impl DedicatedExecutor {
pub fn new(name: &str, priority: ThreadPriority, threads: usize) -> std::io::Result<Self> {
let threads = threads.max(1);
let queue = Arc::new(PriorityQueue::new(
CONFIGURED_QUEUE_SIZE.load(Ordering::Relaxed).max(1),
));
let mut workers = Vec::with_capacity(threads);
for j in 0..threads {
let worker_name = if threads > 1 {
format!("{name}-{j}")
} else {
name.to_string()
};
let pq = Arc::clone(&queue);
let watched_name = worker_name.clone();
let spawned = crate::runtime::task::spawn_dedicated_thread(
worker_name,
priority,
StackSizeClass::Big,
move || {
let _watched = crate::runtime::taskwd::taskwd_insert(
watched_name,
crate::runtime::taskwd::CheckIn::Unbounded,
None,
);
run_facility_loop(
FACILITY,
|| worker_loop(&pq),
|| recover(FACILITY, pq.state.lock()).shutdown = true,
);
},
);
match spawned {
Ok(handle) => workers.push(handle),
Err(e) => {
let mut partial = DedicatedExecutor { queue, workers };
partial.shutdown();
return Err(e);
}
}
}
Ok(DedicatedExecutor { queue, workers })
}
pub fn handle(&self) -> CallbackHandle {
CallbackHandle {
queues: [
Arc::clone(&self.queue),
Arc::clone(&self.queue),
Arc::clone(&self.queue),
],
}
}
pub fn shutdown(&mut self) {
recover(FACILITY, self.queue.state.lock()).shutdown = true;
self.queue.wake.notify_all();
for w in self.workers.drain(..) {
let _ = w.join();
}
}
}
impl std::fmt::Debug for DedicatedExecutor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DedicatedExecutor")
.field("workers", &self.workers.len())
.finish()
}
}
impl Drop for DedicatedExecutor {
fn drop(&mut self) {
self.shutdown();
}
}
impl Drop for CallbackPool {
fn drop(&mut self) {
self.shutdown();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
const T: Duration = Duration::from_secs(5);
#[test]
fn a_dedicated_executor_runs_its_work_at_the_priority_it_was_given() {
let exec = DedicatedExecutor::new("TESTEXEC", ThreadPriority::Custom(18), 1)
.expect("executor starts");
let (tx, rx) = mpsc::channel();
exec.handle()
.request(
CallbackPriority::Medium,
Box::new(move || {
let name = std::thread::current()
.name()
.unwrap_or_default()
.to_string();
tx.send(name).expect("send");
}),
)
.expect("enqueue");
assert_eq!(rx.recv_timeout(T).expect("callback ran"), "TESTEXEC");
}
#[test]
fn every_band_on_a_dedicated_executor_names_the_same_ring() {
let exec = DedicatedExecutor::new("BANDEXEC", ThreadPriority::Custom(18), 1)
.expect("executor starts");
for band in CallbackPriority::ALL {
let (tx, rx) = mpsc::channel();
exec.handle()
.request(band, Box::new(move || tx.send(()).expect("send")))
.expect("enqueue");
rx.recv_timeout(T)
.unwrap_or_else(|_| panic!("{band:?} reached a worker"));
}
}
#[test]
fn parallel_workers_share_the_ring_and_are_numbered() {
let exec = DedicatedExecutor::new("PAREXEC", ThreadPriority::Custom(18), 2)
.expect("executor starts");
let (tx, rx) = mpsc::channel();
for _ in 0..8 {
let tx = tx.clone();
exec.handle()
.request(
CallbackPriority::Medium,
Box::new(move || {
let name = std::thread::current()
.name()
.unwrap_or_default()
.to_string();
tx.send(name).expect("send");
}),
)
.expect("enqueue");
}
drop(tx);
let names: Vec<String> = rx.iter().take(8).collect();
assert_eq!(names.len(), 8, "every task ran");
for name in &names {
assert!(
name == "PAREXEC-0" || name == "PAREXEC-1",
"unexpected worker {name}"
);
}
}
#[test]
fn shutting_a_dedicated_executor_down_twice_is_a_no_op() {
let mut exec = DedicatedExecutor::new("DUPEXEC", ThreadPriority::Custom(18), 1)
.expect("executor starts");
exec.shutdown();
exec.shutdown();
assert!(
exec.handle()
.request(CallbackPriority::Medium, Box::new(|| {}))
.is_ok()
);
}
#[test]
fn a_panicking_callback_does_not_stop_the_band() {
let pool = CallbackPool::new();
pool.request(
CallbackPriority::Medium,
Box::new(|| panic!("a callback panicked on its band")),
)
.expect("enqueue the panicking callback");
let (tx, rx) = mpsc::channel();
pool.request(
CallbackPriority::Medium,
Box::new(move || tx.send(42u32).unwrap()),
)
.expect("enqueue the next callback");
assert_eq!(
rx.recv_timeout(T).unwrap(),
42,
"the callback after a panicking one never ran: the band worker died with it"
);
}
#[test]
fn enqueued_callback_runs() {
let pool = CallbackPool::new();
let (tx, rx) = mpsc::channel();
pool.request(
CallbackPriority::Medium,
Box::new(move || tx.send(42u32).unwrap()),
)
.unwrap();
assert_eq!(rx.recv_timeout(T).unwrap(), 42);
}
#[test]
fn priority_bands_are_independent() {
let pool = CallbackPool::new();
let (started_tx, started_rx) = mpsc::channel();
let (gate_tx, gate_rx) = mpsc::channel::<()>();
pool.request(
CallbackPriority::Low,
Box::new(move || {
started_tx.send(()).unwrap();
gate_rx.recv().unwrap();
}),
)
.unwrap();
started_rx.recv_timeout(T).unwrap();
let (high_tx, high_rx) = mpsc::channel();
pool.request(
CallbackPriority::High,
Box::new(move || high_tx.send(()).unwrap()),
)
.unwrap();
high_rx
.recv_timeout(T)
.expect("High band stalled behind a blocked Low worker");
gate_tx.send(()).unwrap(); }
#[test]
fn full_ring_latches_overflow_then_recovers() {
let mut pool = CallbackPool::with_config(1, 1);
let (started_tx, started_rx) = mpsc::channel();
let (gate_tx, gate_rx) = mpsc::channel::<()>();
pool.request(
CallbackPriority::Low,
Box::new(move || {
started_tx.send(()).unwrap();
gate_rx.recv().unwrap();
}),
)
.unwrap();
started_rx.recv_timeout(T).unwrap();
pool.request(CallbackPriority::Low, Box::new(|| {}))
.unwrap();
assert_eq!(
pool.request(CallbackPriority::Low, Box::new(|| {})),
Err(CallbackError::QueueFull)
);
assert_eq!(
pool.request(CallbackPriority::Low, Box::new(|| {})),
Err(CallbackError::QueueFull)
);
assert_eq!(pool.overflow_count(CallbackPriority::Low), 1);
gate_tx.send(()).unwrap(); pool.shutdown();
}
#[test]
fn request_after_shutdown_is_silent_noop() {
let pool = CallbackPool::new();
let h = pool.handle();
drop(pool);
let ran = Arc::new(AtomicBool::new(false));
let r = Arc::clone(&ran);
let res = h.request(
CallbackPriority::High,
Box::new(move || r.store(true, Ordering::SeqCst)),
);
assert_eq!(res, Ok(())); assert!(
!ran.load(Ordering::SeqCst),
"callback ran after shutdown; it must be dropped, not invoked"
);
}
#[cfg(target_os = "linux")]
#[test]
fn cpu_count_respects_the_threads_affinity_mask() {
let host = cpu_count();
if host < 2 {
return;
}
let pinned = std::thread::spawn(|| {
unsafe {
let mut have: libc::cpu_set_t = std::mem::zeroed();
if libc::sched_getaffinity(0, size_of::<libc::cpu_set_t>(), &mut have) != 0 {
return None;
}
let first = (0..libc::CPU_SETSIZE as usize).find(|&c| libc::CPU_ISSET(c, &have))?;
let mut one: libc::cpu_set_t = std::mem::zeroed();
libc::CPU_ZERO(&mut one);
libc::CPU_SET(first, &mut one);
if libc::sched_setaffinity(0, size_of::<libc::cpu_set_t>(), &one) != 0 {
return None;
}
}
Some(cpu_count())
})
.join()
.expect("the pinned thread must not panic");
let Some(pinned) = pinned else {
return;
};
assert_eq!(
pinned, 1,
"cpu_count() reported {host} for a thread pinned to one \
processor — that is the pre-556de06ff sysconf behaviour"
);
}
}