Платформа .NET предоставляет множество готовых примитивов синхронизации и потокобезопасных коллекций. Если при разработке приложения нужно реализовать, например, потокобезопасный кэш или очередь запросов — обычно используются эти готовые решения, иногда сразу несколько. В отдельных случаях это приводит к проблемам с производительностью: долгим ожиданием на блокировках, избыточному потреблению памяти и долгим сборкам мусора.

Эти проблемы можно решить, если учесть, что стандартные решения сделаны достаточно общими — они могут иметь избыточный в наших сценариях оверхед. Соответственно, можно написать, например, собственную эффективную потокобезопасную коллекцию для конкретного случая.

Под катом — видео и расшифровка моего доклада с конференции DotNext, где я разбираю несколько примеров, когда использование средств из стандартной библиотеки .NET (Task.Delay, SemaphoreSlim, ConcurrentDictionary) привело к просадкам производительности, и предлагаю решения, заточенные под конкретные задачи и лишённые этих недостатков.


На момент доклада работал в Контуре. Контур разрабатывает различные приложения для бизнеса, а команда, в которой работал я, занимается инфраструктурой и разрабатывает различные вспомогательные сервисы и библиотеки, помогающие разработчикам в других командах делать продуктовые сервисы.

Команда Инфраструктуры делает свое хранилище данных, систему хостинга приложений под Windows и различные библиотеки для разработки микросервисов. Наши приложения основаны на микросервисной архитектуре — все сервисы взаимодействуют между собой по сети, и, естественно, в них используется довольно много асинхронного и многопоточного кода. Некоторые из этих приложений довольно критичны по производительности, им нужно уметь обрабатывать много запросов.

О чём мы сегодня будем говорить?

  • Многопоточность и асинхронность в .NET;
  • Начинка примитивов синхронизации и коллекций;
  • Что делать, если стандартные подходы не справляются с нагрузкой?

Разберем некоторые особенности работы с многопоточным и асинхронным кодом в .NET. Разберём некоторые примитивы синхронизации и concurrent-коллекции, посмотрим, как они устроены внутри. Обсудим, что делать, если не хватило производительности, если стандартные классы не справляются с нагрузкой и можно ли в этой ситуации что-либо сделать.

Расскажу четыре истории, которые произошли у нас на продакшене.

История 1: Task.Delay & TimerQueue


Эта история уже довольно известная, о ней рассказывали в том числе и на предыдущих DotNext. Тем не менее, она получила довольно интересное продолжение, поэтому я её добавил. Итак, в чём суть?

1.1 Polling и long polling


Сервер выполняет долгие операции, клиент – ждет их.
Polling: клиент периодически спрашивает сервер про результат.
Long polling: клиент отправляет запрос с большим таймаутом, а сервер отвечает по завершению операции.

Преимущества:

  • Меньший объем трафика
  • Клиент узнает о результате быстрее

Представьте, что у нас есть сервер, который умеет обрабатывать какие-то долгие запросы, например, приложение, которое конвертирует XML-файлы в PDF, и есть клиенты, которые запускают эти задачи на обработку и хотят дожидаться их результата асинхронно. Как такое ожидание можно реализовать?

Первый способ — это polling. Клиент запускает задачу на сервере, дальше периодически проверяет статус этой задачи, при этом сервер возвращает статус задачи («выполнена»/«не выполнена»/«завершилась с ошибкой»). Клиент периодически отправляет запросы, пока не появится результат.

Второй способ — long polling. Здесь отличие в том, что клиент отправляет запросы с большими таймаутами. Сервер, получая такой запрос, не сразу сообщит о том, что задача не выполнена, а попробует некоторое время подождать появления результата.
Так в чем же преимущество long polling'а перед обычным polling'ом? Во-первых, генерируется меньший объём трафика. Мы делаем меньше сетевых запросов — меньше трафика гоняется по сети. Также клиент сможет узнать о результате быстрее, чем при обычном polling'е, потому что ему не надо ждать промежутка между несколькими запросами polling'а. Что мы хотим получить — понятно. Как мы будем реализовывать это в коде?
Задача: timeout
Хотим подождать Task с таймаутом
await SendAsync ();
Например, у нас есть Task, который отправляет запрос на сервер, и мы хотим подождать его результата с таймаутом, то есть мы либо вернём результат этого Task'а, либо отправим какую-то ошибку. Код на С# будет выглядеть так:

var sendTask = SendAsync();
var delayTask = Task.Delay(timeout);
var task = await Task.WhenAny(sendTask, delayTask);

if (task == delayTask)
    return Timeout;

Данный код запускает наш Task, результат которого хотим ждать, и Task.Delay. Далее, используя Task.WhenAny, ждём либо наш Task, либо Task.Delay. Если получится так, что первым выполнится Task.Delay, значит, время вышло и у нас случился таймаут, мы должны вернуть ошибку.

Этот код, конечно, не идеален и его можно доработать. Например, здесь бы не помешало отменить Task.Delay, если SendAsync вернулся раньше, но это нас сейчас не очень интересует. Суть в том, что, если мы напишем такой код и применим его при long polling'е с большими таймаутами, мы получим некоторые перформансные проблемы.

1.2 Проблемы при long polling


  • Большие таймауты
  • Много параллельных запросов
  • => Высокая загрузка CPU

В этом случае, проблема — высокое потребление ресурсов процессора. Может получиться так, что процессор загрузится полностью на 100%, и приложение вообще перестанет работать. Казалось бы, мы вообще не потребляем ресурсов процессора: мы делаем какие-то асинхронные операции, дожидаемся ответа с сервера, а процессор у нас всё равно загружен.

Когда мы с этой ситуацией столкнулись, мы сняли дамп памяти с нашего приложения:

      ~*e!clrstack
System.Threading.Monitor.Enter(System.Object)
System.Threading.TimerQueueTimer.Change(…)
System.Threading.Timer.TimerSetup(…)
System.Threading.Timer..ctor(…)
System.Threading.Tasks.Task.Delay(…)

Для анализа дампа мы использовали инструмент WinDbg. Ввели команду, которая показывает stack trace'ы всех managed-потоков, и увидели такой результат. У нас есть очень много потоков в процессе, которые ждут на некотором lock'е. Метод Monitor.Enter — это то, во что разворачивается конструкция lock в C#. Этот lock захватывается внутри классов под названием Timer и TimerQueueTimer. В Timer'ы мы пришли как раз из Task.Delay при попытке их создания. Что получается? При запуске Task.Delay захватывается блокировка внутри TimerQueue.

1.3 Lock convoy


  • Много потоков пытаются захватить один lock
  • Под lock'ом выполняется мало кода
  • Время тратится на синхронизацию потоков, а не на выполнение кода
  • Блокируются потоки из тредпула – они не бесконечны

У нас произошёл lock convoy в приложении. Много потоков пытаются захватить один и тот же lock. Под этим lock'ом выполняется довольно мало кода. Ресурсы процессора здесь расходуются не на сам код приложения, а именно на операции по синхронизации потоков между собой на этом lock'е. Надо ещё отметить особенность, связанную с .NET: потоки, которые участвуют в lock convoy, — это потоки из тредпула.

Соответственно, если у нас блокируются потоки из тредпула, они могут закончиться — количество потоков в тредпуле ограничено. Его можно настроить, но при этом верхний предел всё равно есть. После его достижения все тредпульные потоки будут участвовать в lock convoy, и в приложении перестанет выполняться вообще какой-либо код, задействующий тредпул. Это значительно ухудшает ситуацию.

1.4 TimerQueue


  • Управляет таймерами в .NET-приложении.
  • Таймеры используются в:
    — Task.Delay
    — CancellationTocken.CancelAfter
    — HttpClient

TimerQueue — это некоторый класс, который управляет всеми таймерами в .NET-приложении. Если вы когда-то программировали на WinForms, возможно, вы создавали таймеры вручную. Для тех, кто не знает, что такое таймеры: они используются в Task.Delay (это как раз наш случай), также они используются внутри CancellationToken, в методе CancelAfter. То есть замена Task.Delay на CancellationToken.CancelAfter нам бы никак не помогла. Кроме этого, таймеры используются во многих внутренних классах .NET, например, в HttpClient.

Насколько я знаю, в некоторых реализациях HttpClient handler'ов задействованы таймеры. Даже если вы ими не пользуетесь явно, не запускаете Task.Delay, скорее всего, вы их всё равно так или иначе используете.

Теперь давайте посмотрим на то, как TimerQueue устроен внутри.

  • Global state (per-appdomain):
    — Double linked list of TimerQueueTimer
    — Lock object
  • Routine, запускающая коллбэки таймеров
  • Таймеры не упорядочены по времени срабатывания
  • Добавление таймера: O(1) + lock
  • Удаление таймера: O(1) + lock
  • Запуск таймеров: O(N) + lock

Внутри TimerQueue есть глобальное состояние, это двусвязный список объектов типа TimerQueueTimer. TimerQueueTimer содержит в себе ссылку на другие TimerQueueTimer, на соседние в связном списке, также он содержит время срабатывания таймера и callback, который будет вызван при срабатывании таймера. Этот двусвязный список защищается lock-объектом, как раз тем, на котором в нашем приложении случился lock convoy. Также внутри TimerQueue есть Routine, которая запускает callback'и, привязанные к нашим таймерам.

Таймеры никак не упорядочены по времени срабатывания, вся структура оптимизирована под добавление/удаление новых таймеров. Когда запускается Routine, она пробегается по всему двусвязному списку, выбирает те таймеры, которые должны сработать, и вызывает для них callback'и.

