Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions docs/articles/concepts/buffers.md
Original file line number Diff line number Diff line change
@@ -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<T>.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<T>.Shared`, so it is safe to reach for even when you can't prove exclusive, single-threaded access. Opt into `STArrayPool<T>.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

Expand All @@ -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<T>.Shared` |
| Any code that might run concurrently, or where you can't prove exclusive ownership | `ArrayPool<T>.Shared` |
| The default: any code, including code that might run concurrently or where you can't prove exclusive ownership | `ArrayPool<T>.Shared` |
| Opt-in: a single-threaded hot path that owns its buffer exclusively (a parser, formatter, or one tick of a game loop) | `STArrayPool<T>.Shared` |

`STArrayPool<T>` 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.

Expand Down Expand Up @@ -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<char>.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<char>.Shared` instead:
By default (`mt: true`), the builder rents from the thread-safe `ArrayPool<char>.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<char>.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<char>.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<char>.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();
Expand All @@ -79,11 +79,11 @@ try
}
finally
{
STArrayPool<char>.Shared.Return(array);
ArrayPool<char>.Shared.Return(array);
}
```

`PooledArraySpanFormattable` wraps a `STArrayPool<char>`-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<char>`-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

Expand Down
8 changes: 5 additions & 3 deletions src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Buffers;

namespace SquidStd.Core.Buffers;

/// <summary>
/// Wraps a <see cref="STArrayPool{T}" />-rented char buffer as an <see cref="ISpanFormattable" /> so it can be
/// Wraps an <see cref="ArrayPool{T}" />-rented char buffer as an <see cref="ISpanFormattable" /> so it can be
/// passed through interpolated string handlers without materializing an intermediate string.
/// <see cref="TryFormat" /> can only be called once: it returns the buffer to the pool. To read the
/// characters multiple times use <see cref="Chars" />; <see cref="ToString(string?, IFormatProvider?)" />
Expand All @@ -21,7 +23,7 @@ public struct PooledArraySpanFormattable : ISpanFormattable, IDisposable
/// <summary>
/// Initializes the wrapper over a pooled buffer and the number of valid characters in it.
/// </summary>
/// <param name="arrayToReturnToPool">Buffer rented from <see cref="STArrayPool{T}" />.</param>
/// <param name="arrayToReturnToPool">Buffer rented from <see cref="ArrayPool{T}" />.Shared.</param>
/// <param name="length">Count of valid characters at the start of the buffer.</param>
public PooledArraySpanFormattable(char[] arrayToReturnToPool, int length)
{
Expand Down Expand Up @@ -79,7 +81,7 @@ private void ReleaseBuffer()
{
if (_arrayToReturnToPool is not null)
{
STArrayPool<char>.Shared.Return(_arrayToReturnToPool);
ArrayPool<char>.Shared.Return(_arrayToReturnToPool);
_arrayToReturnToPool = null;
}
}
Expand Down
11 changes: 6 additions & 5 deletions src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -65,7 +66,7 @@ public ref struct RawInterpolatedStringHandler
public RawInterpolatedStringHandler(int literalLength, int formattedCount)
{
_provider = null;
_chars = _arrayToReturnToPool = STArrayPool<char>.Shared.Rent(GetDefaultLength(literalLength, formattedCount));
_chars = _arrayToReturnToPool = ArrayPool<char>.Shared.Rent(GetDefaultLength(literalLength, formattedCount));
_pos = 0;
_hasCustomFormatter = false;
}
Expand All @@ -84,7 +85,7 @@ public RawInterpolatedStringHandler(int literalLength, int formattedCount)
public RawInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider)
{
_provider = provider;
_chars = _arrayToReturnToPool = STArrayPool<char>.Shared.Rent(GetDefaultLength(literalLength, formattedCount));
_chars = _arrayToReturnToPool = ArrayPool<char>.Shared.Rent(GetDefaultLength(literalLength, formattedCount));
_pos = 0;
_hasCustomFormatter = provider is not null && HasCustomFormatter(provider);
}
Expand Down Expand Up @@ -127,7 +128,7 @@ public void Clear()

if (toReturn is not null)
{
STArrayPool<char>.Shared.Return(toReturn);
ArrayPool<char>.Shared.Return(toReturn);
}
}

Expand Down Expand Up @@ -287,15 +288,15 @@ 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<char>.Shared.Rent(arraySize);
var newArray = ArrayPool<char>.Shared.Rent(arraySize);
_chars[.._pos].CopyTo(newArray);

var toReturn = _arrayToReturnToPool;
_chars = _arrayToReturnToPool = newArray;

if (toReturn is not null)
{
STArrayPool<char>.Shared.Return(toReturn);
ArrayPool<char>.Shared.Return(toReturn);
}
}

Expand Down
44 changes: 25 additions & 19 deletions src/SquidStd.Core/Buffers/ValueStringBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ namespace SquidStd.Core.Buffers;
/// </summary>
/// <remarks>
/// This is a <see langword="ref struct" />: 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 <c>mt: false</c> (the default),
/// growth buffers are rented from <see cref="STArrayPool{T}" />.Shared, which is NOT thread-safe, so the
/// builder must be created, appended to, and disposed from a single thread. Pass <c>mt: true</c> to switch
/// to <see cref="ArrayPool{T}" />.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 <c>mt: true</c> (the default),
/// growth buffers are rented from the thread-safe <see cref="ArrayPool{T}" />.Shared, so the builder (or a
/// buffer it grew into) may safely cross threads. Pass <c>mt: false</c> to opt into the single-threaded
/// <see cref="STArrayPool{T}" />.Shared for hot paths; the builder must then be created, appended to, and
/// disposed from a single thread.
/// </remarks>
public ref struct ValueStringBuilder
{
Expand All @@ -35,11 +36,12 @@ private ArrayPool<char> ArrayPool
/// </summary>
/// <param name="initialString">The characters to seed the builder with.</param>
/// <param name="mt">
/// When <see langword="true" />, buffers grow via <see cref="ArrayPool{T}" />.Shared instead of the
/// single-threaded <see cref="STArrayPool{T}" />.Shared.
/// Defaults to <see langword="true" />, so buffers grow via the thread-safe <see cref="ArrayPool{T}" />
/// .Shared. Pass <see langword="false" /> to opt into the single-threaded
/// <see cref="STArrayPool{T}" />.Shared for hot paths, keeping the builder on one thread.
/// </param>
/// <remarks>If this ctor is used, you cannot pass in stackalloc ROS for append/replace.</remarks>
public ValueStringBuilder(ReadOnlySpan<char> initialString, bool mt = false) : this(initialString.Length, mt)
public ValueStringBuilder(ReadOnlySpan<char> initialString, bool mt = true) : this(initialString.Length, mt)
{
Append(initialString);
}
Expand All @@ -51,10 +53,11 @@ public ValueStringBuilder(ReadOnlySpan<char> initialString, bool mt = false) : t
/// <param name="initialString">The characters to seed the builder with.</param>
/// <param name="initialBuffer">The initial backing storage, e.g. a stackalloc'd span.</param>
/// <param name="mt">
/// When <see langword="true" />, buffers grow via <see cref="ArrayPool{T}" />.Shared instead of the
/// single-threaded <see cref="STArrayPool{T}" />.Shared.
/// Defaults to <see langword="true" />, so buffers grow via the thread-safe <see cref="ArrayPool{T}" />
/// .Shared. Pass <see langword="false" /> to opt into the single-threaded
/// <see cref="STArrayPool{T}" />.Shared for hot paths, keeping the builder on one thread.
/// </param>
public ValueStringBuilder(ReadOnlySpan<char> initialString, Span<char> initialBuffer, bool mt = false) : this(
public ValueStringBuilder(ReadOnlySpan<char> initialString, Span<char> initialBuffer, bool mt = true) : this(
initialBuffer,
mt
)
Expand All @@ -67,10 +70,11 @@ public ValueStringBuilder(ReadOnlySpan<char> initialString, Span<char> initialBu
/// </summary>
/// <param name="initialBuffer">The initial backing storage.</param>
/// <param name="mt">
/// When <see langword="true" />, buffers grow via <see cref="ArrayPool{T}" />.Shared instead of the
/// single-threaded <see cref="STArrayPool{T}" />.Shared.
/// Defaults to <see langword="true" />, so buffers grow via the thread-safe <see cref="ArrayPool{T}" />
/// .Shared. Pass <see langword="false" /> to opt into the single-threaded
/// <see cref="STArrayPool{T}" />.Shared for hot paths, keeping the builder on one thread.
/// </param>
public ValueStringBuilder(Span<char> initialBuffer, bool mt = false)
public ValueStringBuilder(Span<char> initialBuffer, bool mt = true)
{
_mt = mt;
_arrayToReturnToPool = null;
Expand All @@ -83,11 +87,12 @@ public ValueStringBuilder(Span<char> initialBuffer, bool mt = false)
/// </summary>
/// <param name="initialCapacity">The minimum initial buffer capacity.</param>
/// <param name="mt">
/// When <see langword="true" />, the initial buffer and any growth are rented from
/// <see cref="ArrayPool{T}" />.Shared instead of the single-threaded <see cref="STArrayPool{T}" />.Shared.
/// Defaults to <see langword="true" />, so the initial buffer and any growth are rented from the
/// thread-safe <see cref="ArrayPool{T}" />.Shared. Pass <see langword="false" /> to opt into the
/// single-threaded <see cref="STArrayPool{T}" />.Shared for hot paths, keeping the builder on one thread.
/// </param>
/// <remarks>If this ctor is used, you cannot pass in stackalloc ROS for append/replace.</remarks>
public ValueStringBuilder(int initialCapacity, bool mt = false)
public ValueStringBuilder(int initialCapacity, bool mt = true)
{
_mt = mt;
Length = 0;
Expand Down Expand Up @@ -318,11 +323,12 @@ public ReadOnlySpan<char> AsSpan(int start, int length)
/// <summary>Creates a builder with a rented buffer of at least <paramref name="capacity" /> characters.</summary>
/// <param name="capacity">The minimum initial buffer capacity.</param>
/// <param name="mt">
/// When <see langword="true" />, the buffer is rented from <see cref="ArrayPool{T}" />.Shared instead of
/// the single-threaded <see cref="STArrayPool{T}" />.Shared.
/// Defaults to <see langword="true" />, so the buffer is rented from the thread-safe
/// <see cref="ArrayPool{T}" />.Shared. Pass <see langword="false" /> to opt into the single-threaded
/// <see cref="STArrayPool{T}" />.Shared for hot paths, keeping the builder on one thread.
/// </param>
[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);

/// <summary>Creates a builder whose buffers are rented from <see cref="ArrayPool{T}" />.Shared.</summary>
Expand Down
13 changes: 7 additions & 6 deletions src/SquidStd.Core/Extensions/Strings/StringHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SquidStd.Core.Buffers;

namespace SquidStd.Core.Extensions.Strings;

/// <summary>
/// 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.
/// </summary>
public static class StringHelpers
{
Expand Down Expand Up @@ -173,13 +174,13 @@ public static string Remove(this ReadOnlySpan<char> a, ReadOnlySpan<char> b, Str
return string.Empty;
}

var rented = STArrayPool<char>.Shared.Rent(a.Length);
var rented = ArrayPool<char>.Shared.Rent(a.Length);

a.Remove(b, comparison, rented.AsSpan(0, a.Length), out var size);

var result = new string(rented, 0, size);

STArrayPool<char>.Shared.Return(rented);
ArrayPool<char>.Shared.Return(rented);

return result;
}
Expand Down Expand Up @@ -215,13 +216,13 @@ ReadOnlySpan<char> replacementChars
}

/// <summary>
/// Copies the string into a buffer rented from <see cref="STArrayPool{T}" />. The CALLER owns
/// the array and must return it via <c>STArrayPool&lt;char&gt;.Shared.Return(array)</c>; the
/// Copies the string into a buffer rented from <see cref="ArrayPool{T}" />.Shared. The CALLER owns
/// the array and must return it via <c>ArrayPool&lt;char&gt;.Shared.Return(array)</c>; the
/// buffer may be longer than the string.
/// </summary>
public static char[] ToPooledArray(this string str)
{
var chars = STArrayPool<char>.Shared.Rent(str.Length);
var chars = ArrayPool<char>.Shared.Rent(str.Length);

str.CopyTo(chars);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
using SquidStd.Core.Buffers;

namespace SquidStd.Tests.Core.Buffers;
Expand All @@ -6,7 +7,7 @@ public class PooledArraySpanFormattableTests
{
private static PooledArraySpanFormattable Create(string content)
{
var array = STArrayPool<char>.Shared.Rent(content.Length);
var array = ArrayPool<char>.Shared.Rent(content.Length);
content.CopyTo(array);

return new(array, content.Length);
Expand Down
8 changes: 4 additions & 4 deletions tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
using SquidStd.Core.Buffers;
using SquidStd.Core.Extensions.Strings;

Expand Down Expand Up @@ -87,7 +88,7 @@ public void ToPooledArray_CopiesContent_CallerReturnsIt()
Assert.True(array.Length >= 6);
Assert.Equal("pooled", array.AsSpan(0, 6).ToString());

STArrayPool<char>.Shared.Return(array);
ArrayPool<char>.Shared.Return(array);
}

[Fact]
Expand Down
Loading