Потребление ресурсов сервисом
От: RushDevion Россия  
Дата: 13.04.23 10:35
Оценка:
Коллеги, всем привет.
Нужна помощь коллективного разума.

Есть GRPC-сервис вот с таким контрактом:
message Event {    ... }

service SubscriptionService {
   rpc Subscribe(google.protobuf.Empty) returns (stream Event);
}

Предполагается, что клиент запрашивает подписку и держит постоянное подключение, ожидая событий.

На сервере это устроено так, что на каждый запрос заводится клиент подписки, который:
а) Подписывается на серверные события, фильтрует их, отбирая релевантные для клиента, и складывает в свой outbox.
б) Стартует таску обработки outbox, в которой асинхронно (т.е. без блокировки потока) ждет появления чего-то в outbox,
и по факту появления начинает пушить это в клиентский GRPC-стрим, а когда outbox опустеет, снова входит в асинхронное ожидание.

Теперь даем нагрузку в 2000 клиентов, каждый из которых просто делает подписку и ждет.
В TaskManager смотрим, что происходит.

Картина такая. В обычном состоянии имеем ~40 потоков и 80MB занятой памяти.
Под нагрузкой имеем ~250 потоков и 400мб RAM.
Снимаем нагрузку — все постепенно возвращается к начальному состоянию.
Т.е. понятно, что потоки — это ThreadPool. Единственное, не проверил это workers или io-completion.

Мое сомнение в том, что 250 потоков — это как-то дофига.
Потому что, когда я (давненько) писал что-то подобное на C++ под Linux, то обходился двумя потоками: один слушает входящие подключения, второй
обрабатывает через epoll
С другой стороны, мне не доводилось писать на C# нагруженный бэк, скажем, для игрового сервера, где нужно обрабатывать
большое количество постоянно подключенных клиентов. Может 250 потоков — это как бы и нормально.

Собственно, ваши мысли?
Вдруг у кого-то был опыт написания игровых серверов именно на C#.
Отредактировано 15.04.2023 17:39 RushDevion . Предыдущая версия . Еще …
Отредактировано 13.04.2023 10:36 RushDevion . Предыдущая версия .
Отредактировано 13.04.2023 10:36 RushDevion . Предыдущая версия .
Отредактировано 13.04.2023 10:36 RushDevion . Предыдущая версия .
Re: Потребление ресурсов сервисом
От: gandjustas Россия http://blog.gandjustas.ru/
Дата: 13.04.23 10:45
Оценка:
Здравствуйте, RushDevion, Вы писали:

RD>Собственно, ваши мысли?

Без кода сервиса и клиента сложно сказать откуда там 250 потоков.

Для полноты картины нужно сделать чистый пример: сервер который делает ничего (тупо держит открытым соединение) и клиент, который подключается и висит в ожидание потока. Посмотреть какой в этом случае оверхед дает GRPC сервер на .net


RD>Вдруг у кого-то был опыт написания игровых серверов именно на C#.

Если вам нужен сервер с низкой латентностью, то вы в любом случае дойдете до самописного сервера на UDP.
Re: Потребление ресурсов сервисом
От: Философ Ад http://vk.com/id10256428
Дата: 13.04.23 11:21
Оценка:
Здравствуйте, RushDevion, Вы писали:

RD>Собственно, ваши мысли?

RD>Вдруг у кого-то был опыт написания игровых серверов именно на C#.

Когда ты пихаешь что-то в пулл, то с высокой вероятностью в пуле может не оказаться свободных потоков. Фактически, ты туда пихаешь быстрее чем оно освобождает потоки из пула.
Я бы посмотрел, почему: что именно долго занимает поток из пула. Есть подозрение, что тупят клиенты, которые не могу сразу забрать события, которыми их пушит сервис. В итоге сервис долго удерживает потоки сетевыми операциями.
Всё сказанное выше — личное мнение, если не указано обратное.
Re: Потребление ресурсов сервисом
От: hi_octane Беларусь  
Дата: 13.04.23 11:52
Оценка: 80 (2)
RD>Собственно, ваши мысли?
Фантазировать сложно. Нагрузи, сними дамп и глянь. Может там какая-то утечка или блокирующее ожидание чего-то?

RD>Вдруг у кого-то был опыт написания игровых серверов именно на C#.

В моменте, с рабочего проекта, не игровой, но требования примерно такие-же:

.NET Core 7, Linux, на 30к коннектов примерно 30 потоков ThreadPool, и по гигу оперативы (в основном на буфера). На соседнем сервере 50к коннектов, и картина точно такая-же. Собственно на сервере порядка тех же 32 ядер, так что если бы потоков пула было меньше чем ядер, было бы странно.

