Асинхронность в C# 5. Часть 5: слишком много задач

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

Алгоритм следующий:

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

Предположим, клиент нашел отделение с достаточно короткой очередью. Он становится в очередь. (Очередь может работать как по M-модели, так и по W-модели; в данном случае это не важно. Но давайте предположим, что она работает по W-модели.) Когда клиент приходит к служащему, происходит следующая транзакция: клиент требует оплаты нескольких счетов, а служащий их принимает: первый, второй, третий, четвертый, пятый и т.д. Клиент уходит, а служащий обслуживает следующего клиента.

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

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

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

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

Клиент находит ближайшее отделение банка. В нем нет очереди, так что он проходит прямо в него. В нем нет служащего. Там стоит только машина для получения номеров и стрелка, указывающая на дверь. Клиент получает номер, и заходит через дверь в огромное хранилище, в котором находятся десятки тысяч клиентов. Там на огромной скорости носятся служащие от одного клиента к другому, выдавая по одному счету за раз. Если возникает проблема, то служащий обращается к мальчику и несется к следующему клиенту. Когда мальчик возвращается, служащий следит за всеми, кого он сейчас «одновременно обслуживает» и никто не уходит, пока они все не получат денег. Если служащие не справляются, то хранилище заполняется все большим и большим количеством клиентов, и чем больше становится толпа, тем дольше приходится ждать клиенту своих денег, это приводит к появлению еще большего количества клиентов… это как снежный ком.

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

Это может звучать глупо, но мы изредка делаем нечто подобное сами. Мы создали анализатор кода, который использует TAP с потоком пользовательского интерфейса. Идея была в том, что при каждом нажатии кнопки мы запустим асинхронную задачу, которая, в свою очередь, запустит еще одну асинхронную задачу. Задача была следующей:

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

2) Раскрасить текст пользовательского интерфейса на основе анализа изменений синтаксического дерева после нажатия кнопки.

3) Запустить таймер, отслеживающий отсутствие нажатий в течение половины секунды. Если он сработает, значит пользователь сделал паузу и мы можем пнуть background worker для выполнения более глубокого анализа.

Вы видите проблему? Задачи создаются так быстро между нажатиями клавиш, что при быстром наборе вы вскоре получите хранилище с десятками тысяч заданий, 99.99% которых ожидают отмены своего выполнения. А половина из тех заданий, которым удалось запуститься, создают таймеры, большая часть которых будет удалена, даже не сработав. Сборка мусора только всех этих таймеров убьет всю производительность. Асинхронность – это классная штука, но вы должны удостовериться, что ее гранулярность подходящая. Анализатор кода был переписан с использованием одного глобального таймера и менее агрессивным получением задач из очереди, которые наверняка будут отменены позднее. В результате чего производительность существенно возросла.

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

В следующий раз: дополнительные размышления о проблемах синтаксиса.

Оригинал статьи