Все слышали о том, что иногда dotnet на Linux потребляет больше ресурсов, чем на Windows. Порой эта разница практически незаметна. Но случается и такое, что одно и то же приложение потребляет на Linux в 2–3 раза больше CPU, чем на Windows.

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

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

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

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

Спустя неделю глубокой медитаций Магосу Г явилась мысль: нужно изменить предел вращения несправедливого семафора.

Причин разницы в потреблении CPU dotnet-приложениями под разными ОС очень много и все они разнообразны. Если очень коротко — отличается реализация большого числа примитивов или даже крупных кусков логики. И в каждом конкретном случае может сыграть что-то своё.

Некоторые из этих различий (или даже «проблем») со временем устраняются, улучшаются, если это возможно со стороны самого dotnet-а. Для некоторых же «особенностей» в определённых версиях dotnet появляются экспериментальные фичи, которыми можно управлять самостоятельно. Иногда эти фичи переезжают в новые версии включенными по умолчанию.

Неудивительно, что большинство деградаций в производительности dotnet-а на Linux-е находятся вокруг асинхронной работы, вокруг тредпула. В определённый момент разработчики dotnet-а даже переписали код тредпула с native на managed-C# код, чтобы оно хотя бы пыталось быть похожим под разными ОС. Но базовые примитивы для асинхронной работы всё равно очень разные в разных ОС — даже набор асинхронных методов в API операционных систем различается. Не каждый async-метод на самом деле честный и асинхронный во всех ОС.

Опишем ситуацию

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

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

А ещё они нас интересуют потому, что от перехода с Windows на Linux суммарное потребление ресурсов увеличилось чуть больше, чем в два раза.

На что тратилось CPU?

К сожалению, никакой широкодоступный и популярный способ диагностики потребления CPU в данном случае не работал. Все инструменты показывали, что «все CPU потрачено где-то в тредпуле». Порой спускаясь до максимум такой конкретики: PortableThreadPool.WorkerThread.WorkerThreadStart(). Этого было не достаточно.

На помощь пришёл инструмент perf. Вы без труда найдёте, как им воспользоваться. И с трудом, но всё-таки сможете провести анализ сколько-нибудь сложного приложения.

Не будем вдаваться в детали изучения артефактов. Но всё указывало на то, что CPU тратится в SpinWait-ах внутри семафора.

Что такое SpinWait?

На этот вопрос отлично отвечает документация с сайта Microsoft:

SpinWait is a lightweight synchronization type that you can use in low-level scenarios to avoid the expensive context switches and kernel transitions that are required for kernel events.

Как на пальцах работает SpinWait? Он просто прожигает несколько тактов CPU какой-нибудь бесполезной работой, примерно равной нескольким десяткам наносекунд по времени.

Можно предположить, что авторы тредпула считают работу в методе WorkerThreadStart внутри взятого семафора очень короткой. И если семафор сейчас кем-то занят, то, очень вероятно, надо пропустить всего чуть-чуть процессорных циклов, и семафор станет пустой. И это должно быть сильно дешевле и быстрее, чем проваливаться в настоящий Wait. Потому что настоящий Wait будет делать thread yield — то есть делать context switch и возвращать поток планировщику потоков. А это очень дорогая и длительная операция. Обычно значительно дороже, чем пара пропущенных процессорных циклов.

Почему на Linux он дороже?

А черт его знает. Он просто по-другому работает, и всё тут. Не хуже, не лучше — иначе. И по-хорошему поведение dotnet-а, его использование таких примитивов, нужно настраивать разными способами в зависимости от ОС.

Что будем делать?

Судя по коду, семафор сконфигурирован так, что он ограничен 70-ю итерациями SpinWait-а по умолчанию. И — о чудо — это значение конфигурируется переменной окружения!

Что будет, если это число уменьшить? Например, написать туда 0?

Прописываем переменную окружения DOTNET_ThreadPool_UnfairSemaphoreSpinLimit=0всем 600+ инстансам. Релизим, смотрим на графики суммарного потребления CPU:

Пора радоваться?

Успех? Бежим проставлять эту переменную окружения всем dotnet приложениям на Linux? Ни в коем случае.

Теоретизируем, что может пойти не так