Стандартный GRPC, не используется. Вообще чужие обёртки стараюсь в проект не пускать. Единственная которая выжила из стандартных SSLStream — его что-то с наскоку на что-то более быстрое заменить не удалось. В остальных случаях Socket и погнали.
Re[2]: Потребление ресурсов сервисом
От: RushDevion Россия  
Дата: 13.04.23 16:03
Оценка:
_>.NET Core 7, Linux, на 30к коннектов примерно 30 потоков ThreadPool, и по гигу оперативы (в основном на буфера). На соседнем сервере 50к коннектов, и картина точно такая-же. Собственно на сервере порядка тех же 32 ядер, так что если бы потоков пула было меньше чем ядер, было бы странно.

А можно чуть подробнее для общего развития?
Какая предметка? Как это организовано в плане архитектуры? Протокол свой, наверное, или все-таки что-то стандартное?
Re[3]: Потребление ресурсов сервисом
От: hi_octane Беларусь  
Дата: 14.04.23 08:31
Оценка: 139 (4)
RD>А можно чуть подробнее для общего развития?
RD>Какая предметка?
Передача данных от больших источников к мелким приёмникам и обратно через толстые каналы облачных ДЦ. Подробнее буду болтать только когда в теме будут сильные конкуренты. Пока отсутствие сколько-нибудь серьёзных соперников даёт и деньги и фору на развитие, и возможность не кранчить наперегонки.
Есть поставщики, есть клиенты которые очень хотят бесперебойный коннект и скорость больше чем у них получается когда они коннектятся напрямую. Наши сервера в каком-то смысле CDN, только в обе стороны. Из-за всяких требований, мы даже на новую версию переезжаем очень аккуратно — старая версия перестаёт делать listen но остаётся в сети до последнего клиента. А новая версия делает listen, и клиенты плавно переконнекчиваются на неё в своём темпе.
Из-за такой дичи приходится например различать в логгерах инстансы разных версий одного приложения. И даже один клиент может иметь два коннекта, один в старую версию, а один в новую, и с этим приходится как-то жить.
Зато для большинства клиентов у нас годами аптайм 100%. Ни единого разрыва

RD>Как это организовано в плане архитектуры?

Да честно говоря никаких особых архитектурных хитростей нету. Микросервисы что-то мало радовали, поэтому чистый монолит. Десятки коннектов к внешним api, цепочки синхронных и асинхронных обработчиков, несколько объёктов на каждого входящего клиента, тысячи этих самых клиентов, и всё.
Из интересного только то что очень много кодогенерации. И всяких хитростей на базе этой кодогенерации. Например, классы которые для истории сериализуются — объявляют интерфейс с пропертями, а кодогенератор делает blittable структуру с полями, которая сериализуется простым копированием байтов. А в качестве реализации интерфейса добавляет проперти, которые в структуру ходят. Ну кэширования всякого много. По возможности zero-copy, span'ы, поменьше глупых аллокаций, вот это всё. При этом за zero-allocations не убиваюсь, главное чтобы под нагрузкой GC в Gen0 вывозил, и ладушки.

Из странного — тестов на такую систему исчезающе мало. И юнит и интеграционные вместе покрывают наверное 20% проекта, только самые опасные части. На большее времени нет. 99% проблем к нам в систему приходит от чужих систем, приславших что-то странное, чего в тестах заслать не додумаешься. Или на такие сочетания внешних условий, которые создать сложнее, чем нашу систему. Например один раз наш пакет упорно отбрасывался чужой системой, потому что у него был такой же crc или sha хэш как у предыдущего, и их система думала что мы засылаем повтор операции. Теперь словив ответ "повтор" там где по нашим данным повтора нет, мы меняем таймштамп на миллисекунду, или ещё какое-нибудь ненужное поле. Но как такой глюк тестом повторить?
Просто защищаемся от всего что в голову приходит. Описано в доках что запрос будет в 3 plain text параметра — готовимся ловить гигабайт бинарного мусора Написано что отрицательных цифр где-то там быть не может — готовимся словить отрицательные, а с ними плавающие и NaN. Готовы к тому что любой запрос, даже к файлу на диске, кончится таймаутом. Написано что числа целые — проверяем, что парсер точно кинет исключение если прилетит немножко числа и дальше мусор, или целое будет в 1000 знаков, и т.д. Очень много тестов имеют дату и описание инцидента, который симулируется. Думаю тебе в играх тоже самое понадобится, когда хакеры в каждый открытый порт полезут.