Сложности операции здесь получаются такие. Добавление и удаление таймера происходит за O от единицы, а запуск таймеров происходит за линию. При этом если с алгоритмической сложностью здесь всё приемлемо, есть одна проблема: все эти операции захватывают блокировку, что не очень хорошо.

Какая ситуация может произойти? У нас в TimerQueue скопилось слишком много таймеров, соответственно, когда запускается Routine, она захватывает lock на свою долгую линейную операцию, в это время те, кто пытается запустить или удалить таймеры из TimerQueue, ничего с этим сделать не могут. Из-за этого происходит lock convoy. Эта проблема была исправлена в .NET Core.
Reduce Timer lock contention (coreclr#14527)
  • Lock sharding
    — Environment.ProcessorCount TimerQueue's TimerQueueTimer
  • Separate queues for short/long-living timers
  • Short timer: time <= 1/3 second

https://github.com/dotnet/coreclr/issues/14462
https://github.com/dotnet/coreclr/pull/14527
Как её исправили? Расшардили TimerQueue: вместо одной TimerQueue, которая была статической на весь AppDomain, на всё приложение, сделали несколько TimerQueue. Когда туда приходят потоки и пытаются запустить свои таймеры, эти таймеры попадут в случайную TimerQueue, и у потоков будет меньше вероятность столкнуться на одной блокировке.

Также в .NET Core применили некоторые оптимизации. Разделили таймеры на долгоживущие и короткоживущие, для них теперь используются отдельные TimerQueue. Время короткоживущего таймера выбрали меньше 1/3 секунды. Не знаю, почему именно такую константу выбрали. В .NET Core проблемы с таймерами нам поймать никак не удалось.



https://github.com/Microsoft/dotnet-framework-early-access/blob/master/release-notes/NET48/dotnet-48-changes.md
https://github.com/dotnet/coreclr/labels/netfx-port-consider

Этот фикс бэкпортнули в .NET Framework, в версию 4.8. Выше в ссылке указан тег netfx-port-consider, если зайдёте в репозиторий .NET Core, CoreCLR, CoreFX, по этому тегу можете поискать issue, которые будут бэкпортиться в .NET Framework, сейчас их там порядка пятидесяти. То есть опенсорс .NET сильно помог, довольно много багов было исправлено. Можете почитать changelog .NET Framework 4.8: исправлено очень много багов, гораздо больше, чем в других релизах .NET. Что интересно, этот фикс по умолчанию в .NET Framework 4.8 выключен. Он включается во всем вам известном файле под названием App.config

Настройка в App.config, которая включает этот фикс, называется UseNetCoreTimer. До того, как вышел .NET Framework 4.8, чтобы наше приложение работало и не уходило в lock convoy, приходилось использовать свою реализацию Task.Delay. В ней мы попробовали использовать бинарную кучу, чтобы более эффективно понимать, какие таймеры нужно сейчас вызывать.

1.5 Task.Delay: собственная реализация


  • BinaryHeap
  • Sharding
  • Помогло, но не во всех случаях

Использование бинарной кучи позволяет оптимизировать Routine, которая вызывает callback'и, но ухудшает время удаления произвольного таймера из очереди — для этого нужно перестраивать кучу. Скорее всего, именно поэтому в .NET используется двусвязный список. Конечно, только лишь использование бинарной кучи нам здесь бы не помогло, также пришлось расшардить TimerQueue. Это решение какое-то время работало, но потом всё равно всё снова упало в lock convoy из-за того, что таймеры используются не только там, где они запускаются в коде явно, но и в сторонних библиотеках и коде .NET. Чтобы полностью исправить эту проблему, необходимо обновиться до .NET Framework версии 4.8 и включить фикс от разработчиков .NET.

1.6 Task.Delay: выводы


  • Подводные камни везде — даже в самых используемых вещах
  • Проводите нагрузочное тестирование
  • Переходите на Core, получайте багфиксы (и новые баги) первыми :)

Какие выводы из всей этой истории? Во-первых, подводные камни могут находиться реально везде, даже в тех классах, которые вы используете каждый день, не задумываясь, например, те же Task'и, Task.Delay.

Рекомендую проводить нагрузочное тестирование ваших предложений. Вот эту проблему мы как раз выявили ещё на этапе нагрузочного тестирования. Она у нас потом ещё несколько раз выстрелила на продакшне в других приложениях, но, тем не менее нагрузочное тестирование нам помогло отдалить время до того, как мы столкнулись с этой проблемой в реальности.

Переходите на .NET Core — вы будете получать исправления багов (и новые баги) самыми первыми. Куда же без новых багов?

На этом история про таймеры закончилась, и мы переходим к следующей.

История 2: SemaphoreSlim


Следующая история про широко известный SemaphoreSlim.

2.1 Серверный троттлинг


  • Требуется ограничить число параллельно обрабатываемых запросов на сервере

Мы хотели реализовать троттлинг на сервере. Что это такое? Наверное, вы все знаете троттлинг по CPU: когда процессор перегревается, он сам снижает свою частоту, чтобы охладиться, и у него за счет этого ограничивается производительность. Так же и здесь. Мы знаем, что наш сервер умеет обрабатывать параллельно N запросов и не падать при этом. Что мы хотим сделать? Ограничить количество одновременно обрабатываемых запросов этой константой и сделать так, что, если к нему приходит больше запросов, они встают в очередь и ждут, пока выполнятся те запросы, которые пришли раньше. Как эту задачу можно решать? Надо использовать какой-то примитив синхронизации.

Semaphore — примитив синхронизации, на котором можно подождать N раз, после чего тот, кто придёт N + первым и так далее, будет ждать на нём, пока Semaphore не освободят те, кто зашли под него раньше. Получается примерно вот такая картина: два потока выполнения, два воркера прошли под Semaphore, остальные — встали в очередь.



Конечно, просто Semaphore нам не очень подойдёт, он в .NET синхронный, поэтому мы взяли SemaphoreSlim и написали такой код:

var semaphore = new SemaphoreSlim(N);
…
await semaphore.WaitAsync();
await HandleRequestAsync(request);
semaphore.Release();

Создаём SemaphoreSlim, ждём на нём, под Semaphore обрабатываем ваш запрос, после этого Semaphore освобождаем. Казалось бы, это идеальная реализация серверного троттлинга, и лучше быть уже не может. Но всё гораздо сложнее.

2.2 Серверный троттлинг: усложнение


  • Обработка запросов в LIFO порядке
  • SemaphoreSlim
  • ConcurrentStack
  • TaskCompletionSource

Мы немного забыли про бизнес-логику. Запросы, которые приходят на троттлинг, являются реальными http-запросами. У них есть, как правило, некоторый таймаут, который задан тем, кто отправил этот запрос автоматически, или таймаут пользователя, который нажмёт F5 через какое-то время. Соответственно, если обрабатывать запросы в порядке очереди, как обычный Semaphore, то в первую очередь будут обрабатываться те запросы из очереди, у которых таймаут, возможно, уже истёк. Если же работать в порядке стека — обрабатывать в первую очередь те запросы, которые пришли самыми последними, такой проблемы не возникнет.

Кроме SemaphoreSlim нам пришлось использовать ConcurrentStack, TaskCompletionSource, навернуть очень много кода вокруг всего этого, чтобы всё работало в том порядке, как нам нужно. TaskCompletionSource — это такая штука, которая похожа на CancellationTokenSource, только не для CancellationToken, а для Task'а. Вы можете создать TaskCompletionSource, вытащить из него Task, отдать его наружу и потом сказать TaskCompletionSource, что надо выставить результат этому Task'у, и об этом результате узнают те, кто на этом Task'е ждёт.

Мы всё это реализовали. Код получился ужасным. и, что хуже всего, он оказался нерабочим.

Спустя несколько месяцев с начала его использования в довольно высоконагруженном приложении мы столкнулись с проблемой. Точно так же, как и в предыдущем случае, возросло потребление CPU до 100 %. Мы проделали аналогичные действия, сняли дамп, посмотрели его в WinDbg, и снова обнаружили lock convoy.



В этот раз Lock convoy произошёл внутри SemaphoreSlim.WaitAsync и SemaphoreSlim.Release. Выяснилось, что внутри SemaphoreSlim есть блокировка, он не lock-free. Это оказалось для нас довольно серьезным недостатком.



Внутри SemaphoreSlim есть внутреннее состояние (счётчик того, сколько под него ещё могут пройти воркеров), и двусвязный список тех, кто на этом Semaphore ждёт. Идеи здесь примерно такие же: можно подождать на этом Semaphore, можно отменить своё ожидание — удалиться из этой очереди. Есть блокировка, которая как раз нам жизнь и попортила.

Мы решили: долой весь ужасный код, который нам пришлось написать.



Давайте напишем свой Semaphore, который сразу будет lock-free и который будет сразу работать в порядке стека. Отмена ожидания при этом нам не важна.



Определим данное состояние. Здесь будет число currentCount — это сколько ещё мест осталось в Semaphore. Если мест в Semaphore не осталось, то это число будет отрицательным и будет показывать, сколько воркеров находится в очереди. Также будет ConcurrentStack, состоящий из TaskCompletionSource'ов — это как раз стек waiter'ов, из которых они при необходимости будут вытаскиваться. Напишем метод WaitAsync.

var decrementedCount = Interlocked.Decrement(ref currentCount);

