С появлением C# 5, Visual Basic .NET 11, Microsoft .NET Framework 4.5 и .NET для приложений Windows Store асинхронное программирование значительно упростилось. Новые ключевые слова async и await (Async и Await в Visual Basic) позволяют разработчикам поддерживать ту же абстракцию, к которой они привыкли при написании синхронного кода.
В Visual Studio 2012 было вложено много усилий для улучшения отладки асинхронного кода такими средствами, как Parallel Stacks, Parallel Tasks, Parallel Watch и Concurrency Visualizer. Однако в сравнении с возможностями отладки синхронного кода необходимый уровень еще не достигнут.
Одна из более значимых проблем, разрушающая абстракцию и раскрывающая внутреннюю инфраструктуру за фасадом async/await, — отсутствие в отладчике информации о стеке вызовов. В этой статье я попытаюсь восполнить этот пробел и улучшить удобство отладки асинхронного кода в ваших приложениях .NET 4.5 или Windows Store.
Но сначала договоримся о терминологии.
Определение стека вызовов
В документации MSDN (bit.ly/Tukvkm) стек вызовов раньше определяли как «серии вызовов методов, ведущих от начала программы к выражению, выполняемому в данный момент». Эта трактовка была совершенно верна в отношении однопоточной модели синхронного программирования, но теперь, когда активно развиваются параллелизм и асинхронность, нужна более точная формулировка.
Для целей этой статьи важно различать цепочку причинности (causality chain) и стек возвратов (return stack). В синхронной парадигме эти два термина почти идентичны (об исключении я скажу пару слов немного позже). В асинхронном коде вышеупомянутое определение описывает цепочку причинности.
С другой стороны, факт окончания выполняемого в данный момент выражения приведет к тому, что набор других методов продолжит свое выполнение. Этот набор образует стек возвратов. В качестве альтернативы для читателей, знакомых со стилем передачи продолжений (continuation passing style) (по этой тематике Эрик Липперт [Eric Lippert] написал знаменитую серию статей, первая из которых лежит по ссылке bit.ly/d9V0Dc), стек возвратов можно было бы определить как набор продолжений, зарегистрированных для выполнения по завершении текущего выполняемого метода.
Если в двух словах, то цепочка причинности отвечает на вопрос «как я попал сюда?», тогда как стек возвратов — на вопрос «куда мне идти далее?». Например, если в вашем приложении произошла взаимоблокировка, вы могли бы выяснить, что привело к ней, по цепочке причинности, а стек возвратов сообщил бы вам о следствиях. Заметьте: хотя цепочка причинности всегда обеспечивает отслеживание вплоть до точки входа в программу, стек возвратов обрезается в точке, где результат асинхронной операции не наблюдается (например, в асинхронных void-методах или в рабочих элементах, запланированных для выполнения через ThreadPool.QueueUserWorkItem).
Существует также понятие трассировки стека, которая является копией стека синхронных вызовов, сохраняемой для диагностики; я буду использовать эти два термина как взаимозаменяемые.
Вы должны осознавать, что в предыдущих определениях есть несколько неявных допущений.
- «Вызовы методов», упоминаемые в первом определении, в целом подразумевают «методы, выполнение которых еще не закончено», что имеет физический смысл «наличия в стеке» в модели синхронного программирования. Однако, хотя мы, как правило, не заинтересованы в методах, уже вернувших управление, при отладке асинхронного кода их не всегда можно различить. В этом случае нет физического понятия «наличия в стеке», и все продолжения являются равно допустимыми элементами в цепочке причинности.
- Даже в синхронном коде цепочка причинности и стек возвратов не всегда идентичны. Один конкретный случай — завершающий (хвостовой) вызов (tail call): некий метод может присутствовать в одном, но отсутствовать в другом. Это нельзя напрямую выразить в C# и Visual Basic .NET, но может быть закодировано на Intermediate Language (IL) (префикс «tail.») или сгенерировано JIT-компилятором (особенно в 64-разрядном процессе).
- Последнее, но не менее важное. Цепочки причинности и стеки возвратов могут быть нелинейными. То есть в самом общем случае они являются направленными графами, имеющими текущее выражение в качестве приемника (граф причинности) или источника (граф возвратов). Нелинейность в асинхронном коде вызывается разветвлениями (параллельные асинхронные операции, исходящие из одной) и схождениями (продолжения, запланированные к выполнению по завершении набора параллельных асинхронных операций). Для целей этой статьи и в связи с ограничениями платформы (поясню позже) я буду учитывать только линейные цепочки причинности и стеки возвратов, которые являются подмножествами соответствующих графов.
К счастью, если асинхронность вводится в программу с помощью ключевых слов async и await безо всяких разветвлений (forks) или схождений (joins) и все async-методы помечены как ожидаемые (ключевое слово await), то цепочка причинности по-прежнему идентична стеку возвратов — как в синхронном коде. В этом случае оба понятия равно полезны для ориентирования в потоке управления.
С другой стороны, цепочки причинности редко тождественны стекам возвратов в программах, использующих явно запланированные продолжения; яркий пример тому — поток данных (dataflow) в Task Parallel Library (TPL). Это связано с природой данных, передаваемых от блока источника блоку получателя и никогда не возвращаемых блоку источника.
Существующие средства
Рассмотрим краткий пример:
static void Main()
{
OperationAsync().Wait();
}
async static Task OperationAsync()
{
await Task.Delay(1000);
Console.WriteLine("Where is my call stack?");
}
Экстраполируя абстракцию, к чему разработчики привыкли в отладке синхронного кода, можно подумать, что при приостановке выполнения в методе Console.WriteLine будет следующая цепочка причинности/стек возвратов:
ConsoleSample.exe!ConsoleSample.Program.OperationAsync() Line 19
ConsoleSample.exe!ConsoleSample.Program.Main() Line 13
Но если вы попробуете это сделать, то обнаружите, что в окне Call Stack метод Main отсутствует, а трассировка стека начинается прямо в методе OperationAsync, которому предшествует [Resuming Async Method]. В Parallel Stacks будут оба метода; однако в нем не будет показано, что Main вызывает OperationAsync. Parallel Tasks также не поможет — вы увидите «No tasks to display».
Примечание: к этому моменту отладчик знает, что метод Main является частью стека вызовов; вы могли заметить это по серому фону за вызовом OperationAsync. CLR и Windows Runtime (WinRT) должны знать, откуда следует продолжить выполнение после возврата управления из самого верхнего фрейма стека; таким образом, они действительно хранят стеки возвратов. Но в этой статье я буду говорить только об отслеживании причинности, оставив стеки возвратов для следующей статьи.
Сохранение цепочек причинности
На самом деле цепочки причинности никогда не хранятся исполняющей средой. Даже стеки вызовов, которые вы видите при отладке синхронного кода, по сути, являются стеками возвратов — как было сказано, они нужны CLR и Windows Runtime, чтобы знать, какие методы следует выполнять после возврата из самого верхнего фрейма стека. Исполняющей среде безразлично, что вызвало выполнение конкретного метода.
Чтобы иметь возможность просматривать цепочки причинности при активной (live) и посмертной (post-mortem) отладке, вы должны явным образом сохранять их. Предположительно, это потребует сохранения (синхронного) информации трассировки стека в каждой точке, где запланировано продолжение, и восстановления этих данных, когда продолжение начнет выполняться. Потом эти сегменты трассировки стека можно было бы сшить друг с другом и получить цепочку причинности.
Мы больше заинтересованы в передаче информации о причинности между конструкциями await, поскольку именно здесь разрушается абстракция схожести с синхронным кодом. Давайте посмотрим, как и когда можно захватывать эти данные.
Как указал Стефен Тауб (Stephen Toub) (bit.ly/yF8eGu), если FooAsync возвращает Task, следующий код:
await FooAsync();
RestOfMethod();
трансформируется компилятором в примерно такой эквивалент:
var t = FooAsync();
var currentContext = SynchronizationContext.Current;
t.ContinueWith(delegate
{
if (currentContext == null)
RestOfMethod();
else
currentContext.Post(delegate { RestOfMethod(); }, null);
}, TaskScheduler.Current);
Из этого раскрытого кода видно, что есть не менее двух точек расширения, которые могли бы позволить получать информацию о причинности: TaskScheduler и SynchronizationContext. Действительно оба предлагают сходные пары виртуальных методов, где должна быть возможность захвата сегментов стека вызовов в нужные моменты: QueueTask/TryDequeue в TaskScheduler и Post/OperationStarted в SynchronizationContext.
К сожалению, можно замещать только исходный TaskScheduler при явном планировании делегата через TPL API, например Task.Run, Task.ContinueWith, TaskFactory.StartNew и др. Это означает, что всякий раз, когда продолжение планируется вне выполняемой задачи, будет действовать исходный TaskScheduler. Таким образом, подход с применением TaskScheduler не позволит захватывать необходимую информацию.
Что касается SynchronizationContext, то, хоть и возможно переопределять исходный экземпляр этого класса для текущего потока, вызывая метод SynchronizationContext.SetSynchronizationContext, это должно делаться для каждого потока в приложении. Значит, вам пришлось бы управлять сроком жизни потока, что неосуществимо, если вы не собираетесь заново реализовать пул потоков. Более того, Windows Forms, Windows Presentation Foundation (WPF) и ASP.NET предоставляют собственные реализации SynchronizationContext в дополнение к SynchronizationContext.Default, который планирует работу для пула потоков. Следовательно, ваша реализация должна была бы вести себя по-разному в зависимости от источника потока, в котором она работает.
Кроме того, заметьте, что при ожидании пользовательского ожидаемого объекта (awaitable) вопрос об использовании SynchronizationContext для планирования продолжения полностью возлагается на реализацию.
К счастью, в нашем сценарии есть две подходящие точки расширения: подписка на TPL-события без модификации существующей кодовой базы или явное согласие на получение рассылки путем небольшого изменения каждого await-выражения в приложении. Первый подход работает только в настольных .NET-приложениях, а второй позволяет работать и с приложениями Windows Store. Я подробно изложу эти подходы в следующих разделах.
Введение EventSource
.NET Framework поддерживает Event Tracing for Windows (ETW), определяющий провайдеры событий практически для каждого аспекта исполняющей среды (bit.ly/VDfrtP). В частности, TPL генерирует события, позволяющие отслеживать срок жизни Task. Хотя не все из этих событий документированы, вы можете сами получить их определения, анализируя mscorlib.dll с помощью таких инструментов, как ILSpy или Reflector, либо просмотрев справочный исходный код инфраструктуры (bit.ly/HRU3) и найдя класс TplEtwProvider. Конечно, в таком случае следует обычное предупреждение: если API не документирован, нет никаких гарантий, что в следующем выпуске его эмпирически наблюдаемое поведение останется прежним.
TplEtwProvider наследует от System.Diagnostics.Tracing.EventSource, который был введен в .NET Framework 4.5 и теперь является рекомендуемым способом генерации ETW-событий в вашем приложении (ранее вам приходилось вручную создавать манифест ETW). Кроме того, EventSource позволяет использовать события в процессе, подписываясь на них через EventListener, который тоже является новинкой .NET Framework 4.5 (подробнее о нем чуть позже).
Провайдер событий можно идентифицировать по имени или GUID. Каждый конкретный тип событий в свою очередь описывается идентификатором события и дополнительно (необязательно) ключевым словом, чтобы отличать его от других несвязанных типов событий, генерируемых тем же провайдером (TplEtwProvider не использует ключевые слова). Также есть необязательные параметры Task и Opcode, которые могут оказаться полезными для фильтрации, но я буду полагаться исключительно на идентификатор события. В каждом событии также определяется уровень детализации.
TPL-события находят много применений, помимо цепочек причинности, например при отслеживании задач «на лету», в телеметрии и т. д. Однако они не генерируются для пользовательских ожидаемых объектов.
Введение EventListener
В .NET Framework 4, чтобы захватывать ETW-события, вы должны были запускать внепроцессный ETW-слушатель, такой как Windows Performance Recorder или PerfView от Вэнса Моррисона (Vance Morrison), а затем коррелировать полученные данные с состоянием, наблюдаемым вами в отладчике. Это создавало дополнительные проблемы, поскольку данные хранились вне пространства памяти процесса и не включались в аварийные дампы, что делало это решение менее пригодным для посмертной отладки. Например, если вы получаете дампы через Windows Error Reporting, вы не получите никаких ETW-трассировок, а значит, утеряете информацию о причинности.
Однако, начиная с .NET Framework 4.5 можно подписываться на TPL-события (и другие события, генерируемые производными от EventSource) через System.Diagnostics.Tracing.EventListener (bit.ly/XJelwF). Это позволяет захватывать и сохранять сегменты трассировки стека в пространстве памяти процесса. Таким образом, для извлечения информации о причинности будет достаточно малого дампа памяти (mini-dump with heap). В этой статье я опишу детали подписок только на основе EventListener.
Стоит упомянуть, что преимущество внепроцессного (внешнего) слушателя заключается в том, что вы всегда можете получить стеки вызовов прослушиванием Stack ETW Events (либо полагаясь на какой-либо существующий инструмент или выполняя утомительный проход по стеку и самостоятельно отслеживая адреса модулей). При подписке на события с помощью EventListener информацию о стеках вызовов нельзя получить в приложениях Windows Store, так как StackTrace API запрещен. (Подход, работающий для приложений Windows Store, описывается далее в этой статье.)
Чтобы подписаться на события, вы должны наследовать от EventListener, переопределить метод OnEventSourceCreated и убедиться, что экземпляр вашего слушателя создается в каждом AppDomain вашей программы (подписка создается для каждого домена приложения). После создания экземпляра EventListener будет вызван этот метод, чтобы уведомить слушатель о создаваемых источниках событий. Он также предоставляет уведомления по всем источникам событий, существовавших до создания слушателя. После фильтрации источников событий по имени или GUID (для большей производительности фильтруйте по GUID) подписка слушателя на источник осуществляется вызовом EnableEvents:
private static readonly Guid tplGuid =
new Guid("2e5dba47-a3d2-4d16-8ee0-6671ffdcd7b5");
protected override void OnEventSourceCreated(EventSource eventSource)
{
if (eventSource.Guid == tplGuid)
EnableEvents(eventSource, EventLevel.LogAlways);
}
Чтобы обрабатывать события, вам нужно реализовать абстрактный метод OnEventWritten. Для сохранения и восстановления сегментов трассировки стека вы должны захватывать стек вызов непосредственно перед планированием асинхронной операции, а затем, когда она начинает выполняться, сопоставлять с ней сохраненный сегмент трассировки стека. Чтобы коррелировать эти два события, можно использовать параметр TaskID. Параметры, передаваемые соответствующему методу, генерирующему событие в источнике событий, упаковываются в набор объектов только для чтения, который передается в свойстве Payload объекта EventWrittenEventArgs.
Любопытно, что существуют специальные быстрые пути для событий EventSource, которые используются как ETW (не через EventListener); в них не применяется упаковка аргументов. Это повышает производительность, но по большей части выигрыш теряется из-за механизмов взаимодействия между процессами.
В методе OnEventWritten нужно различать источники событий (если вы подписаны более чем на один источник) и идентифицировать само событие. Трассировка стека будет захватываться (сохраняться), когда срабатывает событие TaskScheduled или TaskWaitBegin, и сопоставляться с только что начатой асинхронной операцией (восстанавливаться) в TaskWaitEnd. Кроме того, вы должны передавать taskId как идентификатор корреляции. На рис. 1 кратко показано, как будут обрабатываться события.
Рис. 1. ОбработкаTPL-событий в методе OnEventWritten
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
if (eventData.EventSource.Guid == tplGuid)
{
int taskId;
switch (eventData.EventId)
{
case 7: // задача запланирована
taskId = (int)eventData.Payload[2];
stackStorage.StoreStack(taskId);
break;
case 10: // началось ожидание задачи
taskId = (int)eventData.Payload[2];
bool waitBehaviorIsSynchronous =
(int)eventData.Payload[3] == 1;
if (!waitBehaviorIsSynchronous)
stackStorage.StoreStack(taskId);
break;
case 11: // ожидания задачи закончено
taskId = (int)eventData.Payload[2];
stackStorage.RestoreStack(taskId);
break;
}
}
}
Примечание: явные значения («волшебные числа») в коде — скверная практика программирования и используется здесь только для краткости. В сопутствующем проекте с примером кода все значения удобно структурированы в виде констант и перечислений, чтобы избежать дублирования и риска опечаток.
Обратите внимание на то, что в TaskWaitBegin я проверяют TaskWaitBehavior на синхронность; такое происходит, когда ожидаемая задача выполняется синхронно или уже завершена. В этом случае стек синхронных вызовов по-прежнему на месте, и его не требуется явным образом сохранять.
Асинхронное локальное хранилище
Какую структуру данных вы ни выбрали бы для хранения сегментов стека вызовов, у нее должно быть одно качество: сохраняемое значение (цепочка причинности) должно запоминаться для каждой асинхронной операции в потоке управления между await-границами и продолжениями и с учетом того, что продолжения могут выполняться в разных потоках.
Это предполагает наличие переменной, подобной локальной для потока, которая сохраняла бы свое значение, относящееся к текущей асинхронной операции (цепочке продолжений), а не к конкретному потоку. Ее можно было бы назвать чем-то вроде асинхронного локального хранилища (async-local storage).
В CLR уже есть структура данных ExecutionContext, которая захватывается в одном потоке и восстанавливается в другом (где продолжение начинает выполняться) и тем самым передается соответственно потоку управления. По сути, это контейнер, хранящий другие контексты (SynchronizationContext, CallContext и др.), которые могли бы понадобиться для продолжения выполнения точно в той же среде, где они были прерваны. Стефен Тауб (Stephen Toub) детально обсуждает это по ссылке bit.ly/M0amHk. Самое важное, что вы можете хранить произвольные данные в CallContext (вызывая его статические методы LogicalSetData и LogicalGetData), вроде бы подходящие для вышеупомянутой цели.
Учитывайте, что CallContext (на внутреннем уровне их на самом деле два: LogicalCallContext и IllogicalCallContext) — массивный объект, предназначенный для передачи через границы удаленных процессов. Когда пользовательских данных для сохранения нет, исполняющая среда не инициализирует контексты, тем самым снижая издержки на их поддержку. Как только вы вызываете метод CallContext.LogicalSetData, это приводит к созданию и передаче или клонированию изменяемого ExecutionContext и нескольких Hashtable.
К сожалению, ExecutionContext (вместе со всеми составляющими) захватывается до генерации описанных TPL-событий и вскоре после этого восстанавливается. Таким образом, любые пользовательские данные, сохраненные в CallContext в этот промежуток, отбрасываются после восстановления ExecutionContext, что делает его непригодным для наших целей.
Кроме того, класс CallContext недоступен в .NET для подмножества Windows Store, так что для этого сценария в любом случае нужна какая-то альтернатива.
Один из способов создания асинхронного локального хранилища, который позволил бы обойти все эти проблемы, — поддерживать значение в памяти, локальной для потока (thread-local storage, TLS), пока выполняется синхронная часть кода. В таком случае при генерации события TaskWaitStart вы сохраняете значение в общем словаре (отличном от TLS) и индексируете его по TaskID. Когда срабатывает парное событие, TaskWaitEnd, вы удаляете сохраненное значение из этого словаря и сохраняете его обратно в TLS, возможно, в другом потоке.
Как вы, вероятно, знаете, значения, хранимые в TLS, сохраняются даже после того, как поток возвращается в пул и начинает выполнять новый рабочий элемент. Поэтому в некий момент это значение нужно удалить из TLS (иначе какая-то другая асинхронная операция, выполняемая в этом потоке впоследствии, может обратиться к значению, сохраненному предыдущей операцией, и счесть его собственным значением). В обработчике событий TaskWaitBegin этого делать нельзя, так как в случае вложенных await события TaskWaitBegin и TaskWaitEnd возникают несколько раз — по одному на каждое выражение await, а сохраненное значение может понадобиться в промежутке, как, например, в следующем фрагменте кода:
async Task OuterAsync()
{
await InnerAsync();
}
async Task InnerAsync()
{
await Task.Delay(1000);
}
Вместо этого безопасно считать, что значение в TLS подлежит очистке, когда текущая асинхронная операция больше не выполняется в потоке. Поскольку в CLR нет внутрипроцессного (внутреннего) события, которое уведомляло бы о потоке, возвращаемом обратно в пул (есть ETW-событие; см. bit.ly/ZfAWrb), для этой цели я буду использовать событие ThreadPoolDequeueWork, генерируемое FrameworkEventSource (также не документированным), когда в потоке из пула начинается новая операция. Это оставляет за рамками потоки, не принадлежащие пулу, для которых вам пришлось бы вручную очищать TLS, например, когда UI-поток возвращается в цикл обработки сообщений.
Работающую реализацию этой концепции вместе с захватом и конкатенацией сегментов стека, пожалуйста, изучите в классе StackStorage в исходном коде, сопутствующем этой статье. Там же вы найдете более четкую абстракцию, AsyncLocal<T>, которая позволяет сохранять любое значение и передавать его с потоком управления в последующие асинхронные продолжения. Я буду использовать ее как хранилище цепочки причинности в сценариях отладки приложений Windows Store.
Отслеживание причинности в приложениях Windows Store
Описанный подход был бы приемлем в сценарии с Windows Store, если бы в этом случае был доступен System.Diagnostics.StackTrace API. Как бы там ни было, его нет, а значит, вы не сможете получить из своего кода информацию о фреймах стека вызовов, расположенных над текущим фреймом. Таким образом, хотя TPL-события поддерживаются, вызов TaskWaitStart или TaskWaitEnd будет похоронен глубоко в вызовах методов инфраструктуры, поэтому вы ничего не узнаете о том, что вызвало эти события в вашем коде.
К счастью, в .NET для приложений Windows Store (равно как и в .NET Framework 4.5) имеется CallerMemberNameAttribute (bit.ly/PsDH0p) и родственные ему атрибуты CallerFilePathAttribute и CallerLineNumberAttribute. Когда необязательные аргументы метода дополняются этими атрибутами, компилятор будет инициализировать аргументы соответствующими значениями на этапе компиляции. Например, следующий код будет выводить «Main() in c:\Full\Path\To\Program.cs at line 14»:
static void Main(string[] args)
{
LogCurrentFrame();
}
static void LogCurrentFrame([CallerMemberName] string name = null,
[CallerFilePath] string path = null,
[CallerLineNumber] int line = 0)
{
Console.WriteLine("{0}() in {1} at line {2}", name, path, line);
}
Это позволяет методу протоколирования получать информацию только о вызвавшем фрейме (calling frame), т. е. вы должны обеспечить, чтобы он вызывался изо всех методов, которые вы хотите захватывать в цепочку причинности. Один из удобных вариантов для этого — дополнение каждого await-выражения вызовом метода расширения, например:
await WorkAsync().WithCausality();
Здесь метод WithCausality захватывает текущий фрейм, дописывает его к цепочке причинности и возвращает Task или ожидаемый объект (в зависимости от того, что именно возвращает WorkAsync), который по завершении выполнения исходного объекта удаляет этот фрейм из цепочки причинности.
Так как ожидать можно разные вещи, требуется несколько перегруженных версий WithCausality. Это делается достаточно прямолинейно для Task<T> (и еще легче для Task):
public static Task<T> WithCausality<T>(this Task<T> task,
[CallerMemberName] string member = null,
[CallerFilePath] string file = null,
[CallerLineNumber] int line = 0)
{
var removeAction =
AddFrameAndCreateRemoveAction(member, file, line);
return task.ContinueWith(t => { removeAction(); return t.Result; });
}
Однако это далеко не просто для пользовательских ожидаемых объектов (custom awaitables). Как вы, по-видимому, знаете, компилятор C# позволяет вам ожидать экземпляр любого типа, соответствующего определенному шаблону (см. bit.ly/AmAUIF), что делает невозможным написание перегруженных версий, работающих с пользовательскими ожидаемыми объектами, при использовании только статической типизации. Вы можете создать несколько перегрузок-сокращений (shortcut overloads) для ожидаемых объектов, предопределенных в инфраструктуре, таких как YieldAwaitable или ConfiguredTaskAwaitable (или определенных в вашем решении), но, в целом, вам придется прибегнуть к Dynamic Language Runtime (DLR). Обработка всех этих случаев требует написания уймы стереотипного кода, так что детали смотрите в сопутствующем исходном коде.
Также стоит отметить, что в случае вложенных await-выражений методы WithCausality будут выполняться от внутреннего к внешнему (по мере оценки await-выражений), поэтому следует быть очень внимательным, чтобы собрать стек в правильном порядке.
Просмотр цепочек причинности
Оба описанных подхода подразумевают хранение информации о причинности в памяти как списки сегментов или фреймов стека вызовов. Однако проход по ним и их конкатенация в единую цепочку причинности для отображения — задача весьма утомительная, если делать это вручную.
Самый простой вариант автоматизации этого процесса — использовать средство оценки в отладчике (debugger evaluator). В этом случае вы создаете открытое статическое свойство (или метод) в открытом классе, который при вызове проходит список хранящихся сегментов и возвращает соединенную цепочку причинности. Затем вы можете оценить это свойство (проверить его значение) при отладке и увидеть результат в средстве визуализации текста.
К сожалению, этот подход не работает в двух ситуациях. Одна из них — когда самый верхний фрейм стека находится в неуправляемом коде, что является довольно распространенным сценарием при отладке зависаний приложения, так как синхронизирующие примитивы ядра вызывают именно неуправляемый код. Тогда средство оценки в отладчике просто сообщит «Cannot evaluate expression because the code of the current method is optimized» («Нельзя оценить выражение, поскольку код текущего метода оптимизирован»). Это ограничение детально описано Майком Столлом (Mike Stall) (bit.ly/SLlNuT).
Другая проблема возникает при посмертной отладке. Вы можете открыть мини-дамп в Visual Studio и, как это ни странно (учитывая, что процесса для отладки нет, а если лишь дамп его памяти), изучать значения свойств (выполнять get-аксессоры свойств) и даже вызывать некоторые методы! Этот удивительный функционал встроен в отладчик Visual Studio и работает за счет интерпретации контрольного выражения (watch expression) и всех вызывающих его методов (в противоположность активной отладке, где выполняется скомпилированный код).
Очевидно, что ограничения есть. Например, при отладке с использованием дампа вы никак не можете вызывать неуправляемые методы (а значит, нельзя даже выполнить какой-нибудь делегат, так его метод Invoke генерируется в неуправляемом коде) или обращаться к некоторым ограниченным API (например, System.Reflection). Кроме того, оценка на основе интерпретатора ожидаемо медленная и, как это ни печально, из-за ошибки тайм-аут оценки при отладке на основе дампа равен всего одной секунде в Visual Studio 2012 независимо от конфигурации. С учетом количества вызовов методов, требуемых для прохода по списку сегментов трассировки стека и перебора всех фреймов это блокирует использование средства оценки для данной цели.
К счастью, отладчик всегда разрешает обращаться к значениям полей (даже при отладке на основе дампа или в том случае, когда верхний фрейм стека находится в неуправляемом коде), что позволяет перебирать объекты, образующие сохраненную цепочку причинности и реконструировать ее. Естественно, это занятие крайне утомительное, поэтому я написал расширение для Visual Studio, которое делает это за вас (см. пример в сопутствующем коде). На рис. 2 показано, что получается в конечном счете. Заметьте, что граф справа также генерируется этим расширением и представляет асинхронный эквивалент Parallel Stacks.
Рис. 2. Цепочка причинности для асинхронного метода и «параллельная» причинность для всех потоков
Сравнение подходов и их проблемы
Оба подхода к отслеживанию причинности не даются бесплатно. Второй из них (основанный на информации о вызывающем) имеет меньшие издержки, так как не использует дорогостоящий StackTrace API, полагаясь вместо него на компилятор в получении информации о фрейме вызывающего на этапе компиляции, а значит, в выполняемой программе он «бесплатен». Однако он все равно использует инфраструктуру событий с ее издержками в поддержке AsyncLocal<T>. С другой стороны, первый подход позволяет получать больше данных, не пропуская фреймы без await-выражений. Кроме того, он автоматически отслеживает несколько других ситуаций, где асинхронность на основе Task проявляется без await-выражений, например метод Task.Run. Но, увы, он не работает с пользовательскими ожидаемыми объектами.
Дополнительное преимущество отслеживания на основе TPL-событий заключается в том, что существующий асинхронный код не требуется модифицировать, тогда как при подходе на основе атрибутов информации о вызывающем вы должны изменить каждое await-выражение в своей программе. Но лишь этот подход поддерживает приложения Windows Store.
Отслеживание на основе TPL-событий также страдает от обилия стереотипного инфраструктурного кода в сегментах трассировки стека, хотя его можно легко отфильтровать по пространстве имен фрейма или имени класса. Список распространенных фильтров см. в примере в сопутствующем коде.
Другая проблема касается циклов в асинхронном коде. Рассмотрим такой фрагмент:
async static Task Loop()
{
for (int i = 0; i < 10; i++)
{
await FirstAsync();
await SecondAsync();
await ThirdAsync();
}
}
К концу этого метода его цепочка причинности будет состоять из более 30 сегментов с многократно перемежающимися фреймами FirstAsync, SecondAsync и ThirdAsync. Для конечного цикла это может быть приемлемо, хотя все равно будет впустую тратиться большой объем памяти для хранения десятка фреймов-дубликатов. Однако в некоторых случаях в программе может быть допустимый бесконечный цикл, например цикл обработки сообщений. Более того, бесконечное повторение может быть введено без цикла или await-конструкций; отличный пример — таймер, заново планирующий сам себя на каждом такте. Отслеживание бесконечной цепочки причинности — гарантированный способ исчерпания доступной памяти, поэтому объем сохраняемых данных должен быть так или иначе сокращен.
Эта проблема не касается отслеживания на основе информации о вызывающем, так как фрейм немедленно удаляется из списка при запуске продолжения. Существует два (комбинируемых) подхода к устранению проблем в сценарии с TPL-событиями. Один из них — удаление устаревших данных на основе ограничении максимального объема хранилища. Другой заключается в эффективном представлении циклов и предотвращении дублирования. При обоих подходах можно было бы обнаруживать распространенные шаблоны бесконечных циклов и явным образом обрезать цепочку причинности в этих точках.
Как можно реализовать свертывание цикла, см. в соответствующем проекте в сопутствующем коде.
Как отмечалось, API TPL-событий позволяет захватывать лишь цепочку причинности, а не граф. Это связано с тем, что методы Task.WaitAll и Task.WhenAll реализованы с применением счетчиков обратного отсчета (countdowns), где продолжение планируется, только когда последняя задача завершается и счетчик обнуляется. Таким образом, лишь последняя завершенная задача формирует цепочку причинности.
Заключение
В этой статье я рассказал, в чем заключается разница между стеком вызовов, стеком возвратов и цепочкой причинности. Теперь вы должны знать точки расширения, предоставляемые .NET Framework для отслеживания планирования и выполнения асинхронных операций, и уметь использовать их для захвата и сохранения цепочек причинности. Были описаны подходы к отслеживанию причинности в традиционных приложениях и в приложениях Windows Store как при активной, так и при посмертной отладке. Кроме того, вы узнали о концепции асинхронного локального хранилища и о его возможных реализациях для приложений Windows Store.
Теперь вы можете включать функциональность отслеживания причинности в свою базу асинхронного кода или использовать асинхронное локальное хранилище при параллельных вычислениях, исследовать источники событий, предлагаемые .NET Framework 4.5 и .NET для приложений Windows Store, чтобы создать нечто новое, например средство отслеживания незавершенных задач в своей программе, или задействовать эту точку расширения для генерации собственных событий с целью тонкой оптимизации производительности вашего приложения.