Предполагается, что клиент запрашивает подписку и держит постоянное подключение, ожидая событий.
На сервере это устроено так, что на каждый запрос заводится клиент подписки, который:
а) Подписывается на серверные события, фильтрует их, отбирая релевантные для клиента, и складывает в свой outbox.
б) Стартует таску обработки outbox, в которой асинхронно (т.е. без блокировки потока) ждет появления чего-то в outbox,
и по факту появления начинает пушить это в клиентский GRPC-стрим, а когда outbox опустеет, снова входит в асинхронное ожидание.
Теперь даем нагрузку в 2000 клиентов, каждый из которых просто делает подписку и ждет.
В TaskManager смотрим, что происходит.
Картина такая. В обычном состоянии имеем ~40 потоков и 80MB занятой памяти.
Под нагрузкой имеем ~250 потоков и 400мб RAM.
Снимаем нагрузку — все постепенно возвращается к начальному состоянию.
Т.е. понятно, что потоки — это ThreadPool. Единственное, не проверил это workers или io-completion.
Мое сомнение в том, что 250 потоков — это как-то дофига.
Потому что, когда я (давненько) писал что-то подобное на C++ под Linux, то обходился двумя потоками: один слушает входящие подключения, второй
обрабатывает через epoll
С другой стороны, мне не доводилось писать на C# нагруженный бэк, скажем, для игрового сервера, где нужно обрабатывать
большое количество постоянно подключенных клиентов. Может 250 потоков — это как бы и нормально.
Собственно, ваши мысли?
Вдруг у кого-то был опыт написания игровых серверов именно на C#.
Здравствуйте, RushDevion, Вы писали:
RD>Собственно, ваши мысли?
Без кода сервиса и клиента сложно сказать откуда там 250 потоков.
Для полноты картины нужно сделать чистый пример: сервер который делает ничего (тупо держит открытым соединение) и клиент, который подключается и висит в ожидание потока. Посмотреть какой в этом случае оверхед дает GRPC сервер на .net
RD>Вдруг у кого-то был опыт написания игровых серверов именно на C#.
Если вам нужен сервер с низкой латентностью, то вы в любом случае дойдете до самописного сервера на UDP.
Здравствуйте, RushDevion, Вы писали:
RD>Собственно, ваши мысли? RD>Вдруг у кого-то был опыт написания игровых серверов именно на C#.
Когда ты пихаешь что-то в пулл, то с высокой вероятностью в пуле может не оказаться свободных потоков. Фактически, ты туда пихаешь быстрее чем оно освобождает потоки из пула.
Я бы посмотрел, почему: что именно долго занимает поток из пула. Есть подозрение, что тупят клиенты, которые не могу сразу забрать события, которыми их пушит сервис. В итоге сервис долго удерживает потоки сетевыми операциями.
Всё сказанное выше — личное мнение, если не указано обратное.
RD>Собственно, ваши мысли?
Фантазировать сложно. Нагрузи, сними дамп и глянь. Может там какая-то утечка или блокирующее ожидание чего-то?
RD>Вдруг у кого-то был опыт написания игровых серверов именно на C#.
В моменте, с рабочего проекта, не игровой, но требования примерно такие-же:
.NET Core 7, Linux, на 30к коннектов примерно 30 потоков ThreadPool, и по гигу оперативы (в основном на буфера). На соседнем сервере 50к коннектов, и картина точно такая-же. Собственно на сервере порядка тех же 32 ядер, так что если бы потоков пула было меньше чем ядер, было бы странно.
Стандартный GRPC, не используется. Вообще чужие обёртки стараюсь в проект не пускать. Единственная которая выжила из стандартных SSLStream — его что-то с наскоку на что-то более быстрое заменить не удалось. В остальных случаях Socket и погнали.
_>.NET Core 7, Linux, на 30к коннектов примерно 30 потоков ThreadPool, и по гигу оперативы (в основном на буфера). На соседнем сервере 50к коннектов, и картина точно такая-же. Собственно на сервере порядка тех же 32 ядер, так что если бы потоков пула было меньше чем ядер, было бы странно.
А можно чуть подробнее для общего развития?
Какая предметка? Как это организовано в плане архитектуры? Протокол свой, наверное, или все-таки что-то стандартное?
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 пишется и переписывается, многие части уже сменили реализацию пару раз.
Здравствуйте, hi_octane, Вы писали:
_>Ещё, по сути любой вызов async у меня оборачивается генератором в свой генерируемый метод, в котором вместо стандартного Task используется персонально для этого метода сгенерированный Task-like тип. Этот тип сохраняет место вызова, время вызова, сколько работал, чем закончился, параметры Caller'a, и чё угодно что задаётся аттрибутами.
А это разве нельзя сейчас обрулить через компилятор? Там же при добавлении ValueTask сделали возможность вообще любые Task-like типы применять в качестве результата.
Уйдемте отсюда, Румата! У вас слишком богатые погреба.
_>>Ещё, по сути любой вызов 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) мы позорный недуг в доблесть обратили — во время релиз-билда генератор запускается в режиме "тест" и валит билд, если его сгенерированное отличается от того что закоммитили в гит (вопреки правилам хорошего тона — генерированный код коммитится). Так проверяется что при изменении методов или обновлении генератора, не была забыта перегенерация, и что вообще программист работал и тестировал тот код, который ожидал, который и в релиз пойдёт. С одной стороны чуть меньше удобства — надо запускать ручками, и иногда таки забывают. Но плюсом защита от всяких мутных глюков, когда что-то под капотом тихонько поменялось, а никто не заметил.
Здравствуйте, 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?
Уйдемте отсюда, Румата! У вас слишком богатые погреба.
public interface IMethod1Task { }
public interface IMyService
{
[Sync("Method1"]
IMethod1Task<(int,string)> Method1(args, timeout, [CallerMemberName] member);
}
Делает вот такое:
// Generated classpublic partial class MyServiceImpl: IMyService {
// Generated methodpublic 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 typeprivate struct Method1TaskLike: IMethod1Task, INotifyCompletion {
// Поля, которые захватываются из контекста + имплементация INotifyCompletion
}
// Это метод, с бизнес-логикой, который нужно реализоватьprotected partial ValueTask<(int,string)> Method1Impl(args) {
// А тут простой однопоточный код, который, в принципе, может делать await
}
}
Не пойму, однако, как тут Razor-генератор примазался
Ну т.е. я понимаю, что метаинформацию об интерфейсах можно через Roslin API вытянуть, но вот зачем Razor?
Просто, чтобы генерируемый код удобнее строить, а не через string builder склеивать?
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. В общем это видеть надо — в студии оно раскрашено, и автокомплит работает. А на стринг-билдерах это была бы боль. Собственно потому генератор так и вырос, что фичи в него удобно докручивать.
А ещё выхлоп генератора форматируется тем же рослином по нашему кодинг-стайлу. Там буквально один вызов, и после этого по коду не страшно отладчиком пройтись, несмотря на то что всё сгенерированное.
RD>Насколько я понял, генератор из такого:
Коллеги-друзья напомнили — два года назад в аттрибут [Sync("name")] добавили приоритеты, 3 штуки — Low, Normal, High. Это не round robbin, или что-то подобное, тупо то что большего приоритета, то и выполняется. Если будете повторять лучше сразу заложиться, потому что эта штука позволила легко решать конфликты, которые иначе разруливались через всякие мьютексо-подобные хрени.