Книга «Высокопроизводительный код на платформе .NET. 2-е издание»

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

    Попутно Бен Уотсон погрузит в конкретные составляющие среды .NET, в частности в положенную в ее основу общеязыковую среду выполнения (Common Language Runtime (CLR)), и увидим, как происходит управление памятью вашей машины, генерируется код, организуется многопоточное выполнение и делается многое другое. Вам будет показано, как архитектура .NET одновременно и ограничивает ваше программное средство, и предоставляет ему дополнительные возможности и как выбор путей программирования может существенно повлиять на общую производительность приложения. В качестве бонуса автор поделится с вами историями из опыта создания в течение последних девяти лет очень крупных, сложных, высокопроизводительных .NET-систем в компании Microsoft.

    Отрывок.Подберите подходящий размер пула потоков


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

    const int MinWorkerThreads = 25;
    const int MinIoThreads = 25;
    ThreadPool.SetMinThreads(MinWorkerThreads, MinIoThreads);

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

    Можно также установить максимальное их количество, воспользовавшись методом SetMaxThreads, но данный прием подвержен аналогичным рискам.

    Чтобы выяснить нужное количество потоков, оставьте этот параметр в покое и проанализируйте свое приложение в устойчивом состоянии, воспользовавшись методами ThreadPool.GetMaxThreads и ThreadPool.GetMinThreads или счетчиками производительности, которые покажут количество потоков, задействованных в процессе.

    Не прерывайте потоки


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

    Для безопасного завершения работы потока нужно воспользоваться каким-то совместно используемым состоянием, и сама функция потока должна проверить это состояние, чтобы определить, когда должна завершиться его работа. Безопасность должна достигаться за счет согласованности.

    Вообще, стоит всегда задействовать Task-объекты — API для прерывания задачи Task не предоставляется. Чтобы получить возможность согласованно завершить работу потока, нужно, как отмечалось ранее, воспользоваться маркером отмены CancellationToken.

    Не меняйте приоритет потоков


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

    Лучше понизить приоритет потока, если в нем выполняется что-то, что может подождать завершения выполнения задач обычной приоритетности. Одной из веских причин понижения приоритета потока может быть обнаружение вышедшего из-под контроля потока, выполняющего бесконечный цикл. Безопасно прервать работу потока невозможно, поэтому единственный способ вернуть данный поток и ресурсы процессора — перезапуск процесса. До тех пор пока не появится возможность закрыть поток и сделать это чисто, понижение приоритета вышедшего из-под контроля потока будет вполне разумным средством минимизации последствий. Следует заметить, что даже потокам с пониженным приоритетом все же со временем гарантируется запуск: чем дольше они будут обделены запусками, тем выше будет устанавливаемый системой Windows их динамический приоритет. Исключение составляет приоритет простоя THREAD_‑PRIORITY_IDLE, при котором операционная система спланирует выполнение потока только в том случае, когда ей в буквальном смысле будет больше нечего запускать.

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

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

    Синхронизация потоков и блокировки


    Как только разговор заходит о нескольких потоках, возникает необходимость их синхронизации. Синхронизация заключается в обеспечении доступа только одного потока к совместно используемому состоянию, например к полю класса. Обычно синхронизация потоков выполняется с помощью таких объектов синхронизации, как Monitor, Semaphore, ManualResetEvent и т. д. Иногда их неформально называют блокировками, а процесс синхронизации в конкретном потоке — блокировкой.

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

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

    Нужно ли вообще заботиться о производительности?


    Сперва обоснуйте необходимость повышения производительности. Это возвращает нас к принципам, рассмотренным в главе 1. Не для всего кода вашего приложения производительность одинаково важна. Не весь код должен подвергаться оптимизации n-й степени. Как правило, все начинается с «внутреннего цикла» — кода, выполняемого наиболее часто или наиболее критического для производительности, — и распространяется во все стороны, пока затраты не превысят получаемую выгоду. В коде есть множество областей, гораздо менее важных с точки зрения производительности. В такой ситуации, если нужна блокировка, спокойно применяйте ее.

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

    А нужна ли вообще блокировка?


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

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

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

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

    object syncObj = new object();
    var masterList = new List<long >();
    const int NumTasks = 8;
    Task[] tasks = new Task[NumTasks];
    
    for (int i = 0; i < NumTasks; i++)
    {
         tasks[i] = Task.Run(()=>
         {
               for (int j = 0; j < 5000000; j++)
               {
                    lock (syncObj)
                    {
                          masterList.Add(j);
                    }
                }
          });
    }
    Task.WaitAll(tasks);

    Этот код можно преобразовать следующим образом:

    object syncObj = new object();
    var masterList = new List<long >();
    const int NumTasks = 8;
    Task[] tasks = new Task[NumTasks];
    
    for (int i = 0; i < NumTasks; i++)
    {
         tasks[i] = Task.Run(()=>
         {
               var localList = new List<long >();
               for (int j = 0; j < 5000000; j++)
               {
                    localList.Add(j);
               }
               lock (syncObj)
               {
                    masterList.AddRange(localList);
               }
          });
    }
    Task.WaitAll(tasks);

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

    Порядок предпочтения синхронизации


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

    1. lock/класс Monitor — сохраняет простоту, доступность кода для понимания и обеспечивает хороший баланс производительности.

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

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

    И наконец, если действительно можно будет доказать пользу от их применения, задействуйте более замысловатые, сложные блокировки (имейте в виду: они редко оказываются настолько полезными, как вы ожидаете):

    1. асинхронные блокировки (будут рассмотрены далее в этой главе);
    2. все остальные.

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

    » Более подробно с книгой можно ознакомиться на сайте издательства
    » Оглавление
    » Отрывок

    Для Хаброжителей скидка 25% по купону — .NET

    По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
    Издательский дом «Питер»
    260,99
    Компания
    Поделиться публикацией

    Похожие публикации

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

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

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