Skip to main content

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