Ещё, по сути любой вызов async у меня оборачивается генератором в свой генерируемый метод, в котором вместо стандартного Task используется персонально для этого метода сгенерированный Task-like тип. Этот тип сохраняет место вызова, время вызова, сколько работал, чем закончился, параметры Caller'a, и чё угодно что задаётся аттрибутами. Ну и ещё методы и генератор умеют понимать аттрибуты [Sync("name") и ReadSync("name")], где name — просто ключ. Те у кого одинаковый name — параллельно не вызываются, выстраиваются в очередь. По сути ReaderWriterLock, только его случайно не забыть. При помощи этой фишки примитивов синхронизации не используется никаких совсем, кроме этого аттрибута, а он вылизан, и почти всегда исполняется вообще без ожидания. И логгер внутри такого метода тоже благодаря генерации знает кучу информации о контексте, и оверхед на такое знание почти нулевой, потому что большая часть инфы хранится подготовленной с этапа компиляции. Но при этом этот Task-like тип в принципе ускорения не даёт, на обычных Task/ValueTask всё работало плюс-минус также. Тут не столько ускорение, сколько удобство — я могу в любой коннект заглянуть с лупой и посмотреть почему мы отвечали конкретному клиенту 200мс, когда планировали, со всеми ожиданиями других сервисов, не больше 20-30.

Source Generators не используется. Вместо этого отдельная прога-генератор с Razor-синтаксисом, на базе либки RazorLight. Два раза пытался переделать это на Source Generators, и чёт не едут лыжи. Видимо после Nemerle я слишком многого от Roslyn'a хочу

RD>Протокол свой, наверное, или все-таки что-то стандартное?

И стандартное и своё. Сервера общаются не только с клиентами но и друг с другом. В общении друг с другом своё + немножко на MQTTnet. А с клиентами — там зоопарк. От проприетарных протоколов, до json-а по вебсокетам и даже REST. Причём REST, внезапно, ASP.NET. С 6-го, стал вывозить на ура, и с 7-го свою "ускоренную" реализацию отломали и заменили на ASP.NET. Но в куче других мест свои реализации живут. Например вебсокеты до сих пор свои. По сути очень много стандартных вещей было переписано, с отбрасыванием "ненужного" функционала, чтобы они меньше тормозили и (пере-)использовали наши буфера.
При таком числе коннектов объёмы логов тоже титанические, поэтому пришлось делать свои протоколы логгера и писать свои аддоны к seq, opentelemetry, и т.д. Стандартные не вывозили. Вообще с этого проекта у меня на любой логгер/трейсер: "если не в сорцах, и без api для dll аддоном — сразу нет".

Короче как-то так. Но это всё не одним махом сделалось. Проект года 3 пишется и переписывается, многие части уже сменили реализацию пару раз.
Re[4]: Потребление ресурсов сервисом
От: Sinclair Россия https://github.com/evilguest/
Дата: 14.04.23 08:59
Оценка:
Здравствуйте, hi_octane, Вы писали:

_>Ещё, по сути любой вызов async у меня оборачивается генератором в свой генерируемый метод, в котором вместо стандартного Task используется персонально для этого метода сгенерированный Task-like тип. Этот тип сохраняет место вызова, время вызова, сколько работал, чем закончился, параметры Caller'a, и чё угодно что задаётся аттрибутами.

А это разве нельзя сейчас обрулить через компилятор? Там же при добавлении ValueTask сделали возможность вообще любые Task-like типы применять в качестве результата.
Уйдемте отсюда, Румата! У вас слишком богатые погреба.
Re[5]: Потребление ресурсов сервисом
От: hi_octane Беларусь  
Дата: 14.04.23 10:13
Оценка: 111 (1)
_>>Ещё, по сути любой вызов async у меня оборачивается генератором в свой генерируемый метод, в котором вместо стандартного Task используется персонально для этого метода сгенерированный Task-like тип. Этот тип сохраняет место вызова, время вызова, сколько работал, чем закончился, параметры Caller'a, и чё угодно что задаётся аттрибутами.

S>А это разве нельзя сейчас обрулить через компилятор? Там же при добавлении ValueTask сделали возможность вообще любые Task-like типы применять в качестве результата.

