intern_lang/concurrent.rs
1//! The thread-safe [`ConcurrentInterner`].
2
3use alloc::string::{String, ToString};
4use core::fmt;
5use std::sync::{PoisonError, RwLock};
6
7use crate::error::InternError;
8use crate::interner::Interner;
9use crate::lookup::Lookup;
10use crate::symbol::Symbol;
11
12/// A thread-safe string interner that many threads can intern into at once.
13///
14/// `ConcurrentInterner` wraps the single-threaded [`Interner`] in an `RwLock` and
15/// exposes the same operations through `&self`, so a pool of lexer or parser
16/// threads can share one symbol space. It is additive, not a rewrite: the storage,
17/// the dedup index, and the symbol stability guarantees are exactly those of
18/// [`Interner`]; this type only adds the synchronisation.
19///
20/// # Correctness under contention
21///
22/// Interning takes a two-step path. A string that is already present is found
23/// under a *shared read lock*, so the common warm-cache case — most identifiers
24/// have been seen before — runs concurrently across threads with no exclusive
25/// access. Only a genuinely new string escalates to the *exclusive write lock*,
26/// and the insert re-checks for the string while holding it, so two threads
27/// racing to intern the same new string still resolve to one symbol: the writer
28/// that loses the race finds the winner's entry instead of creating a duplicate.
29/// The `RwLock`'s exclusivity is what makes "no duplicate symbols for the same
30/// string" hold without a custom lock-free protocol.
31///
32/// Reads dominate once warm, so the `RwLock` is the right primitive here; writes
33/// to new strings serialise. If write contention on a write-heavy workload ever
34/// shows up in benchmarks, the storage can be sharded behind this same surface
35/// without changing the API.
36///
37/// # Lock poisoning
38///
39/// If a thread panics while holding the lock it becomes poisoned. The interner's
40/// own operations do not panic, so a poisoned lock means a panic originated
41/// elsewhere; rather than propagate that as a second panic, every method recovers
42/// the guard and continues. The stored data is structurally intact because
43/// interning is the only mutator and it does not unwind partway through under any
44/// input that fits in memory.
45///
46/// # Examples
47///
48/// ```
49/// use std::sync::Arc;
50/// use std::thread;
51///
52/// use intern_lang::ConcurrentInterner;
53///
54/// let interner = Arc::new(ConcurrentInterner::new());
55///
56/// let mut handles = Vec::new();
57/// for _ in 0..4 {
58/// let interner = Arc::clone(&interner);
59/// handles.push(thread::spawn(move || {
60/// // Every thread interns the same names; they all agree on the symbols.
61/// (interner.intern("loop"), interner.intern("break"))
62/// }));
63/// }
64///
65/// let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
66/// let first = results[0];
67/// assert!(results.iter().all(|&pair| pair == first));
68/// assert_eq!(interner.len(), 2); // "loop" and "break", interned once each
69/// ```
70#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
71pub struct ConcurrentInterner {
72 inner: RwLock<Interner>,
73}
74
75impl ConcurrentInterner {
76 /// Creates an empty concurrent interner. Like [`Interner::new`], no allocation
77 /// happens until the first string is interned.
78 ///
79 /// # Examples
80 ///
81 /// ```
82 /// use intern_lang::ConcurrentInterner;
83 ///
84 /// let interner = ConcurrentInterner::new();
85 /// assert!(interner.is_empty());
86 /// ```
87 #[must_use]
88 pub fn new() -> Self {
89 Self {
90 inner: RwLock::new(Interner::new()),
91 }
92 }
93
94 /// Creates an empty concurrent interner sized for about `capacity` distinct
95 /// strings before the dedup index grows. See [`Interner::with_capacity`].
96 ///
97 /// # Examples
98 ///
99 /// ```
100 /// use intern_lang::ConcurrentInterner;
101 ///
102 /// let interner = ConcurrentInterner::with_capacity(4_096);
103 /// assert!(interner.is_empty());
104 /// ```
105 #[must_use]
106 pub fn with_capacity(capacity: usize) -> Self {
107 Self {
108 inner: RwLock::new(Interner::with_capacity(capacity)),
109 }
110 }
111
112 /// Interns `s` from a shared reference, returning its [`Symbol`].
113 ///
114 /// A string already present is resolved under a shared read lock; only a new
115 /// string takes the exclusive write lock. The same string always yields the
116 /// same symbol, even when several threads intern it at once.
117 ///
118 /// # Examples
119 ///
120 /// ```
121 /// use intern_lang::ConcurrentInterner;
122 ///
123 /// let interner = ConcurrentInterner::new();
124 /// let a = interner.intern("shared");
125 /// let b = interner.intern("shared");
126 /// assert_eq!(a, b);
127 /// ```
128 pub fn intern(&self, s: &str) -> Symbol {
129 // Fast path: a string that already exists is found under a shared lock,
130 // so concurrent readers do not block one another.
131 {
132 let guard = self.inner.read().unwrap_or_else(PoisonError::into_inner);
133 if let Some(symbol) = guard.get(s) {
134 return symbol;
135 }
136 }
137 // Slow path: take the exclusive lock and intern. `Interner::intern`
138 // re-checks for the string, so a racer that inserted it between our read
139 // and write returns the existing symbol rather than a duplicate.
140 let mut guard = self.inner.write().unwrap_or_else(PoisonError::into_inner);
141 guard.intern(s)
142 }
143
144 /// Interns `s` from a shared reference, returning its [`Symbol`], or an error
145 /// if the symbol space is exhausted.
146 ///
147 /// The fallible counterpart to [`intern`](Self::intern), with the same
148 /// two-step locking. A string already present is returned under the read lock
149 /// and never errors; only a new string at the symbol-space bound returns
150 /// [`InternError::SymbolSpaceExhausted`].
151 ///
152 /// # Errors
153 ///
154 /// Returns [`InternError::SymbolSpaceExhausted`] when `s` is new and the
155 /// interner has issued all of its symbols — unreachable for any input that
156 /// fits in memory.
157 ///
158 /// # Examples
159 ///
160 /// ```
161 /// use intern_lang::ConcurrentInterner;
162 ///
163 /// let interner = ConcurrentInterner::new();
164 /// let sym = interner.try_intern("name").expect("space available");
165 /// assert_eq!(interner.try_intern("name"), Ok(sym));
166 /// ```
167 pub fn try_intern(&self, s: &str) -> Result<Symbol, InternError> {
168 {
169 let guard = self.inner.read().unwrap_or_else(PoisonError::into_inner);
170 if let Some(symbol) = guard.get(s) {
171 return Ok(symbol);
172 }
173 }
174 let mut guard = self.inner.write().unwrap_or_else(PoisonError::into_inner);
175 guard.try_intern(s)
176 }
177
178 /// Returns the symbol for `s` if it has already been interned, without
179 /// interning it.
180 ///
181 /// # Examples
182 ///
183 /// ```
184 /// use intern_lang::ConcurrentInterner;
185 ///
186 /// let interner = ConcurrentInterner::new();
187 /// let sym = interner.intern("present");
188 /// assert_eq!(interner.get("present"), Some(sym));
189 /// assert_eq!(interner.get("absent"), None);
190 /// ```
191 #[must_use]
192 pub fn get(&self, s: &str) -> Option<Symbol> {
193 self.inner
194 .read()
195 .unwrap_or_else(PoisonError::into_inner)
196 .get(s)
197 }
198
199 /// Runs `f` against the string `symbol` names, returning its result, or `None`
200 /// if `symbol` is out of range.
201 ///
202 /// The read lock is held only for the duration of `f`, so the borrow stays
203 /// zero-copy without escaping the lock. Keep `f` short; it runs under the lock.
204 ///
205 /// # Examples
206 ///
207 /// ```
208 /// use intern_lang::ConcurrentInterner;
209 ///
210 /// let interner = ConcurrentInterner::new();
211 /// let sym = interner.intern("measured");
212 /// assert_eq!(interner.resolve_with(sym, str::len), Some(8));
213 /// ```
214 pub fn resolve_with<R, F>(&self, symbol: Symbol, f: F) -> Option<R>
215 where
216 F: FnOnce(&str) -> R,
217 {
218 self.inner
219 .read()
220 .unwrap_or_else(PoisonError::into_inner)
221 .resolve(symbol)
222 .map(f)
223 }
224
225 /// Resolves `symbol` to an owned `String`, or `None` if it is out of range.
226 ///
227 /// This is the ergonomic counterpart to [`resolve_with`](Self::resolve_with):
228 /// it copies the bytes out so the result outlives the lock. On a hot path that
229 /// only inspects the string, prefer `resolve_with` to avoid the allocation.
230 ///
231 /// # Examples
232 ///
233 /// ```
234 /// use intern_lang::ConcurrentInterner;
235 ///
236 /// let interner = ConcurrentInterner::new();
237 /// let sym = interner.intern("owned");
238 /// assert_eq!(interner.resolve(sym).as_deref(), Some("owned"));
239 /// ```
240 #[must_use]
241 pub fn resolve(&self, symbol: Symbol) -> Option<String> {
242 self.resolve_with(symbol, ToString::to_string)
243 }
244
245 /// Returns the number of distinct strings interned so far.
246 ///
247 /// # Examples
248 ///
249 /// ```
250 /// use intern_lang::ConcurrentInterner;
251 ///
252 /// let interner = ConcurrentInterner::new();
253 /// let _ = interner.intern("a");
254 /// let _ = interner.intern("a");
255 /// let _ = interner.intern("b");
256 /// assert_eq!(interner.len(), 2);
257 /// ```
258 #[must_use]
259 pub fn len(&self) -> usize {
260 self.inner
261 .read()
262 .unwrap_or_else(PoisonError::into_inner)
263 .len()
264 }
265
266 /// Returns `true` if no strings have been interned.
267 ///
268 /// # Examples
269 ///
270 /// ```
271 /// use intern_lang::ConcurrentInterner;
272 ///
273 /// let interner = ConcurrentInterner::new();
274 /// assert!(interner.is_empty());
275 /// let _ = interner.intern("x");
276 /// assert!(!interner.is_empty());
277 /// ```
278 #[must_use]
279 pub fn is_empty(&self) -> bool {
280 self.len() == 0
281 }
282}
283
284impl Default for ConcurrentInterner {
285 #[inline]
286 fn default() -> Self {
287 Self::new()
288 }
289}
290
291impl Lookup for ConcurrentInterner {
292 #[inline]
293 fn get(&self, s: &str) -> Option<Symbol> {
294 ConcurrentInterner::get(self, s)
295 }
296
297 #[inline]
298 fn resolve_with<R, F>(&self, symbol: Symbol, f: F) -> Option<R>
299 where
300 F: FnOnce(&str) -> R,
301 {
302 ConcurrentInterner::resolve_with(self, symbol, f)
303 }
304
305 #[inline]
306 fn len(&self) -> usize {
307 ConcurrentInterner::len(self)
308 }
309
310 #[inline]
311 fn is_empty(&self) -> bool {
312 ConcurrentInterner::is_empty(self)
313 }
314}
315
316impl fmt::Debug for ConcurrentInterner {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 f.debug_struct("ConcurrentInterner")
319 .field("strings", &self.len())
320 .finish_non_exhaustive()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn test_intern_deduplicates() {
330 let interner = ConcurrentInterner::new();
331 let a = interner.intern("x");
332 let b = interner.intern("x");
333 assert_eq!(a, b);
334 assert_eq!(interner.len(), 1);
335 }
336
337 #[test]
338 fn test_resolve_roundtrips() {
339 let interner = ConcurrentInterner::new();
340 let sym = interner.intern("value");
341 assert_eq!(interner.resolve(sym).as_deref(), Some("value"));
342 assert_eq!(interner.resolve_with(sym, str::len), Some(5));
343 }
344
345 #[test]
346 fn test_get_does_not_intern() {
347 let interner = ConcurrentInterner::new();
348 assert_eq!(interner.get("absent"), None);
349 assert!(interner.is_empty());
350 }
351
352 #[test]
353 fn test_is_send_and_sync() {
354 fn assert_send_sync<T: Send + Sync>() {}
355 assert_send_sync::<ConcurrentInterner>();
356 }
357
358 #[test]
359 fn test_recovers_from_poisoned_lock() {
360 use std::panic::{AssertUnwindSafe, catch_unwind};
361 use std::sync::Arc;
362
363 let interner = Arc::new(ConcurrentInterner::new());
364 let first = interner.intern("before");
365
366 // Poison the lock by panicking while holding the write guard.
367 let poisoner = Arc::clone(&interner);
368 let _ = catch_unwind(AssertUnwindSafe(|| {
369 let _guard = poisoner
370 .inner
371 .write()
372 .unwrap_or_else(PoisonError::into_inner);
373 panic!("poison the lock");
374 }));
375
376 // The interner still works and earlier symbols still resolve.
377 assert_eq!(interner.resolve(first).as_deref(), Some("before"));
378 let second = interner.intern("after");
379 assert_eq!(interner.resolve(second).as_deref(), Some("after"));
380 }
381}