Что плохого может случиться от того, что мы теперь никогда не делаем SpinWait, а всегда проваливаемся в честный Wait? От такого может резко упасть пропускная способность (throughput) тредпула.

Легко себе представить, что у вас в приложении регулярный и стабильный поток возникающих и очень быстро выполняющихся Task-ов. И тредпул в методе WorkerThreadStart частенько натыкается на занятый семафор, чуть-чуть ждёт в SpinWait-е, дожидается семафора, берёт таску, и идёт исполнять её. Отношение бесполезной работы (SpinWait) к полезной минимально. Время «простоя» (проведённого не в исполнении полезной работы) минимально.

А если SpinWait-ов нет (или их число мало и не хватает дождаться семафора), мы в теории можем часто проваливаться в честный Wait и делать context switch. Это будет отнимать у нас очень много времени и отношение времени «простоя» (проведённого не в исполнении полезной работы) будет расти по отношению к времени, потраченному на полезную работу. Чем короче будут Task-и и чем их больше, тем хуже будет это отношение.

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

Выводы

  • ThreadPool — невероятно сложная абстракция, позволяющая писать «многопоточный» код легко и непринужденно. Но иногда у него огромная цена.

  • Даже разработчики ThreadPool-а не могут написать его так, чтобы он был идеален во всех corner-case. В некоторых особенных ситуациях он работает «неидеально».

  • Если ваше приложение при переходе с Windows на Linux стало потреблять значительно больше CPU, можете поиграться с переменной окружения DOTNET_ThreadPool_UnfairSemaphoreSpinLimit, выставляя туда числа от 0 до какого вам хочется значения, оглядываясь на умолчание в коде ThreadPool-а.

  • Правда, это поможет далеко не каждому приложению. А многим, наверное, даже помешает. Ведь не просто так выбраны такие умолчания — они должны быть хорошими «в среднем».

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

  • В каждой следующей версии dotnet-а всё может полностью измениться, а переменная окружения может перестать использоваться. Читайте changelog-и.

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


  1. unreal_undead2
    23.09.2024 06:59

    Выглядит очень похоже на OMP_WAIT_POLICY (только там ручка с двумя положениями).


  1. m3ta10ph0b
    23.09.2024 06:59
    +2

    Большая работа проделана и описана, спасибо!
    Проблема известная (к счастью, редким "счастливчикам"). Даже открытый issue есть на эту тему, где обсуждается, в том числе, и описанное тут решение.
    Оставлю ссыль, вдруг кому пригодится для отслеживания статуса https://github.com/dotnet/runtime/issues/71921
    В какой-то момент его могут закрыть и проблема может стать неактуальной


  1. SideshowBob
    23.09.2024 06:59
    +1

    с сокетами тоже есть нюансы - https://github.com/dotnet/runtime/issues/72153


  1. mvv-rus
    23.09.2024 06:59
    +4

    Почему на Linux он дороже?

    А черт его знает. Он просто по-другому работает, и всё тут. Не хуже, не лучше — иначе. И по-хорошему поведение dotnet-а, его использование таких примитивов, нужно настраивать разными способами в зависимости от ОС.

    Нет, этот семафор в Windows не просто по-другому работает. Он - речь идет о классе LowLevelLifoSemaphore - в Windows и в Unix по-разному реализован. Сам по себе LowLevelLifoSemaphore в LowLevelLifoSemaphore.cs (лежит там же, рядом с PortableThreadPool.WorkerThread.cs на который ведет ссылка) описан как partial class, то есть, содержит только часть кода. А другая часть лежит в других, разных для разных ОС файлах: LowLevelLifoSemaphore.Windows.cs и LowLevelLifoSemaphore.Unix.cs. И в реализации для Windows никакого SpinLock нет. Там используется I/O Completion Port - есть в Windows, начиная с Win2K, такой объект ядра, который предназначен специально для управления пулом потоков. То есть, тем, чтобы найти поток для выполнения нагрузки в пуле, занимается ядро. А в *nix такого объекта нет (и AFAIK нет даже аналога). И там нужное поведение реализуется через LowLevelMonitor, где, по-видимому, и используется SpinLock, Так что насчет "настраивать разными способами в зависимости от ОС" в .NET все нормально: так и сделано.


    1. deniaa Автор
      23.09.2024 06:59

      Спасибо за более точное описание различия.