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
122 changes: 122 additions & 0 deletions docs/articles/concepts/buffers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# 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.

## 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.

## STArrayPool

`STArrayPool<T>` is a single-threaded adaptation of the .NET runtime's shared `ArrayPool<T>` (`TlsOverPerCoreLockedStacksArrayPool`). It keeps the same bucketing scheme - powers of two, from 16 elements up to roughly 1 GiB - and the same rent/return API, but drops every lock: there is no per-core or thread-local synchronization at all.

> [!WARNING]
> `STArrayPool<T>.Shared` is NOT thread-safe. It performs no locking, so renting or returning from more than one thread at a time is a race - it can hand out the same array twice or corrupt its internal buckets. Only use it from code that already guarantees exclusive, single-threaded access.

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` |

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

`Return` does not clear the array's contents unless you pass `clearArray: true`. For arrays of a reference type, that means previously stored object references stay reachable through the pool until the array is rented out again (and overwritten) or discarded by a trim - pass `clearArray: true` when that matters.

```csharp
using SquidStd.Core.Buffers;

var array = STArrayPool<byte>.Shared.Rent(1024);
try
{
// use array...
}
finally
{
STArrayPool<byte>.Shared.Return(array);
}
```

## ValueStringBuilder

`ValueStringBuilder` is a `ref struct` string builder backed by a single growable `Span<char>` instead of `StringBuilder`'s linked chunks. Being a ref struct, it must stay on the stack: it cannot be boxed, stored in a field of a non-ref struct, or captured by a lambda or async method.

```csharp
using SquidStd.Core.Buffers;

using var builder = new ValueStringBuilder(stackalloc char[64]);

builder.Append("hello ");
builder.Append($"world {42}");

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:

```csharp
var builder = new ValueStringBuilder(64, mt: true);
builder.Append("thread safe 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.

```csharp
using SquidStd.Core.Buffers;
using SquidStd.Core.Extensions.Strings;

