yo_alloc/lib.rs
1//! A global allocator that turns an accidental heap allocation on a shard path
2//! into a crash.
3//!
4//! Y7 says there is no global allocator call on a command path. That is easy to
5//! write down and impossible to keep by review once more than one person is in
6//! the codebase, because the allocating constructs in Rust are the comfortable
7//! ones: `format!`, `to_vec`, `collect`, `Box::new`, a `Vec` that grows, a
8//! `String` built to make an error message. Each costs tens of nanoseconds
9//! against a 150 ns budget, and none of them looks wrong in a diff.
10//!
11//! So the rule is enforced instead of reviewed. A shard thread marks itself
12//! [`enter_no_alloc`] before the command loop, and from that point any
13//! allocation aborts the process with a message naming the size. Setup, arena
14//! growth and anything else that legitimately needs the heap wraps itself in
15//! [`allow`], which is a visible, greppable, deliberate act.
16//!
17//! # Cost when it is off
18//!
19//! The check is one thread local load and a branch, on a path that already
20//! calls into the system allocator. It is not measurable next to `malloc`.
21//! Non shard threads never set the flag and pay the same single branch.
22//!
23//! # Using it
24//!
25//! ```no_run
26//! # use yo_alloc::YoAlloc;
27//! #[global_allocator]
28//! static ALLOC: YoAlloc = YoAlloc::new();
29//! ```
30//!
31//! The engine installs this in its binaries and in its test and bench targets.
32//! A library consumer of `yodb` does not get it, because choosing a global
33//! allocator is the application's call and never a library's.
34
35#![deny(missing_docs)]
36
37use std::alloc::{GlobalAlloc, Layout, System};
38use std::cell::Cell;
39
40thread_local! {
41 /// Zero means allocation is allowed. Anything above zero forbids it.
42 ///
43 /// A counter rather than a flag so that [`allow`] nests correctly, which
44 /// matters because arena growth can be reached from more than one depth.
45 static FORBID: Cell<u32> = const { Cell::new(0) };
46}
47
48/// Mark this thread as a shard thread: from here on, allocating aborts.
49///
50/// Called once by each shard as it enters its loop. There is no matching exit
51/// in normal operation because a shard thread never stops being one.
52#[inline]
53pub fn enter_no_alloc() {
54 FORBID.with(|f| f.set(f.get().saturating_add(1)));
55}
56
57/// Undo one [`enter_no_alloc`].
58///
59/// Exists for tests and for the embedded single thread mode (`15` section 7),
60/// where the caller's thread is temporarily the shard and then goes back to
61/// being the caller's thread.
62#[inline]
63pub fn exit_no_alloc() {
64 FORBID.with(|f| f.set(f.get().saturating_sub(1)));
65}
66
67/// Whether allocation is currently forbidden on this thread.
68#[inline]
69pub fn is_forbidden() -> bool {
70 FORBID.with(|f| f.get()) > 0
71}
72
73/// Run `f` with allocation permitted, then restore the previous state.
74///
75/// Every call to this is a claim that the work inside is off the command path.
76/// Wrapping a command path in it to silence an abort is the one way to misuse
77/// this module, so the calls are meant to be few and easy to find.
78#[inline]
79pub fn allow<T>(f: impl FnOnce() -> T) -> T {
80 let saved = FORBID.with(|c| c.replace(0));
81 let guard = Restore(saved);
82 let out = f();
83 drop(guard);
84 out
85}
86
87struct Restore(u32);
88
89impl Drop for Restore {
90 #[inline]
91 fn drop(&mut self) {
92 FORBID.with(|c| c.set(self.0));
93 }
94}
95
96/// The allocator. Delegates to the system allocator and checks the flag first.
97#[derive(Debug, Default, Clone, Copy)]
98pub struct YoAlloc;
99
100impl YoAlloc {
101 /// A new allocator.
102 pub const fn new() -> YoAlloc {
103 YoAlloc
104 }
105}
106
107#[cold]
108#[inline(never)]
109fn violation(layout: Layout, what: &str) -> ! {
110 // No formatting machinery here on purpose. `format!` allocates, and this is
111 // the one place in the process where allocating is known to be unavailable.
112 // Two `write_str` calls and an integer written by hand cost nothing and
113 // cannot recurse.
114 use std::io::Write as _;
115 let mut buf = [0u8; 32];
116 let n = write_usize(&mut buf, layout.size());
117 let mut err = std::io::stderr().lock();
118 let _ = err.write_all(b"yo: allocation on a shard thread: ");
119 let _ = err.write_all(what.as_bytes());
120 let _ = err.write_all(b" of ");
121 let _ = err.write_all(&buf[..n]);
122 let _ = err.write_all(
123 b" bytes.\nThis is Y7: no global allocator call on a command path.\n\
124 Move the allocation to setup, or wrap it in yo_alloc::allow if it is\n\
125 genuinely off the command path.\n",
126 );
127 let _ = err.flush();
128 std::process::abort()
129}
130
131fn write_usize(buf: &mut [u8; 32], mut v: usize) -> usize {
132 if v == 0 {
133 buf[0] = b'0';
134 return 1;
135 }
136 let mut tmp = [0u8; 32];
137 let mut n = 0;
138 while v > 0 {
139 tmp[n] = b'0' + (v % 10) as u8;
140 v /= 10;
141 n += 1;
142 }
143 for i in 0..n {
144 buf[i] = tmp[n - 1 - i];
145 }
146 n
147}
148
149// SAFETY: every method forwards to `System`, which upholds the `GlobalAlloc`
150// contract. The added check only ever diverges before calling through, so no
151// pointer is created, invalidated or leaked by it.
152unsafe impl GlobalAlloc for YoAlloc {
153 #[inline]
154 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
155 if is_forbidden() {
156 violation(layout, "alloc");
157 }
158 // SAFETY: forwarding the caller's own valid layout.
159 unsafe { System.alloc(layout) }
160 }
161
162 #[inline]
163 unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
164 if is_forbidden() {
165 violation(layout, "alloc_zeroed");
166 }
167 // SAFETY: forwarding the caller's own valid layout.
168 unsafe { System.alloc_zeroed(layout) }
169 }
170
171 #[inline]
172 unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
173 if is_forbidden() {
174 violation(layout, "realloc");
175 }
176 // SAFETY: forwarding the caller's own valid pointer and layout.
177 unsafe { System.realloc(ptr, layout, new_size) }
178 }
179
180 #[inline]
181 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
182 // Deallocation is deliberately not checked. A value allocated during
183 // setup and dropped on the shard thread is normal and harmless, and
184 // aborting on it would make the rule unusable. What costs time is the
185 // allocation, and that is what is caught.
186 //
187 // SAFETY: forwarding the caller's own valid pointer and layout.
188 unsafe { System.dealloc(ptr, layout) }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn starts_permitted() {
198 assert!(!is_forbidden());
199 }
200
201 #[test]
202 fn enter_and_exit_are_balanced() {
203 assert!(!is_forbidden());
204 enter_no_alloc();
205 assert!(is_forbidden());
206 enter_no_alloc();
207 assert!(is_forbidden());
208 exit_no_alloc();
209 assert!(is_forbidden(), "one exit must not undo two enters");
210 exit_no_alloc();
211 assert!(!is_forbidden());
212 }
213
214 #[test]
215 fn allow_permits_and_restores() {
216 enter_no_alloc();
217 assert!(is_forbidden());
218 let v = allow(|| {
219 assert!(!is_forbidden());
220 vec![1u8, 2, 3]
221 });
222 assert_eq!(v.len(), 3);
223 assert!(is_forbidden(), "allow must restore the previous state");
224 exit_no_alloc();
225 }
226
227 #[test]
228 fn allow_nests() {
229 enter_no_alloc();
230 allow(|| {
231 allow(|| assert!(!is_forbidden()));
232 assert!(!is_forbidden());
233 });
234 assert!(is_forbidden());
235 exit_no_alloc();
236 }
237
238 #[test]
239 fn allow_restores_when_the_body_panics() {
240 enter_no_alloc();
241 let r = std::panic::catch_unwind(|| {
242 allow(|| panic!("boom"));
243 });
244 assert!(r.is_err());
245 assert!(
246 is_forbidden(),
247 "a panic inside allow must not leave the thread permitted"
248 );
249 exit_no_alloc();
250 }
251
252 /// The flag is per thread. A shard marking itself must not affect the
253 /// accept loop or a test harness thread.
254 #[test]
255 fn the_flag_does_not_cross_threads() {
256 enter_no_alloc();
257 let other = std::thread::spawn(is_forbidden).join().unwrap();
258 assert!(!other, "another thread saw this thread's flag");
259 exit_no_alloc();
260 }
261
262 #[test]
263 fn integers_render_without_allocating() {
264 let mut buf = [0u8; 32];
265 for (v, want) in [
266 (0usize, "0"),
267 (7, "7"),
268 (1024, "1024"),
269 (2097152, "2097152"),
270 ] {
271 let n = write_usize(&mut buf, v);
272 assert_eq!(std::str::from_utf8(&buf[..n]).unwrap(), want);
273 }
274 }
275}