Использование ЦП не максимально и высокая синхронизация в серверном приложении, полагающемся на async/await

В настоящее время я выполняю некоторые тесты серверного приложения, которое я разработал, в значительной степени полагаясь на конструкции async/await C # 5.

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

Операции ввода-вывода, основанные на async/await, удаляются из очереди из MSMSQ, считываются/записываются данные в базу данных SQL-сервера и, наконец, отправляется запрос HttpClient в конце цепочки.

В настоящее время для моих тестов БД полностью подделана (результаты возвращаются напрямую через Task.FromResult), а также подделан HttpClient (ждите случайного Task.Delay между 0-50 мс и возвращайте ответ), поэтому только реальный Ввод-вывод — это удаление из очереди из MSMQ.

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

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

Есть две вещи, которые я не понимаю, и, возможно, за этим стоит какая-то возможность улучшения пропускной способности:

1) У меня 4 ядра процессора (на самом деле всего 2 настоящих... ЦП i7), и когда приложение работает, оно использует максимум 3 ядра ЦП (в визуализаторе параллелизма VS2012 я могу ясно видно, что используются только 3 ядра, а в Windows Perfmon я вижу, что загрузка ЦП составляет ~ 75/80%). Любая идея, почему? У меня нет контроля над потоками, поскольку я не создаю их явно, а полагаюсь только на задачи, так почему же планировщик задач не максимизирует использование ЦП в моем случае? Кто-нибудь испытал это?

2) Используя визуализатор параллелизма VS2012, я вижу очень большое время синхронизации (примерно 20% выполнения и 80% синхронизации). F.Y.I Создается около 15 тем.

Приблизительно 60% синхронизации происходит из следующего стека вызовов:

clr.dll!ThreadPoolMgr::WorkerThreadStart
clr.dll!CLRSemaphore::Wait
kernelbase.dll!WaitForSingleObjectEx

и

clr.dll!ThreadPoolMgr::WorkerThreadStart
clr.dll!ThreadPoolMgr::UnfairSemaphore::Wait
clr.dll!CLRSemaphore::Wait 
kernelbase.dll!WaitForSingleObjectEx

И примерно 30% синхронизации исходит от:

clr.dll!ThreadPoolMgr::CompletionPortThreadStart
kernel32.dll!GetQueueCompletionStatusStub
kernelbase.dll!GetQueuedCompletionStatus
ntdll.dll!ZwRemoveIoCompletion 
..... blablabla 
ntoskrnl.exe!KeRemoveQueueEx

Я не знаю, нормально ли испытывать такую ​​высокую синхронизацию или нет.

EDIT: Основываясь на ответе Стивена, я добавляю более подробную информацию о своей реализации:

Действительно, мой сервер полностью асинхронный. Однако некоторая работа процессора выполняется для обработки каждого сообщения (не так много, я признаю, но все же). После того, как сообщение получено из очереди MSMQ, оно сначала десериализуется (большая часть затрат ЦП/памяти, по-видимому, происходит в этот момент), затем оно проходит через различные этапы обработки/проверки, которые требуют некоторого количества ЦП, прежде чем, наконец, достичь «конца». канала», где обработанное сообщение отправляется во внешний мир через HttpClient.

Моя реализация не ожидает полной обработки сообщения, прежде чем исключить следующее из очереди. Действительно, мой насос сообщений, извлекающий сообщения из очереди, очень прост и сразу же «пересылает» сообщение, чтобы иметь возможность исключить из очереди следующее. Упрощенный код выглядит так (без учета управления исключениями, отмены...):

while (true)
{
    var message = await this.queue.ReceiveNextMessageAsync();
    this.DeserializeDispatchMessageAsync();
}

private async void DeserializeDispatchMessageAsync()
{
    // Immediately yield to avoid blocking the asynchronous messaging pump
    // while deserializing the body which would otherwise impact the throughput.
    await Task.Yield();

    this.messageDispatcher.DispatchAsync(message).ForgetSafely();
}

ReceiveNextMessageAsync — это пользовательский метод, использующий TaskCompletionSource, поскольку .NET MessageQueue не предлагал асинхронный метод в .NET Framework 4.5. Так что я просто использую пару BeginReceive / EndReceive с TaskCompletionSource.

