Skip to content

Commit 324888c

Browse files
authored
Merge pull request #60 from tgiachi/feature/buffers-string-helpers
feat(core): buffers stack and string helpers ported from moongatev2
2 parents 370b6b9 + 7688dcb commit 324888c

26 files changed

Lines changed: 3391 additions & 1 deletion

docs/articles/concepts/buffers.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Buffers and pooled strings
2+
3+
`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.
4+
5+
## When to reach for these types
6+
7+
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.
8+
9+
## STArrayPool
10+
11+
`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.
12+
13+
> [!WARNING]
14+
> `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.
15+
16+
Use the decision table to pick the right pool:
17+
18+
| Scenario | Use |
19+
|---|---|
20+
| A single-threaded hot path that owns its buffer exclusively (a parser, formatter, or one tick of a game loop) | `STArrayPool<T>.Shared` |
21+
| Any code that might run concurrently, or where you can't prove exclusive ownership | `ArrayPool<T>.Shared` |
22+
23+
`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.
24+
25+
`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.
26+
27+
```csharp
28+
using SquidStd.Core.Buffers;
29+
30+
var array = STArrayPool<byte>.Shared.Rent(1024);
31+
try
32+
{
33+
// use array...
34+
}
35+
finally
36+
{
37+
STArrayPool<byte>.Shared.Return(array);
38+
}
39+
```
40+
41+
## ValueStringBuilder
42+
43+
`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.
44+
45+
```csharp
46+
using SquidStd.Core.Buffers;
47+
48+
using var builder = new ValueStringBuilder(stackalloc char[64]);
49+
50+
builder.Append("hello ");
51+
builder.Append($"world {42}");
52+
53+
var text = builder.ToString(); // copies the written span into a new string
54+
```
55+
56+
`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.
57+
58+
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:
59+
60+
```csharp
61+
var builder = new ValueStringBuilder(64, mt: true);
62+
builder.Append("thread safe pool path");
63+
var text = builder.ToString();
64+
builder.Dispose();
65+
```
66+
67+
## Pooled helpers
68+
69+
`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.
70+
71+
```csharp
72+
using SquidStd.Core.Buffers;
73+
using SquidStd.Core.Extensions.Strings;
74+
75+
var array = "hello world".ToPooledArray();
76+
try
77+
{
78+
// only array[..11] holds the copied characters
79+
}
80+
finally
81+
{
82+
STArrayPool<char>.Shared.Return(array);
83+
}
84+
```
85+
86+
`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.
87+
88+
## String helpers
89+
90+
`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.
91+
92+
| Family | Ordinal | Insensitive |
93+
|---|---|---|
94+
| Starts with | `StartsWithOrdinal` | `InsensitiveStartsWith` |
95+
| Ends with | `EndsWithOrdinal` | `InsensitiveEndsWith` |
96+
| Equals | `EqualsOrdinal` | `InsensitiveEquals` |
97+
| Contains | `ContainsOrdinal` | `InsensitiveContains` |
98+
| Compare | `CompareOrdinal` | `InsensitiveCompare` |
99+
| Index of | `IndexOfOrdinal` | `InsensitiveIndexOf` |
100+
| Remove | `RemoveOrdinal` | `InsensitiveRemove` |
101+
| Replace | `ReplaceOrdinal` | `InsensitiveReplace` |
102+
103+
```csharp
104+
using SquidStd.Core.Extensions.Strings;
105+
106+
"hello world".StartsWithOrdinal("hello"); // true, no culture lookup
107+
"Hello".InsensitiveEquals("hELLO"); // true
108+
```
109+
110+
`StringHelpers` rounds out the set with general-purpose utilities:
111+
112+
- `Capitalize` - title-cases every space-separated word, leaving "the " untouched.
113+
- `Wrap` - word-wraps text into lines of at most N characters, hard-breaking words that don't fit on their own line.
114+
- `IndentMultiline` - prefixes every line of a multiline string with an indent.
115+
- `TrimMultiline` - trims every line of a multiline string independently.
116+
- `IndexOfTerminator` - finds a null terminator in a byte/char/uint buffer.
117+
- `ReplaceAny` - replaces every occurrence of a set of characters with their paired replacements, in place only.
118+
119+
## Related
120+
121+
- [SquidStd.Core](../core.md) - the package these types ship in, alongside the rest of the dependency-free helpers.
122+
- [Home](../../index.md) - back to the SquidStd documentation home.