if (decrementedCount >= 0)
    return Task.CompletedTask;

var waiter = new TaskCompletionSource<bool>();
waiters.Push(waiter);

return waiter.Task;

Сначала мы уменьшаем счётчик, забираем себе одно место в Semaphore, если у нас были свободные места, и потом говорим: «Всё, ты прошёл под Semaphore».

Если мест в Semaphore не было, мы создаём TaskCompletionSource, кидаем его в стек waiter'ов и возвращаем во внешний мир Task'у. Когда придёт время, эта Task'а отработает, и воркер сможет продолжить свою работу и пройдёт под Semaphore.

Теперь напишем метод Release.

var countBefore = Interlocked.Increment(ref currentCount) - 1;

if (countBefore < 0)
{
    if (waiters.TryPop(out var waiter))
        waiter.TrySetResult(true);
}

Метод Release выглядит следующим образом:

  • Освобождаем одно место в Semaphore
  • Инкрементим currentCount

Если по currentCount можно сказать, есть ли внутри стека waiter'ы, о которых нужно сигнализировать, мы такие waiter'а вытаскиваем из стека и сигнализируем. Здесь waiter — это TaskCompletionSource. Вопрос к этому коду: он вроде бы логичный, но он вообще работает? Какие здесь есть проблемы? Есть нюанс, связанный с тем, где запускаются continuation'ы и TaskCompletionSource'ы.



Рассмотрим этот код. Мы создали TaskCompletionSource и запустили два Task'а. Первый Task выводит единицу, выставляет результат в TaskCompletionSource, а дальше выводит на консоль двойку. Второй Task ждёт на этом TaskCompletionSource, на его Task'е, а затем навсегда блокирует свой поток из тредпула.

Что здесь произойдёт? Task 2 при компиляции разделится на два метода, второй из которых — continuation, содержащий Thread.Sleep. После выставления результата TaskCompletionSource, этот continuation выполнится в том же потоке, в котором выполнялся первый Task. Соответственно, поток первого Task'а будет навсегда заблокирован, и двойка на консоль уже не напечатается.

Что интересно, я пробовал поменять этот код, и если я убирал вывод на консоль единицы, continuation запускался на другом потоке из тредпула и двойка печаталась. В каких случаях continuation будет выполнен в том же потоке, а в каких — попадёт на тредпул — вопрос для читателей.

var tcs = new TaskCompletionSource<bool>(
 TaskCreationOptions.RunContinuationsAsynchronously);
	
/* OR */
	
Task.Run(() => tcs.TrySetResult(true));

Для решения этой проблемы мы можем либо создавать TaskCompletionSource с соответствующим флагом RunContinuationsAsynchronously, либо вызывать метод TrySetResult внутри Task.Run/ThreadPool.QueueUserWorkItem, чтобы он выполнялся не на нашем потоке. Если он будет выполняться на нашем потоке, у нас могут возникнуть нежелательные side effect'ы. Вдобавок здесь есть вторая проблема, остановимся на ней подробнее.



Посмотрите на методы WaitAsync и Release и попробуйте найти в методе Release ещё одну проблему.

Скорее всего, найти ее так просто невозможно. Здесь есть гонка.



Она связана с тем, что в методе WaitAsync изменение состояния не атомарно. Вначале мы декрементим счётчик и только потом пушим waiter'а на стек. Если так получится, что Release выполнится между декрементом и пушем, то может выйти так, что он ничего не вытащит из стека. Это нужно учесть, и в методе Release дожидаться появления waiter'а в стеке.

var countBefore = Interlocked.Increment(ref currentCount) - 1;
	
if (countBefore < 0)
{
    Waiter waiter;
	
    var spinner = new SpinWait();
	
    while (!waiter.TryPop(out waiter))
      spinner.SpinOnce();
	
    waiter.TrySetResult(true);
}

Здесь мы это делаем в цикле, пока у нас не получается его вытащить. Чтобы лишний раз не тратить циклы процессора, мы используем SpinWait.

В первые несколько итераций он будет крутиться по циклу. Если итераций станет много, waiter долго не будет появляться, то наш поток уйдет в Thread.Sleep, чтобы лишний раз не расходовать ресурсы CPU.

На самом деле, Semaphore с LIFO-порядком — это не только наша идея.
LowLevelLifoSemaphore
  • Синхронный
  • На Windows использует в качестве стека Windows IO Completion port

https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs
Такой Semaphore есть в самом .NET, но не в CoreCLR, не в CoreFX, а в CoreRT. Иногда бывает довольно полезно заглядывать в репозитории .NET. Здесь есть Semaphore под названием LowLevelLifoSemaphore. Этот Semaphore нам бы всё равно не подошёл: он синхронный.

Что примечательно, на Windows он работает через IO Completion-порты. У них есть свойство, что на них могут ждать потоки, и эти потоки будут релизиться как раз в LIFO-порядке. Эта особенность там используется, он действительно LowLevel.

2.3 Выводы:


  • Не надейтесь, что начинка фреймворка выживет под вашей нагрузкой
  • Проще решать конкретную задачу, чем общий случай
  • Нагрузочное тестирование помогает не всегда
  • Опасайтесь блокировок

Какие выводы из всей этой истории? Во-первых, не надейтесь, что какие-то классы из фреймворка, которые вы используете из стандартной библиотеки, справятся с вашей нагрузкой. Я не хочу сказать, что SemaphoreSlim плохой, просто он оказался неподходящим конкретно в данном сценарии.

Нам оказалась гораздо проще написать свой Semaphore для конкретной задачи. Например, он не поддерживает отмену ожидания. Эта возможность есть в обычном SemaphoreSlim, у нас её нет, но это позволило упростить код.

Нагрузочное тестирование, хоть оно и помогает, может помочь далеко не всегда.

.NET известен тем, что у него довольно часто в неожиданных местах есть блокировки — их лучше опасаться. Если в своём коде вы пишете конструкцию lock, лучше задуматься: «Какая реально здесь будет нагрузка?» И если вдруг потребление CPU 100%, все потоки стоят на lock'е, то, возможно, это происходит где-то внутри .NET. Просто имейте в виду такую возможность.

Переходим к следующей истории.

История 3: (A)sync IO


История про асинхронный ввод/вывод, который оказался не таким уж асинхронным.



Здесь также случился lock convoy, он произошёл по stack trace внутри класса под названием Overlapped и PinnableBufferCache. Там оказался lock. Что же это за классы: Overlapped и PinnableBufferCache?

OVERLAPPED — это структура в Windows, которая используется для всех операций ввода/вывода. У нас было довольно нагруженное приложение, это один из шардов нашей распределённой файловой системы. Он много работает и с файлами на диске, и сетью. И таких структур ему понадобилось очень много, вследствие этого и выявился lock convoy. Мы стали разбираться, в чём вообще причина этого lock convoy, почему раньше всё работало, а сейчас перестало.



Надо заметить, что эта история произошла уже довольно давно, во времена .NET 4.5.1 и 4.5.2. Тогда как раз вышел .NET 4.5.2, и отличие оказалось в изменениях, которые появились в .NET 4.5.2. В .NET 4.5.1 был класс под названием OverlappedDataCache, представлявший собой пул этих объектов Overlapped — действительно, зачем их создавать на каждую асинхронную операцию, проще сделать пул. Этот пул был хороший, был lock-free, основанный на ConcurrentStack, и с ним не возникало никаких проблем. В .NET 4.5.2 решили оптимизировать пуллинг этих объектов: убрали OverlappedDataCache и сделали PinnableBufferCache.

В чём отличие? PinnableBufferCache сделан с расчётом на то, что объекты Overlapped нужно передавать в нативный код, при этом объекты пиннятся, а пиннить объекты в младших поколениях — это дополнительная нагрузка на сборщик мусора. Соответственно, наружу бы неплохо отдавать объекты, которые уже попали во второе поколение. PinnableBufferCache был разбит на две части. Первая часть хорошая, lock-free, основанная на ConcurrentStack. Она предназначена для тех объектов, которые уже попали во второе поколение. Внутри этого пула есть вторая часть для объектов, которые ещё находятся в нулевом и первом поколении, и почему-то для них вместо lock-free структуры используется обычный list с lock'ом.

3.1 PinnableBufferCache


LockConvoy:

  • Если закончились буферы
  • При возврате объектов в пул

Здесь lock convoy происходил тогда, когда заканчивались буфер-объекты и нужно было создавать новые. В таком случае они попадают в плохой list при возврате этих объектов в пул, поскольку при возврате объектов lock захватывается для того, чтобы проверить, а не пора ли объекты из пула для нулевого и первого поколения переносить во второе поколение.

Мы стали изучать код PinnableBufferCache и обнаружили, что в нём есть обращение к недокументированной переменной окружения. Она называлась вот так:

PinnableBufferCache_System.ThreadingOverlappedData_MinCount

Эта переменная позволяла задать количество объектов, которые будут находиться в пуле изначально. Мы решили: «Отличная возможность! Давайте ей воспользуемся и поставим в эту переменную какое-нибудь большое число». Теперь у нас в приложении появился вот такой вуду-код:

Environment.SetEnvironmentVariable(
  "PinnableBufferCache_System.Threading.OverlappedData_MinCount", "10000");
	
new Overlapped().GetHashCode();
	
for (int i = 0; i < 3; i++)
    GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);