Это одно из немногих мест в моем коде, где я не жду асинхронного метода. Цикл удаляется из очереди так быстро, как только может. Он даже не ожидает десериализации сообщения (десериализация сообщения лениво выполняется реализацией .NET FCL Message при явном доступе к свойству Body). Я немедленно делаю Task.Yield(), чтобы разветвить десериализацию/обработку сообщения на другую задачу и немедленно освободить цикл.

Прямо сейчас, в контексте моих стендов, как я уже говорил ранее, все операции ввода-вывода (только доступ к БД) являются поддельными. Все вызовы асинхронных методов для получения данных из БД просто возвращают Task.FromResult с поддельными данными. Во время обработки сообщения происходит что-то около 20 вызовов БД, и все они подделаны прямо сейчас / синхронно. Единственная точка асинхронности находится в конце обработки сообщения, когда оно отправляется через HttpClient. Отправка HttpClient также подделана, но в этот момент я выполняю случайное (0-50 мс) «ожидание Task.Delay». В любом случае, из-за подделки БД обработка каждого сообщения может рассматриваться как одна задача.

Для моих стендов я сохраняю в очереди около 300 тыс. сообщений, а затем запускаю серверное приложение. Он удаляется из очереди довольно быстро, переполняя серверное приложение, и все сообщения обрабатываются одновременно. Вот почему я не понимаю, почему я не достигаю 100% ЦП и 4 ядер, а только 75% и 3 ядра (вопросы синхронизации в сторону).

