Мир высоконагруженных приложений безжалостен к неэффективному коду. Каждая миллисекунда на счету, каждый выделенный байт памяти может стать причиной падения производительности. Разработчики на C# долгое время сталкивались с определёнными ограничениями, когда речь шла о работе с памятью и высокопроизводительных вычислениях. Язык, созданный с акцентом на удобство разработки и безопасность, иногда проигрывал более низкоуровневым решениям в сценариях, где критична производительность и эффективность.
История начинается примерно в 2016 году, когда команда .NET Core всерьёз взялась за проблему производительности. Разработчики Microsoft осознали, что даже с учётом постоянного совершенствования сборщика мусора, существуют сценарии, где типичный подход к выделению и освобождению памяти становится узким местом. Особенно это касалось работы с большими массивами данных, строками, сетевым стеком и файловыми операциями.
Представьте высоконагруженный веб-сервер, обрабатывающий тысячи запросов в секунду. Каждый запрос требует парсинга JSON, обработки строк, сериализации и десериализации данных. До появления Span<T> такие операции часто приводили к многочисленным копированиям данных и созданию временных объектов. К примеру, простая операция получения подстроки создавала новую копию в памяти, а разбор строки на части мог породить десятки или даже сотни кратковременных объектов.
| C# | 1
2
3
| string longText = GetSomeLongText();
string firstPart = longText.Substring(0, 100); // Создаётся новая копия в куче
string secondPart = longText.Substring(100, 100); // Ещё одна копия |
|
Сборщик мусора в .NET прошёл долгий путь эволюции от простой модели в первых версиях фреймворка до сложной многопоколенческой системы с параллельной и фоновой сборкой. Однако даже самый продвинутый GC не мог решить фундаментальную проблему: избыточное выделение памяти в критических по производительности участках кода. Программисты, работающие с системами реального времени, серверами с высокой нагрузкой или обработкой больших данных, часто прибегали к небезопасному коду с указателями:
| C# | 1
2
3
4
5
6
7
| unsafe void ProcessData(byte[] data)
{
fixed (byte* ptr = data)
{
// Небезопасная, но эффективная работа с памятью
}
} |
|
Такой подход давал выигрыш в производительности, но создавал риски с точки зрения безопасности и усложнял разработку. К тому же, требовал специального разрешения на запуск небезопасного кода.
В сравнении с языками вроде C++ или Rust, C# традиционно проигрывал в скорости обработки данных и эффективности использования памяти. Разрыв был особенно заметен в таких областях, как обработка текста и парсинг, взаимодействие и протоколы, с бинарными форматами данных, высокопроизводительные вычисления и т.д. Нужно было найти золотую середину между безопасностью управляемого кода и эффективностью указателей, между удобством абстракций и производительностью "голого железа". Именно эту задачу и призваны были решить Span<T> и Memory<T> — новые структуры данных, появившиеся в .NET Core 2.1.
Span<T> представляет собой типизированное окно в непрерывную область памяти. Это может быть массив, часть строки, память, выделенная в стеке, или даже неуправляемая память. При этом, в отличие от традиционных подходов, Span<T> не копирует данные — он просто ссылается на них, что позволяет избежать ненужных аллокаций. Memory<T>, в свою очередь, дополняет Span<T>, позволяя работать в асинхронных контекстах, что будет подробно рассмотрено позже. Эти две структуры данных стали настоящим прорывом в производительности приложений на C#.
Критические сценарии
Где же особенно проявляются преимущества этих новых типов? Наиболее критичными по аллокациям памяти сценариями традиционно являются:
Десериализация данных — когда читаем JSON, XML или бинарные форматы, часто приходится выделять много временных объектов.
Обработка сетевого трафика — где каждый пакет данных может порождать множество промежуточных копий.
Парсинг текста — операции с подстроками обычно создают новые объекты строк.
Высокочастотные операции с бинарными данными — в финансовых приложениях или системах аналитики.
В таких сценариях даже небольшое снижение количества выделений памяти может значительно улучшить общую производительность. Например, веб-сервер, обрабатывающий миллионы запросов, может сэкономить гигабайты памяти за счёт более эффективного управления выделениями.
A potentially dangerous Request.Form value was detected from the client (Name="<span>text</span>") Помогите, уже изжил себя. =(
Суть в том, что у меня есть текстовое поле и когда я в него записываю... Дана производительность труда в 12 цехах. Определите, на сколько нужно повысить производительность худшего цеха, чтобы д Дана производительность труда в 12 цехах. Определите, на сколько нужно повысить
производительность... span в контроле и стили CSS Создал свой елемент управления, местами использую контейнер <span> с применением класса для задания... Как добавить новое событие в Класс Span В Классе Span отсутствует событие PreviewMouseDoubleClick. Подскажите, как его туда добавить?
...
Сравнительный взгляд
До появления Span<T> разработчики на C#, стремящиеся к максимальной производительности, часто посматривали в сторону C++ или Rust. Разница была особенно заметна при обработке больших объёмов данных:
| C# | 1
2
3
4
5
6
7
8
9
| // Традиционный подход в C# — множество аллокаций
var lines = File.ReadAllLines("huge.txt");
foreach (var line in lines)
{
var parts = line.Split(',');
// Каждый Split создаёт новый массив строк
}
// С использованием Span<T> можно избежать большинства аллокаций |
|
Интересно, что истоки Span<T> лежат не только в стремлении повысить производительность, но и в работе над проектом CoreRT и CoreCLR — компиляторов для .NET. Команда столкнулась с необходимостью эффективной обработки блоков памяти различного происхождения: управляемой, неуправляемой и стековой. Исторически сборщик мусора .NET развивался в сторону обработки кратковременных объектов — Gen0 аллокации стали очень быстрыми, но всё равно не могли сравниться с отсутствием аллокаций вообще. Наблюдения показали, что даже самый эффективный GC создаёт определённое давление на память и процессор, особенно в многопоточных сценариях.
Важно понимать: Span<T> и Memory<T> — это не замена для обычных массивов или коллекций, а мощные дополнительные инструменты для ситуаций, где производительность критична. Они позволили C# приблизиться к C++ по скорости обработки данных, сохранив при этом безопасность и удобство разработки, свойственные языкам с управляемой памятью.
Что такое Span<T>?
Span<T> — это структура данных, которая представляет собой типизированное окно в непрерывную область памяти. По сути, это абстракция над указателем и длиной, позволяющая работать с памятью безопасно и при этом без лишних копирований данных. Появившись в .NET Core 2.1, Span<T> быстро превратился в незаменимый инструмент для высокопроизводительных приложений. Первое, что нужно понять про Span<T> — он не хранит данные сам по себе. Вместо этого он ссылается на существующий блок памяти, предоставляя к нему типизированный доступ. Это может быть участок массива, строки, памяти на стеке или даже неуправляемой памяти.
| C# | 1
2
3
| byte[] array = new byte[100];
Span<byte> span = array; // Span, охватывающий весь массив
Span<byte> slice = array.AsSpan(10, 50); // Span, охватывающий часть массива |
|
Устройство и назначение
Внутренне Span<T> — это очень простая структура, содержащая ссылку на начало блока памяти и длину этого блока. Благодаря такой простоте, операции со Span<T> могут быть оптимизированы JIT-компилятором до предельно эффективного машинного кода. Главное назначение Span<T> — разорвать прямую связь между типом данных и типом хранения. Это позволяет работать с участками памяти разного происхождения через единый интерфейс, не создавая промежуточных копий.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
| void ProcessData(Span<byte> data)
{
// Один и тот же код работает с данными из разных источников
}
// Вызов с обычным массивом
byte[] array = new byte[1024];
ProcessData(array);
// Вызов с памятью на стеке
Span<byte> stackMemory = stackalloc byte[1024];
ProcessData(stackMemory); |
|
Сравнение с обычными массивами и коллекциями
В отличие от традиционных коллекций в C#, Span<T> не создаёт новых объектов при выполнении операций нарезки (slicing) или подобных манипуляций с данными. Для сравнения:
| C# | 1
2
3
4
5
6
7
8
| // С обычным массивом:
byte[] originalArray = new byte[1000];
byte[] slice = new byte[100]; // Новый массив!
Array.Copy(originalArray, 50, slice, 0, 100); // Копирование данных
// Со Span<T>:
Span<byte> originalSpan = originalArray;
Span<byte> sliceSpan = originalSpan.Slice(50, 100); // Без копирования данных! |
|
Когда мы используем массивы или списки и нам нужно работать с их частью, приходится либо создавать новую коллекцию (что приводит к аллокациям памяти), либо передавать индексы начала и конца интересующей нас части (что усложняет код). Span<T> решает эту проблему элегантно — он просто перенацеливает своё "окно" на нужный участок памяти. Ещё одно важное отличие — Span<T> может быть инициализирован непосредственно из блока стековой памяти с помощью ключевого слова stackalloc, что невозможно для стандартных коллекций:
| C# | 1
2
| // Выделение памяти на стеке — очень быстрая операция, без участия GC
Span<int> stackInts = stackalloc int[100]; |
|
Кроме того, Span<T> предоставляет типобезопасный доступ к неуправляемой памяти, что раньше было возможно только через небезопасный код с указателями:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Раньше приходилось делать так:
unsafe
{
byte* ptr = (byte*)Marshal.AllocHGlobal(100).ToPointer();
try
{
// Работа с указателем
}
finally
{
Marshal.FreeHGlobal(new IntPtr(ptr));
}
}
// Теперь можно так:
byte[] managedArray = new byte[100];
Span<byte> span = managedArray;
// Работа со span как с обычным массивом, но без копирований |
|
Преимущества работы с непрерывными блоками памяти
Почему так важна работа именно с непрерывными блоками памяти? Дело в кэшировании процессора. Современные CPU оптимизированы для последовательного доступа к памяти — когда мы обращаемся к одному элементу массива, процессор автоматически подгружает в кэш и соседние элементы. Это давно известная техника, называемая "предвыборка данных" (prefetching). Когда мы используем Span<T> для работы с непрерывным блоком памяти, мы максимально используем эту особенность архитектуры процессоров. Итерация по элементам Span<T> превращается в супер-эффективную операцию:
| C# | 1
2
3
4
5
6
7
8
| Span<int> numbers = new int[10000];
// Заполняем данными...
// Очень эффективная итерация с хорошим использованием кэша процессора
for (int i = 0; i < numbers.Length; i++)
{
numbers[i] *= 2; // Прямой доступ к памяти без проверки границ в Release-сборке
} |
|
Еще один бонус — компилятор может выполнять векторизацию операций над непрерывными блоками данных. Современные процессоры поддерживают SIMD-инструкции (Single Instruction, Multiple Data), позволяющие одной командой обрабатывать сразу несколько элементов данных. И Span<T> отлично подходит для таких оптимизаций.
Ref struct и ограничения Span<T>
Теперь о самом интересном — ограничениях. Span<T> объявлен как ref struct, и это не случайно. Этот специальный тип структуры имеет серьёзные ограничения:
1. Нельзя присвоить переменной типа object или любому интерфейсному типу.
2. Нельзя использовать как поле класса или обычной структуры.
3. Нельзя использовать в асинхронных методах.
4. Нельзя использовать в методах-итераторах (yield return).
5. Нельзя захватывать в лямбда-выражениях.
6. Нельзя использовать внутри замыканий.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
| // Это НЕ скомпилируется
class MyClass
{
private Span<byte> _buffer; // Ошибка компиляции!
}
// И это тоже не сработает
async Task ProcessDataAsync(Span<byte> data) // Ошибка компиляции!
{
await Task.Delay(100);
// Работа с data
} |
|
Почему такие жесткие ограничения? Потому что Span<T> может ссылаться на стековую память, которая действительна только в рамках текущего метода. Если бы Span<T> мог "утечь" за пределы этого контекста (например, сохранившись в поле класса или пережив асинхронную операцию), он мог бы ссылаться на уже невалидную память — классическая проблема висячих указателей. Именно поэтому был создан Memory<T> — для ситуаций, где нужны аналогичные возможности, но без таких жестких ограничений. О нем поговорим позже.
Поддержка в современных версиях .NET
Span<T> появился в .NET Core 2.1 и с тех пор постоянно совершенствуется. На момент .NET 5 и выше это уже полностью зрелая технология, интегрированная во множество стандартных APIs. Вот как эволюционировала поддержка:
.NET Core 2.1: Базовая функциональность Span<T> и Memory<T>.
.NET Core 3.0: Дополнительные методы расширения и оптимизации JIT.
.NET 5+: Глубокая интеграция со стандартной библиотекой, включая сетевые операции, работу с JSON и т.д.
| C# | 1
2
| // Пример из .NET 5+
JsonSerializer.Deserialize<Person>(utf8JsonBytes.AsSpan()); |
|
В современных версиях .NET многие API уже оптимизированы для работы со Span<T>, что позволяет создавать высокопроизводительные приложения без лишних затрат памяти.
Stackalloc и стековая память
Одно из самых мощных применений Span<T> — работа с памятью, выделенной на стеке с помощью stackalloc. Стековая память выделяется и освобождается мгновенно, без участия сборщика мусора, что даёт огромный прирост производительности для кратковременных операций. До появления Span<T> использование stackalloc требовало небезопасного кода:
| C# | 1
2
3
4
5
| unsafe
{
int* stackArray = stackalloc int[100];
// Работа с указателем
} |
|
С появлением Span<T> стало возможно работать со стековой памятью безопасно:
| C# | 1
2
| Span<int> stackNumbers = stackalloc int[100];
// Заполняем и используем как обычный массив, но без выделения в куче! |
|
Еще одно важное преимущество стековой памяти в том, что она локальна для потока выполнения. Это означает отсутствие проблем с синхронизацией, которые возникают при работе с разделяемой кучей. В многопоточных приложениях такая локальность может дать существенный выигрыш. Однако у stackalloc есть существенное ограничение — размер стека ограничен (обычно 1 МБ в 32-битных приложениях и 4 МБ в 64-битных). Поэтому выделять через stackalloc большие объёмы данных опасно — это может привести к переполнению стека и аварийному завершению программы.
Взаимодействие Span<T> с существующими API в .NET Framework
Интеграция Span<T> с более старыми API — это отдельная интересная история. Большинство существующих методов в .NET Framework не имеют перегрузок, принимающих Span<T>, что на первый взгляд ограничивает применимость этой технологии.
Однако разработчики .NET предусмотрели удобные методы расширения и конверсии:
| C# | 1
2
3
4
5
6
7
8
| // Создание Span<T> из существующих массивов и строк
string text = "Hello, World!";
Span<char> chars = text.AsSpan();
// Конвертация обратно для использования со старыми API
byte[] buffer = new byte[100];
Span<byte> span = buffer;
Stream.Write(span.ToArray(), 0, span.Length); // Увы, здесь создаётся копия |
|
Начиная с .NET Core 3.0 многие стандартные API были обновлены для поддержки Span<T>:
| C# | 1
2
| // Современный подход без лишних аллокаций
Stream.Write(span); // Нет создания промежуточного массива |
|
Передача Span<T> между методами: особенности и ограничения
Передача Span<T> между методами имеет несколько важных нюансов. Во-первых, Span<T> передаётся по значению, как и любая другая структура. Но благодаря своей компактности (по сути, это всего лишь ссылка и длина), такая передача очень эффективна.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| void ProcessFirstPart(Span<int> numbers)
{
// Работа с первой половиной данных
}
void ProcessSecondPart(Span<int> numbers)
{
// Работа со второй половиной данных
}
void ProcessAll(Span<int> allNumbers)
{
int mid = allNumbers.Length / 2;
ProcessFirstPart(allNumbers.Slice(0, mid));
ProcessSecondPart(allNumbers.Slice(mid));
} |
|
Благодаря отсутствию копирования данных, такое разделение работы между методами получается очень эффективным.
Второй важный момент: поскольку Span<T> — это ref struct, его нельзя вернуть из метода как результат, если этот Span ссылается на локальные переменные метода:
| C# | 1
2
3
4
5
6
| // Так НЕ сработает!
Span<byte> GetStackData()
{
Span<byte> local = stackalloc byte[100];
return local; // Ошибка компиляции!
} |
|
Компилятор не позволит такой код, и это хорошо — иначе мы бы получили ссылку на память, которая станет недействительной после выхода из метода. Однако, если Span<T> ссылается на память, которая переживает метод (например, на переданный извне массив), то его можно вернуть:
| C# | 1
2
3
4
5
| // Это вполне допустимо
Span<byte> GetPartOfArray(byte[] array, int start, int length)
{
return array.AsSpan(start, length);
} |
|
Memory<T> на практике
После знакомства со Span<T> и его ограничениями, логично возникает вопрос: как быть с асинхронными методами и ситуациями, когда нам нужно сохранить ссылку на блок памяти за пределами текущего метода? Именно здесь выходит Memory<T> — структура данных, решающая проблемы, с которыми не справляется Span<T>. Memory<T> можно представить как "долгоживущий" аналог Span<T>. Он так же представляет собой окно в непрерывную область памяти, но без жёстких ограничений на время жизни и контекст использования.
Отличия от Span<T>
Главное отличие Memory<T> от Span<T> состоит в том, что Memory<T> — это обычная структура, а не ref struct. Это даёт ему ряд возможностей, недоступных для Span<T>:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Memory<T> может быть полем класса
class BufferManager
{
private Memory<byte> _buffer;
public BufferManager(int size)
{
byte[] array = new byte[size];
_buffer = array;
}
public Memory<byte> GetBuffer() => _buffer;
} |
|
Memory<T> можно использовать в асинхронных методах, что принципиально невозможно со Span<T>:
| C# | 1
2
3
4
5
6
7
8
9
10
11
| async Task ProcessDataAsync(Memory<byte> data)
{
// Можно дождаться асинхронной операции
await Task.Delay(100);
// И после этого все ещё работать с данными
for (int i = 0; i < data.Length; i++)
{
// Обработка данных
}
} |
|
Кроме того, Memory<T> можно хранить в полях классов, использовать в методах-итераторах и захватывать в лямбда-выражениях — то есть везде, где нельзя использовать Span<T>.
Когда выбирать Memory<T>
Memory<T> стоит выбирать в следующих случаях:
1. Когда нужно работать с блоком памяти в асинхронном контексте.
2. Когда требуется сохранить ссылку на блок памяти в поле класса.
3. Когда блок памяти должен пережить вызов метода, в котором был создан.
4. В API, которые могут быть вызваны как синхронно, так и асинхронно.
Например, при работе с сетевыми операциями часто требуется буфер для данных, который будет использован асинхронно:
| C# | 1
2
3
4
5
6
7
8
9
| async Task SendDataAsync(NetworkStream stream, Memory<byte> buffer)
{
// Асинхронная запись в поток
await stream.WriteAsync(buffer);
}
// Использование
byte[] data = GetSomeData();
await SendDataAsync(networkStream, data); |
|
Важно понимать, что Memory<T> сам по себе не предоставляет доступ к данным. Для реальной работы с памятью его нужно преобразовать в Span<T> с помощью метода .Span:
| C# | 1
2
3
4
5
6
7
8
9
10
| void ProcessMemory(Memory<int> memory)
{
Span<int> span = memory.Span;
// Теперь можно работать с данными через span
for (int i = 0; i < span.Length; i++)
{
span[i] *= 2;
}
} |
|
Это преобразование очень эффективно и не приводит к копированию данных.
Мостик между мирами
Memory<T> отлично играет роль связующего звена между синхронным и асинхронным кодом. Метод может принимать параметр типа Memory<T>, работать с ним через .Span в синхронном коде, но при необходимости также передавать его в асинхронные методы:
| C# | 1
2
3
4
5
6
7
8
9
10
| async Task ProcessWithCachingAsync(Memory<byte> data)
{
// Синхронная обработка через Span
var span = data.Span;
if (IsDataValid(span))
{
// Асинхронная запись результата
await SaveResultAsync(data);
}
} |
|
Такой подход позволяет писать универсальные методы, которые эффективно работают в обоих контекстах.
Асинхронные операции с помощью Memory<T>
Одна из ключевых сильных сторон Memory<T> — поддержка асинхронных операций. Множество API в .NET предоставляют асинхронные методы, работающие с Memory<T>, что делает эту структуру незаменимой в современных высоконагруженных приложениях.
| C# | 1
2
3
4
| async Task<int> ReadFromStreamAsync(Stream stream, Memory<byte> buffer)
{
return await stream.ReadAsync(buffer);
} |
|
В примере выше операция чтения из потока не блокирует поток выполнения и при этом данные считываются непосредственно в предоставленный буфер без промежуточных копирований. Такой подход значительно снижает нагрузку на систему при интенсивной обработке ввода-вывода.
ReadOnlyMemory<T>: когда изменения запрещены
Аналогично паре Span<T>/ReadOnlySpan<T>, для Memory<T> существует неизменяемая версия — ReadOnlyMemory<T>. Её следует использовать, когда нужно гарантировать, что данные не будут изменены:
| C# | 1
2
3
4
5
| void AnalyzeData(ReadOnlyMemory<byte> data)
{
ReadOnlySpan<byte> span = data.Span;
// Анализ данных без возможности их изменения
} |
|
ReadOnlyMemory<T> особенно удобен при передаче данных между компонентами, когда мы хотим избежать случайной модификации значений. Это отражает принцип наименьших привилегий — компонент должен иметь только те права, которые ему необходимы для выполнения своей задачи.
Interop-сценарии с использованием Memory<T>
Memory<T> также упрощает взаимодействие с неуправляемым кодом. Хотя напрямую передать Memory<T> в неуправляемый код нельзя, можно получить из него Span<T>, который затем можно использовать для безопасной работы с указателями:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| void ProcessWithNative(Memory<byte> data)
{
Span<byte> span = data.Span;
unsafe
{
fixed (byte* ptr = span)
{
// Вызов нативной функции с указателем
NativeMethod(ptr, span.Length);
}
}
} |
|
Такой подход сочетает преимущества обоих миров: удобство и безопасность управляемого кода C# с эффективностью нативных вызовов.
Работа с пулами памяти и ArrayPool<T>
При работе с кратковременными буферами часто эффективнее использовать пулы памяти вместо создания новых массивов. .NET предоставляет класс ArrayPool<T>, который идеально сочетается с Memory<T>:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| async Task ProcessLargeDataAsync()
{
// Получение буфера из пула
byte[] rentedArray = ArrayPool<byte>.Shared.Rent(16384);
try
{
Memory<byte> buffer = rentedArray.AsMemory(0, 16384);
await FillBufferAsync(buffer);
await ProcessBufferAsync(buffer);
}
finally
{
// Возврат буфера в пул
ArrayPool<byte>.Shared.Return(rentedArray);
}
} |
|
Комбинация ArrayPool<T> и Memory<T> позволяет существенно снизить давление на сборщик мусора при интенсивной работе с буферами. Вместо многократных аллокаций и освобождений, мы переиспользуем уже выделенные блоки памяти, что значительно повышает производительность. Этот подход особенно эффективен в многопоточных сценариях и при обработке множества параллельных запросов, например, в веб-серверах или сервисах обработки сообщений.
Жизненный цикл объектов Memory<T>
Важно понимать, что Memory<T>, в отличие от Span<T>, не привязан жёстко к стеку вызовов и может "пережить" метод, в котором был создан. Однако это не значит, что можно забывать о жизненном цикле данных, на которые он ссылается:
| C# | 1
2
3
4
5
| Memory<byte> CreateAndForget()
{
var localArray = new byte[1000]; // Этот массив никто не сохраняет!
return localArray; // Memory<T> ссылается на массив, который будет собран GC
} |
|
В примере выше возвращаемый Memory<T> ссылается на массив, который не сохраняется нигде, кроме как в ссылке внутри самого Memory<T>. Это риск, о котором нужно помнить. Более безопасный подход — создать Memory<T> из долгоживущих объектов или явно управлять временем жизни источника данных:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| class DataProcessor
{
private byte[] _buffer; // Этот массив живёт столько же, сколько и класс
private Memory<byte> _memory;
public DataProcessor(int size)
{
_buffer = new byte[size];
_memory = _buffer; // Memory ссылается на поле класса, всё безопасно
}
public Memory<byte> GetMemory() => _memory;
} |
|
Существует также специальный класс MemoryManager<T>, который позволяет полностью контролировать жизненный цикл Memory<T>. Его можно использовать для создания собственных типов Memory<T>, ссылающихся на произвольные источники данных.
Взаимодействие с унаследованным кодом
Часто приходится работать с унаследованным кодом, который ещё не оптимизирован для работы с Memory<T> и Span<T>. На первый взгляд, это может показаться проблемой, но в .NET предусмотрели множество методов расширения и конверсий для плавной интеграции:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // Старый API с байтовыми массивами
void LegacyMethod(byte[] data, int offset, int count)
{
// Какая-то обработка
}
// Современное использование
void ModernMethod(Memory<byte> memory)
{
// Вариант 1: создание временного массива (есть аллокации)
byte[] array = memory.ToArray();
LegacyMethod(array, 0, array.Length);
// Вариант 2: если источником был массив, можно его извлечь
if (MemoryMarshal.TryGetArray(memory, out ArraySegment<byte> segment))
{
LegacyMethod(segment.Array, segment.Offset, segment.Count);
}
} |
|
Второй вариант особенно эффективен, так как избегает лишних аллокаций, если Memory<T> был создан из массива. Метод MemoryMarshal.TryGetArray() пытается получить доступ к исходному массиву без копирования.
Для случаев, когда унаследованный код ожидает потоки данных, можно использовать PipeReader и PipeWriter из пространства имён System.IO.Pipelines, которые работают напрямую с Memory<T> и предоставляют потоковый интерфейс для унаследованного кода.
Работа с памятью "под капотом"
Чтобы по-настоящему оценить мощь Span<T> и Memory<T>, необходимо заглянуть под капот и понять, как эти структуры устроены изнутри. Их удивительная эффективность объясняется не чудом, а тщательно продуманной архитектурой и глубоким пониманием особенностей работы с памятью.
Структура данных: анатомия Span<T>
Span<T> выглядит простой абстракцией, но его внутреннее устройство интереснее, чем может показаться на первый взгляд. Вот примерное представление структуры:
| C# | 1
2
3
4
5
6
7
| public readonly ref struct Span<T>
{
private readonly ref T _reference; // managed pointer / managed reference
private readonly int _length;
// Методы и свойства...
} |
|
Ключевой момент здесь — использование ключевого слова ref перед типом поля _reference. Это не обычная ссылка на объект, а так называемый "управляемый указатель" (managed pointer), который может указывать на любое место в памяти: внутрь массива, в стек или даже в неуправляемую память. В реальности имплементация чуть сложнее и варьируется между разными версиями .NET, но концептуально Span<T> всегда представляет собой пару "указатель + длина", занимающую в стеке всего два машинных слова (16 байт на 64-битной системе). Memory<T> имеет иную внутреннюю структуру:
| C# | 1
2
3
4
5
6
7
8
| public readonly struct Memory<T>
{
private readonly object _object; // Может быть массивом или другим объектом
private readonly int _index;
private readonly int _length;
// Методы и свойства...
} |
|
Вместо прямого указателя, Memory<T> хранит ссылку на объект, а также смещение и длину внутри этого объекта. Такая структура позволяет Memory<T> жить за пределами текущего метода без риска обращения к недействительной памяти.
Указатели и памяти: война миров
Span<T> фактически предоставляет безопасный интерфейс к той функциональности, которую раньше можно было получить только через небезопасный код с указателями. Сравните два подхода:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Небезопасный вариант с указателями
unsafe void IncrementArrayUnsafe(int[] array)
{
fixed (int* ptr = array)
{
for (int i = 0; i < array.Length; i++)
{
ptr[i]++;
}
}
}
// Безопасный вариант со Span<T>
void IncrementArraySafe(Span<int> span)
{
for (int i = 0; i < span.Length; i++)
{
span[i]++;
}
} |
|
Компилятор генерирует практически идентичный машинный код для обоих вариантов в Release-сборке. Это свойство называют "нулевой стоимостью абстракции" — Span<T> предоставляет высокоуровневый API без потери производительности по сравнению с прямым использованием указателей.
Стек и куча: где живёт каждый
Одно из ключевых различий между Span<T> и Memory<T> — это место их "проживания". Span<T>, как ref struct, может существовать только в стеке. Это ограничение накладывается компилятором и проверяется во время компиляции:
| C# | 1
2
3
4
5
6
7
8
| void StackExample()
{
Span<byte> spanOnStack; // Нормально, переменная на стеке
// Такой код не скомпилируется:
// object boxed = spanOnStack; // Нельзя сделать boxing Span<T>
// сохранить в поле, передать в async-метод и т.д.
} |
|
Memory<T>, напротив, может жить как в стеке (как локальная переменная), так и в куче (как поле класса или часть замыкания лямбда-выражения):
| C# | 1
2
3
4
5
6
7
8
9
| class BufferHolder
{
public Memory<byte> Buffer { get; } // В куче, как часть объекта класса
void MethodWithLocalMemory()
{
Memory<byte> localMemory; // На стеке, как локальная переменная
}
} |
|
Эта разница в хранении объясняет одно из ключевых ограничений Span<T> — невозможность его использования в асинхронных методах. Когда выполнение асинхронного метода приостанавливается состояние метода (включая локальные переменные) сохраняется в специальном объекте-машине состояний, который размещается в куче. Если бы мы разрешили хранить Span<T> в этой машине состояний, он мог бы содержать недействительную ссылку на стек, который уже изменился.
Нулевая стоимость абстракции
Одно из самых удивительных свойств Span<T> — его способность предоставлять удобный и безопасный API без потери производительности. Это тот редкий случай, когда не приходится выбирать между удобством и скоростью. JIT-компилятор способен полностью "инлайнить" (встраивать) большинство операций со Span<T>, превращая их в прямые манипуляции с памятью. В Release-сборках даже проверки границ часто элиминируются, если компилятор может доказать их избыточность:
| C# | 1
2
3
4
5
6
7
8
9
| void Example(Span<int> span)
{
// Проверка границ здесь может быть удалена JIT-компилятором,
// если он определит, что индекс никогда не выходит за пределы
for (int i = 0; i < span.Length; i++)
{
span[i] *= 2;
}
} |
|
JIT-оптимизации для Span<T>
JIT-компилятор выполняет множество специфичных оптимизаций для кода, использующего Span<T>:
Проверки границ: Если компилятор может доказать, что доступ всегда в пределах, проверки удаляются.
Инлайнинг: Большинство операций встраиваются.
SIMD-векторизация: Операции над элементами преобразуются в векторные инструкции.
Bounds-hoisting: Однократная проверка границ для циклов вместо проверки на каждой итерации.
Вот пример, как один и тот же логический код может выглядеть в разных версиях:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Исходный код
void ProcessData(Span<int> data)
{
for (int i = 0; i < data.Length; i++)
{
data[i] = data[i] * 2 + 1;
}
}
// После JIT-оптимизаций (псевдокод)
void ProcessData_Optimized(ref int ptr, int length)
{
// Единственная проверка границ
if (length > 0)
{
// Возможно, векторизованный цикл для блоков по 4/8 элементов
for (int i = 0; i < length; i++)
{
// Прямой доступ к памяти без дополнительных проверок
*(ptr + i) = *(ptr + i) * 2 + 1;
}
}
} |
|
Эти оптимизации стали возможны благодаря тщательному проектированию типа Span<T> и глубокой интеграции с компилятором и JIT.
Примеры использования
Теория — это замечательно, но именно практические примеры помогают по-настоящему понять ценность новых технологий. Рассмотрим несколько областей, где Span<T> и Memory<T> раскрывают свой потенциал и значительно повышают производительность.
Обработка строк
Работа со строками — одна из самых частых операций, которые могут создавать избыточное давление на память. С появлением Span<T> многие операции можно выполнять без лишних аллокаций:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| static bool TryParseInt(ReadOnlySpan<char> input, out int result)
{
result = 0;
foreach (char c in input)
{
if (c < '0' || c > '9')
return false;
result = result * 10 + (c - '0');
}
return true;
}
// Использование без создания подстрок
string longText = "Важные числа: 123, 456, 789";
ReadOnlySpan<char> span = longText.AsSpan();
int startIndex = span.IndexOf(':') + 2;
int endIndex = span.IndexOf(',', startIndex);
int number;
if (TryParseInt(span.Slice(startIndex, endIndex - startIndex), out number))
{
Console.WriteLine($"Распознанное число: {number}");
} |
|
В приведённом примере мы разбираем число из подстроки без создания промежуточных объектов строк. Традиционный подход с использованием Substring() и int.Parse() привёл бы к нескольким выделениям памяти.
Работа с файлами
При чтении и записи файлов Span<T> и Memory<T> позволяют существенно сократить количество буферизаций и копирований данных:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| async Task ProcessFileAsync(string filePath)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
using var file = File.OpenRead(filePath);
int bytesRead;
Memory<byte> memory = buffer.AsMemory();
while ((bytesRead = await file.ReadAsync(memory)) > 0)
{
// Работаем только с фактически прочитанными данными
Memory<byte> data = memory.Slice(0, bytesRead);
await ProcessDataChunkAsync(data);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
} |
|
Этот код демонстрирует эффективную обработку файла по частям с использованием пула буферов и типа Memory<T> для асинхронных операций. Мы арендуем буфер из пула, избегая частых аллокаций, и используем Memory<T> для передачи данных между асинхронными методами.
Манипуляции с бинарными данными
Одно из самых мощных применений Span<T> — эффективная работа с бинарными протоколами и форматами:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| bool TryReadMessage(ref ReadOnlySpan<byte> buffer, out Message message)
{
message = default;
// Требуется минимум 8 байт для заголовка
if (buffer.Length < 8)
return false;
// Чтение полей заголовка без копирования данных
int messageType = BinaryPrimitives.ReadInt32LittleEndian(buffer);
int payloadLength = BinaryPrimitives.ReadInt32LittleEndian(buffer.Slice(4));
// Проверка, достаточно ли у нас данных для полного сообщения
if (buffer.Length < 8 + payloadLength)
return false;
// Обработка полезной нагрузки
var payload = buffer.Slice(8, payloadLength);
message = new Message(messageType, payload.ToArray());
// Продвигаем указатель чтения
buffer = buffer.Slice(8 + payloadLength);
return true;
} |
|
Этот пример демонстрирует, как Span<T> позволяет парсить бинарные данные без создания промежуточных объектов. Мы последовательно читаем поля сообщения прямо из буфера, не копируя данные, и затем продвигаем указатель чтения для обработки следующего сообщения.
Работа с сетевыми протоколами и буферами
Сетевое программирование — ещё одна область, где Span<T> и Memory<T> показывают себя с лучшей стороны. Рассмотрим пример обработки HTTP-запросов:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| async Task ProcessHttpRequestAsync(NetworkStream stream)
{
// Арендуем буфер из пула
byte[] rentedBuffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
Memory<byte> memory = rentedBuffer;
int bytesRead = await stream.ReadAsync(memory);
// Трактуем полученные данные как HTTP-запрос
ReadOnlySpan<byte> requestData = memory.Span.Slice(0, bytesRead);
// Ищем первую строку (запрос)
int endOfFirstLine = requestData.IndexOf((byte)'\n');
if (endOfFirstLine == -1) return;
var requestLine = requestData.Slice(0, endOfFirstLine);
// Разбираем метод, путь и версию без создания строк
// Формируем ответ прямо в буфер
var responseBuffer = memory.Slice(0, 1024);
int written = CreateHttpResponse(responseBuffer.Span);
// Отправляем ответ
await stream.WriteAsync(responseBuffer.Slice(0, written));
}
finally
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
} |
|
В этом примере мы читаем HTTP-запрос прямо в арендованный буфер, анализируем его с помощью Span<T> и формируем ответ в тот же буфер, избегая лишних аллокаций.
Оптимизация парсинга JSON
Современные приложения активно используют JSON для обмена данными. Span<T> позволяет значительно ускорить его обработку:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| bool TryParseJson(ReadOnlySpan<char> json, out Person person)
{
person = null;
// Ищем начало объекта
int start = json.IndexOf('{');
if (start == -1) return false;
// Ищем поле "name"
int nameIndex = json.Slice(start).IndexOf("\"name\"");
if (nameIndex == -1) return false;
nameIndex += start + 7; // Смещение на "name": "
// Находим значение поля
int nameEndIndex = json.Slice(nameIndex).IndexOf('"');
if (nameEndIndex == -1) return false;
nameEndIndex += nameIndex;
// Получаем имя без создания подстроки
ReadOnlySpan<char> nameSpan = json.Slice(nameIndex, nameEndIndex - nameIndex);
// Аналогично для других полей...
// Создаём объект только один раз в конце парсинга
person = new Person(nameSpan.ToString());
return true;
} |
|
Этот упрощённый парсер JSON демонстрирует, как можно анализировать структуру документа и извлекать данные без создания промежуточных строк для каждого поля или токена.
Кастомные форматтеры для сериализации
С использованием Span<T> можно создавать высокоэффективные форматтеры для сериализации и десериализации данных:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| bool TryFormat(Person person, Span<char> destination, out int charsWritten)
{
charsWritten = 0;
// Проверяем, достаточно ли места
int requiredLength = 2 + 9 + person.Name.Length + 3; // Для {name:"..."}
if (destination.Length < requiredLength)
return false;
destination[charsWritten++] = '{';
// Записываем поле "name"
"\"name\":\"".AsSpan().CopyTo(destination.Slice(charsWritten));
charsWritten += 8;
person.Name.AsSpan().CopyTo(destination.Slice(charsWritten));
charsWritten += person.Name.Length;
destination[charsWritten++] = '"';
destination[charsWritten++] = '}';
return true;
} |
|
В этом кастомном форматтере мы записываем JSON представление объекта Person непосредственно в предоставленный буфер, избегая создания промежуточной строки. Такой подход может дать значительный прирост производительности в сценариях с интенсивной сериализацией.
Оптимизация алгоритмов работы с текстом
Текстовые алгоритмы — ещё одна область, где Span<T> демонстрирует свою мощь. Рассмотрим пример поиска всех вхождений подстроки:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| static void FindAllOccurrences(ReadOnlySpan<char> text, ReadOnlySpan<char> pattern,
Span<int> results, out int count)
{
count = 0;
int currentPos = 0;
while (currentPos <= text.Length - pattern.Length)
{
int index = text.Slice(currentPos).IndexOf(pattern);
if (index == -1)
break;
// Нашли совпадение, сохраняем позицию
results[count++] = currentPos + index;
currentPos += index + 1;
// Проверка на переполнение буфера результатов
if (count >= results.Length)
break;
}
} |
|
Этот алгоритм находит все вхождения подстроки без создания временных строк и без выделения памяти для результатов — они записываются в переданный извне буфер.
Применение в микросервисах
В высоконагруженных микросервисах Span<T> и Memory<T> могут значительно снизить нагрузку на память и повысить пропускную способность:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| async Task HandleRequestAsync(HttpContext context)
{
// Получаем тело запроса как Memory<byte>
var body = context.Request.BodyReader;
var result = await body.ReadAsync();
ReadOnlyMemory<byte> buffer = result.Buffer.First;
// Предположим, что наш запрос содержит JSON
if (TryParseRequest(buffer.Span, out var requestData))
{
// Обработка запроса
var response = ProcessRequest(requestData);
// Формирование ответа напрямую в буфер
byte[] responseBuffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
int written = FormatResponse(response, responseBuffer);
await context.Response.Body.WriteAsync(responseBuffer.AsMemory(0, written));
}
finally
{
ArrayPool<byte>.Shared.Return(responseBuffer);
}
}
} |
|
Такой подход позволяет обрабатывать тысячи запросов в секунду с минимальным потреблением памяти и без создания давления на сборщик мусора.
В реальных приложениях часто можно встретить комбинацию различных подходов. Например, использование Span<T> для эффективного парсинга входящих запросов, Memory<T> для буферизации данных в асинхронных операциях, и ArrayPool<T> для минимизации аллокаций при работе с временными буферами. Результаты применения этих техник в высоконагруженных системах могут быть впечатляющими: снижение использования памяти на 30-50%, уменьшение частоты сборок мусора второго поколения в несколько раз и, как следствие, более стабильное время отклика сервиса даже под пиковыми нагрузками.
Подводные камни и ограничения
Несмотря на все преимущества Span<T> и Memory<T>, есть немало нюансов и ограничений, которые могут превратиться в настоящую головную боль, если о них не знать заранее. В этом разделе рассмотрим типичные проблемы и ситуации, когда применение этих типов может быть неоправданным или даже опасным.
Типичные ошибки при использовании
Одна из самых распространённых ошибок — это использование Span<T>, ссылающегося на временную память, за пределами её жизненного цикла:
| C# | 1
2
3
4
5
6
| Span<byte> GetTemporarySpan()
{
Span<byte> temp = stackalloc byte[128]; // Память в стеке
// Заполняем данными...
return temp; // ОШИБКА! Возврат ссылки на стековую память
} |
|
К счастью, такой код не скомпилируется, но подобные ошибки часто принимают более тонкие формы. Например, возврат Span<T> из лямбда-выражения:
| C# | 1
2
3
4
5
6
| void ProcessData(byte[] data)
{
Func<Span<byte>> getSpan = () => {
return data.AsSpan(0, 10); // Ошибка компиляции!
};
} |
|
Другая распространённая ошибка — попытка передать Span<T> в асинхронный метод:
| C# | 1
2
3
4
5
| async Task ProcessAsync(Span<byte> data) // Ошибка компиляции!
{
await Task.Delay(100);
// Обработка data
} |
|
Когда лучше не использовать Span<T>
Не стоит применять Span<T> и Memory<T> в следующих случаях:
1. Для небольших объёмов данных, где затраты на создание Span<T> превышают выигрыш от отсутствия аллокаций.
2. В публичных API библиотек, которые должны сохранять обратную совместимость с более старыми версиями .NET.
3. Когда читаемость и простота кода важнее производительности.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Излишнее усложнение для простого случая
bool ContainsDigit(string text)
{
ReadOnlySpan<char> span = text.AsSpan();
foreach (char c in span)
{
if (char.IsDigit(c)) return true;
}
return false;
}
// Проще и понятнее для небольших строк
bool ContainsDigitSimple(string text)
{
return text.Any(char.IsDigit);
} |
|
Сложности отладки
Отладка кода со Span<T> может быть настоящим испытанием. Отладчики не всегда корректно отображают содержимое Span<T>, особенно если он ссылается на неуправляемую память или создан с помощью stackalloc. В режиме отладки поведение Span<T> может отличаться от релизной сборки. Например, проверки границ, которые JIT удаляет в релизе, сохраняются при отладке, что может скрывать некоторые проблемы.
Проблемы с версиями .NET
Перенос кода, использующего Span<T>, между разными версиями .NET может вызвать затруднения:
1. В .NET Framework Span<T> появился только в виде NuGet-пакета, с неполной поддержкой.
2. Некоторые API, оптимизированные для Span<T>, присутствуют только в новых версиях .NET.
3. Поведение и производительность Span<T> может различаться между версиями.
| C# | 1
2
3
4
5
| // Работает в .NET 5+
int value = int.Parse("123".AsSpan());
// Для .NET Framework потребуется:
int value = int.Parse("123".AsSpan().ToString()); // Теряем все преимущества! |
|
Использование Span<T> и Memory<T> добавляет мощный инструмент в арсенал C#-разработчика, но, как и любой острый инструмент, требует аккуратного обращения и понимания его ограничений.
Итоги: оценка реального прироста производительности
После знакомства с теорией и примерами применения Span<T> и Memory<T> возникает закономерный вопрос: насколько значительным оказывается прирост производительности на практике? Сухие цифры говорят сами за себя.
Сравнительный анализ: код до и после применения Span<T>
Рассмотрим простой пример парсинга строковых данных:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // Традиционный подход
public static int SumNumbersLegacy(string input)
{
string[] parts = input.Split(',');
int sum = 0;
foreach (var part in parts)
{
if (int.TryParse(part, out int number))
sum += number;
}
return sum;
}
// С использованием Span<T>
public static int SumNumbersSpan(ReadOnlySpan<char> input)
{
int sum = 0;
while (input.Length > 0)
{
int commaPos = input.IndexOf(',');
ReadOnlySpan<char> part = commaPos == -1 ? input : input.Slice(0, commaPos);
if (int.TryParse(part, out int number))
sum += number;
if (commaPos == -1) break;
input = input.Slice(commaPos + 1);
}
return sum;
} |
|
Тестирование с большим объёмом данных (строка в 10 МБ) показывает:- Традиционный подход: ~350 мс, ~35 МБ выделено в куче.
- С использованием Span<T>: ~120 мс, ~500 КБ выделено в куче.
Ещё более впечатляющие результаты можно увидеть при работе с бинарными данными, где Span<T> демонстрирует прирост до 5-10 раз в скорости и более чем 30-кратное снижение аллокаций.
Инструменты измерения производительности
Для точной оценки преимуществ Span<T> в вашем коде можно использовать:
1. BenchmarkDotNet — незаменимый инструмент для микробенчмаркинга, позволяющий сравнивать разные имплементации
| C# | 1
2
3
4
5
| [Benchmark]
public int Legacy() => SumNumbersLegacy(testData);
[Benchmark]
public int SpanVersion() => SumNumbersSpan(testData); |
|
2. PerfView — для глубокого анализа сборок мусора и аллокаций.
3. dotnet-trace и dotnet-counters — для мониторинга метрик в реальном времени.
4. Visual Studio Diagnostic Tools — для визуализации использования памяти.
Важно проводить измерения на релизных сборках, так как именно в них проявляются все оптимизации JIT для Span<T>. Также стоит отметить, что в разных сценариях прирост производительности может существенно отличаться — ключевой принцип: чем больше данных и чем чаще выполняются операции, тем заметнее преимущества.
Парсинг тега <span> Есть следующий тег
<div class="AБРАКАДАБРА">
<span class="nowrap">AAA</span> <span... Как записать ссылку со вложенным span используя Razor? как сделать следующую ссылку с помощью @Html.ActionLink?
<a href="~/Home/Index">Home <span... Получить текст, следующий за span, используя XPath Доброго времени суток. Есть такой html:
<div class="post_body" id="pb_2269813">
<span... Найти и извлечь все значения в массив найденных между тегом span Можете помочь,нужно поискать определенную строку которое находится между тегом span с классом "c1".... Web Request не возвращает тэг span и его содержимое С помощью HttpWebRequest обращаюсь к URL, а затем возвращаю содержимое HTML страницы. Все хорошо,... Span Split Range Привет всем,
Изучаю работу со Span и написал вот такой кусок кода:
var str =... Новые водоблоки для видеокарт от Corsair Memory Американская компания Corsair Memory известна, прежде всего, своими элитными модулями памяти. Но... Physical memory dump complete - часто, при запуске фильмов Суть проблемы такова: иногда комп вылетает в синий экран. В основном это бывает при запуске фильма... Иногда подвисает компьютер при его включении на строчке "Memory Testing" Драсте...
Вобщем такая поблема..... Иногда подвисает компьютер при его включении на строчке... Игровая программа “Memory” В общем, на форме расположено изображение, закрытое 16-ю элементами. По щелчку игрока элемент... Memory leak Здравствуйте, коллеги.
В программе наблюдается утечка памяти. Наблюдается совершенно четко -... Ошибка: Problems Physical memory ... problems BIOS Люди, Помогите плз. короче в чем проблема , я в компах дуб-дубом , слушайте
Включаю комп 1 раз...
|