четверг, 30 октября 2014 г.

О книге “Writing High-Performance .NET Code”

Как-то давно не было рецензий умных книжек, так что пришло время это исправить. Недавно, уважаемый Sinix на rsdn.ru упомянул о книге, с любопытным названием “Writing High-Performance .NET Code”. Я вообще не собирался переключаться пока на литературу по .NET-у, но поскольку по работе мне нужно было пообщаться поближе с виндовыми счетчиками производительности (a.k.a. performance counters), а они были описаны в одной из глав этой книги, то меня как-то зацепило.

clip_image002

Ну, ок. Давайте поговорим о высокопроизводительном .NET коде. Что это такое и бывает ли вообще? Тут сказать сложно. Когда речь заходит о действительно высокой производительности, то практика показывает, что с управляемыми языками, несмотря на все заявления, добиться высокой производительности не всегда возможно. Мы можем создать эффективное управляемое приложение, но если наша цель – эффективно использовать каждый такт процессора, то неуправляемый код все равно будет впереди планеты всей. Достаточно вспомнить, что ядро виндофона ушло от управляемого кода на неуправлямый, да и Windows Runtime сложно назвать полностью «управляемой платформой».

Но тот факт, что мы не напишем на чистом управляемом коде системное ПО (не забываем о System C#, о котором нам поведал Joe Duffy), не говорит о том, что думать об эффективности управляемого кода не стоит. На .NET-е написано много прикладного ПО и различных сервисов, эффективность которых нужна не меньше, чем базе данных, операционке или встроенному софту. Все это значит, что идея у книги просто замечательная, осталось разобраться с «реализацией».

О чем эта книга?

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

Во-первых, понятие эффективности должно быть не субъективным критерием, а определенной метрикой. Мы должны четко понимать, что подразумевается под эффективностью: обработка 100 запросов в секунду, задержка ответа не более 3 мс и т.п. Но еще не менее важно, чтобы мы знали, что эти критерии означают. Означают ли 100 запросов в секунду среднюю пропускную способность? Автор настоятельно рекомендует использовать перцентили. «Время отклика должно составлять 3мс в 95% случаев» значительно точнее говорит о том, что мы хотим получить, чем «среднее время отклика не должно превышать 3мс». Первое точнее, поскольку среднее время может обладать слишком сильными отклонениями: насколько допустимо, чтобы 5% запросов исполнялись 2 минуты, в то время, как 90% будут выполняться за 1мс? Среднее время будет 3мс, но вряд ли вы будете рады, если именно вы будете попадать в 5% тормознутых клиентов.

Во-вторых, эффективность не меряется на глаз. А значит, там нужны инструменты для получения характеристик системы. Тут подойдут всякие профайлеры, счетчики производительности и ETW (Event Trace For Windows). Последние позволяют анализировать метрики эффективности на боевых машинах, а не только на машине разработчика. В книге показано множество примеров поиска узких мест с памятью и производительностью с помощью PerfView – инструмента, написанного Винсом Моррисоном, одним из архитектором .NET Framework.

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

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

И как реализация?

В книге “Writing High-Performance .NET Code” покрыты все вышеперечисленные моменты. В ней дается хороший обзор инструментов, проедается мозг о необходимости мерять (Measure! Measure! Measure!), и рассказывается о «кишках» сборщика мусора и языковых конструкций языка C#. Вопрос лишь в том, насколько эти темы успешно раскрыты.

И вот тут начинают возникать вопросы.

Я бы сказал так: инструменты расписаны хорошо, вопросы метрик и их измерения расписаны нормально, а внутренности .NET-а и CLR расписаны плохо.

В книге приведено множество ссылок на разные тулы, показаны примеры использования и неплохо расписаны подходы к инструментированию кода с помощью счетчиков производительности (кастомных счетчиков, счетчиков операционной системы и CLR) и ETW (Event Tracing for Windows). В этой теме мне не хватило лишь более подробного описания практик создания бенчмарков. В книге приводится довольно много «вот это эффективнее вот того», и мне бы хотелось в такой книге видеть хотя бы подраздел в главе, описывающий хорошие практики измерения производительности (хотя бы на уровне постов Скита “Benchmarking made easy” и Эрика Липперта C# Performance Benchmark Mistakes, Part One, Part Two, Part Three, Part Four или того же Винса Моррисона “Measure Early and Often for Performance, Part 1” и Part 2.

Но вот продвинутые вещи о дот-нетиках и сборке мусора меня не порадовали.

Deep Dive?

Одна из ключевых проблем книги, ИМХО, в том, что помимо продвинутых вещей, автор иногда старался дать основы, и уж потом копать в глубь. Этот подход сделает книгу читабельной для начинающего разработчика, но не позволит постичь глубину всех глубин. Самым эпичным примером этого подхода является глава о параллелизме. Тут есть все, начиная от рассказов о пользе асинхронности, недостатках голодания потоков, до примеров Parallel.For и стратегий партиционирования. Первое и второе очень полезно, но какой смысл в примерах, скопированных из MSDN-а?

WTF #1
.NET 4.0 introduced an abstraction of threads called the Task Parallel Library (TPL), which is the preferred way of achieving parallelism in your code.

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

WTF #2
If your continuation Task is a fast, short piece of code, you should specify that it runs on the same thread as its owning Task. This is vitally important in an extremely multithreaded system as it can be a significant waste to spend time queuing Tasks to execute on a separate thread which may involve a context switch.

Конечно, автор на протяжении всей книги говорит о том, что любое решение нужно принимать на основе измерений, но давать обобщенные советы нужно все же осторожнее. Пул потоков в .NET 4.0 специально был изменен таким образом, чтобы эффективно обрабатывать тысячи задач (о чем неоднократно говорил тот же Стивен Тауб). Это не значит, что нужно плодить слишком много мелкогранулярных задач, но точно не стоит по умолчанию менять TaskScheduler для продолжений, среднее время исполнения которых небольшое. Тем более, что начиная с .NET 4.0 пул потоков работает таким образом, чтобы дочерние «подзадачи» обрабатывались тем же потоком, а загрузка выравнивается благодаря «воровству задач» (Work Stealing), так что почти наверняка, пул потоков сам справится с равномерной загрузкой ядер без нашей с вами помощи.

WTF #3
If your software is extremely asynchronous and uses a lot of CPU, then it may be hit with a steep startup cost as it waits for more threads to be created and become available. To reach the steady state sooner, you can tweak the startup parameters to maintain a minimum number of threads ready upon startup.

Да, автор предлагает подумать об изменении ThreadPool.SetMinThreads, но дело все в том, что в этом просто не видится никакого смысла, особенно в приведенном автором контексте!

WTF #4
For local variables, this can be after the last local usage, even before the end of the method. You can lexically scope it narrower by using the { } bracket, but this will probably not make a practical difference because the compiler will generally recognize when an object is no longer used anyway. If your code spreads out operations on an object, try to reduce the time between the first and last uses so that the GC can collect the object as early as possible.

На самом деле, совет абсолютно точный, но без должного контекста все эти “probably not make a practical difference” может серьезно запутать читателя. Все дело в том, что среда исполнения действительно не знает о лексической области видимости и ее наличие точно никак повлияет на время жизни объекта. Но вот будет ли объект удален до завершения метода или нет и правда зависит … от реализации CLR! Десктопная CLR сохраняет информацию о том, где в методе перестает использоваться локальная переменная, после чего объект станет достижимым для сборки мусора. Но вот виндофонная CLR этого не делает! (Подробнее об этом я писал в статье «О сборке мусора и достижимости объектов»)

Очень «порадовал» раздел с описанием возможности упаковки LOH-а с помощью GCSettings.LargeObjectHeapCompactionMode:

Because of the expense of this operation, I recommend you reduce the number of LOH allocations to as little as possible and pool those that you do make. This will significantly reduce the need for compaction. View this feature as a last resort and only if fragmentation and very large heap sizes are an issue.

Это пример достаточно распространенной проблемы в этой книге. Дается совет (инструмент), но не объясняется, как им пользоваться. Да, я понял, что иногда в моем приложении возникает проблема с LOH-ом. Но как мне ее задетектить во время исполнения? Запускать упаковку раз в день, раз в час, по запросу через внутренний интерфейс?

В некоторых случаях автор дает очень толковые решения. Например, он предлагает такой трюк: высоконагруженное серверное приложение, которое должно обеспечить минимальную латентность, может перенаправлять запросы другому серверу при уведомлении от GC о приближающейся сборке мусора (узнать это можно через специальный API уведомлений).

В целом, в книге хватает сомнительных советов. То автор предлагает рассмотреть использование Therad.Sleep в качестве таймера (хотя тремя абзацами выше советует этого не делать), то говорит о вреде LINQ-а (в книге он нигде не используется, даже там, где очень напрашивается) из-за слишком большого количества генерируемого компилятором кода. То автор сравнивает for vs. foreach на примере массива с приведением его к IEnumerable<T>, что приводит к дополнительным аллокациям, но не сравнивает for vs. foreach на конкретных типах коллекций, итераторы которых являются структурами.

Автор говорит об опасностях упаковки, и советует искать их с помощью поиска инструкции box в результирующем IL-е, и даже не говорит о крайних случаях, когда упаковка происходит при вызове GetType или непереопределенных методах ToString/Equals/GetHashCode (ничего подобного на перечень всех вариантов боксинга, как описал Control Flow в «Боксинг в C#» и близко в книге нет). Автор оспаривает эмпирическое правило о размере структуры (что размер структуры не должен превышать 3 машинных слова), но не приводит свои измерения (тот же Control Flow построил замечательный график, который показывает, что неворованные структуры менее эффективны и automatic layout рулит):

clip_image004

Будет ли книга полезна вам?

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

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

Дополнительные ссылки

З.Ы. А если у кого есть на примете интересные ссылки по разработке высокоэффективных .NET приложений, то делитесь ими, не стесняйтесь;)

10 комментариев:

  1. "что Task не является абстракцией потоков. Task – это реализация паттерна Future, который не просто абстрагирует понятие потока, а заставляет разработчика думать о системе, как о наборе взаимодействующих активных агентов, а не потоков" - это совершенно не мешает ей быть также и абстракцией потоков. Помойму то, что тпл реализует Future и является абстракцией потоков вещи ортогональные.

    ОтветитьУдалить
    Ответы
    1. Ну, мы можем говорить, что WCF является абстракцией сокетов, поскольку в некоторых сулчаях в качестве транспорта может использоваться TCP или UDP. Так же и с тасками.
      Да, в некоторых случаях таски используют потоки, но ведь это не обязательно.

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

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

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

      Удалить
    2. "Если таски рассматривать лишь как упрощенный способ запустить некоторый кусок работы в другом потоке, который потом дернет событие о завершении своей работы, то толку от таких тасок не будет."
      следует ли понимать так, что если я хочу " запустить некоторый кусок работы в другом потоке" то таски не лучший вариант? А что использовать вместо них?

      Удалить
    3. Если вам нужно лишь запустить задачу в другом потоке, то проще использовать ThreadPool.QueueUserWorkItem и не морочить голову с тасками:)

      Удалить
  2. Сергей, спасибо за статью и блог. Как всегда интересно.
    Эта книга была след. в очереди на чтение, но теперь стоит призадуматься :).

    Вы упомянули :"Я бы сказал так: инструменты расписаны хорошо, вопросы метрик и их измерения расписаны нормально, а внутренности .NET-а и CLR расписаны плохо. ". Хотелось бы узнать ваше мнение о след. книге http://www.amazon.com/Deconstructed-Discover-works-NET-Framework/dp/1430266708/
    Может быть уже читали и есть какие-либо выводы о ней?

    ОтветитьУдалить
    Ответы
    1. Я посмотрел содержание книги, есть все шансы, что она ни о чем:

      Understand how C# handles your filesystem requests and passes them down to hard disks and memory

      Learn how RAM works and how programs map to address spaces

      Discover the C# compilation sequence in detail and follow it down from abstract code to actual function

      See how your device’s micro-processor executes Machine Code and just-in-time compilation provides it when it’s needed

      Learn how the Common Language Runtime (CLR) determines the execution of your code and handles Threading and Scheduling for your instructions

      Все это довольно специфические темы....
      Если нужна книга по глубинам, то я бы порекомендовал бы "Pro .NET Performance".

      Удалить
  3. Спасибо за рецензию книги.
    На счет "внутренности .NET-а и CLR расписаны плохо" - о них вообще везде плохо расписано :) Тема кишек .NET литературой совершенно не раскрыта. Приходится довольствоваться моделями - типа "это работает примерно вот так, но на самом деле MS не раскрывает всех деталей реализации".
    Недавно видел сообщение о том что платформу делают открытой. Думаю, в связи с этим количество качественной литературы должно стать побольше.

    ОтветитьУдалить
    Ответы
    1. MS давно уже выложило в сеть reference sources of .NET Framework http://referencesource.microsoft.com/
      А позавчера и coreclr выложили на github.
      Теперь подобные книги потеряли актуальность, бери сорсы и изучай/дебаж

      Удалить