Когда я только удаляю очередь без какой-либо десериализации или обработки сообщений (комментируя вызов DeserializeDispatchMessageAsync, я достигаю пропускной способности примерно 20 000 сообщений/сек. Когда я выполняю всю обработку, я достигаю пропускной способности примерно 10 000 сообщений/сек.

Тот факт, что сообщения быстро удаляются из очереди и что десериализация + обработка сообщений выполняется в отдельной задаче, заставляет меня визуализировать в моей голове множество задач (по одной на сообщение), поставленных в очередь в планировщике задач (пул потоков здесь... нет контекста синхронизации), поэтому я ожидаю, что пул потоков будет отправлять все эти сообщения на максимальное количество ядер, и все 4 ядра будут полностью заняты для обработки всех задач, но мне кажется, что это не так.

В любом случае, любой ответ приветствуется, я ищу любые идеи/советы.


person darkey    schedule 26.07.2013    source источник


Ответы (1)


Похоже, ваш сервер почти полностью асинхронен (асинхронный MSMQ, асинхронный DB, асинхронный HttpClient). Так что в таком случае я не нахожу ваши результаты удивительными.

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

Task, возвращаемый асинхронной операцией MSMQ/DB/HttpClient, не выполняется в потоке пула потоков; он просто представляет собой завершение операции ввода-вывода. Единственная работа пула потоков, которую вы видите, — это короткие объемы синхронной работы внутри асинхронных методов, которые обычно просто организуют буферы для ввода-вывода.

Что касается пропускной способности, у вас есть возможности для масштабирования (при условии, что ваш тест переполнял существующую службу). Возможно, ваш код просто (асинхронно) извлекает одно значение из MSMQ, а затем (асинхронно) обрабатывает его перед получением другого значения; в этом случае вы определенно увидите улучшение от непрерывного чтения MSMQ. Помните, что код async является асинхронным, но он все еще сериализован; ваш метод async может приостановиться в любой момент await.

В этом случае вам может помочь настройка конвейера потока данных TPL (с MaxDegreeOfParallelism установлено значение Unbounded) и запускается замкнутый цикл, который асинхронно считывает из MSMQ и помещает данные в конвейер. Это было бы проще, чем делать собственную перекрывающуюся обработку.

Обновление для редактирования:

У меня есть несколько предложений:

  1. Используйте Task.Run вместо await Task.Yield. Task.Run имеет более четкое намерение.
  2. Оболочки Begin/End могут использовать Task.Factory.FromAsync вместо TCS, что дает более чистый код.

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

person Stephen Cleary    schedule 26.07.2013
comment
Большое спасибо за ответ, Стивен, и поздравляю с наградой MVP (с небольшим опозданием ;)). Я отредактировал свой вопрос, добавив больше деталей. Я до сих пор не понимаю, почему я не достигаю 100% загрузки процессора. Добавленные детали могут дать вам больше информации о реализации, чтобы улучшить ваш ответ. Спасибо ! - person darkey; 29.07.2013
comment
+1. У меня есть целая куча размещенных веб-сервисов, которые выполняют много работы асинхронно, и ЦП не делает ничего интересного, пока определенные вызовы не должны выполнять некоторые преобразования POCO с использованием Parallel.ForEach... тогда и только тогда заметно, что работа распределяется по ядрам. - person Moo-Juice; 29.07.2013
comment
@darkey: см. обновленный ответ. Я тоже не понимаю, почему вы не видите 100% CPU. - person Stephen Cleary; 30.07.2013
comment
@Stephen: Спасибо, что нашли время, чтобы просмотреть мои изменения и соответствующим образом отредактировать свой ответ. Я изменю свой код на основе ваших предложений и попытаюсь копнуть немного больше. Со временем обновлю свой пост, чтобы добавить больше соответствующих деталей. - person darkey; 30.07.2013
comment
@Stephen: Это становится все более странным ... сделал изменение для использования Task.Run вместо Task.Yield, с этим нет проблем. Затем я внес изменение, чтобы вернуть Task.Factory.FromAsync вместо использования собственного метода с TaskCompletionSource. Это изменение привело к потере пропускной способности. Там, где я создаю новый TaskCompletionSource для каждого вызова метода, версия FromAsync выделяет более одного объекта (я взглянул на источник). Таким образом, для 300 000 сообщений это больше нагрузки на сборщик мусора. Я снова запустил стенд, по три раза для каждого варианта (пользовательская оболочка против FromAsync). результаты в моем комментарии ниже - person darkey; 30.07.2013
comment
Пользовательская оболочка с использованием TaskCompletionSource (средняя загрузка ЦП на компьютере /% времени, затраченного на сборщик мусора для приложения): 76%/17% - 82%/17% - 78%/17%. Использование FromAsync: 68%/23% - 65%/23% - 66%/23%. Таким образом, из-за большего количества выделений я, конечно, провожу больше времени в сборщике мусора, поэтому пропускная способность снижается, но также снижается и загрузка ЦП. ДУХ ??? Я теряю его :( - person darkey; 30.07.2013
comment
@darkey: убедитесь, что вы передаете метод Begin в FromAsync, а не вызываете метод Begin самостоятельно, а затем передаете IAsyncResult в FromAsync. - person Stephen Cleary; 30.07.2013
comment
@Stepen: вот что я делаю: Task‹Message›.Factory.FromAsync(this.messageQueue.BeginReceive(MessageQueue.InfiniteTimeout, null), this.messageQueue.EndReceive); тем не менее это приводит к большему распределению, потому что каждый вызов выделяет две задачи + один CancellationToken из того, что я вижу из кода, тогда как моя оболочка выделяла только один TaskCompletionSource. Тем не менее, я думаю, что это не главная проблема, а скорее тот факт, что большее количество выделений вызывает больше коллекций GC, поэтому больше времени тратится на GC и потеря пропускной способности. Но меньше потребление процессора? - person darkey; 30.07.2013
comment
Я попытаюсь выделить больше тупых объектов и посмотреть, есть ли прямая связь с использованием ЦП, потому что сейчас кажется, что использование ЦП +% времени в GC = 100% (использование ЦП???) ... это не так. имеет смысл для меня, но давайте посмотрим. - person darkey; 30.07.2013
comment
@darkey: измените код FromAsync на этот: FromAsync(messageQueue.BeginReceive, messageQueue.EndReceive, MessageQueue.InfiniteTimeout, null, null) - person Stephen Cleary; 30.07.2013
comment
@Stephen: Спасибо, Стивен, теперь я получаю аналогичные результаты по сравнению с моей пользовательской оболочкой, поэтому я оставлю FromAsync. Мне просто нужно было пройти через лямбду, чтобы заменить messageQueue.BeginReceive, потому что, как ни странно, реализация BeginReceive .NET Framework принимает объект в качестве второго параметра и AsyncCallback в качестве третьего параметра (тогда как для других методов APM объект состояния является последним параметром после AsyncCallback). Ну в любом случае спасибо за подсказку. Тем не менее, я не достиг 100% загрузки процессора, поэтому мне придется исследовать больше;) - person darkey; 30.07.2013
comment
Обратите внимание, что ответ не решил проблему, я все еще не достиг пика использования ЦП и до сих пор не нашел причину. Однако ответ Стивена дал несколько хороших предложений, а также интересные технические детали. - person darkey; 13.08.2013