Да, так и обруливается, если я правильно понял вопрос. Для вызывающего там обычный await метода, без вникания в детали. А тот, кого вызывают, имеет публичный интерфейс с методами примерно такой сигнатуры [Sync("name")]IMyTask<(int res1, string res2)> Method(args, timeout, [CallerMemberName]...) который реализовывать не надо. Всю реализацию именно вызова берёт на себя генератор. Ну и генератор заодно создаёт заглушку, в которой собственно писать код.
Иногда передаётся IMyTask caller, если там какая-то хитрая цепочка операций, но это в общем частные заморочки. В целом фича awaitable types очень помогла.
Без кодогенератора часть этих вещей пришлось бы ручками делать. Например синхронизацию по ключу, или там перекладывание аргументов с которыми вызвали в структурку для записи на диск. А так объявил, генератор запустил, и сразу реализуй простой однопоточный код, остальное как бы само. Ну и строковые ключи генератор подменяет на уникальные числовые — чтобы строки, и даже ссылки на строки не тягать, и делает мапу ключ->строка для логгеров.

Ну и ещё, генератор не каждый билд запускается, а только с ручника. По сразу нескольким прчинам — 1) публичные сигнатуры у классов меняются редко 2) так билд быстрее 3) мы позорный недуг в доблесть обратили — во время релиз-билда генератор запускается в режиме "тест" и валит билд, если его сгенерированное отличается от того что закоммитили в гит (вопреки правилам хорошего тона — генерированный код коммитится). Так проверяется что при изменении методов или обновлении генератора, не была забыта перегенерация, и что вообще программист работал и тестировал тот код, который ожидал, который и в релиз пойдёт. С одной стороны чуть меньше удобства — надо запускать ручками, и иногда таки забывают. Но плюсом защита от всяких мутных глюков, когда что-то под капотом тихонько поменялось, а никто не заметил.
Re[6]: Потребление ресурсов сервисом
От: Sinclair Россия https://github.com/evilguest/
Дата: 14.04.23 11:25
Оценка:
Здравствуйте, hi_octane, Вы писали:
S>>А это разве нельзя сейчас обрулить через компилятор? Там же при добавлении ValueTask сделали возможность вообще любые Task-like типы применять в качестве результата.
_>Да, так и обруливается, если я правильно понял вопрос. Для вызывающего там обычный await метода, без вникания в детали. А тот, кого вызывают, имеет публичный интерфейс с методами примерно такой сигнатуры [Sync("name")]IMyTask<(int res1, string res2)> Method(args, timeout, [CallerMemberName]...) который реализовывать не надо. Всю реализацию именно вызова берёт на себя генератор. Ну и генератор заодно создаёт заглушку, в которой собственно писать код.
А, нет, не совсем.
Просто ключевое слово async сейчас означает "порождение стейт-машины"; и в новых версиях шарпа можно рулить построением этой стейт-машины через пользовательские типы.
Сами редмондцы используют это как раз для того, чтобы порождать разный код для async Task<T> Method() и для async ValueTask<T> Method().
Я с этим сам не разбирался, поэтому не в курсе, можно ли сделать то, что вам нужно, при помощи этой техники.

_>Иногда передаётся IMyTask caller, если там какая-то хитрая цепочка операций, но это в общем частные заморочки. В целом фича awaitable types очень помогла.

_>Без кодогенератора часть этих вещей пришлось бы ручками делать. Например синхронизацию по ключу, или там перекладывание аргументов с которыми вызвали в структурку для записи на диск. А так объявил, генератор запустил, и сразу реализуй простой однопоточный код, остальное как бы само. Ну и строковые ключи генератор подменяет на уникальные числовые — чтобы строки, и даже ссылки на строки не тягать, и делает мапу ключ->строка для логгеров.
Ну, простой однопоточный код же должен временами делать await?
Уйдемте отсюда, Румата! У вас слишком богатые погреба.
Re[6]: Потребление ресурсов сервисом
От: RushDevion Россия  
Дата: 15.04.23 19:21
Оценка:
Спасибо, интересно.

Насколько я понял, генератор из такого:
public interface IMethod1Task { }

public interface IMyService 
{
  [Sync("Method1"]
  IMethod1Task<(int,string)> Method1(args, timeout, [CallerMemberName] member);    
}

Делает вот такое:
// Generated class
public partial class MyServiceImpl: IMyService {
  
  // Generated method
  public IMethod1Task<(int,string)> Method1(args, timeout, [CallerMemberName] member)     {
      var result = new Method1TaskLike();

      // Тут в task-like наваливаем все, что нужно из контекста вызова
      result.CaptureContext(args, member);    

      // Тут делается синхронизация, замеряется время исполнения, ловятся ошибки и т.п.
        ...
      Method1Impl(args).ContinueWith(t=>result.SetResult(t.Result));

      return result;
  }
  
  // Generated task-like type
  private struct Method1TaskLike: IMethod1Task, INotifyCompletion {
      // Поля, которые захватываются из контекста + имплементация INotifyCompletion 
  }

