Уникальная группа
- Статус
- Оффлайн
- Регистрация
- 24 Сен 2024
- Сообщения
- 64
- Реакции
- 19
Поздравляю всех с новым годом. Пусть этот год принесёт вам всем только счастье и добро!
Долгое время манипуляции с текстом в .NET были синонимом нагрузки на сборщик мусора. Каждый раз, когда вы писали $"Score: {score}", происходило маленькое преступление против производительности:
- Выделялась память под строку в UTF-16
- Число score боксилось или конвертировалось через промежуточный буфер.
- Если вам нужен был UTF-8 (для сети или графических библиотек типа Raylib/OpenGL), происходил транскодинг - создание еще одного массива байтов.
В высоконагруженных системах это буквально заваливало Gen0 мусорными строками, провоцируя фризы. С выходом современных версий .NET и метода System.Text.Unicode.Utf8.TryWrite, этот подход официально признан устаревшим.
Что такое Utf8.TryWrite?
Это статический метод, который позволяет выполнять интерполяцию строк напрямую в целевой буфер в формате UTF-8. Секрет состоит в том, что он не использует string.Format. Вместо этого компилятор C# задействует специальный обработчик интерполированных строк (TryWriteInterpolatedStringHandler). Весь процесс происходит без создания промежуточных объектов в куче.
Raylib code without allocation:
using Raylib_cs;
using System.Globalization;
using System.Numerics;
using System.Text.Unicode;
public unsafe class Program
{
private static void Main(string[] args)
{
Raylib.SetConfigFlags(ConfigFlags.Msaa4xHint | ConfigFlags.ResizableWindow);
Raylib.SetTargetFPS(75);
Raylib.InitWindow(800, 480, "Hello World");
int centerX = Raylib.GetScreenWidth() / 2;
int centerY = Raylib.GetScreenHeight() / 2;
var position = new Vector2(centerX, centerY);
float speed = 500f;
float radius = 36;
Span<byte> positionTextBuffer = stackalloc byte[255];
while (!Raylib.WindowShouldClose())
{
float dt = Raylib.GetFrameTime();
Vector2 direction = Vector2.Zero;
if (Raylib.IsKeyDown(KeyboardKey.W)) direction.Y -= 1;
if (Raylib.IsKeyDown(KeyboardKey.S)) direction.Y += 1;
if (Raylib.IsKeyDown(KeyboardKey.A)) direction.X -= 1;
if (Raylib.IsKeyDown(KeyboardKey.D)) direction.X += 1;
if (direction != Vector2.Zero)
{
direction = Vector2.Normalize(direction);
position += direction * speed * dt;
}
var min = new Vector2(radius, radius);
var max = new Vector2(Raylib.GetScreenWidth() - radius, Raylib.GetScreenHeight() - radius);
position = Vector2.Clamp(position, min, max);
Raylib.BeginDrawing();
Raylib.ClearBackground(Color.Black);
Raylib.DrawCircleV(position, radius, Color.White);
if (Utf8.TryWrite(positionTextBuffer, CultureInfo.InvariantCulture,
$"FPS: {Raylib.GetFPS()} | Pos: {position.X:F0},{position.Y:F0}\0",
out _))
fixed (byte* ptr = positionTextBuffer)
Raylib.DrawText((sbyte*)ptr, 10, 10, 30, Color.White);
Raylib.EndDrawing();
}
Raylib.CloseWindow();
}
}
Мы выделили память на стеке. Это бесплатно. Эта память исчезнет, как только метод завершится. Сборщик мусора даже не узнает, что здесь что-то происходило. В классическом подходе мы бы генерировали новую строку из System.String 75 раз в секунду. Это 4500 мусорных объектов в минуту только на вывод FPS. Это позор.
Utf8.TryWrite не создает строку. Компилятор разворачивает интерполяцию $"FPS:..." в последовательность вызовов AppendFormatted. Числа (FPS, координаты) конвертируются из float/int сразу в ASCII-байты в буфере positionTextBuffer. Нет промежуточного char[]. Нет конвертации UTF-16 -> UTF-8. Нет аллокаций. Мы фиксируем буфер (хотя для стека это формальность) и скармливаем указатель прямо в C-функцию Raylib.DrawText.
В итоге, в этом цикле while происходит ноль аллокаций. GC.GetAllocatedBytesForCurrentThread() покажет абсолютный штиль.
НО! Не расслабляйтесь! Utf8.TryWrite не исправляет кривой код. Взгляните на кишки метода AppendFormatted<T>, который генерирует компилятор.
Метод AppendFormatted из TryWriteInterpolatedStringHandler:
/// <summary>Writes the specified value to the handler.</summary>
/// <param name="value">The value to write.</param>
/// <param name="format">The format string.</param>
/// <typeparam name="T">The type of the value to write.</typeparam>
public bool AppendFormatted<T>(T value, string? format)
{
// If there's a custom formatter, always use it.
if (_hasCustomFormatter)
{
return AppendCustomFormatter(value, format);
}
if (value is null)
{
return true;
}
// Special-case enums to avoid boxing them.
if (typeof(T).IsEnum)
{
// TODO https://github.com/dotnet/runtime/issues/81500:
// Once Enum.TryFormat provides direct UTF8 support, use that here instead.
return AppendEnum(value, format);
}
// If the value can format itself directly into our buffer, do so.
if (value is IUtf8SpanFormattable)
{
if (((IUtf8SpanFormattable)value).TryFormat(_destination.Slice(_pos), out int bytesWritten, format, _provider))
{
_pos += bytesWritten;
return true;
}
return Fail();
}
string? s;
if (value is IFormattable)
{
// If the value can format itself directly into a UTF16 buffer, do so, then transcode.
if (value is ISpanFormattable)
{
return AppendSpanFormattable(value, format);
}
// If the value can ToString with the format / provider, get the resulting string, then append that.
s = ((IFormattable)value).ToString(format, _provider);
}
else
{
// Fall back to a normal ToString and append that.
s = value.ToString();
}
return AppendFormatted(s.AsSpan());
}
Если ваш тип (структура или класс) реализует IUtf8SpanFormattable (как это делают int, float, DateTime в .NET 8+), всё работает быстро. Байты пишутся сразу.
Если есть только ISpanFormattable, рантайм вынужден использовать промежуточный буфер для UTF-16, а затем транскодировать его в UTF-8. Это не аллоцирует строку в куче (обычно), но жрет такты на перекодировку.
Если ваш условный struct PlayerData не реализует ничего из вышеперечисленного, рантайм молча вызывает ToString().