company_banner

Многопоточность в .NET: когда не хватает производительности



    Платформа .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, и объясню, какие можно использовать подходы к решению этих проблем.
    JUG Ru Group
    507,41
    Конференции для программистов и сочувствующих. 18+
    Поделиться публикацией

    Комментарии 87

    • НЛО прилетело и опубликовало эту надпись здесь
        +1

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

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

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

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

            Не понятно, зачем вообще dotnet с его граблями и костылями для конкурентности, если есть голанг, в котором примитивы синхронизации надёжны, как швейцарские часы, и просты. Каков смысл писать сложный и заумный код вместо простого, решающего те же задачи?
              +7
              Проблема с реализаций Task.Delay здесь опосредована от протокола. Конкретно для этой задачи мы пробовали HTTP/2 и столкнулись с таким же lock convoy'ем и некоторыми другими проблемами.

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

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

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

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

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

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

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


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

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


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

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

                    –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. (С)
                      +3
                      Значит гугл, фейсбук, яндекс и практически весь крупный бизнес делают несерьёзный продакшен.

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


                      Либо у вас какие-то свои, ни кем не признанные понятия о «серьёзном продакшене». Либо вы что-то делаете не так в разработке микросервисов на разных языках. Или может быть вы живете в танке и не использовали 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 не может понять генерики. Ссылку найдёте сами.


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

                        –3
                        Ну, все компании в мире должны работать как гугл, фейсбук и яндекс.
                        Так уже давно работают. Монолитные системы в прошлом, для микросервисных никакой разницы нет на каком яп написан микросервис (работающий согласно спеке) ни у кого кроме вас.
                        Нет, мощь ЯП измеряется наличием инструментов. Например, 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». что не так то?
                          +2
                          В том виде, в котором они существуют в C#, дженерики карго культ, т.к. усложняют понимание кода.

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


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


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

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


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

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

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


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


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

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


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


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

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

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


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

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

                                    0
                                    Так ведь я выше пояснил своё определение карго культа применительно к программированию — технология ради технологии без внятного технического обоснования. И нет, в Go я такого не припомню.
                                    Вы меня наверное с кем-то спутали.
                                    А как же «если кто-то заболеет/уволится, то очень сложно найти замену»? «Сложно» == «дороже», т.е. удорожание. «если кто-то заболеет/уволится» — недостаток экспертизы.
                                    Если у вас только одни микросервисы, то ещё может быть.
                                    Go категорически не подходит для десктопа, фронтенда, embeded, системного программирования, клиентского геймдева, а так же для ML и вычислений с персистентными неизменяемыми структурами данных. Для всего остального — велкам
                                      0
                                      Так ведь я выше пояснил своё определение карго культа применительно к программированию — технология ради технологии без внятного технического обоснования. И нет, в Go я такого не припомню.

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


                                      А как же «если кто-то заболеет/уволится, то очень сложно найти замену»? «Сложно» == «дороже»

                                      Сложнее это сложнее. Оно может коррелировать с дороже, а может и не коррелировать. Но это разные вещи.


                                      Для всего остального — велкам

                                      Например для обычных сервисов он тоже далеко не всегда оптимальнее альтернатив.

                                        –1
                                        По вашему определению большая часть рантаймов, фреймворков и языков програмирования это тогда карго культ
                                        У большинства технологий технологий есть коммерческое обоснование, с которым бесполезно спорить. У «сделайте дженерики в Go (в таком виде как в C#)» нет ни коммерческого ни технического. Предполагаемые удобства людей, привыкших к дженерикам в C#, породили бы массовые неудобства код ревьюеров (усложнение кода), программистов (дольше компиляция) и пользователей (оверхэд). При том что внятного объяснения, какие именно вещи в Go, кроме sort и b tree, надо параметризировать типами, предоставлено не было
                                          +1

                                          Вы почему-то определили Go как "золотую середину" и что-то по умолчанию технически обоснованное. Потом понапридумывали каких-то "массовых неудобств", которые если вообще и существуют, то являются достаточно субъективными.


                                          И потом исходя из этого делаете какие-то глобальные и кактегоричные выводы о коммерческой и технической обоснованности. При этом никаких цифр и расчётов вы не приводите.


                                          П. С. И вообще создаётся такое впечатление что только вы обладаете каким-то тайным знанием, а все остальные недостойны и вообще ни в чём не разбираются и должны вот прямо сейчас последовать вашим советам чтобы себя спасти. Ничего не напоминает? :)

                                            0
                                            При этом никаких цифр и расчётов вы не приводите.
                                            Привожу, так память у вас слишком короткая. Ссылку давал — сервисы, написанные на Go, обходятся в 10 раз дешевле аналогичных сервисов на Java-подобных языках на AWS Lambda. А что вам цифры-то? У вас исключительно к цифрам сводится любое техническое обоснование? Тогда можно рандомно выбрать любую технологию
                                            создаётся такое впечатление что только вы обладаете каким-то тайным знанием
                                            Опять таки из-за короткой памяти не желания заглянуть в гугл. Так же этим знанием обладают свитчеры с разных языков программирования на Go и ещё овер 9000 компаний. Такой секрет полишенеля. Go золотая серидина потому, что он против бесполезных абстракций и монадирующих программистов. Подробности тут.
                                              +1
                                              Привожу, так память у вас слишком короткая. Ссылку давал — сервисы, написанные на Go, обходятся в 10 раз дешевле аналогичных сервисов на Java-подобных языках на AWS Lambda.

                                              И это всё что у вас есть? Сравнение с java на AWS? Вам самим то не смешно? А давайте сравним его с C++ в embedded и сделаем вывод что Go никуда не годен :)


                                              У вас исключительно к цифрам сводится любое техническое обоснование?

                                              Вы там что-то ещё про комерческое обоснование говорили? Или нет?
                                              Вот давайте простой пример: у нас где-то 100-150 программистов пишуших на С#. Из них может быть десяток-два немного умеют в Go. За последние 15 лет они написали кучу кода. У нас десктоп, мобайл, веб и сервисы. Всё на C#. В клауд нам нельзя и всё на своих серверах.
                                              Сколько нам по вашему примерно будет стоить перейти на Go и когда это окупится для фирмы?


                                              У вас исключительно к цифрам сводится любое техническое обоснование?

                                              А как вы хотите? Чтобы в вашу "коммерческую и техническую обоснованность Go" вам на слово верили? :)


                                              Так же этим знанием обладают свитчеры с разных языков программирования на Go и ещё овер 9000 компаний.

                                              А сколько компаний пишут на Java? Сколько на C#? Что, ти 9000 компаний должны доказывать?


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

                                              Правда? А как вы определяете какая абстракция полезная, а какая нет? Вот давайте возьмём банальное наследование. Разве оно не попадает под ваше определение "бесполезности" :


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

                                              Так давайте тогда наследование тоже уберём. И кучу других вещей вместе с ним. И будем на ассемблере программировать. В нём ничего бесполезного нет.

                                                0
                                                Сравнение с java на AWS?
                                                aws по той причине, что для него проще получить статистику. Никакой принципиальной разницы с обычными серверами в данном случае не вижу в упор.

                                                java, C# — какая разница-то? Оба языка похожи. Там ещё котлин есть, с чего бы в C# разработка была дешевле чем на колине? из-за того, что у вас на фирме много десктопных C# программистов?
                                                А как вы хотите? Чтобы в вашу «коммерческую и техническую обоснованность Go» вам на слово верили? :)
                                                Про «коммерческую обоснованность Go» у меня не было, только про техническую. С помощью цифр можно обосновать тактические решения, но не стратегические. Ни кто не выбирает C# из других яп с помощью калькулятора и эксель (или вы выбирали?).
                                                Вот давайте простой пример: у нас где-то 100-150… Сколько нам по вашему примерно будет стоить перейти на Go и когда это окупится для фирмы?
                                                А вы точно уверены, что я предлагал перевести «десктоп, мобайл, веб» на go? Что до сервисов, так тут всё просто. У нас была команда из 52 человек на C# (фин.организация, биллинг). Через 2 года стала 16 человек на Go, из них 5 со знанием C#. Эти параметры плюс минус подтверждают коллеги с аналогичным опытом перехода с C# на Go. Дальше можете взять калькулятор и посчитать ваши риски с параметрами вашего бизнеса.
                                                А как вы определяете какая абстракция полезная, а какая нет?
                                                Опыт.
                                                Вот давайте возьмём банальное наследование. Разве оно не попадает под ваше определение «бесполезности»
                                                Попадает.
                                                Так давайте тогда наследование тоже уберём.
                                                Убрали. В Go нет наследования.
                                                И будем на ассемблере программировать. В нём ничего бесполезного нет.
                                                доведения тезиса до абсурда — так себе контраргумент.
                                                  +1
                                                  aws по той причине, что для него проще получить статистику. Никакой принципиальной разницы с обычными серверами в данном случае не вижу в упор.

                                                  Ну так может быть стоит сначала посмотреть повнимательнее, а потом уже заявления делать? :)


                                                  java, C# — какая разница-то?

                                                  Действительно С#, Java, Go, Kotlin — какая разница то? :)


                                                  Про «коммерческую обоснованность Go» у меня не было

                                                  А вот это что было :


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

                                                  У большинства технологий технологий есть коммерческое обоснование, с которым бесполезно спорить. У «сделайте дженерики в Go (в таком виде как в C#)» нет ни коммерческого ни технического.

                                                  А вы точно уверены, что я предлагал перевести «десктоп, мобайл, веб» на go?

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


                                                  У нас была команда из 52 человек на C# (фин.организация, биллинг). Через 2 года стала 16 человек на Go, из них 5 со знанием C#. Эти параметры плюс минус подтверждают коллеги с аналогичным опытом перехода с C# на Go.

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


                                                  Кроме того откуда мне знать, может у вас на фирме просто работы меньше стало и вам бы теперь и 16 сишарпников хватило бы за глаза и за уши :)


                                                  Опыт

                                                  У вас один опыт, у кого-то другой. Почему вы считаете что надо слушать именно вас? Есть какие-то объективные критерии?


                                                  Убрали. В Go нет наследования.

                                                  И по вашему композиция это проще, понятнее и создаёт меньше оверхеда?


                                                  И кстати ООП целиком тогда тоже убираем? Инкапсуляцию? Полиморфизм? Интерфейсы? Классы? Где вы предлагаете остановиться и почему именно там?


                                                  доведения тезиса до абсурда — так себе контраргумент.

                                                  Этим я показываю абсурдность вашего аргумента в том виде как вы его презентируете.

                                +3

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


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

                                  –3
                                  Observable не нужен. Ни в одном успешном проекте на Go не встречал (разве что у новичков, пришедших из .net, в наивных попытках перетащить привычные костыли из C#). В сообществе «реактивное программирование» в Go осуждается, а pub-sub считается антипаттерном

                                  Деревья тоже не нужны за редким исключением. Нет смысла пихать маргинальные структуры данных в стандартную библиотеку, third-party достаточно
                                    +1
                                    Ни в одном успешном проекте на Go не встречал

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


                                    third-party достаточно

                                    Да-да:


                                    Values() []interface{}

                                    Очень, наверное, удобно таким third-party деревом пользоваться.

                                      –3
                                      Все, кому нужны Observable
                                      Вы имеете ввиду — все ментальные маструбаторы вприсядку и любители бесполезных абстракций из допотопно-десктопного программирования? И вы конечно можете показать многопоточный Observable в серьёзном проекте, не так ли?
                                      Очень, наверное, удобно таким third-party деревом пользоваться.
                                      Учитывая насколько редко нужны rb tree, совершенно наплевать на удобства. Ну так и типизированные rb tree тоже есть github.com/ncw/gotemplate
                                  +1
                                  На практике прекрасно заменяются кодогенерацией и реализацией под конкретный тип в 10 строчек, без оверхэда и роста mental cost.

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

                                    0

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


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

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

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


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

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

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

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


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


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

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

                                          0

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


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


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


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

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

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

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

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


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

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


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

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


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

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

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

                            Что-то я очень сильно в этом сомневаюсь. Внутри у go все те же примитивы синхронизации, чудес не бывает.
                              –3
                              В прмитивы те же лишь по функционалу, а работают абсолютно по другому. «Потоки» другие, и асинхроный под капотом рантайм по другому распределяет между ними «кванты производительности». Когда много горутин («зелёных потоков») пытаются получить доступ к защищённому блокировкой ресурсу, ни какого переключения контекста (с переходом CPU в режим ядра) не происходит. Соответственно не происходит и ухудшения производительности
                                0

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


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


                                В итоге всё фундаментальное отличие Go от .NET — в том, что в Go используются stackfull coroutines, а в .NET — stackless. Но на lock convoy это не влияет.

                                  0
                                  Суммарная сложность такого «переключения» настолько мала в сравнении с dotnet, что на практике не заметна. В Go — О[количество ядер], тогда как в dotnet — O[размер тредпулла]. Например, миллион горутин, одновременно пишущих в канал, никак не влияют на работу старого ноута с I5. Само «О» опять таки в Go значительно меньше, поскольку стек горутины не 1 мб, как у треда ос, а 16 кб.
                                    0

                                    Тредпул также не будет сильно расти, если не делать блокирующих вызовов (у меня на синтетическом тесте только что получилось 12 потоков на 8 лог. процессорах). А расходы памяти на Task в дотнете ещё меньше чем на стек горутины в Go.

                                      0
                                      Тредпул также не будет сильно расти, если не делать блокирующих вызовов
                                      Так и в Go можно с таким же успехом использовать lock free алторитмы. Мы же обсуждаем именно очередь на блокировку. Которая в C# — огромная проблема, к тому же возникает внезапно и в самых неожиданных местах (вследствие общей забагованности dotnet), о чём автор поведал. А в Go — не проблема.
                                      А расходы памяти на Task в дотнете ещё меньше чем на стек горутины в Go.
                                      Абсолютно наплевать. Какое отношение может иметь оверхэд асинхронной операции к стеку треда ос и цене context switches?
                                        0
                                        Так и в Go можно с таким же успехом использовать lock free алторитмы.

                                        Можно. Но исходно-то утверждалось, что в Go достаточно использовать стандартные примитивы. А они не lock free.


                                        Какое отношение может иметь оверхэд асинхронной операции к стеку треда ос и цене context switches?

                                        Самое прямое. Там, где в Go у вас выполняется N горотин, в C# будет NK тасков (где K — средняя глубина асинхронного стека). А нативных потоков много не будет ни там, ни там.

                                          0
                                          Вы не с тем спорите. В этой ветке речь о том, что в Go нет lock convoy, о которой рассказал автор. По простой причине — нет утечки системных потоков из пула.

                                          Я ни где не утверждал что в Go примитивы синхронизации lock free, или что они серебряная пуля, с чего вы это взяли? Я утверждал, что они надёжны и просты в сравнении с аналогами из C#. Вам пояснить, что такое примитивы синхронизации?
                                          Там, где в Go у вас выполняется N горотин, в C# будет NK тасков (где K — средняя глубина асинхронного стека). А нативных потоков много не будет ни там, ни там.
                                          Пойнт, который вы упустили — в C# при lock convoy нативных потоков таки будет много, вплоть до полного исчерпания асинхронного пула. О чём и шла речь у автора. И сравнивать таски C# с зелёными потоками Го абсолютно не корректно. Потому что горутины можно безопасно блокировать без риска утечки CPU, а таски — нет.
                                          0
                                          Мы же обсуждаем именно очередь на блокировку. Которая в C# — огромная проблема, к тому же возникает внезапно и в самых неожиданных местах (вследствие общей забагованности dotnet), о чём автор поведал. А в Go — не проблема.


                                          Автор про забагованность ничего не говорил. Автор рассказывал о том, как он наступали на грабли, связанные с кишками dotnet и специфическими проектными решениями.

                                          Давайте посмотрим, что будет с Go если добавлять таймер на каждый запрос. Открываем time.go и видим
                                          func addtimer(t *timer) {
                                          	tb := t.assignBucket()
                                          	lock(&tb.lock)
                                          	ok := tb.addtimerLocked(t)
                                          	unlock(&tb.lock)
                                          	if !ok {
                                          		badTimer()
                                          	}
                                          }
                                          

                                          Как думаете, что будет с Go если вызвать addtimer 10000 раз в секунду из разных goroutine?
                                            –1
                                            Я это называю граблями. И ничего из описанного автором в Go не встречал.
                                            Давайте посмотрим, что будет с Go если добавлять таймер на каждый запрос.
                                            Ничего не будет.
                                            Открываем time.go и видим
                                            Не могли бы вы перейти к сути без наводящих вопросов? Я не знаю, что видите вы. Я вижу набор загадочных не импортируемых имён. Разбираться в том, что такое в этом коде *timer, lock и unlock, пока что желания нет.
                                              0
                                              В dotnet при создании таймер берется лок, чтобы добавить таймер в очередь таймеров. Если таймеров создается много, то потоки, которые создают таймеры выстраиваются в очередь на этом локе и система начинаем тормозить. Это то, с чем столкнулся автор.

                                              В Go также при создании таймера берется лок, чтобы добавить его в очередь таймеров, что вы можете увидеть, если посмотрите на реализацию таймеров в Go, ссылку я указал выше. То есть и в dotnet и в go происходит одно и то же, и результат будет одним и тем же — система будет «тормозить», выстраиваться в очередь на локе.
                                                –2
                                                Если таймеров создается много, то потоки, которые создают таймеры выстраиваются в очередь на этом локе и система начинаем тормозить
                                                В C# начинает, в Go не начинает. Я три раза объяснил почему так и привёл пример кода. Казалось бы что ещё, ан нет
                                                В Go также при создании таймера берется лок, чтобы добавить его в очередь таймеров
                                                О ужас, блокировка! какой кошмар!
                                                Вообще то Go все операции блокирующие и все операторы синхронные за исключением select и go. И весь пользовательский код блокирующий, включая бизнес логику. И работает это всё мега предсказуемо и мега надёжно. Вы вообще понимаете, что блокирование вычислений в таске дорогое потому, что при этом лочится целый тред ос? А при блокировке горутины в Го тот тред ОС, в котором она выполнялась, переиспользуется рантаймом для выполнения других горутин? Или это настолько сложно для вас, что мы продолжим спор об очевидном?
                                                  0
                                                  Вы вообще понимаете, что блокирование вычислений в таске дорогое потому, что при этом лочится целый тред ос?

                                                  Конечно я это понимаю.

                                                  А при блокировке горутины в Го тот тред ОС, в котором она выполнялась, переиспользуется рантаймом для выполнения других горутин?

                                                  А вот это не верно или не всегда верно, пример я привел выше. Вот еще несколько выдержек из исходного кода рантайма Go, сначала timer.go
                                                  type timersBucket struct {
                                                  	lock         mutex  //обратим внимание, в timersBucket  есть что-то типа mutex
                                                  ...
                                                  }
                                                  ...
                                                  lock(&tb.lock) //tb это timersBucket, вызывается функция lock, в которую передается объект типа mutex.
                                                  if !tb.addtimerLocked(t) {
                                                      unlock(&tb.lock)
                                                      badTimer()
                                                  }
                                                  ...
                                                  


                                                  А теперь представим ситуацию, в go 4 потока (потому что 4 ядра) и тысячи goroutine, которые добавляют таймеры. Вопрос — сколько goroutine выполняется одновременно? Ответ — в худшем случае 1, остальные висят в lock(&tb.lock). Прежде чем вы начнете опровергать, посмотрите на runtime2.go и эту выдержку из него
                                                  // Mutual exclusion locks.  In the uncontended case,
                                                  // as fast as spin locks (just a few user-level instructions),
                                                  // but on the contention path they sleep in the kernel.
                                                  // A zeroed Mutex is unlocked (no need to initialize each lock).
                                                  type mutex struct {
                                                  	// Futex-based impl treats it as uint32 key,
                                                  	// while sema-based impl as M* waitm.
                                                  	// Used to be a union, but unions break precise GC.
                                                  	key uintptr
                                                  }
                                                  

                                                  Выделю отдельно вот этот кусок — but on the contention path they sleep in the kernel

                                                  Повторю свой тезис — чудес не бывает, рантайм go также как и рантайм dotnet, можно сломать если делать «странные» вещи и не понимать как они работают. Можно, например, устроить себе lock contention на мьютексах на создание таймеров.
                                                    0
                                                    пример я привел выше
                                                    Не привели. Вы показали фрагмент закрытого кода из стандартной библиотеки и привели свои выводы о его работе без объяснений. Пример — это работающий код на Go. Я вам привел пример, в котором, как вы хотели, каждую секунду (с.12) запускается 10000 (с.14) таймеров. Любой желающий может убедится, что никакого лок коновоя со 100% загрузкой CPU он не вызывает. Таймер запускается в с.17. с.18 гарантирует его завершение.

                                                    Это же касается и второго примера. Мютекс в Go реализован в совершенно другом пакете. Видите, там нет ничего про «but on the contention path they sleep in the kernel». И лочится он абсолютно по другому нежели чем блокировки в dotnet Покажите пользовательский сценарий с «lock contention на мьютексах на создание таймеров», до тех пор это не более чем догадки
                                                      0
                                                      Утверждение 1 — если много потоков конкурируют за один и тот-же примитив синхронизации и делают spin wait, то нагрузка на CPU возрастает и в пределе достигает 100%

                                                      Утверждение 2 — и dotnet и go используют spin wait в своей стандартной библиотеке, при неправильном использовании можно получить 100% использование CPU в силу утверждения 1.

                                                      С чем из вышеперечисленного вы не согласны?

                                                      Касательно вашего комментария
                                                      — Я привел пример открытого кода из рантайма go, это не «закрытый» код.
                                                      — Как этот код работает должно быть очевидно из исходников и комментариев в них
                                                      — Я не хотел никаких примеров, вы сделали синтетический тест, который добавляет 10 000 таймеров в секунду.
                                                      — Вы нашли Mutex, а в таймерах mutex. Если посмотреть как работает Mutex, то там видно, что он делает все те же spin lock, а затем блокируется на семафоре.
                                                      — Таймеры я привел как один из примеров, где go делает spin wait и блокирует поток выполнения. Таких мест в go достаточно, чтобы в «умелых» руках наступить на эти грабли.
                                                        0
                                                        Ну и пример с таймерами :) Ничего страшного не происходит, CPU в норме.
                                                          0

                                                          Непонято, чего вы спорите, как написано в статье, конкретно с таймерами проблема исправлена, хоть в 4.8 по умолчанию используется старый вариант, я не смог воспроизвести. Если что, мне это интересно чисто в академических целях, ну чтобы лучше понимать как вообще всё это работает.
                                                          Из описания проблемы понятно, что она существует только когда много потоков одновременно пытаются блокировать один ресурс. Но не написали какое количество потоков приводит к этой проблеме, интересен хотя бы порядок.
                                                          Тредпул .NET по умолчанию ограничен очень большим числом потоков (на моей тачке 32767), но в реале по умолчанию использует количество потоков равное количеству ядер. При этом если потоки блокируются, то похоже рантайм это отслеживает и увеличивает тредпул автоматически (и уменьшает кстати тоже). И тут вопрос — где описано это поведение? И второй вопрос — можно ведь сразу ограничить размер тредпула количеством ядер, и тогда, как мне кажется, можно избежать этой проблемы?


                                                          ThreadPool.GetMaxThreads(out int workerThreads, out int completionPortThreads);
                                                          ThreadPool.SetMaxThreads(Environment.ProcessorCount, completionPortThreads);

                                                          Эта идея с автоматическим увеличением тредпула наверное хороша для какого-то легаси. Но кажется для новых проектов, особенно на net core, было бы разумно сразу ограничивать размер тредпула каким-то разумным числом, не сильно большим чем число ядер.

                                                            0

                                                            Порядок потоков, чтобы система зависла на спинлоке в мониторе должен быть тысячи.


                                                            Спорю я с утверждением о том, что в go нельзя устроить contended lock, потому что go что-то делает не так, как dotnet и потому в go таких проблем быть не может в принципе. Моя позиция — внутри что у dotnet, что у go все те же спинлоки, семафоры и мьютексы и все проблемы с ними связанные могут вылезать в самых неожиданных местах.

                                                            0

                                                            Хороший пример кстати, я увеличил количество таймеров до 100к, у меня получилось что GO расходует раза в 2 больше процессора и раз в 10 больше памяти.

                                                            –2
                                                            Не согласен с 2 в отношении Go. Если ОЧЕНЬ много горутин конкурируют за один и тот-же примитив синхронизации из стандартной библиотеки Go, то скорее исчерпается память из-за оверхэда на функционирование горутины (мизерного), чем нагрузка на CPU вырастет до 100%. Причины объяснял несколько раз, повторяться не хотелось бы.
                                                            Я привел пример открытого кода из рантайма go, это не «закрытый» код.
                                                            Закрытые в том смысле, что там все имена начинаются с символа в нижнем регистре, а следовательно не могут быть использованы вне пакета runtime. Ни из самого кода, ни из комментариев нельзя сделать вывод, каким образом и для чего этот код вызывается из пользовательского кода. Я просил вас привести пример пользовательского кода, в котором addtimer по вашему мнению вызывается не правильно и загружает CPU на 100%. Вы не привели.
                                                            Вы нашли Mutex, а в таймерах mutex
                                                            В таймерах Go нет ни Mutex, ни mutex. И вообще этот код к таймерам Go, используемым в прикладном коде гоферами, ни малейшего отношения не имеет. А имеет к реализации рантайма. Не понятно, что вы хотели доказать на примере addtimer. Ну блокирует она системный тред, что с того? Есть ещё паблик функция runtime.LockOSThread, она блокирует явно, в чём крамола то?
                                                            Я не хотел никаких примеров, вы сделали синтетический тест, который добавляет 10 000 таймеров в секунду.
                                                            Этот код я написал, чтобы снять вопрос, что якобы при создании таймера Go может произойти блокировка потока ОС. Это не правда.
                                                            Таймеры я привел как один из примеров, где go делает spin wait и блокирует поток выполнения. Таких мест в go достаточно, чтобы в «умелых» руках наступить на эти грабли.
                                                            Ну так это не правда. Go блокирует горутину, поток ОС при этом не блокируется, и другие горутины могут спокойно продолжать свою работу. Это основной принцип CSP модели праллелизма в Go.

                                                              0
                                                              Утверждение 2 — и dotnet и go используют spin wait в своей стандартной библиотеке, при неправильном использовании можно получить 100% использование CPU в силу утверждения 1.


                                                              Не согласен с 2 в отношении Go. Если ОЧЕНЬ много горутин конкурируют за один и тот-же примитив синхронизации из стандартной библиотеки Go, то скорее исчерпается память из-за оверхэда на функционирование горутины (мизерного), чем нагрузка на CPU вырастет до 100%.


                                                              Я с вами соглашусь, если вы предложите механизм синхронизации, который бы обладал такими свойствами, а именно
                                                              — был бы очень быстрым, почти бесплатным в случае если конкуренция низкая
                                                              — не съедал CPU при высокой конкуренции за него

                                                              Мне такие не известны, лучшее что я знаю, это комбинация spin lock + мьютексподобный примитив синхронизации ядра ОС. Но, из за spin lock, при высокой конкуренции на такой ресурс возрастает нагрузка на CPU.

                                                              Обратите еще внимание на вот это сообщение. 21 день назад в трекере go появилось подробное описание высокого потребления CPU при активном использовании таймеров. Вот цитата из сообщения
                                                              I decided to use pprof to figure out what's tying up all of this CPU time. It turned out to be getting tied up in runtime.futex,


                                                              Проблема spkaeros лишь подтверждает мое утверждение 2 — в рантайме go есть локи и они могут приводить к высокому потреблению CPU. В обсуждении на github тут же предложили решение, аналогичное тому, что я предлагал автору статьи в одном из комментариев. Лишняя демонстрация того, что одинаковые проблемы имеют одинаковые решения вне зависимости от языка и рантайма.
                                                                –2
                                                                Я с вами соглашусь, если вы предложите механизм синхронизации, который бы обладал такими свойствами, а именно
                                                                Хоар уже давно предложил, а Пайк взял и сделал в Го.
                                                                Проблема spkaeros лишь подтверждает мое утверждение 2 — в рантайме go есть локи и они могут приводить к высокому потреблению CPU.
                                                                Про блокировки в рантайме там речи не идёт. Проблема юзера spkaeros в утечке ресурсов из-за того, что он забыл вызвать ticker.Stop() при выходе из хэндлера. Вполне штатная ситуация и нормальные, рабочие грабли. С таким же успехом можно забыть вызвать mutex.Unlock() и потом искать проблему в рантайме Go. Утёкшие незакрытые мириады тикеров постоянно вызывают runtime.futex, который содержит в себе сисколы. Сисколы в Go значительно дороже чем в dotnet, примерно на порядок. Вот ему pprof и показал runtime.futex. А вовсе не из-за того что «рантайме go есть локи».
                                                                  +2

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

                                            0
                                            Кажется вы не понимаете о чем говорите. В dotnet тоже есть green threads и можно иметь миллион «goroutine» только называться это будет Task и async методы. Отличия мельчайшие, на производительность сильно они не влияют. Также как и в Go, dotnet мапит N потоков на M асинхронных задач. Этим N можно управлять, обычно этот N примерно равен количеству ядер.

                                            Операция lock в dotnet это spin lock чаще всего, стоимость этой операции сопоставима с делением long на long (меньше 50 ns), при условии, что за лок нет большой конкуренции (lock convoy, который мне хочется назвать lock contention). Конкуренции за лок большой не будет если все делать по уму.

                                            Проблема в dotnet только от того, что там возможностей больше и люди ими пользуются не понимая до конца, что они делают. А затем приходится изобретать странные вещи. Но это не проблема dotnet, загляните в Go через 5 лет и вы удивитесь, как там все станет непросто и сколько появится способов отстрелить себе ногу.
                                              –2
                                              В dotnet тоже есть green threads и можно иметь миллион «goroutine» только называться это будет Task и async методы.
                                              Должен констатировать — вы и близко не представляете, чем параллельные вычисления в Go отличаются от асинхронных в C#. А различия кардинальные абсолютно во всём — как под капотом, так и снаружи. Тот факт, что тасков можно насоздавать много, ни каким образом не приравнивает их к горутинам.
                                              Конкуренции за лок большой не будет если все делать по уму.
                                              Чтобы сделать конкурентный код «по уму» на C# нужно приложить во сто крат больше усилий, чем на Го. В Го умственные усилия программиста расходуются на решение задач бизенса, а не секс с многопоточность, как в C#
                                              Проблема в dotnet только от того, что там возможностей больше и люди ими пользуются не понимая до конца, что они делают
                                              Так ведь нет. Проблема дотнэт в том, что асинхронный код на системном тредпуле 1) уродливый и сложный 2) глючный. Один только lock convoy чего стоит
                                                +2

                                                А чем, собственно, таски отличаются от goroutine? И там и там N потоков выполняет M «задач», M >> N, если заблокировать поток, то будут проблемы. Так как память разделяется между всеми «задачами», то нужно иногда брать локи и останавливать потоки.


                                                В чем я ошибаюсь относительно go?

                                                  –2
                                                  Ну чем асинхронность отличается от параллелизма, вы можете сами посмотреть в источниках. Это, кстати, сложный вопрос и ни разу не стыдно его не знать.
                                                  Применительно к C# vs Go навскидку.

                                                  1.
                                                  В C# затраты непосредственно на вызов асинхронного метода могут быть в десять раз больше затрат на вызов синхронного метода. Учитывая количество await-ов это может стать проблемой (и становится). В Go ничего подобного нет, оператор go практически бесплатная штука

                                                  2.
                                                  В Go заблокированная горутина не блокирует поток ОС (если только очень сильно этого захотите и сделаете в явном виде). Соответственно в Go можно безопасно сделать изменяемое состояние, разделяемая хоть между всеми существующими горутинами.

                                                  в C# же любая блокировка (что в прикладном коде, что в системном) внутри асинк. таска лочит поток ОС и нивелирует все преимущества асинхронного программирования превращая его в синхронное с костылями.

                                                  3.
                                                  var (
                                                      sharedMutableState SharedMutableState
                                                      mu sync.Mutex
                                                  )
                                                  func UseSharedMutableState(){
                                                      mu.Lock()
                                                      sharedMutableState.use()
                                                      mu.Unlock()
                                                  }
                                                  

                                                  Этого кода достаточно, чтобы спокойно вызывать UseSharedMutableState() из любого места программы на Go.

                                                  В C# и программист и кодревьюер обязан постоянно держать в голове все возможные сценарии доступа к sahred mutable state чтобы не получить lock convoy c исчерпанием трепулла. Либо тщательно бдить, чтобы доступ к разделяемым ресурсам был строго последовательным. Но помогает это исключительно в тривиальных кейсах. А на большой кодовой базе при наличии в коде sahred mutable state практически гарантированы различные вариации lock contention. Либо дэдлоков, которые по крайней мере в netcore в разы сложнее отыскать, чем в Go

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

                                                  5.
                                                  Плюс ещё пара экранов текста с перечислением всех багов и глюков асинхронщины в стиле «не возбуждать исключения в async-void методах, потому что...»

                                                    +1
                                                    Ну чем асинхронность отличается от параллелизма, вы можете сами посмотреть в источниках.


                                                    С теорией я знаком, различия в терминологии мне известны. Если что-то мапит M задач на N ядер, то мне плевать называется ли это «асинхронщиной», «многозадачностью», «параллельностью» и т.п. Это все еще мапинг M задач на N ядер. И dotnet и go делают этот самый маппинг. dotnet, кроме всего, умеет подождать на асинхронном IO (IO completion ports, epoll, kqueue, selec, etc) и при появлении данных добавить в очередь на выполнение задачу. Это то, что вы называете «асинхронностью» и это дополнительная фишка к тому самому маппингу. Асинхронность не обсуждаем, ее в go вроде как нет. А вот распределение задач по ядрам и организация их взаимодействия есть и там и там. Вот ее давайте обсуждать.

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


                                                    Так а что под капотом у go, разве там не тредпул с очередями и шедулером? Может там шедулер какой-то «прорывной», такой что никто до go не додумался сделать?

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


                                                    Это как это? С чего бы это добавление таски в очередь на выполнение в одном рантайме сложнее на порядок чем в другом?

                                                    В Go заблокированная горутина не блокирует поток ОС


                                                    Есть SemaphoreSlim с его WaitAsync и аналоги. Работает, абсолютно также как Mutex в go — как только другая таска освободит семафор заблокированные задачи будут добавлены в очередь на выполнение.

                                                    В go есть вещи, которые сделаны лучше чем в dotnet и наоборот, мы не об этом вообще говорим. В этой ветке мы говорим о том, чем существенно реализация goroutine отличается от Task. Цель — показать что goroutine значительно более продвинутая технология, которая выполняет все, что может Task, но делает это значительно лучше. А именно, тратит меньше памяти, имеет меньшие накладные расходы, быстрее работает и т.п.

                                                    Из нашего обсуждения я пока вижу что
                                                    — Для связки горутины с каналами есть удобный синтаксис. В dotnet больше вариантов, нужно выбирать и специального синтаксиса нет.
                                                    — Примитивы синхронизации по умолчанию знают про горутины, в dotnet нужно выбирать правильный примитив в зависимости от ситуации
                                                    — В Го за это приходится платить сложной интеграцией с нативным кодом. В dotnet вызов C методов почти бесплатный, в Go это целая церемония, в первую очередь из-за шедулера goroutine.

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

                                                      –1
                                                      Асинхронность не обсуждаем, ее в go вроде как нет
                                                      Есть. Можно сказать, что await в Go содержится почти в каждой инструкции. В Go большая часть инструкций на самом деле асинхронна и под капотом работает event loop. Но гоферу про это ничего знать не нужно (не очень то и хотелось).
                                                      Может там шедулер какой-то «прорывной», такой что никто до go не додумался сделать?
                                                      Вообще то шедулер в рантайме Го работает по другому нежели чем в C# — он не переключает потоки, а «вытаскивает» инструкции из очереди на выполнение и передаёт их потоку горутины. Это действительно продвинутая технология.
                                                      Это как это? С чего бы это добавление таски в очередь на выполнение в одном рантайме сложнее на порядок чем в другом?
                                                      Из-за SynchronizationContext, в который должен свалиться асинхронный вызов. Я об этом читал в книге Алекса Дэвиса и лично наблюдал на практике в C# разницу в 2 порядка.
                                                      Есть SemaphoreSlim с его WaitAsync и аналоги. Работает, абсолютно также как Mutex в go
                                                      Вы хитрите сейчас. Не могли бы вы показать пример синхронизации, скажем, твердотельного кеша бд или чего-то аналогичного из практики с помощью SemaphoreSlim? Где несколько тасков одновременно обновляют стейт, взятый из бд. Не покажете, вопрос был риторический. Для предотвращения гонок данных только двоичный семафор подходит.
                                                      Для связки горутины с каналами есть удобный синтаксис
                                                      Что вы имеете ввиду? Нет в go ни какой связки каналов с горутинами. Есть операторы чтения и записи в канал, и всё.
                                                      Примитивы синхронизации по умолчанию знают про горутины
                                                      Тот же вопрос. Что именно мьютексы «знают» про горутины?
                                                      В Го за это приходится платить сложной интеграцией с нативным кодом.
                                                      В Go осуждается интеграция прикладного кода с нативным. Ни кому это не нужно.
                                                      В dotnet вызов C методов почти бесплатный, в Go это целая церемония, в первую очередь из-за шедулера goroutine.
                                                      Не верное утверждение. Вызов «сишного» кода из Go по производительности бесплатный, его цена — отсутствие кроскомпиляции. Например, использование sqlite в Go будет в точности таким же по производительности как в C# (при этом на много меньше бойлерплейта, другой вопрос). Из-за шедулера дорогими будут системные вызовы. Как правило на это совершенно наплевать.
                                                        0
                                                        Из-за SynchronizationContext, в который должен свалиться асинхронный вызов.

                                                        Осталось понять откуда возьмётся SynchronizationContext где-то кроме GUI-приложения.


                                                        Вызов «сишного» кода из Go по производительности бесплатный, его цена — отсутствие кроскомпиляции.

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


                                                        В Go осуждается интеграция прикладного кода с нативным. Ни кому это не нужно.

                                                        Ну да, если работает плохо — значит никому не нужно. Наверное, потому что кому это нужно — те на Go не пишут.

                                                          0
                                                          В asp net есть SynchronizationContext. Но есть ещё причины деградации производительности на асинхронные вызовы, в данный момент я их не помню.
                                                          Ничего подобного, его цена — возможная блокировка потока
                                                          Обычно те, кто используют cgo, знают что делают. Ну и на практике не используется ничего сишного, что может блокировать поток. У меня в коде такого нет точно, у коллег тоже не встречал. Разве что для весьма не стандартных задач, выходящих далеко за рамки нормального применения Go.
                                                          Наверное, потому что кому это нужно — те на Go не пишут
                                                          Это нужно в основном в системном программировании и gui. Иногда и на Го пишут. Если это не будут узким местом, то почему бы и нет. Но такая необходимость возникает редко, в стандартной библиотеке все необходимое уже есть
                                                            0
                                                            В ASP.NET нет SynchronisationContext, он там не нужен. Если бы был, то был бы быстрым, потому что не нужно дергать WinAPI. Медленный SynchronisationContext в GUI приложениях, не потому что dotnet, а потому что WinAPI.

                                                            В силу того, как устроен шедулер в Go, он обязан вызывать любой нативный (а не системный) вызов в отдельном потоке, а затем передавать значение в горутину.

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

                                                            Это неверно. Удобный, интегрированный шедулер = головная боль с вызовом кода, который про этот шедулер не знает. Чудес не бывает.
                                                              –2
                                                              В net framework SynchronisationContext был, есть и будет, и в asp net для net framework используется он чуть более чем во всех крупных проектах, отнюдь не только в GUI. netcore не всея Руси, переписывать на него легаси будут не только лишь все (мало кто будет это делать) (проще сразу на Go всё переписать, за одно избавится от тонны хлама и бойлерплейта). Поэтому, как бы вам этого не хотелось, проблема SynchronisationContext ни куда не денется в будущем.

                                                              Но даже и без контекста синхронизации асинхронные вызовы всё равно будут дороже синхронных. Даже в том случае. когда они по счастливой случайности ничего не блокируют. Потому что таск выполняется последовательно в нескольких потоках, между которыми нужно последовательно передавать стек таска.
                                                              В силу того, как устроен шедулер в Go, он обязан вызывать любой нативный (а не системный) вызов в отдельном потоке, а затем передавать значение в горутину.
                                                              С чего вы это взяли? Ничего он не обязан и ничего подобного не делает если не вызвать LockOSThred. Если сишная функция из прикладного кода блокирует поток, то это проблема прикладного кода. В Go есть много небезопасных вещей, это одна из них. Ещё раз. Обычно те сишные функции, которые вызываются из Го, ничего не блокируют. Нет смысла усложнять и утяжелять рантайм на защиту от того, что происходит в одном кейсе на миллиард.
                                                              Удобный, интегрированный шедулер = головная боль с вызовом кода, который про этот шедулер не знает.
                                                              Ещё раз. В Go никому не нужен этот код, который может сломать рантайм. Вы пугаете несуществующим призраком
                                                          +2
                                                          Вообще то шедулер в рантайме Го работает по другому нежели чем в C# — он не переключает потоки, а «вытаскивает» инструкции из очереди на выполнение и передаёт их потоку горутины. Это действительно продвинутая технология.

                                                          dotnet работает точно также — есть очередь задач, пул потоков обрабатывает ее. Никаких переключений потоков нет, все точно также как и в Go. Вот TaskScheduler, а вот и очередь, куда попадает таск.

                                                          Как следствие из этого
                                                          — SynchronisationContext нужен только в GUI, чтобы гарантировать, что таск будет выполнен в GUI потоке. Это требует вызова WinAPI (посылки сообщения в event loop винды), а это не быстро. В других местах его нет, то же самое нужно делать любой GUI библиотеке в винде, dotnet тут ни при чем.
                                                          — Добавление/запуск/создание таски стоит столько же, сколько в Go, потому, что это просто добавление задачи в очередь на выполнение.
                                                          — SemaphoreSlim работает также как Mutex в Go. Также как и в Go он интегрирован с шедулером. Просто посмотрите в исходник, там все написано.

                                                          Давайте вернемся к теме
                                                          — Что там под капотом у go не так как у дотнет?
                                                          — Как работает волшебный лок, который есть в go и который можно быстро взять вне зависимости от количества потоков, которые за него борются.
                                                            –1
                                                            dotnet работает точно также
                                                            Нет. Попробуем ещё раз. dotnet выполняет инструкции синхронно до await, затем достаётся другой поток из пула потоков и в нём выполняется таск из await, а старый поток возвращается в пулл. При этом контекст переключается из старого потока на новый, и оставшаяся часть таска, следующая после await, выполняется в этом новом потоке. И так на каждый await. При этом заблокированный таск == заблокированный поток ОС.

                                                            А вот что происходит в Go вместо этого. Компилятор Go разбивает функцию горутины на серии инструкций, разделённые, утрируя, вводом-выводом и вызовом функций (на самом деле сложнее), При запуске горутины она передаётся одному из работающих потоков ОС, и в дальнейшем все её инструкции выполняются строго в этом потоке. После выполнения каждой такой серии (до ввода вывода или вызова функции) происходит переход к инструкциям другой горутины данного потока. При этом ни какого переключения контекста между потоками (с переходом в режим ядра и копированием стека между потоками как в C#) не происходит, а бесконечный цикл утилизирует 100% процессорного времени в соответствующем потоке ОС и никогда не будет прекращен рантаймом. Вот по этому мьютексы и каналы не блокируют поток ОС — они не могут. А могут лишь создать точку асинхронного переключения выполняющего потока ОС на другие горутины. Поэтому блокировка в Go — это ничто с т.з. производительности в сравнении с C#
                                                            SemaphoreSlim работает также как Mutex в Go. Также как и в Go он интегрирован с шедулером
                                                            Вы это прямо как мантру повторяете. Я же вам ответил уже, что Mutex в Go ни как не интегрирован с шедулером, а семафор с мьютексом абсурдно сравнивать. Семафор не предотвращает гонки данных, мьютекс предотвращает. Зачем возвращаться к этому ещё раз и ещё раз..
                                                            SynchronisationContext нужен только в GUI
                                                            SynchronisationContext — самый дебильный способ синхронизации GUI. В винде в GUI можно обойтись без всякого SynchronisationContext в 99% кейсов синхронизации. Достаточно нотификации через SendMessage(WM_COPYDATA, но для индусов, породивших GUI фреймворки для C#, это оказалось слишком сложно
                                                            Как работает волшебный лок, который есть в go и который можно быстро взять вне зависимости от количества потоков, которые за него борются.
                                                            Потоки за него не борются. За него борются горутины. Переключение контекста горутин — практически бесплатно. Переключение потоков — чудовищно дорого.
                                                              +2
                                                              Позвольте исправить фактическую ошибку в вашем описании работы dotnet. dotnet — выполняет таску в текущем потоке до await.
                                                              затем достаётся другой поток из пула потоков и в нём выполняется таск из await

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

                                                              По вашим словам в go дела обстоят следующим образом. goroutine выполняется до запуска другой функции или ввода/вывода или блокировки. В этот момент запускается следующая горутина. Позволю себе предположить, что все горутины выстраиваются в очередь, из которой поток ОС их достает и выполняет. Возможно это какая-то хитрая очередь с приоритетами, но я сильно сомневаюсь. А раз так, то там просто очередь, как в dotnet, только, по вашим словам, по очереди на каждый поток.

                                                              И там и там потоки переключает шедулер ОС, которому плевать поток go или dotnet. Отмечу, никакого «копирования» стека не происходит, переключение потоков так не работает. При переключении потоков восстанавливаются настройки MMU и восстанавливается содержимое регистров процессора. Это дорого, но не чудовищно дорого, тысячи раз в секунду на одном ядре это делать можно без ущерба производительности.

                                                              Я вижу много одинакового
                                                              — и там и там есть очередь выполнения задач
                                                              — и там и там потоки выполняют задачи из этих очередей.
                                                              — примерно одинаково выглядят события, по которым обычно происходит переключение между задачами
                                                              в dotnet таска переключается в момент вызова «системных» функций, await и вызова SemaphoreSlim.WaitAsync и аналогов
                                                              в go горутина переключается в момент вызова «системных» функций, gc, вызова go, вызова примитивов синхронизации (читать тут)
                                                              Есть и различия
                                                              — dotnet имеет одну очередь из которой много потоков выбирают задачи
                                                              — go имеет по очереди для каждого потока

                                                              Причем я думаю, что go на самом деле так не делает. Потому что тогда бесконечный цикл в горутине «вырубит» все, что должны выполнятся в этом потоке. Это приведет к неравномерной загрузке CPU. Поэтому, я думаю, что вы не правы и в go, также как в dotnet, есть одна очередь на все горутины.

                                                              Буду рад любой обоснованной критике и правкам, желательно с ссылками на конкретный исходный код. Все утверждения выше я уже подкрепил ссылками на конкретные классы и функции.

                                                              И тут стоит вернутся к вопросу — а чем go лучше dotnet в плане выполнения goroutine/task?

                                                              Теперь рассмотрим различие мьютексов go и SemaphoreSlim. Конкретно SemaphoreSlim.cs строка 631 «return asyncAwaiter». Это та самая точка переключения, в которой поток из тредпула начнет выполнять следующую таску. То есть никакого переключения контекста, никаких вызовов ядра и т.п. в SemaphoreSlim нет.

                                                              Вы заметили, что semaphore это не mutex, что верно. Также верно и другое — new SemaphoreSlim(1,1) == mutex, только одна таска сможет захватить такой семафор. Забавно, что функция, которая переключает на следующую горутину называется «runtime_SemacquireMutex», что-то мне подсказывает, что Sem это от Semaphore. Было бы интересно услышать более развернутый комментарий на тему того, что семафор гонки не предотвращает, а мьютекс предотвращает.

                                                              Итого
                                                              — в go mutex передает управление шедулеру с помощью вызова runtime_SemacquireMutex
                                                              — в dotnet управление шедулеру передается с помощью «return asyncAwaiter»

                                                              С чем тут спорить?

                                                              SynchronisationContext — самый дебильный способ синхронизации GUI. В винде в GUI можно обойтись без всякого SynchronisationContext в 99% кейсов синхронизации. Достаточно нотификации через SendMessage(WM_COPYDATA, но для индусов, породивших GUI фреймворки для C#, это оказалось слишком сложно

                                                              А что по вашему делает SynchronisationContext?
                                                              WindowsFormsSynchronisationContext вызывает Control.Invoke
                                                              — Control.Invoke вызывает «User32.PostMessageW(this, s_threadCallbackMessage);»

                                                              Это ничем не отличается от посылки WM_COPYDATA, который вы предлагаете, вместо WM_COPYDATA посылается 'Application.WindowMessagesVersion + "_ThreadCallbackMessage"'
                                                                0
                                                                dotnet имеет одну очередь из которой много потоков выбирают задачи

                                                                Каждый поток имеет свою локальную очередь (https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler?view=netframework-4.8#Queues). Так что различий с го вообще нет :)


                                                                Потому что тогда бесконечный цикл в горутине «вырубит» все, что должны выполнятся в этом потоке.

                                                                Если вы о том, что другие горутины/таски не запустятся, сидя в локальной очереди треда, который гоняет while(true){}, то это не так. Потоки как в дотнете, так и го, могут забирать таски из локальной очереди другого потока, если им скучно ничего нет в их локальной и в глобальной очередях.

                                                                  0

                                                                  Век живи, век учись, спасибо.

                                                                  –1
                                                                  Нет, затем текущий поток начинает выполнять следующую таску в очереди на выполнение. Никакого «другого потока» в этой схеме нет.
                                                                  А, ну значит вы просто не в курсе как это работает.
                                                                  Task.Run(async () =>
                                                                              {
                                                                                  for (var i = 0; i<10; i++)
                                                                                  {
                                                                                      Console.Write("{0} ", Thread.CurrentThread.ManagedThreadId);
                                                                                      await Task.Delay(1);                    
                                                                                  }
                                                                              });
                                                                              Console.ReadKey();
                                                                              // 3 4 4 3 4 3 4 4 4 3
                                                                  
                                                                  Как вы можете видеть, context switch есть даже в самом примитивном сценарии.
                                                                  При переключении потоков восстанавливаются настройки MMU и восстанавливается содержимое регистров процессора.
                                                                  В реальной жизни при блокировках этого достаточно для lock convoy при той нагрузке, которую программа на Go даже не заметит
                                                                  в dotnet таска переключается в момент вызова «системных» функций, await и вызова SemaphoreSlim.WaitAsync и аналогов
                                                                  Так ведь не таска в C# переключается, а поток операционной системы переключается. А в Go поток ОС никуда не переключается, он выполняет свои горутины.
                                                                  dotnet имеет одну очередь из которой много потоков выбирают задачи
                                                                  Нет. Коллега, вы немножко задолбали. Не поток выбирает задачи, а задача выбирает потоки. Предлагаю к этому более не возвращаться.
                                                                  Причем я думаю, что go на самом деле так не делает. Потому что тогда бесконечный цикл в горутине «вырубит» все, что должны выполнятся в этом потоке.
                                                                  Безусловно вырубит. Нет, это не повод уродовать рантайм бессмысленными переключениями между потоками ОС. Те, кто пишет бесконечные циклы, учитывают эту особенность. И, кстати, на практике написать такой бесконечный цикл практически невозможно. Мой пример искусственный, на практике будет инструкция a=12, и компилятор такой код не пропустит
                                                                  И тут стоит вернутся к вопросу — а чем go лучше dotnet в плане выполнения goroutine/task
                                                                  Я вам в каждом комментарии отвечаю на этот вопрос. Тем лучше, что в Go нет хлама в многопоточном коде, при этом многопоточный код на Go более надёжный и эффективный. Но вы всё игнорируете и продолжаете своё «не вижу» «не понимаю»
                                                                  Было бы интересно услышать более развернутый комментарий на тему того, что семафор гонки не предотвращает, а мьютекс предотвращает.
                                                                  Зачем развёрнутые комментарии на очевидное? Это следует из определения семафора и определения гонки данных.
                                                                    +1
                                                                    Ага, а вот go конечно же не переключает потоки :) Я чтобы этот замечательный факт доказать даже пример написал
                                                                    package main
                                                                    
                                                                    import (
                                                                        "fmt"
                                                                        "time"
                                                                        "syscall"
                                                                    )
                                                                    
                                                                    func main() {
                                                                        fmt.Println("Hello, playground ", syscall.Gettid())
                                                                        ticker := time.NewTicker(time.Second)
                                                                        go func() {
                                                                            for t := range ticker.C {
                                                                                fmt.Println("Tick at ", t, " tid ", syscall.Gettid())
                                                                            }
                                                                        }()
                                                                    		
                                                                        time.Sleep(time.Millisecond * 10000)
                                                                        ticker.Stop()
                                                                        fmt.Println("Ticker stopped ", syscall.Gettid())
                                                                    }
                                                                    


                                                                    А вот оно, доказательство того, что Go совсем не так, как dotnet шедулит задачи.

                                                                    ➜ gotest docker run --rm -v "$PWD":/usr/src/myapp -w /usr/src/myapp golang:latest go run main.go
                                                                    Hello, playground 67
                                                                    Thread ID: 70
                                                                    Thread ID: 70
                                                                    Thread ID: 70
                                                                    Thread ID: 70
                                                                    Thread ID: 70
                                                                    Thread ID: 70
                                                                    Thread ID: 70
                                                                    Thread ID: 69
                                                                    Thread ID: 69
                                                                    Ticker stopped 69

                                                                    ➜ gotest docker run --rm -v "$PWD":/usr/src/myapp -w /usr/src/myapp golang:latest go run main.go
                                                                    Hello, playground 63
                                                                    Thread ID: 67
                                                                    Thread ID: 66
                                                                    Thread ID: 65
                                                                    Thread ID: 65
                                                                    Thread ID: 65
                                                                    Thread ID: 63
                                                                    Thread ID: 65
                                                                    Thread ID: 65
                                                                    Thread ID: 65
                                                                    Ticker stopped 65
                                                                    ➜ gotest docker run --rm -v "$PWD":/usr/src/myapp -w /usr/src/myapp golang:latest go run main.go
                                                                    Hello, playground 63
                                                                    Thread ID: 67
                                                                    Thread ID: 66
                                                                    Thread ID: 68
                                                                    Thread ID: 68
                                                                    Thread ID: 68
                                                                    Thread ID: 66
                                                                    Thread ID: 66
                                                                    Thread ID: 66
                                                                    Thread ID: 66
                                                                    Ticker stopped 66

                                                                    Может быть у вас есть теория, почему я вижу разные TID?

                                                                      –2
                                                                      Я не думаю, что syscall.Gettid является корректным и переносимым способом определения нативного потока горутины. А почему вы думаете иначе? откуда такая информация?
                                                                        0

                                                                        А чего он в таком случае вообще в стандартной библиотеке делает?

                                                                          0
                                                                          Ничего. Эта исходный код этой функция сгенерирован из прототипов системных вызовов.
                                                                          0

                                                                          GetTid выдаёт поток ОС, который выполняет кол, он про го ничего не знает. Это аналог managedthreadId

                                                                            0
                                                                            Я склонен с Вами согласится. Пожалуй, в данном вопросе правы вы, а я ошибался. Любой из потоков ОС, используемых рантаймом Го, может в какой-то момент времени отвечать за выполнение кода одной из горутин. Предполагаю, что потоки ОС из рантайма Го, выполняющие горутины, используют общую память, поэтому переключений контекста ядра/пользователя не требуется. Трудно сказать без нагрузочных тестов, насколько это критично в плане различий с производительностью с C#.
                                                                              +1
                                                                              На этой радостной ноте согласия я покидаю этот тред. Мне было интересно покопаться в кишках go, надеюсь вам было/будет интересно посмотреть как оно в dotnet устроено. Уверен, вы найдете больше сходства, чем отличий. Ну а выводы из этого каждый делает сам.
                                        0
                                        Код надо не только написать, но и поддерживать. Средний С# разработчик (не автор кода, а коллега) скорее разберется в заумном C# коде, чем выучит Go. Да и нового разработчика с требованиями «Advance C#» легче и быстрее найти чем C#/Go. F скорее всего и дешевле.
                                        0
                                        Отличные вы походили по граблям :)

                                        История 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>

                                        А это свойство позволяет легко вытащить индекс в нативную память. Индекс большой и если там нет «горячих» элементов, то есть чтения и записи разнесены в пространстве, то можно легко его разбить на сегменты и закрыть простым локом каждый сегмент.
                                          +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, возможно, когда-нибудь и об этом опыте кто-нибудь расскажет.

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


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

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

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

                                            Самое читаемое