С этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .NET CLR, и .NET в целом (уже готово около 200 страниц книги, так что добро пожаловать в конец статьи за ссылками). Как язык, так и платформа существуют уже много лет: и все это время существовало множество средств для работы с неуправляемым кодом. Так почему же сейчас выходит очередной API для работы с неуправляемым кодом если по сути он существовал уже много-много лет? Для того чтобы ответить на этот вопрос достаточно понять чего не хватало нам раньше. Разработчики платформы и раньше пытались нам помочь скрасить будни разработки с использованием неуправляемых ресурсов: это и автоматические врапперы для импортируемых методов. И маршаллинг, который в большинстве случаев работатет автоматически. Это также инструкция stackallloc, о которой говорится в главе про стек потока. Однако, как по мне если ранние разработчики с использованием языка C# приходили из мира C++ (как сделал это и я), то сейчас они приходят из более высокоуровневых языков (я, например, знаю разработчика, который пришел из JavaScript). А что это означает? Это означает что люди со все большим подозрением начинают относиться к неуправляемым ресурсам и конструкциям, близким по духу к C/C++ и уж тем более — к языку Ассемблера.  Как результат такого отношения — все меньшее и меньшее содержание unsafe кода в проектах и все большее доверие к API самой платформы. Это легко проверяется если поискать использование конструкции stackalloc по открытым репозиториям: оно ничтожно мало. Но если взять любой код, который его использует: Класс Interop.ReadDir
/src/mscorlib/shared/Interop/Unix/System.Native/Interop.ReadDir.cs Становится понятна причина непопулярности. Посмотрите не вчитываясь на код и ответьте для себя на один вопрос: доверяете ли вы ему? Могу предположить что ответом будет "нет". Тогда ответьте на другой: почему? Ответ будет очевиден: помимо того что мы видим слово Dangerous, которое как-бы намекает что что-то может пойти не так, второй фактор, влияющий на наше отношение — это строчка byte* buffer = stackalloc byte[s_readBufferSize];, а если еще конкретнее — byte*. Эта запись — триггер для любого чтобы в голове появилась мысль: "а что, по-другому сделать нельзя было что-ли?". Тогда давайте еще чуть-чуть разберемся с психоанализом: отчего может возникнуть подобная мысль? С одной стороны мы пользуемся конструкциями языка и предложенный здесь синтаксис далек от, например, C++/CLI, который позволяет делать вообще все что угодно (в том числе делать вставки на чистом Assembler), а с другой он выглядит непривычно. Так в чем же вопрос? Как вернуть разработчиков обратно в лоно неуправляемого кода? Необходимо дать им чувство спокойствия что они не могут сделать ошибку случайно, по незнанию. Итак, для чего же введены типы Span<T> и Memory<T>? Тип Span олицетворяет собой часть некоторого массива данных, поддиапазон его значений. При этом позволяя как и в случае массива работать с элементами этого диапазона как на запись, так и на чтение. Однако, давайте для разгона и общего понимания сравним типы данных, для которых сделана реализация типа Span и посмотрим на возможные цели его введения. Первый тип данных, о котором хочется завести речь — это обычный массив. Для массивов работа со Span будет выглядеть следующим образом: Как мы видим в данном примере, для начала мы создаем некий массив данных. После этого мы создаем Span (или подмножество), который ссылаясь на сам массив, разрешает его использующему коду доступ только в тот диапазон значений, который был указан при инициализации. Тут мы видим первое свойство этого типа данных: это создание некоторого контекста. Давайте разовьем нашу идею с контекстами: Как мы видим, Span<T> вводит абстракцию доступа к некоторому участку памяти как на чтение так и на запись. Что нам это дает? Если вспомнить, на основе чего еще может быть сделан Span, то мы вспомним как про неуправляемые ресурсы, так и про строки: Т.е., получается, что Span<T> — это средство унификации по работе с памятью: управляемой и неуправляемой, которое гарантирует безопасность в работе с такого рода данными во время Garbage Collection: если участки памяти с управляемыми массивами начнут двигаться, то для нас это будет безопасно. Однако, стоит ли так сильно радоваться? Можно ли было всего этого добиться и раньше? Например, если говорить об управляемых массивах, то тут даже сомневаться не приходится: достаточно просто обернуть массив в еще один класс, предоставив аналогичный интерфейс и все готово. Мало того, аналогичную операцию можно проделать и со строками: они обладают необходимыми методами. Опять же, достаточно строку завернуть в точно такой же тип и предоставить методы по работе с ней. Другое дело что для того чтобы хранить в одном типе строку, буфер или массив, придется сильно повозиться, храня в едином экземпляре ссылки на каждый из возможных вариантов (активным, понятное дело, будет только один): Или же если отталкиваться от архитектуры, то делать три типа, наследующих единый интерфейс. Получается, что для того чтобы сделать средство единого интерфейса между этими типами данных managed, сохранив при этом максимальную производительность, отличного от Span<T> пути не существует. Далее, если продолжить рассуждения, то что такое ref struct в понятиях Span? Это именно те самые "структуры, они только на стеке", о которых мы так часто слышим на собеседованиях. А это значит, что этот тип данных может идти только через стек и не имеет права уходить в кучу. А потому Span, будучи ref структурой, является контекстным типом данных, обеспечивающим работу методов, но не объектов в памяти. От этого для его понимания и надо отталкиваться. Отсюда мы можем сформулировать определение типа Span и связанного с ним readonly типа ReadOnlySpan: И действительно: если мы имеем примерно такой код: то скорость доступа к исходному буферу будет максимально высокой: вы работаете не с managed объектом, а с managed указателем. Т.е. не с .NET managed типом, а с unsafe типом, заключенным в managed оболочку. Человек так устроен что зачастую пока он не получит определенного опыта, то конечного понимания, для чего необходим инструмент часто не приходит. А потому, поскольку нам нужен некий опыт, давайте обратимся к примерам. Одним из самых алгоритмически интересных примеров является тип ValueStringBuilder, который прикопан где-то в недрах mscorlib и почему-то как и многие другие интереснейшие типы данных помечен модификатором internal, что означает что если бы не исследование исходного кода mscorlib, о таком замечательном способе оптимизации мы бы никогда не узнали. Каков основной минус системного типа StringBuilder? Это конечно же его суть: как он сам, так и то, на чем он основан (а это массив символов char[]) — являются типами ссылочными. А это значит как минимум две вещи: мы все равно (хоть и немного) нагружаем кучу и второе — увеличиваем шансы промаха по кэшам процессора. Еще один вопрос, который у меня возникал к StringBuilder — это формирование маленьких строк. Т.е. когда результирующая строка "зуб даю" будет короткой: например, менее 100 символов. Когда мы имеем достаточно короткие форматирования, к производительности возникают вопросы: Насколько эта запись хуже чем ручное формирование через StringBuilder? Ответ далеко не всегда очевиден: все сильно зависит от места формирования: как часто будет вызван данный метод. Ведь сначала string.Format выделяет память под внутренний StringBuilder, который создаст массив символов (SourceString.Length + args.Length * 8) и если в процессе формирования массива выяснится, что длина не была угадана, то для формирования продолжения будет создан еще один StringBuilder, формируя тем самым односвязный список. А в результате — необходимо будет вернуть сформированную строку: а это еще одно копирование. Транжирство и расточительство. Вот если бы избавиться от размещения в куче первого массива формируемой строки, было бы замечательно: от одной проблемы мы бы точно избавились. Взглянем на тип из недр mscorlib: Класс ValueStringBuilder
/src/mscorlib/shared/System/Text/ValueStringBuilder Этот класс по своему функционалу сходен со своим старшим собратом StringBuilder, обладая при этом одной интересной и очень важной особенностью: он является значимым типом. Т.е. хранится и передается целиком по значению. А новейший модификатор типа ref, который приписан к сигнатуре объявления типа говорит нам о том что данный тип обладает дополнительным ограничением: он имеет право находиться только на стеке. Т.е. вывод его экмепляров в поля классов приведет к ошибке. К чему все эти приседания? Для ответа на этот вопрос достаточно посмотреть на класс StringBuilder, суть котоого мы только что описали: Класс StringBuilder /src/mscorlib/src/System/Text/StringBuilder.cs StringBuilder — это класс, внутри которого находится ссылка на массив символов. Т.е. когда вы создаете его то по сути создается как минимум два объекта: сам StringBuilder и массив символов в как минимум 16 символов (кстати именно поэтому так важно задавать предполагаемую длину строки: ее построение будет идти через генерацию односвязного списка 16-символьных массивов. Согласитесь, расточительство). Что это значит в контексте нашего разговора о типе ValueStringBuilder: capacity по-умолчанию отсутствует, т.к. он заимствует память извне плюс он сам является значимым типом и заставляет пользователя расположить буфер для символов на стеке. Как итог весь экземпляр типа помещается на стек вместе с его содержимым и вопрос оптимизации здесь становится решенным. Нет выделения памяти в куче? Нет проблем с проседанием производительности по куче. Но вы мне скажите: почему тогда не пользоваться ValueStringBuilder (или его самописной версией: сам он internal и нам не доступен) всегда? Ответ такой: надо смотреть на задачу, которая вами решается. Будет ли результирующая строка известного размера? Будет ли она иметь некий известный максимум по длине? Если ответ "да" и если при этом размер строки не выходит за некоторые разумные границы, то можно использовать значимую версию StringBuilder. Иначе, если мы ожидаем длинные строки, переходим на использование обычной версии. Второй тип данных, который хочется особенно — отметить — это тип ValueListBuilder. Создан он для ситуаций, когда необходимо на короткое время создать некоторую коллекцию элементов и тут же отдать ее в обработку некоторому алгоритму. Согласитесь: задача очень похожа на задачу ValueStringBuilder. Да и решена она очень похожим образом: Файл ValueListBuilder.cs Если говорить прямо, то такие ситуации достаточно частые. Однако раньше мы решали этот вопрос другим способом: создавали List, заполняли его данными и теряли ссылку. Если при этом метод вызывается достаточно часто, возникает печальная ситуация: множество экземпляров класса List повисает в куче, а вместе с ними повисают в куче и массивы, с ними ассоциированные. Теперь эта проблема решена: дополнительных объектов создано не будет. Однако, как и в случае с ValueStringBuilder решена она только для программистов Microsoft: класс имеет модификатор internal. Для того чтобы окончательно понять суть нового типа данных, необходимо "поиграться" с ним, написав пару-тройку, а лучше — больше методов, его использующих. Однако, основные правила можно почерпнуть уже сейчас: В остальных случаях стоит присмотреться либо к Memory либо использовать классические типы данных. Дополнительно хотелось бы поговорить о том, как работает Span и что в нем такого примечательного. А поговорить есть о чем: сам тип данных делится на две версии: для .NET Core 2.0+ и для всех остальных. Файл Span.Fast.cs, .NET Core 2.0 Файл ??? [decompiled] Все дело в том что большой .NET Framework и .NET Core 1.* не имеют специальным образом измененного сборщика мусора (в отличии от версии .NET Core 2.0+) и потому вынуждены тащить за собой дополнительный указатель: на начало буфера, с которым идет работа. Т.е., получается, что Span внутри себя работает с управляемыми объектами платформы .NET как с неуправляемыми. Взгляните на внутренности второго варианта структуры: там присутствует три поля. Первое поле — это ссылка на managed объект. Второе — смещение относительно начала этого объекта в байтах чтобы получить начало буфера данных (в строках это — буфер с символами char, в массивах — буфер с данными массива). И, наконец, третье поле — количество уложенных друг за другом элементов этого буфера. Для примера возьмем работу Span для строк: Файл coreclr::src/System.Private.CoreLib/shared/System/MemoryExtensions.Fast.cs Где string.GetRawStringData() выглядит следующим образом: Файл с определением полей coreclr::src/System.Private.CoreLib/src/System/String.CoreCLR.cs Файл с определением GetRawStringData coreclr::src/System.Private.CoreLib/shared/System/String.cs Т.е. получается, что метод лезет напрямую вовнутрь строки, а спецификация ref char позволяет отслеживать GC неуправляемую ссылку во внутрь строки, перемещая его вместе со строкой во время срабатывания GC. Та же самая история происходит и с массивами: когда создается Span, то некий код внутри JIT рассчитывает смещение начала данных массива и этим смещением инициализирует Span. А как подсчитать смещения для строк и массивов, мы научились в главе про структуру объектов в памяти. Несмотря на всю идиллию, связанную со Span, существуют хоть и логичные, но неожиданные ограничения на его возврат из метода. Если взглянуть на следующий код: то все выглядит крайне логично и хорошо. Однако, стоит заменить одну инструкцию другой: как компилятор запретит инструкцию такого вида. Но прежде чем написать, почему, я прошу вас самим догадаться, какие проблемы понесет за собой такая конструкция. Итак, я надеюсь, что вы подумали, построили догадки и предположения, а может даже и поняли причину. Если так, главу про стек потока я по винтикам расписывал не зря. Ведь дав таким образом ссылку на локальные параменные метода, который закончил работу, вы можете вызвать другой метод, дождаться окончания его работы и через x[0.99] прочитать его локальные переменные. Однако, к счастью, когда мы делаем попытку написать такого рода код, компилятор дает на по рукам, выдав предупреждение: CS8352 Cannot use local 'reff' in this context because it may expose referenced variables outside of their declaration scope и будет прав: ведь если обойти эту ошибку, то возникет возможность, например, находясь в плагине подстроить такую ситуацию что станет возможным украсть чужие пароли или повысить привилегии выполнения нашего плагина. Если касательно Span<T> появились вопросы, давайте обсудим. Типы данных очень свежие и практически никем не используются, а потому разобрать use cases очень и очень сложно.  Семинары по платформе .NET CLRium