From 0fc4351c59883adad096d009e6e13fd5162a7259 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 4 Jul 2026 19:21:31 +0200 Subject: [PATCH] feat(buffers): default to ArrayPool.Shared with STArrayPool as single-thread opt-in ValueStringBuilder now defaults to mt: true (thread-safe BCL pool); RawInterpolatedStringHandler, PooledArraySpanFormattable, StringHelpers.Remove and ToPooledArray rent from ArrayPool.Shared. STArrayPool stays as the explicit opt-in for single-threaded hot paths. Docs and tests aligned. --- docs/articles/concepts/buffers.md | 22 +++++----- .../Buffers/PooledArraySpanFormattable.cs | 8 ++-- .../Buffers/RawInterpolatedStringHandler.cs | 11 ++--- .../Buffers/ValueStringBuilder.cs | 44 +++++++++++-------- .../Extensions/Strings/StringHelpers.cs | 13 +++--- .../PooledArraySpanFormattableTests.cs | 3 +- .../Core/Buffers/ValueStringBuilderTests.cs | 8 ++-- .../Extensions/Strings/StringHelpersTests.cs | 3 +- 8 files changed, 62 insertions(+), 50 deletions(-) diff --git a/docs/articles/concepts/buffers.md b/docs/articles/concepts/buffers.md index e1f6fea..fc73ab4 100644 --- a/docs/articles/concepts/buffers.md +++ b/docs/articles/concepts/buffers.md @@ -1,10 +1,10 @@ # Buffers and pooled strings -`SquidStd.Core.Buffers` and `SquidStd.Core.Extensions.Strings` are a small, dependency-free toolkit for allocation-sensitive string and byte work: a single-threaded array pool, a stack-friendly string builder, and a family of ordinal/case-insensitive string helpers that skip culture-aware comparisons entirely. +`SquidStd.Core.Buffers` and `SquidStd.Core.Extensions.Strings` are a small, dependency-free toolkit for allocation-sensitive string and byte work: a thread-safe-by-default array pool with a single-threaded opt-in, a stack-friendly string builder, and a family of ordinal/case-insensitive string helpers that skip culture-aware comparisons entirely. ## When to reach for these types -Reach for this toolkit on hot paths that would otherwise allocate a `char[]` or `byte[]` on every call: parsers, network frame encoders/decoders, per-tick game loop work, or any single-threaded pipeline that runs often enough for GC pressure to matter. For one-off, cold-path string work, `string`, `StringBuilder`, and `ArrayPool.Shared` are simpler and perfectly fine. +Reach for this toolkit on hot paths that would otherwise allocate a `char[]` or `byte[]` on every call: parsers, network frame encoders/decoders, per-tick game loop work, or any pipeline that runs often enough for GC pressure to matter. Everything in this toolkit defaults to the thread-safe `ArrayPool.Shared`, so it is safe to reach for even when you can't prove exclusive, single-threaded access. Opt into `STArrayPool.Shared` only for single-threaded hot paths where you want to skip the BCL pool's locking. For one-off, cold-path string work, plain `string` and `StringBuilder` are simpler and perfectly fine. ## STArrayPool @@ -17,8 +17,8 @@ Use the decision table to pick the right pool: | Scenario | Use | |---|---| -| A single-threaded hot path that owns its buffer exclusively (a parser, formatter, or one tick of a game loop) | `STArrayPool.Shared` | -| Any code that might run concurrently, or where you can't prove exclusive ownership | `ArrayPool.Shared` | +| The default: any code, including code that might run concurrently or where you can't prove exclusive ownership | `ArrayPool.Shared` | +| Opt-in: a single-threaded hot path that owns its buffer exclusively (a parser, formatter, or one tick of a game loop) | `STArrayPool.Shared` | `STArrayPool` trims itself automatically: the first time it caches a returned array it registers a Gen2 GC callback, and every Gen2 collection runs `Trim()`. `Trim()` reads `GC.GetGCMemoryInfo()` and classifies the current memory load into `Low`, `Medium`, or `High` pressure (at or above 70% and 90% of the high memory-load threshold). Under `High` pressure the pool clears its single-slot fast-path cache immediately and ages arrays out of the per-size overflow stacks after only 10 seconds. Under `Medium` and `Low` pressure it is gentler: the fast-path cache ages out after 10 or 30 seconds of sitting idle, and the overflow stacks age out after 60 seconds. This keeps a short burst of activity from being trimmed away between GCs while still giving memory back once the process is actually under pressure. @@ -55,21 +55,21 @@ var text = builder.ToString(); // copies the written span into a new string `ToString()` only reads the builder's contents - it does not release the buffer. Wrap the builder in a `using` declaration (as above) so `Dispose()` returns any rented buffer to the pool once you're done; if the builder never grew past its initial `stackalloc` span, `Dispose()` is a harmless no-op. -By default (`mt: false`), the builder rents from `STArrayPool.Shared`, so the same single-threaded rule from the previous section applies: create, append to, and dispose the builder from one thread. Pass `mt: true` when the builder - or a buffer it grew into - might cross threads; this switches renting to `ArrayPool.Shared` instead: +By default (`mt: true`), the builder rents from the thread-safe `ArrayPool.Shared`, so it is safe to use even when the builder - or a buffer it grew into - might cross threads. Pass `mt: false` to opt into `STArrayPool.Shared` for single-threaded hot paths; the same single-threaded rule from the previous section then applies: create, append to, and dispose the builder from one thread: ```csharp -var builder = new ValueStringBuilder(64, mt: true); -builder.Append("thread safe pool path"); +var builder = new ValueStringBuilder(64, mt: false); +builder.Append("single-threaded pool path"); var text = builder.ToString(); builder.Dispose(); ``` ## Pooled helpers -`ToPooledArray` copies a string into a buffer rented from `STArrayPool.Shared`. The caller owns the returned array and must return it; the buffer may be longer than the source string, so only the first `str.Length` characters are valid. +`ToPooledArray` copies a string into a buffer rented from `ArrayPool.Shared`. The caller owns the returned array and must return it; the buffer may be longer than the source string, so only the first `str.Length` characters are valid. ```csharp -using SquidStd.Core.Buffers; +using System.Buffers; using SquidStd.Core.Extensions.Strings; var array = "hello world".ToPooledArray(); @@ -79,11 +79,11 @@ try } finally { - STArrayPool.Shared.Return(array); + ArrayPool.Shared.Return(array); } ``` -`PooledArraySpanFormattable` wraps a `STArrayPool`-rented buffer as an `ISpanFormattable`, so it can flow through interpolated string handlers and span-formatting APIs without allocating an intermediate string. `TryFormat` is single-use: a successful call copies the characters into the destination and returns the buffer to the pool as part of that same call, so the wrapper must not be used again afterward. `ToString()` is idempotent instead - the first call caches the string and releases the buffer, and later calls return the same cached instance. +`PooledArraySpanFormattable` wraps an `ArrayPool`-rented buffer as an `ISpanFormattable`, so it can flow through interpolated string handlers and span-formatting APIs without allocating an intermediate string. `TryFormat` is single-use: a successful call copies the characters into the destination and returns the buffer to the pool as part of that same call, so the wrapper must not be used again afterward. `ToString()` is idempotent instead - the first call caches the string and releases the buffer, and later calls return the same cached instance. ## String helpers diff --git a/src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs b/src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs index f5350ed..4841865 100644 --- a/src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs +++ b/src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs @@ -1,7 +1,9 @@ +using System.Buffers; + namespace SquidStd.Core.Buffers; /// -/// Wraps a -rented char buffer as an so it can be +/// Wraps an -rented char buffer as an so it can be /// passed through interpolated string handlers without materializing an intermediate string. /// can only be called once: it returns the buffer to the pool. To read the /// characters multiple times use ; @@ -21,7 +23,7 @@ public struct PooledArraySpanFormattable : ISpanFormattable, IDisposable /// /// Initializes the wrapper over a pooled buffer and the number of valid characters in it. /// - /// Buffer rented from . + /// Buffer rented from .Shared. /// Count of valid characters at the start of the buffer. public PooledArraySpanFormattable(char[] arrayToReturnToPool, int length) { @@ -79,7 +81,7 @@ private void ReleaseBuffer() { if (_arrayToReturnToPool is not null) { - STArrayPool.Shared.Return(_arrayToReturnToPool); + ArrayPool.Shared.Return(_arrayToReturnToPool); _arrayToReturnToPool = null; } } diff --git a/src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs b/src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs index 7870506..5ac6807 100644 --- a/src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs +++ b/src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; @@ -65,7 +66,7 @@ public ref struct RawInterpolatedStringHandler public RawInterpolatedStringHandler(int literalLength, int formattedCount) { _provider = null; - _chars = _arrayToReturnToPool = STArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _chars = _arrayToReturnToPool = ArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); _pos = 0; _hasCustomFormatter = false; } @@ -84,7 +85,7 @@ public RawInterpolatedStringHandler(int literalLength, int formattedCount) public RawInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider) { _provider = provider; - _chars = _arrayToReturnToPool = STArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _chars = _arrayToReturnToPool = ArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); _pos = 0; _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); } @@ -127,7 +128,7 @@ public void Clear() if (toReturn is not null) { - STArrayPool.Shared.Return(toReturn); + ArrayPool.Shared.Return(toReturn); } } @@ -287,7 +288,7 @@ private void GrowCore(uint requiredMinCapacity) var newCapacity = Math.Max(requiredMinCapacity, Math.Min((uint)_chars.Length * 2, 0x3FFFFFDF)); var arraySize = (int)Math.Clamp(newCapacity, MinimumArrayPoolLength, int.MaxValue); - var newArray = STArrayPool.Shared.Rent(arraySize); + var newArray = ArrayPool.Shared.Rent(arraySize); _chars[.._pos].CopyTo(newArray); var toReturn = _arrayToReturnToPool; @@ -295,7 +296,7 @@ private void GrowCore(uint requiredMinCapacity) if (toReturn is not null) { - STArrayPool.Shared.Return(toReturn); + ArrayPool.Shared.Return(toReturn); } } diff --git a/src/SquidStd.Core/Buffers/ValueStringBuilder.cs b/src/SquidStd.Core/Buffers/ValueStringBuilder.cs index 5157678..ca1da97 100644 --- a/src/SquidStd.Core/Buffers/ValueStringBuilder.cs +++ b/src/SquidStd.Core/Buffers/ValueStringBuilder.cs @@ -13,10 +13,11 @@ namespace SquidStd.Core.Buffers; /// /// /// This is a : it must stay on the stack and cannot be boxed, stored in a -/// field of a non-ref struct, or captured by a lambda/async method. With mt: false (the default), -/// growth buffers are rented from .Shared, which is NOT thread-safe, so the -/// builder must be created, appended to, and disposed from a single thread. Pass mt: true to switch -/// to .Shared when the builder (or a buffer it grew into) might cross threads. +/// field of a non-ref struct, or captured by a lambda/async method. With mt: true (the default), +/// growth buffers are rented from the thread-safe .Shared, so the builder (or a +/// buffer it grew into) may safely cross threads. Pass mt: false to opt into the single-threaded +/// .Shared for hot paths; the builder must then be created, appended to, and +/// disposed from a single thread. /// public ref struct ValueStringBuilder { @@ -35,11 +36,12 @@ private ArrayPool ArrayPool /// /// The characters to seed the builder with. /// - /// When , buffers grow via .Shared instead of the - /// single-threaded .Shared. + /// Defaults to , so buffers grow via the thread-safe + /// .Shared. Pass to opt into the single-threaded + /// .Shared for hot paths, keeping the builder on one thread. /// /// If this ctor is used, you cannot pass in stackalloc ROS for append/replace. - public ValueStringBuilder(ReadOnlySpan initialString, bool mt = false) : this(initialString.Length, mt) + public ValueStringBuilder(ReadOnlySpan initialString, bool mt = true) : this(initialString.Length, mt) { Append(initialString); } @@ -51,10 +53,11 @@ public ValueStringBuilder(ReadOnlySpan initialString, bool mt = false) : t /// The characters to seed the builder with. /// The initial backing storage, e.g. a stackalloc'd span. /// - /// When , buffers grow via .Shared instead of the - /// single-threaded .Shared. + /// Defaults to , so buffers grow via the thread-safe + /// .Shared. Pass to opt into the single-threaded + /// .Shared for hot paths, keeping the builder on one thread. /// - public ValueStringBuilder(ReadOnlySpan initialString, Span initialBuffer, bool mt = false) : this( + public ValueStringBuilder(ReadOnlySpan initialString, Span initialBuffer, bool mt = true) : this( initialBuffer, mt ) @@ -67,10 +70,11 @@ public ValueStringBuilder(ReadOnlySpan initialString, Span initialBu /// /// The initial backing storage. /// - /// When , buffers grow via .Shared instead of the - /// single-threaded .Shared. + /// Defaults to , so buffers grow via the thread-safe + /// .Shared. Pass to opt into the single-threaded + /// .Shared for hot paths, keeping the builder on one thread. /// - public ValueStringBuilder(Span initialBuffer, bool mt = false) + public ValueStringBuilder(Span initialBuffer, bool mt = true) { _mt = mt; _arrayToReturnToPool = null; @@ -83,11 +87,12 @@ public ValueStringBuilder(Span initialBuffer, bool mt = false) /// /// The minimum initial buffer capacity. /// - /// When , the initial buffer and any growth are rented from - /// .Shared instead of the single-threaded .Shared. + /// Defaults to , so the initial buffer and any growth are rented from the + /// thread-safe .Shared. Pass to opt into the + /// single-threaded .Shared for hot paths, keeping the builder on one thread. /// /// If this ctor is used, you cannot pass in stackalloc ROS for append/replace. - public ValueStringBuilder(int initialCapacity, bool mt = false) + public ValueStringBuilder(int initialCapacity, bool mt = true) { _mt = mt; Length = 0; @@ -318,11 +323,12 @@ public ReadOnlySpan AsSpan(int start, int length) /// Creates a builder with a rented buffer of at least characters. /// The minimum initial buffer capacity. /// - /// When , the buffer is rented from .Shared instead of - /// the single-threaded .Shared. + /// Defaults to , so the buffer is rented from the thread-safe + /// .Shared. Pass to opt into the single-threaded + /// .Shared for hot paths, keeping the builder on one thread. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static ValueStringBuilder Create(int capacity = 64, bool mt = false) + public static ValueStringBuilder Create(int capacity = 64, bool mt = true) => new(capacity, mt); /// Creates a builder whose buffers are rented from .Shared. diff --git a/src/SquidStd.Core/Extensions/Strings/StringHelpers.cs b/src/SquidStd.Core/Extensions/Strings/StringHelpers.cs index 03ac0a1..23ad1f1 100644 --- a/src/SquidStd.Core/Extensions/Strings/StringHelpers.cs +++ b/src/SquidStd.Core/Extensions/Strings/StringHelpers.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using SquidStd.Core.Buffers; @@ -5,7 +6,7 @@ namespace SquidStd.Core.Extensions.Strings; /// -/// General-purpose string manipulation helpers built on spans and the single-threaded array pool. +/// General-purpose string manipulation helpers built on spans and pooled buffers. /// public static class StringHelpers { @@ -173,13 +174,13 @@ public static string Remove(this ReadOnlySpan a, ReadOnlySpan b, Str return string.Empty; } - var rented = STArrayPool.Shared.Rent(a.Length); + var rented = ArrayPool.Shared.Rent(a.Length); a.Remove(b, comparison, rented.AsSpan(0, a.Length), out var size); var result = new string(rented, 0, size); - STArrayPool.Shared.Return(rented); + ArrayPool.Shared.Return(rented); return result; } @@ -215,13 +216,13 @@ ReadOnlySpan replacementChars } /// - /// Copies the string into a buffer rented from . The CALLER owns - /// the array and must return it via STArrayPool<char>.Shared.Return(array); the + /// Copies the string into a buffer rented from .Shared. The CALLER owns + /// the array and must return it via ArrayPool<char>.Shared.Return(array); the /// buffer may be longer than the string. /// public static char[] ToPooledArray(this string str) { - var chars = STArrayPool.Shared.Rent(str.Length); + var chars = ArrayPool.Shared.Rent(str.Length); str.CopyTo(chars); diff --git a/tests/SquidStd.Tests/Core/Buffers/PooledArraySpanFormattableTests.cs b/tests/SquidStd.Tests/Core/Buffers/PooledArraySpanFormattableTests.cs index 7e8e62f..3dd2132 100644 --- a/tests/SquidStd.Tests/Core/Buffers/PooledArraySpanFormattableTests.cs +++ b/tests/SquidStd.Tests/Core/Buffers/PooledArraySpanFormattableTests.cs @@ -1,3 +1,4 @@ +using System.Buffers; using SquidStd.Core.Buffers; namespace SquidStd.Tests.Core.Buffers; @@ -6,7 +7,7 @@ public class PooledArraySpanFormattableTests { private static PooledArraySpanFormattable Create(string content) { - var array = STArrayPool.Shared.Rent(content.Length); + var array = ArrayPool.Shared.Rent(content.Length); content.CopyTo(array); return new(array, content.Length); diff --git a/tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs b/tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs index 4545bb9..ef207c7 100644 --- a/tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs +++ b/tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs @@ -81,13 +81,13 @@ public void Append_InterpolatedHandler_FormatsValues() } [Fact] - public void MultiThreadedMode_UsesSharedPoolAndRoundTrips() + public void SingleThreadedMode_UsesSTArrayPoolAndRoundTrips() { - var builder = new ValueStringBuilder(8, mt: true); + var builder = new ValueStringBuilder(8, mt: false); - builder.Append("thread safe pool path"); + builder.Append("single-threaded pool path"); - Assert.Equal("thread safe pool path", builder.ToString()); + Assert.Equal("single-threaded pool path", builder.ToString()); } [Fact] diff --git a/tests/SquidStd.Tests/Core/Extensions/Strings/StringHelpersTests.cs b/tests/SquidStd.Tests/Core/Extensions/Strings/StringHelpersTests.cs index 55125b8..d153ce1 100644 --- a/tests/SquidStd.Tests/Core/Extensions/Strings/StringHelpersTests.cs +++ b/tests/SquidStd.Tests/Core/Extensions/Strings/StringHelpersTests.cs @@ -1,3 +1,4 @@ +using System.Buffers; using SquidStd.Core.Buffers; using SquidStd.Core.Extensions.Strings; @@ -87,7 +88,7 @@ public void ToPooledArray_CopiesContent_CallerReturnsIt() Assert.True(array.Length >= 6); Assert.Equal("pooled", array.AsSpan(0, 6).ToString()); - STArrayPool.Shared.Return(array); + ArrayPool.Shared.Return(array); } [Fact]