docs/articles/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
href: concepts/abstractions-first.md
1111
- name: Messaging models
1212
href: concepts/messaging-models.md
13+
- name: Buffers and pooled strings
14+
href: concepts/buffers.md
1315
- name: Guides
1416
items:
1517
- name: Configuration
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Runtime.ConstrainedExecution;
6+
using System.Runtime.InteropServices;
7+
8+
namespace SquidStd.Core.Buffers;
9+
10+
/// <summary>
11+
/// Schedules a callback roughly every gen 2 GC (you may see a Gen 0 an Gen 1 but only once)
12+
/// (We can fix this by capturing the Gen 2 count at startup and testing, but I mostly don't care)
13+
/// </summary>
14+
internal sealed class Gen2GcCallback : CriticalFinalizerObject
15+
{
16+
private readonly Func<bool>? _callback0;
17+
private readonly Func<object, bool>? _callback1;
18+
private GCHandle _weakTargetObj;
19+
20+
private Gen2GcCallback(Func<bool> callback)
21+
{
22+
_callback0 = callback;
23+
}
24+
25+
private Gen2GcCallback(Func<object, bool> callback, object targetObj)
26+
{
27+
_callback1 = callback;
28+
_weakTargetObj = GCHandle.Alloc(targetObj, GCHandleType.Weak);
29+
}
30+
31+
~Gen2GcCallback()
32+
{
33+
if (_weakTargetObj.IsAllocated)
34+
{
35+
// Check to see if the target object is still alive.
36+
var targetObj = _weakTargetObj.Target;
37+
38+
if (targetObj == null)
39+
{
40+
// The target object is dead, so this callback object is no longer needed.
41+
_weakTargetObj.Free();
42+
43+
return;
44+
}
45+
46+
// Execute the callback method.
47+
Debug.Assert(_callback1 != null);
48+
49+
if (!_callback1(targetObj))
50+
{
51+
// If the callback returns false, this callback object is no longer needed.
52+
_weakTargetObj.Free();
53+
54+
return;
55+
}
56+
}
57+
else
58+
{
59+
// Execute the callback method.
60+
Debug.Assert(_callback0 != null);
61+
62+
if (!_callback0())
63+
{
64+
// If the callback returns false, this callback object is no longer needed.
65+
return;
66+
}
67+
}
68+
69+
// Resurrect ourselves by re-registering for finalization.
70+
GC.ReRegisterForFinalize(this);
71+
}
72+
73+
/// <summary>
74+
/// Schedule 'callback' to be called in the next GC. If the callback returns true it is
75+
/// rescheduled for the next Gen 2 GC, otherwise the callback stops.
76+
/// </summary>
77+
public static void Register(Func<bool> callback)
78+
79+
// Create an unreachable object that remembers the callback function and target object.
80+
=> new Gen2GcCallback(callback);
81+
82+
/// <summary>
83+
/// Schedule 'callback' to be called in the next GC. If the callback returns true it is
84+
/// rescheduled for the next Gen 2 GC, otherwise the callback stops.
85+
/// NOTE: This callback will be kept alive until either the callback function returns false,
86+
/// or the target object dies.
87+
/// </summary>
88+
public static void Register(Func<object, bool> callback, object targetObj)
89+
90+
// Create a unreachable object that remembers the callback function and target object.
91+
=> new Gen2GcCallback(callback, targetObj);
92+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
namespace SquidStd.Core.Buffers;
2+
3+
/// <summary>
4+
/// Wraps a <see cref="STArrayPool{T}" />-rented char buffer as an <see cref="ISpanFormattable" /> so it can be
5+
/// passed through interpolated string handlers without materializing an intermediate string.
6+
/// <see cref="TryFormat" /> can only be called once: it returns the buffer to the pool. To read the
7+
/// characters multiple times use <see cref="Chars" />; <see cref="ToString(string?, IFormatProvider?)" />
8+
/// is idempotent (it caches the string and releases the buffer on first call).
9+
/// </summary>
10+
public struct PooledArraySpanFormattable : ISpanFormattable, IDisposable
11+
{
12+
private char[]? _arrayToReturnToPool;
13+
private readonly int _length;
14+
private string? _value;
15+
16+
/// <summary>
17+
/// Gets the written character slice while the buffer is still owned.
18+
/// </summary>
19+
public ReadOnlySpan<char> Chars => _arrayToReturnToPool.AsSpan(0, _length);
20+
21+
/// <summary>
22+
/// Initializes the wrapper over a pooled buffer and the number of valid characters in it.
23+
/// </summary>
24+
/// <param name="arrayToReturnToPool">Buffer rented from <see cref="STArrayPool{T}" />.</param>
25+
/// <param name="length">Count of valid characters at the start of the buffer.</param>
26+
public PooledArraySpanFormattable(char[] arrayToReturnToPool, int length)
27+
{
28+
_arrayToReturnToPool = arrayToReturnToPool;
29+
_length = length;
30+
_value = null;
31+
}
32+
33+
/// <summary>
34+
/// Converts the wrapper to a string. After the buffer has been released by ToString, the cached string is returned.
35+
/// </summary>
36+
public static implicit operator string(PooledArraySpanFormattable formattable)
37+
=> formattable._value ?? new string(formattable.Chars);
38+
39+
/// <inheritdoc />
40+
public override string ToString()
41+
=> ToString(null, null);
42+
43+
/// <summary>
44+
/// Materializes the content as a string. Idempotent: the first call caches the value and
45+
/// returns the buffer to the pool; later calls return the same instance.
46+
/// </summary>
47+
public string ToString(string? format, IFormatProvider? formatProvider)
48+
{
49+
if (_value is null)
50+
{
51+
_value = new(Chars);
52+
ReleaseBuffer();
53+
}
54+
55+
return _value;
56+
}
57+
58+
/// <summary>
59+
/// Copies the content into <paramref name="destination" /> and returns the buffer to the pool.
60+
/// Single use: the wrapper must not be used again after a successful call.
61+
/// </summary>
62+
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format = default, IFormatProvider? provider = null)
63+
{
64+
if (destination.Length < _length)
65+
{
66+
charsWritten = 0;
67+
68+
return false;
69+
}
70+
71+
Chars.CopyTo(destination);
72+
charsWritten = _length;
73+
ReleaseBuffer();
74+
75+
return true;
76+
}
77+
78+
private void ReleaseBuffer()
79+
{
80+
if (_arrayToReturnToPool is not null)
81+
{
82+
STArrayPool<char>.Shared.Return(_arrayToReturnToPool);
83+
_arrayToReturnToPool = null;
84+
}
85+
}
86+
87+
/// <summary>
88+
/// Returns the buffer to the pool if it is still owned.
89+
/// </summary>
90+
public void Dispose()
91+
=> ReleaseBuffer();
92+
}

0 commit comments

Comments
 (0)