wasmer_types/store_id.rs
1use core::fmt::Display;
2use std::{
3 cell::Cell,
4 num::NonZeroUsize,
5 sync::atomic::{AtomicUsize, Ordering},
6};
7
8/// Unique ID to identify a context.
9///
10/// Every handle to an object managed by a context also contains the ID of the
11/// context. This is used to check that a handle is always used with the
12/// correct context.
13#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
14pub struct StoreId(NonZeroUsize);
15
16#[cfg(feature = "artifact-size")]
17impl loupe::MemoryUsage for StoreId {
18 fn size_of_val(&self, _visited: &mut dyn loupe::MemoryUsageTracker) -> usize {
19 std::mem::size_of_val(self)
20 }
21}
22
23impl StoreId {
24 /// Returns the raw [`NonZeroUsize`] value of this [`StoreId`].
25 pub fn as_raw(&self) -> NonZeroUsize {
26 self.0
27 }
28}
29
30/// Number of IDs each thread reserves from the global counter at a time.
31///
32/// `Default::default` used to hit a single global `AtomicUsize` on every
33/// call, so multiple `Store::new` callers on different cores would
34/// ping-pong the same cache line. With chunked allocation, each thread
35/// reserves `CHUNK_SIZE` consecutive IDs in one atomic step and then
36/// hands them out from a thread-local cursor with zero cross-thread
37/// traffic until the chunk is exhausted.
38///
39/// 256 keeps the global atomic out of the picture for any normal
40/// workload while leaving the total ID space unchanged: the global
41/// counter still grows by the same total amount, just in batches.
42const CHUNK_SIZE: usize = 256;
43
44/// Global pointer to the first ID of the next available chunk.
45///
46/// Starts at 1 so the first ID handed out is non-zero (the wrapper is
47/// `NonZeroUsize`).
48static NEXT_CHUNK_START: AtomicUsize = AtomicUsize::new(1);
49
50/// Per-thread chunk cursor. `next` is the ID we hand out on the next
51/// `Default::default` call; `end` is the exclusive upper bound of the
52/// currently-held chunk. `next == end` (initially `0 == 0`) signals
53/// that the thread must reserve a fresh chunk from the global counter.
54#[derive(Clone, Copy)]
55struct ChunkCursor {
56 next: usize,
57 end: usize,
58}
59
60impl ChunkCursor {
61 const EMPTY: Self = Self { next: 0, end: 0 };
62}
63
64thread_local! {
65 /// Per-thread cursor inside the currently-held chunk.
66 static LOCAL_CURSOR: Cell<ChunkCursor> = const { Cell::new(ChunkCursor::EMPTY) };
67}
68
69impl Default for StoreId {
70 // Allocates a unique ID for a new context.
71 fn default() -> Self {
72 // No overflow checking is needed here: the global counter is
73 // `AtomicUsize`. On 64-bit hosts, exhausting it at one
74 // `Store::new` per nanosecond on every core would still take
75 // centuries.
76 let raw = LOCAL_CURSOR.with(|cell| {
77 let mut cursor = cell.get();
78 if cursor.next == cursor.end {
79 cursor.next = NEXT_CHUNK_START.fetch_add(CHUNK_SIZE, Ordering::Relaxed);
80 cursor.end = cursor.next + CHUNK_SIZE;
81 }
82 let id = cursor.next;
83 cursor.next += 1;
84 cell.set(cursor);
85 id
86 });
87 Self(NonZeroUsize::new(raw).expect("chunked allocator never returns 0"))
88 }
89}
90
91impl Display for StoreId {
92 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
93 let val: usize = self.0.into();
94 if val == usize::MAX {
95 write!(f, "unknown")
96 } else {
97 write!(f, "{}", self.0)
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use std::collections::HashSet;
106 use std::sync::Mutex;
107 use std::thread;
108
109 /// Many sequential `default()` calls from one thread produce unique
110 /// IDs across at least three chunk boundaries (catches off-by-one
111 /// errors at the chunk seam).
112 #[test]
113 fn ids_unique_within_a_thread_across_chunk_boundaries() {
114 let n = CHUNK_SIZE * 3 + 17;
115 let mut seen = HashSet::with_capacity(n);
116 for _ in 0..n {
117 let id = StoreId::default();
118 assert!(
119 seen.insert(id.as_raw()),
120 "duplicate ID handed out within a single thread",
121 );
122 }
123 }
124
125 /// Two threads each pulling many chunks must never see overlap. This
126 /// is the property the global `fetch_add` is preserving: the chunks
127 /// are disjoint windows of the integer space.
128 #[test]
129 fn ids_unique_across_threads_under_load() {
130 const THREADS: usize = 16;
131 const PER_THREAD: usize = CHUNK_SIZE * 8;
132 let collected: Mutex<HashSet<NonZeroUsize>> =
133 Mutex::new(HashSet::with_capacity(THREADS * PER_THREAD));
134 thread::scope(|s| {
135 for _ in 0..THREADS {
136 s.spawn(|| {
137 let mut local = Vec::with_capacity(PER_THREAD);
138 for _ in 0..PER_THREAD {
139 local.push(StoreId::default().as_raw());
140 }
141 let mut guard = collected.lock().unwrap();
142 for id in local {
143 assert!(
144 guard.insert(id),
145 "duplicate ID handed out across threads: {id}",
146 );
147 }
148 });
149 }
150 });
151 let total = collected.into_inner().unwrap().len();
152 assert_eq!(
153 total,
154 THREADS * PER_THREAD,
155 "expected every produced ID to be unique",
156 );
157 }
158
159 /// `StoreId` is `NonZeroUsize`-backed, so the allocator must never
160 /// hand out zero. The chunk start is initialised to 1 and chunk
161 /// reservations only ever increase it, so a zero would indicate a
162 /// regression in chunk bookkeeping.
163 #[test]
164 fn allocator_never_returns_zero() {
165 for _ in 0..10_000 {
166 let id = StoreId::default();
167 assert_ne!(id.as_raw().get(), 0);
168 }
169 }
170}