Что мы здесь делаем? Мы вначале выставляем переменную окружения, затем создаём объект Overlapped для того, чтобы пул инициализировался, а затем несколько раз вручную вызываем сборку мусора. Сборка мусора вызывается для того, чтобы все объекты, которые находятся в этом пуле, попали уже во второе поколение, и PinnableBufferCache от нас отстал со своим lock convoy'ем. Это решение оказалось рабочим, и оно до сих пор живо в нашем коде для фреймворка.

На .NET Core от PinnableBufferCache избавились тем, что перенесли OverlappedData в нативную память. Соответственно, пиннинг их в памяти уже стал не нужен, Garbage collector их никуда передвинуть не может, так как они в нативной памяти. На этом история в .NET Core и закончилась. В .NET Framework, если не ошибаюсь, ещё этот фикс не перенесли.

3.2 Выводы:


  • Не все оптимизации одинаково полезны
  • На этот раз просто повезло
  • И снова .NET Core

Здесь явно хотели сделать как лучше, уменьшив нагрузку на сборщик мусора. Нам очень повезло, что разработчиками .NET была предусмотрена возможность задать минимальное количество объектов для этого пула через переменную, иначе нам бы пришлось писать хак пострашнее. И, опять же, попробуйте .NET Core. Возможно, это решит ваши перформансные проблемы, и вам даже не придётся для этого писать вуду-код.

Теперь перейдём к key-value коллекциям.

История 4: Concurrent key-value collections


В .NET есть несколько concurrent-коллекций. Это lock-free коллекции ConcurrentStack и ConcurrentQueuе, с которыми у нас не возникало проблем. Есть коллекция ConcurrentDictionary, с ней всё интереснее. Она не lock-free на запись, там есть блокировки, но сейчас речь не о них. Почему вообще используют ConcurrentDictionary?

4.1 ConcurrentDictionary


Применения:

  • Кэш
  • Индекс

Плюсы:

  • Входит в стандартную библиотеку
  • Удобные операции (TryAdd/TryUpdate/AddOrUpdate)
  • Lock-free чтения
  • Lock-free enumeration

Его часто используют под различные кэши, memory-индексы, прочие структуры, в которые нужно уметь обращаться из нескольких потоков. Его любят за то, что он абсолютно стандартный, есть даже в .NET Framework. У него есть довольно удобные операции именно для работы из нескольких потоков. И, что важно, у него чтение и перечисление (enumeration) lock-free. Конечно, есть и подвохи.

Давайте рассмотрим, как устроена коллекция, основанная на хэш-таблице в .NET. Большинство key-value коллекций основано на хэш-таблицах и выглядят примерно так:



Вычисляется хэш-код, берётся по модулю количество bucket'ов. В самой коллекции есть несколько bucket'ов, в которых хранятся элементы. Если происходит коллизия, то в один bucket попадает несколько элементов, связанных между собой по принципу связанного списка.

Здесь каждый квадрат — это отдельный объект, почти ConcurrentDictionary. В ConcurrentDictionary на каждую пару «ключ-значение» создаётся отдельный объект. Причем при замене значений, если сами значения больше определённого размера, они постоянно пересоздаются, и от этого возникает ещё и memory traffic. Чтобы это был совсем ConcurrentDictionary, я нарисовал lock'и. Один квадрат — это один объект.

Теперь посмотрим на то, как устроен обычный Dictionary.



Обычный Dictionary устроен похитрее, чем Concurrent, но он более компактный по памяти. В нём есть два массива: массив buckets, массив entries. В массиве buckets находится индекс первого элемента в этом bucket'е в массиве entries. Все пары «ключ-значение» хранятся в массиве entries. Связные списки здесь организованы через ссылки на индексы в массиве. То есть дополнительно с парой «ключ-значение» хранится число int, индекс следующего элемента в bucket'е.

Давайте сравним memory overhead, который возникает при использовании ConcurrentDictionary и обычного Dictionary.



Начнём с обычного Dictionary. Memory overhea'ом я называю здесь всё, что не является самими ключами и значениями. В случае обычного Dictionary этот overhead составляет хэш-код и индекс следующего элемента, два int'а. Это 8 байт.

Теперь посмотрим на ConcurrentDictionary. В ConcurrentDictionary элемент хранится внутри объекта ConcurrentDictionary.Node. Это именно объект, класс. В этом классе находятся int hashCode и ссылка на следующий объект в связном списке. То есть у нас есть заголовки объекта, ссылка на метод table (это уже 16 байт), есть int hashCode и есть ссылка на объект. Если я не перепутал никакие размеры, то на 64-битной платформе это будет 28 байт overhead'а. Довольно много по сравнению с обычным Dictionary.

Кроме memory overhead'а, ConcurrentDictionary способен создавать нагрузку на GC за счёт того, что в нём есть очень много объектов. Я написал очень простой Benchmark. Я создаю ConcurrentDictionary определённого размера, а дальше замеряю время работы метода GC.Collect. Что же я получил?



Я получил вот такие результаты. Если у нас в процессе есть ConcurrentDictionary размером 10 млн элементов, то сборка мусора на моём компьютере занимает полсекунды, на сервере при этом эти полсекунды вполне могут уже превратиться в несколько секунд, что уже может быть довольно неприемлемо. С обычным Dictionary такого не происходит. Сколько элементов в него ни клади, там обычные массивы, два объекта, и всё очень хорошо. На время работы сборщика мусора не влияет.

Как можно справиться с проблемами, которые возникают при использовании ConcurrentDictionary?

4.2 Простые решения


  • Ограничение на размер
  • TTL
  • Dictionary+lock
  • Sharding

Давайте разберём простые эффективные решения. Мы можем ограничить размер нашего ConcurrentDictionary. Вряд ли нам нужно держать кэш на 10 миллионов элементов. Можно держать тысячу, и никаких проблем не будет. Можно сделать TTL для элементов, и периодически их вычищать. В некоторых случаях довольно эффективно использовать обычный Dictionary с lock'ом. Естественно, надо убедиться, что lock здесь не ухудшит производительность. Можно развить этот подход и расшардировать Dictionary с lock'ом самостоятельно перед размещением элементов по словарям, по некоторому хэш-коду раскладывать их в несколько словарей, тогда мы не будем конкурировать за один и тот же lock. Но при этом иногда бывает так, что простые решения не работают.

4.3 Индекс


  • Нужно хранить in-memory индекс <Guid,Guid>
  • В индексе >106 элементов
  • Постоянно происходят чтения из нескольких потоков
  • Записи редкие
  • Нужно уметь перечислять все элементы в коллекции

Мы столкнулись с подобной ситуацией. У нас очень важное приложение — это мастер нашей распределённой файловой системы, и ему нужно хранить в памяти in-memory индекс из Guid'а в Guid, помнить расположение файлов на серверах. В этом индексе было порядка миллиона элементов. Из этого индекса постоянно кто-то что-то читает, пишут в этот индекс редко. Получилось так, что сборка мусора у нас стала занимать в этом приложении порядка 15 секунд для второго поколения. Это было неприемлемо. Мы решили поступить аналогично Semaphore и написать свой аналог ConcurrentDictionary.



Надо, чтобы он был lock-free на чтение и перечисление, чтобы был меньший overhead по памяти и нагрузка на GC. Также нам достаточно, чтобы он поддерживал сценарий с одним писателем и несколькими читателями. То есть пишут в него редко, и нам не нужно, чтобы он был очень хорош на запись, достаточно на чтение. Можно даже так, чтобы он приходил в какое-то невалидное состояние, если в него придут сразу несколько писателей, их можно синхронизировать извне. И пусть, по возможности, всё это не попадает в Large Object Heap. Почему бы и нет?

Когда мы решили попробовать сделать такую штуку, то начали с исследования того, можно ли доработать обычный Dictionary под эти требования.



В обычном Dictionary есть массив bucket'ов, массив Entry. В Entry хранится ключ, значение, хэш и ссылка, индекс следующего элемента.



Обычный Dictionary потокобезопасен на чтение, если нет писателей, читатели не могут поменять его состояние. Возможно, это тоже может решить какие-то проблемы.

Что же может пойти не так, если всё-таки будут записи параллельно с чтениями? Во-первых, при ресайзе, когда массивы заменяются на массивы размеров больше, читатель может увидеть два массива, относящиеся к разным версиям нашей коллекции. Эта проблема решается довольно просто. У нас есть Dictionary, у него есть два массива, buckets, entries, мы объединяем эти массивы в один объект и при необходимости подменяем его через Interlocked. Соответственно, читатель никогда не увидит два массива из разных версий.
Dictionary
  • Потокобезопасен на чтение, если нет писателей
  • Что может пойти не так, если будут записи?
    — При Resize увидели buckets и entries разных версий
    — Поток зациклился при переходе по индексам-ссылкам
    — Прочитали мусор вместо Dictionary.Entry
    — Перешли по индексам-ссылкам в мусор

https://blogs.msdn.microsoft.com/tess/2009/12/21/high-cpu-in-net-app-using-a-static-generic-dictionary/
В интернете есть такая страшилка, что при многопоточном доступе к обычному Dictionary в нём может получиться цикл через индекс-ссылки внутри bucket'ов. На самом деле этот цикл может появиться, только если будет несколько конкурентных писателей. Если конкурентный писатель один, то сломаться могут только читатели, а писатель приведёт коллекцию в нужное ему состояние. Если у нас будет два параллельных писателя, то они могут образовать цикл внутри коллекции, это довольно легко воспроизвести.

