Кирилл Бажайкин — Приемы экономии памяти в .NET

Информация о загрузке и деталях видео Кирилл Бажайкин — Приемы экономии памяти в .NET
Автор:
DotNext — конференция для .NET‑разработчиковДата публикации:
30.08.2024Просмотров:
7.3KОписание:
Подробнее о конференции DotNext: — — Скачать презентацию с сайта DotNext — Современные приложения иногда потребляют очень много памяти. И иногда они делают это, казалось бы, на пустом месте. Вот и в практике спикера произошло подобное: при записи и чтении файлов из S3. Спикер решил проблему и рассказал о тех подходах, которые позволили это сделать. Из доклада вы узнаете об основных приемах экономии памяти в современных .NET приложениях. Вспомните про ArrayPool, stackalloc и, конечно, работу со Span. Цель доклада — показать и доказать, что современный .NET — это платформа, где возможно написать эффективный и быстрый код. О других эффективных способах использования .NET, которые не вошли в доклад, Кирилл рассказал на своем канале —
Транскрибация видео
Добро пожаловать на второй день DotNext.
Сегодня я весь день буду у вас ведущим.
Меня зовут Павел Платонов.
Меня зовут Игорь Лавутин, наверное, вы меня знаете.
Представляю здесь программный комитет и буду еще помогать Кириллу немножко в течение доклада, после доклада.
Вообще в этот зал во второй день я вам очень рекомендую приходить, потому что именно здесь так получилось, это не специально собрались все инженерно-хардкорно.
В общем, доклады, на которых, наверное, придется подумать или узнать что-то суперновое и суперинтересное.
Но это все будет дальше в течение дня.
Заходите, смотрите.
В других залах тоже не хуже, и там тоже вы можете найти что-нибудь интересное.
Но давай все-таки про первый доклад, чтобы сильно не затягивать.
Про Кирилла.
Да, хочу представить сегодняшнего спикера.
Сейчас будет выступать Кирилл Бажакин, который больше 10 лет в индустрии.
Он юрист-программист и очень любит делать оптимизации.
И поэтому сегодня он нам расскажет про то, как мы сможем соптимизировать наш код и сэкономить нам память в .NET.
Здравствуйте.
Привет, DotNext.
Кирилл, привет.
Ты нас слышишь?
Отлично, ты нас слышишь.
Супер.
Смотри, мы с тобой...
Я открою секрет, я этот доклад уже немножко видел, мы его помогали готовить.
Много часов провели за дебагом всяких разных странных штук, про которые вы тоже узнаете.
И на самом деле не хочется сейчас тратить время.
Кирилл, давай ты сначала все расскажешь, а потом все, кому интересно, и все неотвеченные вопросы или незаданные вопросы вы потом будете задавать в конце Кириллу, чтобы он уже детально все ответил, показал с кодом.
Давай, жги.
Спасибо, спасибо большое.
Надеюсь, доклад будет интересным.
Итак, меня зовут Бажайкин Кирилл, и сегодня я вам буду рассказывать про приемы экономии памяти в .NET.
На самом деле я хотел назвать этот доклад «Мои дорогие файлы», потому что файлы для нас, для команды разработки оказались действительно дорогими, дорогими по памяти.
Что вы узнаете из этого доклада?
Вы узнаете техники написания кода с низкой аллокацией, как хорошо экономно работать с HTTP-клиентом, как применять RIPool, Stackalog, зачем вам это все нужно в повседневной разработке и где я про это узнал.
Начнем с конца.
У нас есть система хранения документов.
У нас много читателей, относительно них достаточно немного писателей.
Физически файлы хранятся в S3, реализация меню.
Мы по бизнес-логике, так вот получилось, мы отдаем поток байт, мы не можем отдать ссылку непосредственно на само меню.
И у нас есть S3 клиент, библиотечка.
Первоначально мы использовали AWS SDK, ну, как и многие.
И у нас возникла проблема, out of memory exception.
Что же такое произошло?
У нас, кажется, обычная система.
Хостится в Кубере, в Кибернетисе.
У нас .NET 7.
С нами пользователи и внешняя система связываются по REST.
И средний файл мы посчитали 123 МБ.
Это вот такое вот красивое число.
Казалось бы, что могло бы пойти не так.
Мы применили все рекомендации от Microsoft.
Мы настроили endpoint.
Если там вы читали вот эти вот рекомендации от Microsoft по работе с большими файлами, там есть такой мультипарт-ридер, мы тоже его применили.
Валидируем данные на случай, если нам там присылают, например, вирусы какие-нибудь.
Ну, мы валидируем, короче говоря, данные, применили все рекомендации от Microsoft.
И ничего.
Все равно, out of memory exception.
Что же пошло не так?
Проблема оказалась в S3-клиенте, непосредственно в той библиотеке, которая связывает нас с S3-хранилищем.
Первоначально мы применяли AWS SDK, и я провел синтетические тесты.
В этом тесте мы загружаем файл в S3-хранилище, выгружаем его, получаем какие-то метаданные и удаляем.
AWS тратил на это 207 мегабайт оперативной памяти и еще немножечко загрязнял Gen1.
Мы перешли на Minio.
Minio стала работать чуть шустрее, но потребляла аж 280 мегабайт оперативной памяти.
Нам это очень не нравилось.
РЕшники говорили, куда вы тратите всю эту оперативную память?
Мы столько поддерживать не можем, давайте разбирайтесь.
И мы начали разбираться.
Напомню про то, как все это вообще работает.
Допустим, производится какая-то загрузка или выгрузка документа.
Мы аллоцируем много памяти, создаем какие-то временные промежуточные объектики, перекладываем содержимое файликов.
После этого срабатывает garbage collector, он пытается все за нами это убрать.
Он очищает G0, те объекты, которые выживают, переходят в следующее поколение.
Но проблема в том, что вот этот весь процесс, он зациклен.
Дело в том, что как только количество запросов увеличивается хотя бы на 10-15-20%, количество срабатываний garbage-коллектора увеличивается непропорционально, непропорционально сильно увеличивается.
В связи с этим мы попадаем в бесконечную зависимость.
Как только мы лоцируем много памяти, срабатывает Garbage Collector.
Garbage Collector это не какая-то магическая штуковина, это просто программа, которая работает на том же самом процессоре, на котором работает ваше приложение.
Срабатывание Garbage Collector ест процессорное время.
От этого снижается скорость работы приложения.
Загрузка и выгрузка документов замедляется.
В один квант времени аллоцируется еще больше памяти.
Снова срабатывает garbage collector, снова снижается скорость работы до того момента, пока garbage collector не говорит, все, stop the world, я хочу почистить эту память.
В этот момент приложение перестает принимать входящие запросы.
Как только garbage collector нас отпускает, количество загрузок и выгрузок снова растет взрывными темпами, и это приводит к out-of-memory exception.
Такая вот интересная ситуация.
Как же быть?
Сделать велосипед.
Было наше решение.
Напомню, что велосипедами называется реализация какой-то уже существующей библиотеки под наши конкретные цели, под наши конкретные проблемы.
И забегая вперед, скажу, что у нас получилось снизить аллокацию аж в 200 раз по сравнению с AWS и Minio, при этом сохранить достаточно приличную по сравнению с ними скорость.
Как же мы этого достигли?
Наш путь был достаточно длинный, и для того, чтобы понять, как вообще сделать свой велосипед, свою обертку, свой клиент для S3, необходимо, во-первых, понять, что такое S3, изучить код клиента Minio, раз уж мы, во-первых, перешли на него, во-вторых, по нашим измерениям он работал достаточно быстро.
и не загрязнял ген-1.
Далее мы, собственно, написали, и с вами сейчас мы пройдемся по основным рекордным точкам этого процесса, мы написали свой собственный клиент для S3.
Что такое S3 вообще?
S3 – это на самом деле всего лишь навсего протокол HTTP-взаимодействия.
Со своими особенностями, конечно, например, иногда…
S3-хранилище возвращает XML в ответе.
Но, в принципе, это все тот же самый старый добрый HTTP, связывающийся с S3-хранилищем.
Реализации S3-хранилищ достаточно много, и они существуют почти во всех облаках.
У Яндекса, у Mail.ru, у Сбера, у Амазона, естественно, и даже у самого меню.
Это взаимодействие используют обычные глаголы.
Get, Post, Put.
Вот в данном конкретном примере показан Put-запрос.
Есть большое количество документации.
С моей точки зрения, хорошая документация у Яндекса.
С ней можно ознакомиться и увидеть, что это действительно просто обычный HTTP-запрос.
обмен информацией по HTTP.
А это значит, что мы действительно можем взять старый добрый HTTP-клайент из .NET, сделать над ним некую обертку и, используя особенности протокола, получать файлы и записывать их в S3-хранилище.
Особенностью протокола является, например, что?
Например, подписывание запросов.
Каждый запрос к S3-хранилищу должен быть подписан.
Алгоритм с одной стороны хитрый, с другой нехитрый, но тем не менее именно на этом алгоритме тратится достаточно большое количество оперативной памяти, поскольку он основан на перекладывании строчек из одного места в другое.
Изучаем код-миниум.
Код Minio доступен на GitHub, каждый с ним может ознакомиться.
Этот клиент скачали аж 8 миллионов человек, и мы будем его рассматривать, мы будем рассматривать этот код на примере, например, сохранения файла.
Это достаточно показательная штуковина, это достаточно показательный процесс в плане демонстрации расходов памяти, на что Minio-то тратит много памяти.
Первоначально создается объектик PutObjectArgs.
Ну, казалось бы, ничего особенного, маленький классик, который в ChainStele позволяет нам задать параметры сохраняемого документа, указать контент-тайп, передать стрим, указать размер объекта.
Дальше этот классик, этот объект с аргументиками попадает уже в глубину непосредственно самого клиента меню.
В чем дело?
Дело в том, что клиент-минио исходит из следующего.
Если файл больше 5 МБ, то этот файл разбивается на кусочки по 5 МБ.
Если файл меньше 5 МБ...
он отправляется в S3-хранилище одним кусочком.
Так вот, каждый кусочек, для абсолютно каждого кусочка создается промежуточный массив для того, чтобы сложить туда кусочек стрима входящего файла.
Зачем это необходимо?
А для того, чтобы вычислить хэш содержимого, который нужен для подписи вот этого запроса к S3.
Что происходит дальше?
Что происходит дальше?
Создается на каждый запрос достаточно тяжелый объект.
Он называется Request Message Builder.
Этот Request Message Builder, вы можете с ним ознакомиться непосредственно на GitHub.
Внутри него создаются дикшенари, внутри него создаются какие-то промежуточные коллекции.
В общем, он достаточно большой.
И в том числе в нем делаются некие асинхронные запросы к меню зачем-то, непонятно почему.
Я, в принципе, понимаю, зачем разработчики...
Minio создали вот этот вот Request Message Builder, потому что они хотели обобщить большое количество запросов к S3, которые, с одной стороны, достаточно разнородные, а с другой стороны, они хотели свести их к какому-то общему знаменателю.
А для чего, собственно?
Для того, чтобы в какой-то момент, сконкатенировав для каждого запроса еще раз большое количество строчечек,
всего лишь навсего сформировать HTTP-реквест-месседж и положить в его контент содержимого кусочка файла.
Вот и все.
Нам это показалось странным, нам это показалось подозрительным, и мы подумали, что в тот момент мы подумали, что ну хорошо, вот средний файл, допустим, у нас 123 мегабайта, загрузка-выгрузка, то есть это 246 мегабайт, ну понятно, что мы не сможем их сэкономить, а вот все остальное это вот чистые такие расходы клиента минимум.
У нас было подозрение, что мы сможем сэкономить ну 30 мегабайт максимум, вот тоже в принципе неплохо в нашей ситуации.
Как же мы подошли к написанию своего S3 клиента?
Ну, во-первых, мы поняли, что строки надо собирать заранее.
Во-вторых, мы тогда еще не знали, как мы это будем делать, но один из принципов был избегаем лишних аллокаций по максимуму.
Тот самый zero allocation.
Используем value string builder и array пулы для промежуточного хранения вот этих вот чанков, кусочков файлов.
Ну, во-первых, строки, строки, строки.
Самое простое – это мы всегда знаем по HTTP или по HTTPS мы общаемся, мы всегда знаем endpoint, port, и вот в данной конкретной имплементации мы всегда знаем bucket, почему бы не собрать эту строчку заранее.
Далее.
мы можем предсобрать какие-то кусочки подписи, которые мы каждый раз подписываем запрос к S3-хранилищу.
И не только предсобрать строчки, но еще и получить набор байтиков, вот как в данной конкретной ситуации для...
секретного ключа, не делать это каждый раз.
Напомню, что encoding utf-8-get-bytes каждый раз создает один очень маленький массивчик с этими байтиками.
Казалось бы, ничего особенного, спички, но, как показывает практика, именно из вот этого набора спичек, из вот этих очень маленьких оптимизаций как раз складывается достаточно большая оптимизация.
Что дальше?
Дальше мы решили применить технологии аллокации на стэки, тот самый Zero Allocation.
Напомню, что такое аллокация на стэки, что такое вообще стэк HIP.
У .NET есть две области памяти, одна из них называется стэк, другая из них называется куча.
Так вот, стэк очень быстрый, а куча в плане очистки, ну, по сравнению со стэком, достаточно медленная.
А почему?
А потому что стэк это, как следует из названия, стэк.
То есть мы знаем, что мы аллоцировали, допустим, 20 условных единиц.
Указатель от начала стэка сместился на 20 элементов.
Для того, чтобы его очистить, что нужно сделать?
Правильно, переместить указатель на самое начало стэка.
Все, стэк очищен.
Куча – это граф, по которому ходит garbage collector и смотрит.
А как же мне узнать, а вот этот вот объект, он вообще, как бы на него ссылаются или не ссылаются?
Ага, не ссылаются, ну, значит, можно удалять.
Короче говоря, чисто математически куча, ну, так как она граф, она вот из-за этого чисто математически просто медленнее.
А как же аллоцировать непосредственно на стеке?
раз уж стэк достаточно быстрый, у нас есть ключевое слово stackalog.
Раньше ключевое слово stackalog можно было применять только в случаях, когда мы находимся в unsafe контексте.
Только в этом случае.
Но с последних версий .NET stackalog можно применять и в других случаях.
Например...
если и только если переменная, в которую помещается салоцированный на стеке массив, имеет тип span.
Таким образом мы можем создать массив на стеке.
И вот, собственно, тут написано, каким образом это сделать.
Мы просто пишем stackalog, допустим, в данном случае byte, указываем размер, и в нашем span оказывается...
ссылка на этот массив.
А что, собственно, такое SPAN, опять-таки, напомню.
SPAN — это рестракт, и это и есть просто указатель, безопасный указатель на область памяти, так как стэк — это тоже память, напомню.
Это не какая-то там особенная память компьютера, это та же самая память.
И SPAN просто-напросто содержит указатель на память в области стэка.
А то, что он рефстракт, говорит нам о том, что рефстракт, во-первых, не может находиться в куче, чисто by design, а во-вторых, что его создание почти ничего не стоит.
Рекомендую почитать про рефстракты, очень интересная вещь.
Спэны можно создавать из большого количества сущностей .NET.
Например, из массива мы можем создать спэн.
Stack-a-lock, как мы только что сделали, можем создать span.
Мы даже можем создать span из неуправляемой памяти, воспользовавшись маршалингом и получив поинтер на эту область памяти.
Также мы можем создать span из строки.
Единственное, это будет read-only span, потому что...
строки в дутнете иммутабельные.
Но span существует почти, не почти, а у многих коллекций, назовем это так.
Напомню, что строка – это тоже коллекция, это набор символов, чаров.
Так вот, если салоцировать span на стеке и сравнить,
аллокацию спена на стеке с листом, то мы увидим такую драматичную достаточно ситуацию, что аллокация на стеке ничего не стоит в прямом смысле этого слова.
Я воспользовался бенчмарк.нетом, и вот я сейчас увеличу даже немножечко.
Спен действительно ничего не салоцировал, ни в G0, ни вообще ничего не салоцировал в процессе выполнения бенчмарка.
Откуда я взял эти скриншоты?
Скриншоты я взял из выступления Конора Дакокоса.
Пару тутнекстов назад он выступал.
Это вот как раз скриншоты из его презентации.
А более подробно он описал свою работу, свое исследование спэнов, ревстрактов в книжке про тутмемори Дакокоса.
менеджмент.
Категорически рекомендую для прочтения.
Зачем же нам все это нужно?
А вот для того как раз, чтобы собирать стринги, чтобы собирать строчки.
Дело в том, что у большого количества примитивных типов в .NET появились методы try-формат, которые как бы делают toString, но на самом деле они превращают
допустим, int в данном случае, в набор символов.
А эти символы можно сложить, например, в тот же самый span, который мы только что салонцировали на стеке.
Таким образом, строчечка, назовем это так, которая получается в результате трай-формата, она может быть помещена, например, в какой-то другой span.
И таким образом мы можем конкатенировать строки на стеке.
То есть не тратя никаких ресурсов на new string, string плюсик и так далее.
Это значительно быстрее, во-первых, а во-вторых, это не тратит оперативную память.
Единственное, нужно быть очень аккуратным с аллокацией на стеке, например, массива чаров.
Дело в том, что конкретно в данном случае, если мы ошибемся с размером этого предалацированного буфера, предалацированного массива символов, например, как в данном случае число минус 2 миллиарда, оно не поместится в эти 10 символов.
Или, например, я не учел культурные особенности числа, представление числа, и у меня опять-таки все сломается.
Поэтому вот здесь как раз нужно быть очень внимательным, когда мы вообще в принципе занимаемся микрооптимизациями.
Дело в том, что ошибиться в случае написания кода, который работает оптимально по памяти и по скорости, очень-очень легко.
Надо быть очень-очень внимательным.
Ну и, как я уже говорил, триформаты существуют у большого количества примитивных типов.
Справа это int, слева это datetime.
Мы можем применить триформат, получить набор символов и записать это в какой-то другой буфер.
А, собственно, в какой же буфер мы будем это все записывать?
Дело в том, что есть такая структура данных, которая называется Value String Builder.
Она взята нами и еще там большим количеством спикеров, многие про нее рассказывали, она взята из недр .NET, из недр кода .NET.
Почему-то она там internal, наверное, какая-то мотивация есть у создателей .NET, что не стоит ее делать public.
Но в чем ее особенность?
Это всего лишь навсего обертка над span.
Это всего лишь навсего удобная штуковина для того, чтобы формировать строку, но используя при этом zero allocation подход.
Как это выглядит?
Value String Builder примитивный абсолютно, на вход в конструктор передается первоначальный буфер, это тот самый span, который мы аллоцируем на стеке и в дальнейшем с помощью tri-формата туда добавляются следующие и следующие символы.
Вот как это выглядит.
Я в данном случае создал массив на стеке размером 512.
На самом деле так делать, наверное, не стоит, потому что если мы часто применяем эту технологию, часто применяем подход аллокации на стеке, то мы можем попасть в ситуацию, когда мы просто переполним стек.
Здесь я написал это специально, чтобы как раз про это сказать.
А внутри стринг-билдера используется try-формат, try-формат, try-формат, и мы можем в привычном нам стиле, как будто бы мы работали с обычным стринг-билдером, присоединять туда либо кусочки текста, цифры, даты, все что угодно.
И вы меня спросите, а каким образом ValueStringBuilder сработает, если, например, внутренний массив переполнился, внутренний буфер переполнился.
Что же делать?
Я отвечу.
Внутри используется достаточно простой опять-таки подход.
Мы берем ArrayPool.
Эту конструкцию, этот класс мы рассмотрим чуть позже, подробнее.
Но в данном конкретном случае мы берем ArrayPool, SharedArrayPool, и берем оттуда...
который нам необходим для того, чтобы расширить внутренний буфер, если, например, вновь добавляющаяся строчка туда не умещается.
Мы копируем из старого буфера из спена данные в уже настоящий массив, подменяем буфер на этот и подменяем буфер на этот массив.
Я напомню, что массив может имплицитно каститься к спену вообще без проблем.
А в конце концов мы просто возвращаем этот массив обратно в RIPL.
Никаких проблем с этим нет.
Ну и для того, чтобы развеять миф о том, что...
высокопроизводительный код портит ваш код, то есть он делает его некрасивым.
Нет, это не так.
ValueStringBuilder вы также можете передавать в другие методы, которые ответственны за сбор каких-то кусочков, выходной, финальной строки.
Единственное, нужно обязательно делать ref, то есть использовать ключевое слово ref, чтобы передавать в ValueStringBuilder по ссылке.
Иначе произойдет простое копирование и строчек у нас не соберется.
А зачем же нам все это надо?
Мы применили ValueStringBuilder, собирали-собирали-собирали строку, и мы можем эту строку преобразовать в read-only span.
То есть мы можем взять внутренний буфер, взять его какой-то кусочек уже заполненный и сделать из него read-only span.
Напомню, что есть span, который можно редактировать, и read-only span, который редактировать нельзя.
Зачем же нам этот read-only span нужен?
А дело в том, что, напомню, для создания подписи при обращении к S3-хранилищу нам нужно посчитать хэш, в том числе хэш от подписи, от подписи, от подписи, там такой достаточно вложенный друг в друга алгоритм.
Короче говоря, сформированную таким образом строку мы можем передать в методы,
допустим, того же самого сх256, например, и таким образом у нас сформируется строчечка уже, там дальше уже неудобно работать с редон или спанами, хотя можно было бы продолжить здесь эту оптимизацию, сформируется строчка, в которой будет лежать хэш от достаточно большой строки.
И на этом все.
На этом мы забываем про read-only span, в который мы долго-долго что-то складывали, а garbage collector о нем даже не узнал, потому что мы все это сделали на стеке.
Достаточно сложную, достаточно большую операцию по сбору строки, по конкатенации строк.
Мы все произвели на стеке и не потратили на это ни одного битика, байтика из хипа.
Это очень-очень удобно.
Итак, мы победили строки, и пора загружать файл.
Вот мы получили стрим.
Допустим, напомню, что у нас обычно SP.NET приложение, у нас есть какой-нибудь endpoint, который принимает iPhone файл, и мы начинаем загружать файл.
И из этого стрима мы перекладываем содержимое стрима в массив.
Еще раз напомню, что как только мы
получили стрим, нам надо сформировать подпись для обращения к S3-хранилищу.
А чтобы ее сформировать, нам нужен хэш от содержимого, который мы туда передаем.
Так вот, для того, чтобы собрать это содержимое в каком-то одном месте, мы должны...
Для того, чтобы собрать это содержимое в каком-то одном месте, мы должны проголосировать массивчик, в который мы будем перекладывать байтики из стрима.
А как это сделать?
Ну, собственно, достаточно просто это сделать.
Мы можем последовать примеру Minio, где они создают новый массив размером кусочка файла.
имплицитно привести его к редонали спану, и этот спан уже передать хеширующей функции абсолютно без проблем.
Но нам очень не хочется делать new, нам очень не хочется аллоцировать новые массивы каждый раз, потому что мы не можем надеяться на garbage collector, он нас уже сейчас подводит.
Для того, чтобы...
Чтобы переиспользовать массивы, пулинг – это один из самых эффективных способов оптимизации, мы можем забирать эти массивы из пула массивов.
Дело в том, что в Дутнете существует такая конструкция, которая называется ArrayPool.
Он бывает разный.
Например, в данном случае я использую shared.
Мы можем взять оттуда наш массив нужной нам длины и складывать в него байтики.
Единственная особенность, когда мы берем массив из R и P, на самом деле нам возвращается массив не того размера, который...
Не того размера, который мы попросили.
То есть я попросил, допустим, 10, он мне не вернет массив размером в 10, он мне вернет массив размером в 16 или в 32 размера элемента в зависимости от настроек этого ара и пула.
Когда создавался аэропул, посчитали, что это гораздо более эффективнее.
Но нас это на самом деле смущать не должно, потому что мы всегда знаем длину.
Соответственно, мы можем обрезать наш массив, который мы взяли из аэропула, по этой длине.
Единственное, что нельзя...
Всегда надо помнить, что массив в пул нужно обязательно возвращать, иначе чуда не произойдет.
Мы не сможем выполнить свою основную задачу, переиспользовать память, которую уже кто-то когда-то предалацировал, которую кто-то когда-то уже выделил под, допустим, подобную же ситуацию.
Допустим, пусть это будет наш же алгоритм.
Так вот, мы считываем данные из R и пула.
Мы считываем данные из стрима, складываем в промежуточный буфер, который мы взяли из ары пула.
Это делается достаточно просто.
И вычисляем от него хэш.
А дальше мы этот же массив, этот же буфер передаем в байт-ары контент нашего запроса к S3-хранилищу.
Все предельно просто.
Тут я, конечно же, должен сказать о том, что мы знали, что существует стрим-контент, и его стоит использовать в первую очередь, когда мы говорим о передаче данных между различными системами.
Ну, потому что у нас есть стрим, почему бы не воспользоваться стрим-контентом?
Я должен обязательно вам напомнить, что существует проблема стрим-то-стрим передачи данных.
Например, у меня есть пользователь, этот пользователь передает файл нам в систему, а мы данные из этого же стрима перекладываем в другой стрим, который...
идет в S3, и где-то на этом длинном пути произошла какая-то ошибка.
Мы уже не можем вернуться назад.
На самом деле там есть определенные подходы к решению подобной ситуации, но если не применять какие-то дополнительные усилия, то мы можем потерять данные.
А в случае загрузки документов на сервер я не рекомендую пользоваться таким способом.
все-таки рекомендую пользоваться подходом с промежуточным хранилищем в виде байт-арея.
Это достаточно безопасно и, более того, меню, например, делает точно так же, не просто так, опять-таки.
Еще у AR и pull-off есть особенности.
Про эти особенности написана огромная статья Евгения Пешкова.
Он вчера, по-моему, выступал на DotNext'е.
Вот, огромная статья «Ары и пул.
Подводные камни».
Рекомендую к этой статье обратиться.
Почему?
Потому что настройки ары и пула в многопоточной среде, они достаточно нетривиальные, что ли, скажем так.
И для того, чтобы ары и пул быстро выдавал вам приглацированные массивы, вам нужно немножечко его поднастроить.
Ну, имейте просто это в виду.
Далее.
Мы получили данные, мы положили данные в... Извиняюсь, сейчас технические проблемы.
Просто спикер находится в 7500 километрах от нас.
Вот, и поэтому... Да, мы не специально.
Сейчас пару минут, все починится.
Раз-раз.
О!
Я и так могу говорить громко, ну ладно, микрофон лучше.
Простой вопрос.
Узнал ли кто-нибудь хоть что-нибудь новенькое?
О, отлично.
Уже хорошо.
Пока Кирилл там переподключается, расскажу небольшую байку.
Ну, не байку в смысле, это реальный случай из подготовки доклада.
Пока мы... Вот он там, Кирилл, показывал бенчмарки, вот это все.
Это результат этих бенчмарков.
Когда он первый раз к нам пришел, показал, говорит, вот тут такое дело, хочу соптимизировать.
Надо это самое.
В общем, для того, чтобы эти бенчмарки сошлись в правильное место и не было каких-то выбросов, которые были совершенно непонятные, что это было, Кирилл, я и Женя Пешков, мы, по-моему, то ли 3, то ли 4 часа, где-то с 10 вечера до 2 ночи, до полвторого ночи сидели в Google Meet или в чем-то таком и пытались эти бенчмарки отладить.
На винде, на линуксе, вот это все.
Надо понимать, что Кирилл находится в скольки там тысячах километрах?
Семи с половиной тысяч.
Да, у него это было там то ли 4 утра, то ли 5 утра.
Он в Таиланде.
Соответственно, да, бичмарки были долгие.
По результату мы перекопали весь гитхаб Майкрософта, почему не сходилось.
Мы были готовы создавать ишью, потому что тут нет работает не так, как мы ожидали.
Точнее, не работает.
Но в итоге тут надо заработать так, как нам надо.
Бенчмарки получились хорошие.
Продукты зарелизили, все дела.
Я так понимаю, что вот... Можете потом в дискуссионной зоне спросить у Кирилла, но если я правильно понимаю, релиз продукта был вот что-то там, типа на прошлой неделе, пятница.
Как у нас Кирилл поживает?
Еще минутка.
Кирилл отошел погулять.
Нет, скорее всего, начался дождь.
Временно.
Кабель перекопали, как обычно.
Залетный трактор.
Да, но он говорил, у него там иногда бывает не сезон, там дожди идут, очень шумит, может быть, там что-то такое произошло.
Ну, все, наверное, видели в Азии, как у них провода любят проводить.
Вот, там такие прям куски паутины.
Я надеюсь, там сейчас резервный канал какой-нибудь включат, и все будет хорошо.
Сейчас подождем.
Так, в любом случае у нас время есть, поэтому я думаю, он успеет точно рассказать этот полезный доклад, который, надеюсь, кого-нибудь из вас вдохновит на оптимизации своих продуктов.
Да.
Доклад в каком-то смысле уникальный.
К нам много приходило докладчиков со словами, слушайте, мы тут применили спаны, мы тут применили вот это, все стало зашибись, но как бы...
Мы все откатили нафиг, потому что как-то код ужасен.
Это один из редких, пожалуй, примеров, которые я видел, ну, которые, по крайней мере, мы рассматривали как программный комитет, где то, что получилось сделать, довели до прода.
Ну, я надеюсь, что Кирилл зарелизил вот тут прошлую пятницу.
И при этом это все работает нормально.
То есть это не какая-то там нишевая оптимизация, а прям нормально работающий клиент, который лежит на GitHub, которым можно пользоваться.
Надо, кстати, посмотреть, сколько там звездочек.
Но я надеюсь, Кирилл нам расскажет в конце.
Если не расскажет, спросите его в дискуссионной зоне.
Это получается прям передовые технологии.
Да, да, да, все свежее.
Вот, а вот и Кирилл.
Кирилл, ты нас слышишь?
Да, да.
Все нормально, мы тогда тебе оставляем сцену дальше.
Да, простите, пожалуйста, что-то пошло не так.
Да, провода, я все эти шуточки слышал, только ответить не мог.
Да, да.
Здесь с проводами тяжело, их много.
Иногда перекапывают, иногда идет дождь, и все ломается.
Вот.
Но я напомню, где мы закончили.
Мы...
закончили на ары пуле.
Пуллинг – эффективный способ оптимизации.
Мы берем из пула массив.
Допустим, я прошу 10 элементов, массив размером в 10 элементов.
И массив в 10 элементов мне, естественно, не отдается.
Если было бы так просто, все бы этим пользовались.
Мне отдается массив размера настроек ары пула.
Дело в том, что каждый ары пул можно настроить по-своему.
Так вот, я, например, прошу 10, а мне возвращается 32 или 16.
но мне гарантированно возвращается массив того размера, который я попросил, или больше.
На самом деле это нас не пугает, потому что мы можем взять и обрезать этот массив, мы же знаем размер, мы можем его взять и обрезать, и у нас получится опять-таки тот самый спан, потому что спан может создаваться, например, от нулевого элемента массива до десятого, например, или там...
от 10 до 20.
Почему нет?
Это тоже будет вполне легальный спан.
При работе с RIPL надо не забывать возвращать массив, который мы взяли, непосредственно обратно в RIPL, потому что если мы не будем этого делать, магии переиспользования не произойдет, и RIPL просто будет непрерывно аллоцировать еще и еще массивов, и в конце концов память у нас закончится.
Когда мы взяли вот этот массив из RIPL, мы просто копируем данные из стрима с файлом в этот промежуточный массив и передаем этот же массив, имплицитно приводя его к, ну, если кто-то из вас пользуется райдером, вот видите, тут такие значки, конверсия, мы...
преобразуем, вернее, не мы преобразуем, а .NET преобразует за нас, имплицитно конвертирует массив в read-only span.
И функция создания хэша от этого содержимого срабатывает абсолютно без проблем.
Далее мы этот же массив передаем в байтары-контент.
Байтары-контент это такая, ну, как бы штуковина, сейчас, секундочку,
Вот, извините, пожалуйста.
Значит, мы передаем этот же массив, извините, пожалуйста, еще раз в байтар и контент, и таким образом происходит магия отправки данных непосредственно в S3-хранилище.
Затем мы возвращаем этот промежуточный массив обратно в буфер, и таким образом не тратим память.
Естественно, я должен здесь сказать о том, что при взаимодействии с внешними системами, если у вас есть стрим, в принципе, хороший способ использовать стрим-контент, это штуковина, которая предоставляется из коробки .NET,
Стрим-контент позволяет взять непосредственно стрим и без перекладывания этого стрима из одного места в другое непосредственно передать ее какой-то внешней системе.
Но я должен оговориться, что подобное мероприятие, то есть стрим-то-стрим, несколько проблемно.
В чем?
Если на длинном пути между вашим...
между вашим пользователем, вашей системой и какой-то внешней системой, например, S3-хранилищем, произойдет сбой, то данные вы потеряете, вы не сможете по стриму вернуться обратно.
Ну, чаще всего там есть, конечно, определенные хаки, определенные оптимизации, но в принципе вы не всегда можете это сделать, поэтому подобный
Подобного мероприятия нужно избегать.
И перекладывание при записи данных в какое-то промежуточное хранилище – это вполне нормальный, вполне хороший способ.
Тем более, опять-таки, клиент Minio наверняка с подобной ситуацией сталкивался, и он не зря делает подобное мероприятие, помимо того, что нужно сформировать хэш.
Так вот, при подходе, при использовании AR и Pula существуют еще свои определенные особенности, и эти особенности нужно учитывать.
У AR и Pula достаточно много настроек, у него есть подводные камни, его использование особенно в многопоточной среде.
Наше приложение высоконагруженное, поэтому все эти настройки...
О них нужно, если вы хотите использовать ArrayPool, об этих настройках нужно прочитать в достаточно объемной статье Евгения Пешкова.
Он выступал, по-моему, вчера на DotNext.
Можете прочитать статью, очень хорошая статья, очень много полезных, понятных настроек.
Далее.
Мы положили файл в S3-хранилище.
Теперь нам нужно его оттуда забрать.
В принципе, это действие достаточно простое, и его тоже можно оптимизировать по сравнению с клиентами Minio и клиентами от AWS.
Дело в том, что в большинстве случаев у нас есть все данные, необходимые для того, чтобы передать файл в руки нашего пользователя или внешней системы.
Дело в том, что...
Клиент Minio в документации, и вот это вот скриншот последних дней кода с клиентом Minio в нашем проекте.
Так вот, предлагается примерно следующий подход.
Мы указываем, какой файл нам конкретно нужен, и в callback мы перекладываем стрим файла, который получаем от S3, в некое промежуточное хранилище, в memory stream.
В этом подходе есть следующий минус.
Во-первых, MemoryStream, если заглянуть в его имплементацию, то MemoryStream это всего лишь навсего массив, который как лист просто увеличивается по наполнению.
То есть мы первоначально ему говорим, у нас будет размер 128, потом 128 не хватило, создадим 256, перекладываем данные с первого массива и так далее.
Все было бы ничего, но...
Мемори стрим на 800 мегабайт, на файл 800 мегабайт, это очень-очень такая неприятная штуковина, которая может сразу попасть в large object heap.
Частое создание этих мемори стримов очень-очень плохо сказывается на работе garbage коллектора.
Тем более, что ведь у нас есть все эти данные.
Когда мы общаемся с S3, используя HTTP-клайент, мы создаем HTTP-реквест и получаем респонс, некий ответ, объектное представление ответа от сервера.
И у этого респонса, у его контента уже есть стрим.
Мы можем его взять и передать.
Опять-таки здесь нет необходимости перекладывать из одного места в другое, поскольку мы отдаем данные.
Если что-то порвется между S3, нами и клиентом, то клиент получит сообщение, типа, ой-ой-ой, у нас не получилось докачать файл, пожалуйста, попробуйте еще раз.
В принципе, ничего страшного, потери данных, самое главное, не происходит.
Имея стрим, мы складываем его в ответ ISP.net дистанционно.
File Action Result, по-моему, так называется.
И еще нам нужно сказать, что за тип файла мы отправляем.
Миньо предлагает взять...
взять метаданные объекта отдельным запросом и взять из них контент-тайп.
Ну, опять-таки, дело было достаточно давно, я уже не помню, там это best practice или не best practice, но мы конкретно, вот конкретно мы делали вот именно так, то есть это плюс один запрос на...
каждое забирание файла.
А я напомню, что забирание файла – это формирование большого объекта, там конкатенация строк, подписывание, в общем, достаточно тяжелая операция.
А контент-тайп-то у нас есть.
Контент-тайп лежит в том же самом респонсе, в том же самом ответе от S3 сервера.
В хидрах вы можете оттуда взять контент-тайп без особых проблем.
То есть у нас, во-первых, первая оптимизация – минус один запрос,
Вторая оптимизация – это мы сразу же отдаем стрим, и, казалось бы, красота получается.
Единственное, работать с этим клиентом не очень удобно, поскольку, ну, когда все это создавалось, мы не обладали достаточным опытом в проектировании подобных библиотек.
Так вот, мы создали несколько вспомогательных классов, которые нам показались очень важными, чтобы покрыть случаи.
Например, я получил файл, я уверен теперь, что он существует, но у этого файла я хочу узнать только контент-тайп или только размер.
или что-то еще, а скачивать я его не хочу.
Или я получил 10 файлов, и из них я хочу выбрать какой-то один.
Так вот, мы создали обертку файл, где мы можем отложенно взять стрим из запроса.
Очень удобно.
Ну и этот файл, естественно, классик файл, S3 файл, он disposable, то есть если нам этот файлик не нужен, то мы говорим dispose, и ответ от S3 disposится, все, мы оттуда ничего не считываем.
И еще одна интересная оберточка, это S3 Stream мы его назвали.
Дело в том, что я не знаю, почему именно так делает клиент Minio, но там я не везде, скажем так, редко встречал, где бы они диспозили стрим, диспозили респонс, диспозили реквест.
Ну, я с молоком матери впитал, что если ты что-то создал, то ты это должен задиспозить.
Вот, поэтому мы создали некую обертку для того, чтобы над стримом, это S3 стрим, это наследник от стрима, для того, чтобы управлять вот этим вот временем жизни, временем жизни респонза и временем жизни стрима, который мы получили из контента.
В тот момент, когда ISP.NET нам говорит, типа, все, ребята, мы...
закончили копирование файла клиента, пожалуйста, задиспози на входящий стрим.
Мы говорим, да, окей, и так как это наш стрим, мы используем в этот момент и респонс, и стрим.
Все отлично, надеюсь, что это как-то помогает в экономии памяти.
И еще один полезный классик, который родился в процессе написания этой библиотечки, это XML-ридер.
Такая странноватая штуковина с одной стороны, с другой стороны очень-очень полезная.
Почему нам не подошел XML-ридер непосредственно из .NET?
Во-первых, он медленный.
Во-вторых, он создает объектное представление всей той структуры XML, которая иногда...
приходит из S3, а нам всего этого не надо.
Нам не надо аллокации строчек, нам всего этого хочется избежать.
И мы создали XML-ридер, который берет стрим того самого ответа от сервера, читает его посимвольно, находит нужный нам элемент в структуре XML и просто забирает то значение, которое нам нужно.
На самом деле нам нужно чаще всего 2-3 значения.
Нет, одно-два значения, извините, не 2-3.
Одно-два значения, и, в принципе, этого вполне достаточно, например, для того, чтобы возвращать список файлов, которые лежат в бакете.
С одной стороны, кажется, что, опять-таки, мы экономим на спичках, а с другой стороны, я напоминаю, что из вот этих вот маленьких кусочков складывается как раз тот самый перформанс-эффект, который, в конце концов, мы и достигли.
Так вот, мы вроде как все написали, результат достигнут, тестирование проведено, Benchmark.net уведомляет нас, да, все окей, вы в 200 раз элоцируете меньше, вы элоцируете в 200 раз меньше, чем клиент Minio или клиент AWS, но на Windows 11.
А кажется, что большинство наших приложений не хостятся на Windows 11.
Кажется, что большинство наших приложений хостятся теперь в современном .NET на Linux.
Это Debian, например, или это Alpine.
И в этой связи мы произвели замеры непосредственно в Debian, в Linux, в Alpine.
И оказалось, что если в Windows вот эта вот библиотечка
в комплексе на запись файла, на чтение файла и на удаление файла тратит меньше 1 мегабайта, то на Debian, на Alpine, по-моему, еще больше, но на Debian тратится 3 мегабайта оперативной памяти.
Очень странное поведение.
Почему же так произошло?
Вот как раз вот Игорь рассказывал, и рассказывал, что мы с Евгением тоже это делали, мы пытались понять, а что же там такое, эти люди заставляли меня собирать дампы, там замеры какие-то производить, в общем, достаточно сложные такие мероприятия, которые я не очень-то привык делать, и я чувствовал себя некой такой секретаршей, которую говорили, давай сделай быстрее, быстрее, быстрее, пиши что-то там в командной строке, а, там ты ошибся, ну, короче говоря, в конце концов, мы
Увидели, что именно на Linux имплементация .NET несколько отличается от имплементации на Windows.
Это достаточно очевидно, с одной стороны, лежит на поверхности, но мы доказали это.
Евгений пошел как раз в этот вот issue на GitHub и написал, что проблема повторяется.
Это, ну, как вы видите, она closed, закрытая, закрытая issue.
Вот, непосредственно в этот issue Евгений написал наш отзыв, вернее, ну, написал отзыв, что, типа, проблема опять повторяется.
И я надеюсь, что эта проблема в Дубнете 8 будет решена.
Из этого какой вывод?
Запускайте свои бенчмарки в докере.
Это достаточно просто.
Мы берем benchmark.net, мы пишем докер файл, 5-6 строчек, очень просто, и запускаем наши бенчмарки.
Если нам хочется запустить бенчмарки вместе с, например, S3-хранилищем, мы пишем docker-compose, тоже, опять-таки, предельно быстро.
Здесь весь файлик не вместился, но он тоже очень-очень маленький.
И в результате у нас получается вот такая вот картинка.
Запускается наш бенчмарк, работает, да, он чуть-чуть медленнее, но, в принципе, он дает достаточно интересные результаты того, как работает ваше приложение непосредственно в той среде, где...
оно будет хоститься, будет запускаться.
Итак, что же мы узнали из этой ситуации?
Во-первых, лишние аллокации могут стать проблемой.
Причем эта проблема усугубляется и вызывает некий такой накопительный эффект при высоких нагрузках.
Мы узнали, что строчки лучше собирать через ValueStringBuilder, тем более многие, я еще раз говорю, спикеры про него рассказывают, рассказывали и, видимо, еще будут рассказывать.
Далее, мы узнали, что span в современном мире .NET это обычное дело, его принимают в огромном количестве методов самых разных классов.
Мы узнали, что aripool наш друг, если его правильно настроить и если надо переиспользовать память.
Мы узнали, что иногда проще написать велосипед, чем бодаться с релиз-инженерами.
И реализации .NET под Windows и под Linux немножко разные.
Вывод очевидный, но, тем не менее, теперь есть тому доказательства.
Меня зовут Бажакин Кирилл.
Спасибо вам большое.
Надеюсь, вам понравился мой доклад.
Весь код, который вы сейчас видели, вы можете посмотреть непосредственно в моем гитхабе.
Всем спасибо.
Кирилл, спасибо большое за такой интересный доклад.
Да, надеюсь, реально все вдохновятся.
Сейчас предлагаю всем переместиться в дискуссионную зону, потому что так как доклад онлайн, все вопросы он будет в дискуссионной зоне отвечать на них, чтобы он вас мог видеть, слышать, и вы там его могли видеть, слышать.
Тут сейчас демонстрация завершится.
Спасибо.
Похожие видео: Кирилл Бажайкин

Денис Пешехонов, Александр Химушкин — Укрощаем DDD на практике

Марк Шевченко — Воркшоп «Практические задачи решаем функционально» (Часть 1)

Полный разбор SMD-компонентов

Марк Шевченко — Воркшоп «Практические задачи решаем функционально» (Часть 2)

Денис Цветцих — LINQ Expressions: искусство запрашивать данные