var array = "hello world".ToPooledArray();
try
{
// only array[..11] holds the copied characters
}
finally
{
STArrayPool<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.

## String helpers

`OrdinalStringHelpers` and `InsensitiveStringHelpers` mirror each other: one comparison family, ordinal (case-sensitive) on one side and ordinal-ignore-case on the other, both skipping culture lookups entirely. Every member works on `string`, and most also have a `ReadOnlySpan<char>` overload.

| Family | Ordinal | Insensitive |
|---|---|---|
| Starts with | `StartsWithOrdinal` | `InsensitiveStartsWith` |
| Ends with | `EndsWithOrdinal` | `InsensitiveEndsWith` |
| Equals | `EqualsOrdinal` | `InsensitiveEquals` |
| Contains | `ContainsOrdinal` | `InsensitiveContains` |
| Compare | `CompareOrdinal` | `InsensitiveCompare` |
| Index of | `IndexOfOrdinal` | `InsensitiveIndexOf` |
| Remove | `RemoveOrdinal` | `InsensitiveRemove` |
| Replace | `ReplaceOrdinal` | `InsensitiveReplace` |

```csharp
using SquidStd.Core.Extensions.Strings;

"hello world".StartsWithOrdinal("hello"); // true, no culture lookup
"Hello".InsensitiveEquals("hELLO"); // true
```

`StringHelpers` rounds out the set with general-purpose utilities:

- `Capitalize` - title-cases every space-separated word, leaving "the " untouched.
- `Wrap` - word-wraps text into lines of at most N characters, hard-breaking words that don't fit on their own line.
- `IndentMultiline` - prefixes every line of a multiline string with an indent.
- `TrimMultiline` - trims every line of a multiline string independently.
- `IndexOfTerminator` - finds a null terminator in a byte/char/uint buffer.
- `ReplaceAny` - replaces every occurrence of a set of characters with their paired replacements, in place only.

## Related

- [SquidStd.Core](../core.md) - the package these types ship in, alongside the rest of the dependency-free helpers.
- [Home](../../index.md) - back to the SquidStd documentation home.
2 changes: 2 additions & 0 deletions docs/articles/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
href: concepts/abstractions-first.md
- name: Messaging models
href: concepts/messaging-models.md
- name: Buffers and pooled strings
href: concepts/buffers.md
- name: Guides
items:
- name: Configuration
Expand Down
92 changes: 92 additions & 0 deletions src/SquidStd.Core/Buffers/Gen2GcCallback.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Runtime.ConstrainedExecution;
using System.Runtime.InteropServices;

namespace SquidStd.Core.Buffers;

/// <summary>
/// Schedules a callback roughly every gen 2 GC (you may see a Gen 0 an Gen 1 but only once)
/// (We can fix this by capturing the Gen 2 count at startup and testing, but I mostly don't care)
/// </summary>
internal sealed class Gen2GcCallback : CriticalFinalizerObject
{
private readonly Func<bool>? _callback0;
private readonly Func<object, bool>? _callback1;
private GCHandle _weakTargetObj;

private Gen2GcCallback(Func<bool> callback)
{
_callback0 = callback;
}

private Gen2GcCallback(Func<object, bool> callback, object targetObj)
{
_callback1 = callback;
_weakTargetObj = GCHandle.Alloc(targetObj, GCHandleType.Weak);
}

~Gen2GcCallback()
{
if (_weakTargetObj.IsAllocated)
{
// Check to see if the target object is still alive.
var targetObj = _weakTargetObj.Target;

if (targetObj == null)
{
// The target object is dead, so this callback object is no longer needed.
_weakTargetObj.Free();

return;
}

// Execute the callback method.
Debug.Assert(_callback1 != null);

if (!_callback1(targetObj))
{
// If the callback returns false, this callback object is no longer needed.
_weakTargetObj.Free();

return;
}
}
else
{
// Execute the callback method.
Debug.Assert(_callback0 != null);

if (!_callback0())
{
// If the callback returns false, this callback object is no longer needed.
return;
}
}

// Resurrect ourselves by re-registering for finalization.
GC.ReRegisterForFinalize(this);
}

/// <summary>
/// Schedule 'callback' to be called in the next GC. If the callback returns true it is
/// rescheduled for the next Gen 2 GC, otherwise the callback stops.
/// </summary>
public static void Register(Func<bool> callback)

// Create an unreachable object that remembers the callback function and target object.
=> new Gen2GcCallback(callback);

/// <summary>
/// Schedule 'callback' to be called in the next GC. If the callback returns true it is
/// rescheduled for the next Gen 2 GC, otherwise the callback stops.
/// NOTE: This callback will be kept alive until either the callback function returns false,
/// or the target object dies.
/// </summary>
public static void Register(Func<object, bool> callback, object targetObj)

// Create a unreachable object that remembers the callback function and target object.
=> new Gen2GcCallback(callback, targetObj);
}
92 changes: 92 additions & 0 deletions src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
namespace SquidStd.Core.Buffers;

/// <summary>
/// Wraps a <see cref="STArrayPool{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?)" />
/// is idempotent (it caches the string and releases the buffer on first call).
/// </summary>
public struct PooledArraySpanFormattable : ISpanFormattable, IDisposable
{
private char[]? _arrayToReturnToPool;
private readonly int _length;
private string? _value;

/// <summary>
/// Gets the written character slice while the buffer is still owned.
/// </summary>
public ReadOnlySpan<char> Chars => _arrayToReturnToPool.AsSpan(0, _length);

/// <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="length">Count of valid characters at the start of the buffer.</param>
public PooledArraySpanFormattable(char[] arrayToReturnToPool, int length)
{
_arrayToReturnToPool = arrayToReturnToPool;
_length = length;
_value = null;
}

/// <summary>
/// Converts the wrapper to a string. After the buffer has been released by ToString, the cached string is returned.
/// </summary>
public static implicit operator string(PooledArraySpanFormattable formattable)
=> formattable._value ?? new string(formattable.Chars);

/// <inheritdoc />
public override string ToString()
=> ToString(null, null);

/// <summary>
/// Materializes the content as a string. Idempotent: the first call caches the value and
/// returns the buffer to the pool; later calls return the same instance.
/// </summary>
public string ToString(string? format, IFormatProvider? formatProvider)
{
if (_value is null)
{
_value = new(Chars);
ReleaseBuffer();
}

return _value;
}

/// <summary>
/// Copies the content into <paramref name="destination" /> and returns the buffer to the pool.
/// Single use: the wrapper must not be used again after a successful call.
/// </summary>
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format = default, IFormatProvider? provider = null)
{
if (destination.Length < _length)
{
charsWritten = 0;

return false;
}

Chars.CopyTo(destination);
charsWritten = _length;
ReleaseBuffer();

return true;
}

private void ReleaseBuffer()
{
if (_arrayToReturnToPool is not null)
{
STArrayPool<char>.Shared.Return(_arrayToReturnToPool);
_arrayToReturnToPool = null;
}
}

/// <summary>
/// Returns the buffer to the pool if it is still owned.
/// </summary>
public void Dispose()
=> ReleaseBuffer();
}
Loading
Loading