Мы можем почитать мусор вместо Entry в Dictionary. Можем случайно перескочить по индекс-ссылкам куда-то не туда в коллекции. Давайте посмотрим, как эти проблемы можно решить.



Решение пришло из .NET Framework версии 1.1. В нём появился класс Hashtable, недженериковая версия Dictionary, которая работает с object'ами. Про неё прямо на MSDN сказано, что она реализует нужный нам сценарий. Она потокобезопасна, если из неё будут параллельно читать и при этом будет только один поток-писатель. Это нас заинтересовало. Стали разбирать, как Hashtable устроен внутри. Давайте посмотрим, как он решает некоторые обозначенные проблемы.

4.4 Чтение мусора вместо Dictionary.Entry



Почему так может произойти? Dictionary.Entry большой, он больше, чем 8 байт, его, казалось бы, не прочитать атомарно, хотя на самом деле можно. Как это сделать?

bool writing;
int version;

this.writing = true;
buckets[index] = …;
this.version++;
this.writing = false;

Заводятся две переменные: флаг (пишут ли сейчас в структуру, которую мы хотим читать) и int-версия. Очень стандартное решение, прикрутили версию. Писатель выставляет флаг, что он сейчас будет писать, пишет, обновляет версию, снимает флаг.

bool writing;
int version;
	
while (true)
{
  int version = this.version;
  bucket = bickets[index];
  if (this.writing || version != this.version)
      continue;
  break;
}

Читатель делает чистое чтение, крутится в цикле и проверяет, поменялась ли версия и состояние этого флага при чтении. Если элемент поменялся или его меняют сейчас, надо попробовать перечитать. Такое решение позволяет считать атомарно структуры больше, чем 8 байт.

4.5 Переход по индексам-ссылкам в мусор


Давайте посмотрим, как это происходит.



В Dictionary можно перескочить на другой bucket при чтении из него, если при этом есть запись.

Есть Dictionary, состоящий из двух элементов. Я здесь для простоты написал ключи: 0 и 2. Это один bucket, 1 остаток у них от деления на 2. Что мы делаем? Читатель приходит и читает 0. После чего запоминает, что ему дальше нужно перейти в ячейку, где сейчас находится 2. После этого его поток прерывается. Дальше приходит писатель, удаляет 2, после чего добавляет на место этой двойки, например, 1. 1 даёт другой остаток от деления на 2 — это будет уже другой bucket. Наш читатель запомнил, что ему надо перейти в ячейку, где находилась двойка. Он читает оттуда 1 — всё, мы перескочили на другой bucket. В Hashtable от этой проблемы защитились тем, что вообще отказались от bucket'ов и индекс-ссылок. Там используется другой подход к разрешению коллизий — double hashing.

4.6 Обработка коллизий


  • Элементы хранятся в массиве
  • По хэшкоду определяется порядок обхода

Запись

  • В первую свободную ячейку в порядке обхода
  • Если нет свободных ячеек, то resize

Чтение

  • Ищем элемент в порядке обхода, пока не найдем пустую ячейку

Обработка коллизий идет через так называемый порядок обхода. Все элементы точно так же лежат в одном массиве, но при этом уже нет массива Buckets, а есть только массив Entries (он там называется Buckets, хотя должен бы называться Entries). Дальше берётся хэш-код от элемента, который мы хотим найти в этой структуре, и мы понимаем, в каком порядке надо пройти по ячейкам в этом массиве, чтобы искать наш элемент.

При записи мы запишем наш элемент в первую свободную ячейку в порядке обхода. Если свободных ячеек нет, то будем ресайзить коллекцию.

Про чтение: будем идти в порядке обхода, пока не найдём нужный нам элемент, либо не найдём элемент, в котором записан признак того, что элементов дальше явно нет. Возможно, идея с порядком обхода показалась довольно смутной, поэтому разберем пример.



Рассмотрим самый простой порядок обхода, который может быть, — последовательный обход.

Как он работает? Есть некоторый ключ, и хэш-код от него равен 2. Мы берём хэш-код по модулю Capacity нашей коллекции, чтобы можно было и дальше использовать его как индекс в этом массиве. У нас хэш — 2. Мы хотим найти элемент с таким хэшом, с определённым ключом. Посмотрим на ячейку с индексом 2. Там есть этот элемент? Если есть, то значит, что мы его уже нашли, его и вернём. Если там оказался какой-то другой элемент, то мы идём в следующую ячейку по счёту, а именно в ячейку с индексом 3. Смотрим, есть ли там элемент, если нет, то идём в четвёртую, в нулевую, в первую.

Порядок обхода, который используется в Hashtable, не последовательный. Он более сложный, там используется так называемый double hashing. Он отличается от последовательного тем, что в качестве шага взята не единица, а другое число, которое вычисляется с использованием хэша.

Если размер шага, с которым мы идём по массиву, и размер этого массива — взаимно простые числа, то при обходе массива мы пройдём все его элементы ровно один раз. Это и используется в Hashtable. Там сделано так, что размер массива — это всегда простое число — любое число подходит в качестве размера шага. Мы начинаем идти с произвольной ячейки и дальше в неё же по циклу по массиву и приходим. Таким образом, у нас нет никаких bucket'ов, нам никуда не перескочить или прочитать что-то не то, у нас эти ссылки не могут поломаться. Становится лучше.

Здесь уже реализовали почти всё, что хотели, осталось реализовать lock-free перечисление и избежать попадания в LOH.



Как сделать lock-free перечисления? На MSDN в документации про Hashtable сказано, что на перечисление он не потокобезопасен. Проблема возникает с тем, что в силу особенностей структуры данных могут повторяться элементы с одинаковыми ключами, в случае если они были удалены и добавлены заново во время перечисления.



Если мы хотим избежать подобного поведения, то можем использовать чистое чтение, но не для отдельных элементов в нашей коллекции, а для bucket'ов. Можно положить в основу нашей коллекции идеи обычного Dictionary с bucket'ами, обычную хэш-таблицу, но эти bucket'ы всегда зачитывать целиком с чистым чтением. То есть если кто-то успел записать в bucket, то bucket придётся перечитать. Так как записи не такие частые, то это не очень критично.

Мы хотим, чтобы наша структура не попадала в Large Object Heap.



Для этого мы можем её расшардить. Заменим CustomDictionary на CustomDictionarySegment и поверх сделаем обёртку. Есть наш Dictionary, состоящий из нескольких сегментов, в которых элементы распределены по хэшу. Каждый сегмент — это тот Dictionary, о котором мы говорили до этого. В каждом из этих сегментов массив маленький, и он не попадает в Large Object Heap. Так как сами по себе эти массивы маленькие, то и bucket'ы в них маленькие. Соответственно, мы можем позволить себе такую роскошь, как их чистое чтение, перечитать целый bucket, если вдруг в него кто-то что-то записал.

Как развитие этой идеи мы можем сделать блокировки на наши сегменты. В итоге получится почти тот самый ConcurrentDictionary, который и есть в .NET, но с более ограниченной применимостью для конкретных сценариев, где не нужны все фичи обычного.

4.7 Выводы


  • .NET не идеален
  • Ничто не идеально
  • Проводите тестирование
  • Знайте, как работают стандартные классы
  • Изобретайте велосипеды
  • Тестируйте велосипеды

Какие из всего этого выводы? .NET не идеален. Ничто не идеально. Все те структуры, которые есть в стандартной библиотеке, служат для работы в наибольшем количестве сценариев. Есть у вас какой-то свой сценарий — вам нужно что-то другое. Когда пишете код, проведите тестирование, убедитесь, подходит ли вам стандартное решение.

Если что-то идёт не так, изучите, как оно работает, что находится внутри стандартного класса, который вы используете. Если вы изучили, как оно работает, и не поняли, что именно вам надо поменять в своём коде, возможно, вам нужно пойти на некоторый внутренний конфликт и изобрести велосипед. Изобрели велосипед — протестируйте свой велосипед, убедитесь, что он работает.

Полезные ссылки



Первая ссылка — на статью моего коллеги Ильи Локтионова про некоторые подвохи ConcurrentDictionary. Кстати, надо сказать спасибо команде инфраструктуры Контура, Илье Локтионову (Diafilm), без них этот доклад бы не состоялся.

Также приведу здесь ссылки на GitHub. Вторая ссылка — это ссылка на нашу библиотеку, которая лежит в опенсорсе, содержит в себе LIFO-Semaphore, о котором было рассказано. Третья ссылка на репозиторий с примерами кода, которые были в докладе.
6-7 ноября я выступлю на DotNext 2019 Moscow с докладом «.NET: Лечение зависимостей» и расскажу про случаи, когда возникают ошибки с подключаемыми библиотеками на .NET Framework и .NET Core, и объясню, какие можно использовать подходы к решению этих проблем.