  // Это метод, с бизнес-логикой, который нужно реализовать
  protected partial ValueTask<(int,string)> Method1Impl(args) {
    // А тут простой однопоточный код, который, в принципе, может делать await
  }
}


Не пойму, однако, как тут Razor-генератор примазался
Ну т.е. я понимаю, что метаинформацию об интерфейсах можно через Roslin API вытянуть, но вот зачем Razor?
Просто, чтобы генерируемый код удобнее строить, а не через string builder склеивать?
Отредактировано 15.04.2023 19:30 RushDevion . Предыдущая версия .
Re[7]: Потребление ресурсов сервисом
От: hi_octane Беларусь  
Дата: 15.04.23 20:46
Оценка: 117 (3)
RD>Насколько я понял, генератор из такого:
Ну в общем да. Там ещё логгер, запись таймингов (если включена для класса или по каким-то параметрам), вынос аргументов в blittable структуру, если надо, и т.д.

RD>Ну т.е. я понимаю, что метаинформацию об интерфейсах можно через Roslin API вытянуть, но вот зачем Razor?

RD>Просто, чтобы генерируемый код удобнее строить, а не через string builder склеивать?

Да, генератор со временем очень вырос, и сейчас это 6 здоровенных файлов, которые друг друга включают. Плюс C# библиотека хелеперов, для работы с нашими собственными аттрибутами которыми всё размечено, своими типами, и куча хелперов для удобного использования рослина. Помню что когда всё начиналось
подглядывал сюда: https://github.com/newsoftinc/Newsoft.Roslyn.T4, только переосмыслил на разоре. Х-з правда, будет ли полезно — с развитием source generators должны были появиться библиотеки гораздо лучше, куда подглядеть можно.

В целом разор удобен тем, что в нём не один а 2 синтаксиса генерации. Те кто на ASP.NET писали — сразу поймут. В одном случае у тебя много внешнего C#-кода и меньше текста который генерируется. Тогда удобно использовать блоки @{ код-генератора @($"генерируемый текст {вставки переменных из генератора} ещё генерируемый текст") ещё код генератора }. А в других случаях много генерируемого текста и мало кода. Тогда удобно <text> много текста @чуть-чуть-кода-генератора </text>.
Причём современный разор понимает @foreach, @if и прочие, объявленные по месту, и включение других cshtml файлов с аргументами передаваемыми через ViewModel. В итоге во всём хоть и путаешься, потому что C# и снаружи и "внутри", но всё-таки автокомплит и раскраска спасают. А ещё современный Razor позволяет произвольно друг в друга эти самые <text> @{ <text> @{ } </text> } </text> включать. Тот, который времён Fw 4, так не умел — компилировать умел, но раскраска валилась сразу в красное. Получается ты в каждой ситуации используешь тот способ записи который удобнее в конкретном месте. Типа вот кусочек оттуда:

<text>
//всё это выполняется только если на классе 
//стоит аттрибут что он должен уметь сериализоваться в Json.
var propertyName = reader.GetString();
reader.Read();

switch(propertyName)
{
    @foreach(var par in reqParFields)
    {
       <text>
         case "@(ToJsonArgName(par.ValueName))":
              @(par.RenderJsonRead())//экстеншен к параметру, 
//который генерирует правильное чтение 
//Json этого параметра, в зависимости от его типа
              break;
       </text>
    }
}//switch(propertyName)


Всё выделенное жирным, и скобки возле @foreach — это не в выхлоп, это код самого генератора, и Razor это понимает. А скобки с комментариями — относятся к тексту который пойдёт в output. В общем это видеть надо — в студии оно раскрашено, и автокомплит работает. А на стринг-билдерах это была бы боль. Собственно потому генератор так и вырос, что фичи в него удобно докручивать.

А ещё выхлоп генератора форматируется тем же рослином по нашему кодинг-стайлу. Там буквально один вызов, и после этого по коду не страшно отладчиком пройтись, несмотря на то что всё сгенерированное.
Re[7]: Потребление ресурсов сервисом
От: hi_octane Беларусь  
Дата: 16.04.23 16:40
Оценка:
RD>Насколько я понял, генератор из такого:
Коллеги-друзья напомнили — два года назад в аттрибут [Sync("name")] добавили приоритеты, 3 штуки — Low, Normal, High. Это не round robbin, или что-то подобное, тупо то что большего приоритета, то и выполняется. Если будете повторять лучше сразу заложиться, потому что эта штука позволила легко решать конфликты, которые иначе разруливались через всякие мьютексо-подобные хрени.
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.