Комментарии (30)


  1. Dimtry44
    26.09.2019 17:20

    Тестируйте велосипеды
    Было бы полезно иметь набор заготовок для тестов которые правильно нагружают упомянутые в статье методы.


  1. lostmsu
    26.09.2019 20:31
    +1

    А вы вообще пробовали обычный словарь с reader writer lock (slim)?


    1. epeshk Автор
      26.09.2019 23:32
      +4

      Это решение не стали использовать т.к. это привело бы к формированию большой очереди на локе из запросов, при обработке которых происходит обращение к коллекции на чтение, если одновременно с этим происходит запись. Отвечая на исходный вопрос, пробовали ли — уже не помню.
      Дополнительно стоит сравнить оверхед от использования ReaderWriterLockSlim по сравнению с обычным lock на Monitor, в интернетах ходят слухи, что rwlock жирнее, чтобы убедиться в целесообразно его использования вместе с Dictionary.


  1. Indermove
    26.09.2019 21:52
    +1

    Статья просто отличная! Отличное сочетание примеров и объяснений. Спасибо!


  1. pawlo16
    26.09.2019 22:02
    -6

    Почему не использовали стримы http/2 от сервера к клиенту вместо http longpoll? очевидное же решение.

    Не понятно, зачем вообще dotnet с его граблями и костылями для конкурентности, если есть голанг, в котором примитивы синхронизации надёжны, как швейцарские часы, и просты. Каков смысл писать сложный и заумный код вместо простого, решающего те же задачи?


    1. epeshk Автор
      26.09.2019 23:10
      +7

      Проблема с реализаций Task.Delay здесь опосредована от протокола. Конкретно для этой задачи мы пробовали HTTP/2 и столкнулись с таким же lock convoy'ем и некоторыми другими проблемами.

      Почему не взять голанг?

      примитивы синхронизации надёжны, как швейцарские часы, и просты

      Скорее всего, такая уверенность останется только до первой проблемы, которая возникнет при их использовании. Нет гарантии не набажить самому или не наткнуться на неожиданное поведение в корнер-кейсе.

      Большинство имеющегося в компании кода написано на C#, поддерживается разработчиками, которые пишут на C# и знают особенности .NET. Обосновать переход на Go с отбрасыванием имеющейся кодовой базы по причине более надёжных примитивов синхронизации — сложно, с таким же успехом можно предложить использовать C++ потому что в нём нет GC и ручное управление памятью надёжнее или Python, потому что код на нём не выглядит заумно, да и любую другую технологию по любой другой причине. В новых, изолированных проектах, используется не только .NET, но останавливать разработку уже успешно работающего проекта — большого смысла не вижу.


      1. pawlo16
        27.09.2019 09:16
        -1

        Скорее всего, такая уверенность останется только до первой проблемы, которая возникнет при их использовании. Нет гарантии не набажить самому или не наткнуться на неожиданное поведение в корнер-кейсе.

        С недокументированными утечками CPU/памяти базовых примитивов синхронизации не сталкивался ни разу, в т.ч. на хайлоаде. Проблемы возникают постоянно, но их и порешать проще. Поскольку сам код проще, гарантии надёжнее и есть race detector из коробки
        Обосновать переход на Go с отбрасыванием имеющейся кодовой базы по причине более надёжных примитивов синхронизации — сложно

        Речь не идёт об отказе от работающей кодовой базы. У вас микросервисы, поэтому инжекция Го может быть абсолютно бесшовной — переносится ровно тот функционал, который есть смысл переводить на Го, и не более. C# прекрасно умеет в grpc и appache thrift, поэтому проблем с масштабируемой разработкой на одновременно C# и Go не будет.
        с таким же успехом можно предложить использовать C++ потому что в нём нет GC

        Да ладно) Ручная сборка мусора вместо gc — это переход к низкоуровневому программированию, в данном случае этого нет. Как и «Python, потому что код на нём не выглядит заумно» — Го отнюдь не про то, чтобы спрятать сложность за кажущейся простотой.


        1. aensidhe
          29.09.2019 10:30
          +4

          У вас микросервисы, поэтому инжекция Го может быть абсолютно бесшовной —

          У меня в проекте есть C#, Java, Go, Rust, Dart и PHP (это мы фронтенд ещё не трогали). Очень интересно это всё поддерживать. Люди, которые говорят, что "микросервисы позволяют писать каждый микросервис на чём угодно" серьёзно, никогда этого не делали в серьёзном продакшене.


          переносится ровно тот функционал, который есть смысл переводить на Го, и не более.

          Нет никакого смысла переходить с .net на Go при отсутствии экспертизы. Замена более мощного языка и рантайма на менее мощный — зачем?


          Да ладно) Ручная сборка мусора вместо gc — это переход к низкоуровневому программированию

          Если вам очень надо, в С++ давно есть GC. То, как вы управляете памятью, не является единственным критерием "низкоуровневого" программия. Вам никто не мешает написать код на С++ нормально и тогда там не надо будет думать про управление памятью, всё почистится автоматически (RAII).


          1. pawlo16
            03.10.2019 09:57
            -2

            Люди, которые говорят, что «микросервисы позволяют писать каждый микросервис на чём угодно» серьёзно, никогда этого не делали в серьёзном продакшене.
            Значит гугл, фейсбук, яндекс и практически весь крупный бизнес делают несерьёзный продакшен. Либо у вас какие-то свои, ни кем не признанные понятия о «серьёзном продакшене». Либо вы что-то делаете не так в разработке микросервисов на разных языках. Или может быть вы живете в танке и не использовали grpc?
            Замена более мощного языка и рантайма на менее мощный — зачем?
            Если только мощь яп измеряется количеством багов и костылей многопоточности, о которых поведал автор доклада, а так же сложностью развёртывания и количеством системных зависимостей. В этом go не конкурент C#

            На вопрос «зачем» в контексте обсуждения доклада я выше ответил — упростить конкурентный код, сделать его менее хрупким и более масштабируемым. Но в целом причин для перехода с C# на Go много. Таких историй десятки если не сотни
            Если вам очень надо, в С++ давно есть GC.
            , который тащит за собой воз багов да тележку костылей, и в практических задачах не применим, как и все подобные «сборщики мусора». Ибо C++ is a language strongly optimized for liars and people who go by guesswork and ignorance
            Вам никто не мешает написать код на С++ нормально и тогда там не надо будет думать про управление памятью, всё почистится автоматически (RAII).
            C++ is a horrible language. It’s made more horrible by the fact that a lot of substandard programmers use it, to the point where it’s much much easier to generate total and utter crap with it. (С)


            1. aensidhe
              03.10.2019 10:33
              +2

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

              Ну, все компании в мире должны работать как гугл, фейсбук и яндекс. Все компании в мире имеют точно такой же штат программистов. В какой из них, кстати, работаете вы? Если ни в какой, то продолжайте рассказывать.


              Либо у вас какие-то свои, ни кем не признанные понятия о «серьёзном продакшене». Либо вы что-то делаете не так в разработке микросервисов на разных языках. Или может быть вы живете в танке и не использовали grpc?

              О, очередной silverbullet. gRPC. И это у нас тоже есть. И многое другое.


              Если только мощь яп измеряется количеством багов и костылей многопоточности, о которых поведал автор доклада, а так же сложностью развёртывания и количеством системных зависимостей. В этом go не конкурент C#

              Нет, мощь ЯП измеряется наличием инструментов. Например, generics. Когда там Go2 с ненужными generics выходит? О каких системных зависимостях в C# вы говорите, я не знаю. Полностью кросс-платформенное, системонезависимое решение.


              Кстати, если вы считаете, что я поддерживаю автора статьи с его костылями и остальным — покажите мне цитату, где я это делаю. Не можете? Не приписывайте мне мыслей из своей головы.


              На вопрос «зачем» в контексте обсуждения доклада я выше ответил — упростить конкурентный код, сделать его менее хрупким и более масштабируемым. Но в целом причин для перехода с C# на Go много. Таких историй десятки если не сотни

              Конкурентный код в C#, когда пишется с нуля и нормально, не сильно сложнее Go. Во многом даже проще. У меня как бы опыта на обоих языках достаточно.


              который тащит за собой воз багов да тележку костылей

              Это другой вопрос. Мне в С++ всегда норм и без него было, это вам зачем-то обязателен GC. К слову о. Как там в Go с плагинными GC? Есть? Можно ли свой написать?


              C++ is a horrible language. It’s made more horrible by the fact that a lot of substandard programmers use it, to the point where it’s much much easier to generate total and utter crap with it. (С)

              Ad hominem. Perfect discussion.


              Вы — классический пример того, что когда кончаются аргументы, мы начинаем переходить на личности. Про substandard programmers я хочу напомнить, что именно для них создан Го. Именно поэтому в нём ничего нет. Потому что substandard programmer не может понять генерики. Ссылку найдёте сами.


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


              1. pawlo16
                03.10.2019 12:35
                -2

                Ну, все компании в мире должны работать как гугл, фейсбук и яндекс.
                Так уже давно работают. Монолитные системы в прошлом, для микросервисных никакой разницы нет на каком яп написан микросервис (работающий согласно спеке) ни у кого кроме вас.
                Нет, мощь ЯП измеряется наличием инструментов. Например, generics.
                В том виде, в котором они существуют в C#, дженерики карго культ, т.к. усложняют понимание кода.
                Когда там Go2 с ненужными generics выходит?
                Не факт, что там они будут. А если и будут, то точно не в таком виде, как в в dotnet. Первоначальный драфт отклонён ввиду его полной неадекватности
                если вы считаете, что я поддерживаю автора статьи с его костылями и остальным — покажите мне цитату, где я это делаю.
                Автор детально описал проблемы, с которыми сталкивается типичный легаси проект на C#, в этом смысле ему респект и поддержка. Чтобы опровергнуть тезисы автора, коротких реплик с возражениями не достаточно. Опишите подробно ваши кейсы, покажите результаты бенчмарков.
                Конкурентный код в C#, когда пишется с нуля и нормально, не сильно сложнее Go. Во многом даже проще. У меня как бы опыта на обоих языках достаточно.
                У меня тоже достаточно, и я пришёл к противоположным выводам. И не только я — см. ссылку в предыдущем моём комментарии.
                Как там в Go с плагинными GC? Есть? Можно ли свой написать?
                Можно, но не нужно. Штатный покрывает все реальные кейсы.
                substandard programmer не может понять генерики.
                или думает, что понимает, хотя на самом деле видит лишь вершину айсберга из своего игрушечного маня-мирка
                Вы — классический пример того, что когда кончаются аргументы, мы начинаем переходить на личности
                А я и не переходил, всего лишь процитировал мнение Торвальдса в тему C++ и raii. Принимать его на свой счёт оснований нет, если только не испытывать эмоциональную привязанность к с++. Аргументов почему это так — достаточно более чем, нет смысла тратить время на них кмк
                Про substandard programmers я хочу напомнить, что именно для них создан Го
                На официальном сайте языка не сказано ничего про то, что он создан для substandard programmers. Если вы полагаете, что вам лучше знать для кого создан Go, чем его авторам, так это у вас 100% эффект Даннига-Крюгера.
                вы спорите не с тезисами целиком, а только с интересующими вас кусками тезисов
                Я стараюсь не слишком отклоняться от предмета — костылей и багов с многопоточностью в C# и как с ними проще бороться (перейти на Го). Если вы про то, что raii в C++ позволяет «не думать про управление памятью», так это заблуждение, о чём я вам и ответил цитатой «C++ is a language strongly optimized for liars». что не так то?


                1. Kanut
                  03.10.2019 12:51
                  +2

                  В том виде, в котором они существуют в C#, дженерики карго культ, т.к. усложняют понимание кода.

                  Слишком категоричное на мой взгляд заявление. Пользуемся генериками вовсю и не сказал бы что они усложняют понимание кода. В любом случае это всё очень субъективно и скорее вопрос привычки.


                  И в чём там должен заключаться карго-культ я если честно вообще не понимаю.


                  Монолитные системы в прошлом, для микросервисных никакой разницы нет на каком яп написан микросервис (работающий согласно спеке) ни у кого кроме вас.

                  Проблема часто в том, что если у вас каждый программист(или даже тим) пишет свои микросервисы на своём языке, то если кто-то заболеет/уволится, то очень сложно найти замену. Особенно если она нужна быстро. Поэтому на мой взгляд средней или просто не особо большой фирме логичнее иметь один язык под каждый тип задач(в идеале конечно вообще один на всё, но это практически нереально на данный момент). Второй имеет смысл в качестве "пилотного" если основной не совсем устраивает и ищется альтернатива.


                  Другое дело когда у тебя ИТ-концерн с тысячами программистов. Там уже совсем другие правила игры.


                  1. pawlo16
                    03.10.2019 13:53
                    -3

                    И в чём там должен заключаться карго-культ я если честно вообще не понимаю
                    В том, что ни кто не может привести пример из настоящих программ, где без них никак. На практике прекрасно заменяются кодогенерацией и реализацией под конкретный тип в 10 строчек, без оверхэда и роста mental cost. Например, почти все контейнеры из стандартной библиотеки C# успешно (а за частую и более эффективно), заменяются гошными встроенными дженерик-контейнерами — слайсом и хэшмапой. За исключением heap и sort, которые необходимы лишь в небольшом проценте оптимизированных по скорости программ, где стандартные heap и sort на интерфейсах не подходят из-за накладных расходов на вызов интерфейсных функций. Как пример. Ещё можно здесь посмотреть про проблемы в типичной дженерик реализации хэшмапы и как она решена в Го
                    Проблема часто в том, что если у вас каждый программист(или даже тим) пишет свои микросервисы на своём языке, то если кто-то заболеет/уволится, то очень сложно найти замену
                    Затраты на решение этой проблемы (в случае перехода с C# на Go ) окупятся за счёт упрощения и унификации кодовой базы и инфраструктуры одновременно — сервисы, написанные на Go, обходятся в 10 раз дешевле аналогичных сервисов на Java-подобных языках на AWS Lambda. Выводы по ссылке подтвтерждаю — для саппорта проекта на Го нужно тупо меньше человекочасов, потому и зп у гоферов в среднем выше, чем в C#


                    1. Kanut
                      03.10.2019 14:09

                      В том, что ни кто не может привести пример из настоящих программ, где без них никак.

                      По вашему любая функциональность, без которой можно теоретически обойтись, это автоматом "карго-культ"? Интересный подход.
                      Но если исходить из такой логики, то большая часть функциональности в ЯП это "карго-культ" и все должны на ассемблере программировать :)


                      Конечно без генериков можно обойтись. Но они заметно облегчают работу в определённых ситуациях. Другое дело что возможно ваша ситуация их не требует. Но это опять же субъективно.


                      Затраты на решение этой проблемы (в случае перехода с C# на Go ) окупятся за счёт упрощения и унификации кодовой базы и инфраструктуры одновременно

                      Извините, но это уже совсем о другом. Я нигде не утверждал что надо всем и всегда работать на C#, а не на Go или ещё каком-то другом языке.
                      И если вам больше нравится/подходит Go, то ЛММ с вами, пишите на нём. Или на джаве. Или на С++. Или ещё на чём-то другом.


                      Я всего лишь говорю что для средней фирмы не особо логично писать микросервисы на C#, Go, Java, C++ и каких-то других языках одновременно.


                      П.С. А зарплаты у гоферов выше чем в С# на мой взгляд немного по другой причине. И скорее это потому что среди пишущих на Go больше процент сениоров и меньше джуниоров. Так что возможно что когда язык станет распространённым, то и зарплаты выравняются.


                      1. pawlo16
                        03.10.2019 15:55

                        По вашему любая функциональность, без которой можно теоретически обойтись, это автоматом «карго-культ»?
                        Вы проигнорировали аргументы про оверхэд и mental cost. Если не смотря на них сторонники технологии требуют её внедрения, при этом не могут внятно продемонстрировать её профит на релевантных кейсах, то вот это и есть карго культ, да.
                        Извините, но это уже совсем о другом
                        Как же оно о другом, если у вас было об удорожании разработки в следствии отсутствия должной экспертизы по Go, а у меня об удешевлении в следствии некоторых особенностей Go (которые его выгодно отличают от других языков)?
                        Я всего лишь говорю что для средней фирмы не особо логично писать микросервисы на C#, Go, Java, C++ и каких-то других языках одновременно.
                        С этим я согласен. Как и с тем, что в каждом конкретном бизнесе свой расклад и переход не всегда оправдан. Но и забивать на бонусы от удешевления разработки (как следствие перехода на Go) тоже не всегда разумно. Особенно в кейсах автора статьи, где бонусы более чем очевидны. Асинхронный код на потоках ос — скверная штука. Постепенный переход с переобучением — один из вариантов решения проблемы.


                        1. Kanut
                          03.10.2019 16:10

                          Вы проигнорировали аргументы про оверхэд и mental cost.

                          Да нет, не проигнорировал. Но они точно так же применимы к куче другой функциональности в куче других ЯП. И я уверен что и в Go такое найдётся. Вопрос то в том где вы именно карго-культ увидели? Или у вас какое-то свое определение данного понятия?


                          Как же оно о другом, если у вас было об удорожании разработки в следствии отсутствия должной экспертизы по Go, а у меня об удешевлении в следствии некоторых особенностей Go (которые его выгодно отличают от других языков)?

                          Вы меня наверное с кем-то спутали. Хотя я пожалуй тоже не согласен что Go это по умолчанию удешевление и упрощение разработки. Если у вас только одни микросервисы, то ещё может быть. Но мало где нужны только микросервисы и больше ничего другого. Да и микросервисы далеко не везде нужны.


                    1. mayorovp
                      03.10.2019 14:18
                      +2

                      Это хорошо, что в Go есть встроенные слайсы и хешмапа (а ещё каналы). Осталось добавить встроенные же аналоги Observable, деревьев и ещё кучи не так повсеместно, но всё же часто используемых примитивов.


                      А потом грустно посмотреть на выросшую спеку языка и сказать: "лучше бы мы дженерики добавили — было бы понятнее".


                    1. aensidhe
                      03.10.2019 15:55

                      На практике прекрасно заменяются кодогенерацией и реализацией под конкретный тип в 10 строчек, без оверхэда и роста mental cost.

                      Т.е. копипаста — это нормально, я понял. Это всё, что надо знать про язык го.


                      1. Kanut
                        03.10.2019 16:03

                        В микросервисах в принципе копипаста достаточно частое явление. Особенности архитектуры так сказать.
                        Да и вообще микросервисы достаточно специальная "вещь" и именно для них Go наверное действительно лучше подходит чем C#.


                        Вот только и область применения у микросервисов тоже относительно ограниченая. И в мире существуют не только микросервисы и монолиты :)


                        1. aensidhe
                          03.10.2019 16:54

                          В микросервисах в принципе копипаста достаточно частое явление. Особенности архитектуры так сказать

                          Не соглашусь. Возможно, в мире какого-то языка программирования, в котором нет механизма управления зависимостями и пакетного менеджера, это так.


                          Да и вообще микросервисы достаточно специальная "вещь" и именно для них Go наверное действительно лучше подходит чем C#.

                          Я не вижу проблемы написать микросервис на C#. Более того, мы это успешно делали и запускали их в продакшен. Отлично работает. Кода столько же, если не меньше.


                          1. Kanut
                            03.10.2019 17:06

                            Не соглашусь. Возможно, в мире какого-то языка программирования, в котором нет механизма управления зависимостями и пакетного менеджера, это так.

                            Мы как бы тоже решили пойти по этому пути. Но всё пихать в пакеты тоже имеет свою проблематику. Да и не всё и не всегда в пакеты можно запихать.


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


                            Я не вижу проблемы написать микросервис на C#. Более того, мы это успешно делали и запускали их в продакшен. Отлично работает. Кода столько же, если не меньше.

                            Проблемы я тоже не вижу. А вот что там действительно оптимальнее это совсем другой вопрос. Я в Go совсем не эксперт, так только для себя пытался баловаться. Но вряд ли бы он смог "взлететь" если бы он был хуже С#.


                            1. aensidhe
                              03.10.2019 17:11

                              Го не хуже, го — другой. Кому-то его хватает, удачи им. Мне его мало, мне мало гошного рантайма, поэтому я им не пользуюсь.


                              Го очень прост, любой мидл его учит за 4 дня, поэтому он и взлетел. И в этом же его ограничение, это язык для вечных мидлов с какой-то стороны.


                              Безусловно, есть сильные программисты на го, но они обычно приводят другие, вменяемые аргументы в пользу го.


                              Ну и многие из них (кого я знаю) хотят генериков.


                1. mayorovp
                  03.10.2019 13:39

                  Не факт, что там они будут. [...] Первоначальный драфт отклонён ввиду его полной неадекватности

                  Ну так это ж недостаток языка, а не его достоинство.


                1. aensidhe
                  03.10.2019 16:00

                  Я стараюсь не слишком отклоняться от предмета — костылей и багов с многопоточностью в C# и как с ними проще бороться (перейти на Го). Если вы про то, что raii в C++ позволяет «не думать про управление памятью», так это заблуждение, о чём я вам и ответил цитатой «C++ is a language strongly optimized for liars». что не так то?

                  Я про то, что вы взяли мой тезис:


                  Нет никакого смысла переходить с .net на Go при отсутствии экспертизы. Замена более мощного языка и рантайма на менее мощный — зачем?

                  Изменили его на


                  Замена более мощного языка и рантайма на менее мощный — зачем?

                  И начали с ним спорить. Т.е. вы взяли, придумали тезис, приписали его мне и побежали спорить с этим. Это классический полемический приём в случае отсутствия аргументов.


                  Автор детально описал проблемы, с которыми сталкивается типичный легаси проект на C#, в этом смысле ему респект и поддержка. Чтобы опровергнуть тезисы автора, коротких реплик с возражениями не достаточно. Опишите подробно ваши кейсы, покажите результаты бенчмарков.

                  "Типичный" — сколько проектов в вашей выборке?


      1. pawlo16
        27.09.2019 11:43
        -2

        ну и кстати в Го нет такой штуки, как lock convoy. Можно совершенно спокойно писать в канал из миллиона горутин, ни какой утечки процессора при этом не будет. Точно так же можно безопасно одновременно захватывать мьютекс из миллиона горутин (правда, освобождение чуть более дороге)


        1. andreyverbin
          28.09.2019 04:46
          +6

          ну и кстати в Го нет такой штуки, как lock convoy.

          Что-то я очень сильно в этом сомневаюсь. Внутри у go все те же примитивы синхронизации, чудес не бывает.


  1. andreyverbin
    27.09.2019 00:12

    Отличные вы походили по граблям :)

    История 1: Task.Delay & TimerQueue

    Получается вы стартовали один таймер на каждый запрос? Иначе непонятно как получить слишком много таймеров. Если так, то можно легко обойтись одним concurrent queue или stack (если вам нужно lifo) и одним таймером, срабатывающим раз в секунду.

    История 2: SemaphoreSlim

    Я бы предложил иметь атомарный счетчик, каждый запрос вначале его увеличивает, затем уменьшает. Если счетчик больше N, то давать 504. Это просто и эффективно.
    Положим задача сложнее — мы хотим чтобы а) если запросов меньше N, то он бы выполнялся б) если запросов больше N, то он бы помещался в очередь, где ждал либо таймаута, либо пока один из запросов не выполнится.
    Если нужно такое, то я бы переключился на синхронные методы в контроллерах, тогда они будут выполняться в тредпуле. А если их слишком много, то сервер поставит поступающие запросы на ожидание. Если я правильно понял, то это ровно то, что нужно.
    Если же по каким-то причиним синхронные методы не подходят, то пишем что-то такое, в каждом методе контроллера
    public async Task<MyResponse> MyMethod([FromBody] MyRequest request)
    {
        return Task.Factory.StartNew(MyMethodLogic, 
        CancellationToken.None, TaskCreationOptions.DenyChildAttach, MyThrottledTaskScheduler);
    }
    


    MyThrottledTaskScheduler — это реализация TaskScheduler с тротлингом запросов. Если вдруг с шедулером не взлетает, то делается свой SynchronisationContext. С ним точно все будет ок.

    Внутри этого пула есть вторая часть для объектов, которые ещё находятся в нулевом и первом поколении, и почему-то для них вместо lock-free структуры используется обычный list с lock'ом.

    Думаю потому, что взятие лока, при условии, что других потоков нет, это примерно как поделить long на long по затратам времени. То есть это очень и очень быстро при условии отсутствия большой конкуренции за лок. Я бы сильно подумал откуда вообще такая конкуренция за буферы, почему так много потоков, которые пытаются делать IO?

    4.3 Индекс

    Почему не immutable dictionary, кажется оно должно в этой ситуации работать на ура?

    Нужно хранить in-memory индекс <Guid,Guid>

    А это свойство позволяет легко вытащить индекс в нативную память. Индекс большой и если там нет «горячих» элементов, то есть чтения и записи разнесены в пространстве, то можно легко его разбить на сегменты и закрыть простым локом каждый сегмент.


    1. epeshk Автор
      27.09.2019 01:03
      +2

      Получается вы стартовали один таймер на каждый запрос?

      Да. С таймером много решений можно придумать, но если проблема воспроизвелась в уже готовом приложении, в котором, например активно используется Task.Delay — проще сконфигурировать рантайм, прежде чем делать что-либо ещё.


      я бы переключился на синхронные методы в контроллерах, тогда они будут выполняться в тредпуле. А если их слишком много, то сервер поставит поступающие запросы на ожидание

      Если я правильно понял — предлагается использовать ограничение на количество потоков в тредпуле для реализации троттлинга. Это сработает, но приведёт к тому, что любой код, который хочет попасть на тредпул — будет ждать, пока в тредпуле не освободится или не создастся новый поток. И эта очередь будет обрабатываться в FIFO порядке, что снижает вероятность того, что клиенты не накидают в неё повторные запросы и она успешно разберётся.


      MyThrottledTaskScheduler — это реализация TaskScheduler с тротлингом запросов. Если вдруг с шедулером не взлетает, то делается свой SynchronizationContext. С ним точно все будет ок.

      Подозреваю, в MyThrottledTaskScheduler или MyThrottledSynchronizationContext придётся написать примерно такой же код, как у нас, магии же не бывает :)


      Я бы сильно подумал откуда вообще такая конкуренция за буферы, почему так много потоков, которые пытаются делать IO?

      Здесь проблемным приложением был сервер распределённой кастомной файловой системы, который открывает сразу множество файлов и раздаёт клиентам их фрагменты (здесь возникнет вопрос о целесообразности использования .NET в нём, но так уж получилось). В других приложениях такого не наблюдали.
      К тому же интенсивное IO не было проблемой само по себе, на момент написания приложения проблемы не существовало — она возникла только после обновления рантайма .NET на серверах. .NET Framework позволяет иметь лишь одну версию рантайма (4.х) одновременно — 4.5.2 полностью заменяет 4.5.1, например. .NET Core, во избежание подобных проблем позволяет держать несколько версий рантайма (shared framework) на одной машине side-by-side, либо деплоить рантайм вместе с приложением.


      Почему не immutable dictionary, кажется оно должно в этой ситуации работать на ура?

      Immutable коллекции, такие как ImmutableList и ImmutableDictionary основаны на деревьях, объекты в которых переиспользуются их различными версиями. Но т.к. это деревья объектов — они дают большой оверхэд по памяти и нагрузку на GC, как и ConcurrentDictionary.


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

      Такое решение тоже должно сработать, придётся только подумать о том, как контролировать размеры сегментов и переаллоцировать их при необходимости. Другое дело, что само разбиение на сегменты уменьшит их размеры, что может сделать нецелесообразным их перенос в нативную память (не будут попадать в LOH), а с локом можно по-разному экспериментировать — тот же SpinLock использовать.
      В другом проекте инфраструктуры переносили часть объектов, создающих большой memory traffic в нативную память, там дошло до использования TCMalloc, возможно, когда-нибудь и об этом опыте кто-нибудь расскажет.


  1. max_mustermann
    27.09.2019 13:17
    -2

    есть клиенты, которые запускают эти задачи на обработку и хотят дожидаться их результата асинхронно. Как такое ожидание можно реализовать?


    Ну первое, что приходит в голову — на сокетах, конечно.


  1. icoder
    28.09.2019 11:40
    +1

    По поводу семафора и LIFO, вам же не нужен был математически строгий LIFO, достаточно было сделать так, чтобы семафор не захватывал тот, кому уже не актуально. А значит можно было просто передавать разумный тайм-аут в SemaforeSlim.WaitAsync