В жизни каждого инженера может произойти ситуация, когда процесс его приложения «завис» и не выполняет ту работу, которую должен. Причины могут быть разные и чтобы узнать их, нужно заглянуть во внутренности процесса и расследовать, что же там происходит. Для этого, можно снять дамп и проанализировать его. Каждый такой случай уникальный, но подходы к анализу дампов — общие. В этой статье я покажу, как расследовал одну из подобных ситуаций и пришёл к неожиданным выводам. Подобные истории всегда начинаются с того, что существует какой-то .NET процесс, с которым что-то не так. Вот и у меня был такой процесс. Он выглядел «живым», писал логи, отправлял метрики, но полезную работу делать перестал. Конечно же, хочется разобраться в причинах такой аномалии. В этом почти всегда помогает анализ дампа процесса, ведь там можно увидеть, чем конкретно занимаются потоки приложения и какие данные лежат в памяти. Снимать и анализировать дампы можно по-разному, в этот раз я решил воспользоваться утилитой dotnet-dump. Она кроссплатформенная и является неплохой альтернативой для WinDbg. Алгоритм действий такой: выполняем команду dotnet dump ps и находим там pid своего процесса; снимаем дамп командой dotnet dump collect -p <pid>; открываем дамп командой dotnet dump analyze <dump file>. Теперь можно переходить к анализу. Для начала хочется понять общую картину происходящего внутри процесса. Для этого, проанализируем его потоки и посмотрим, чем они заняты. Выполняем команду clrstack -all, которая покажет стектрейсы всех управляемых потоков на момент снятия дампа. В моём случае их было немного и я сразу увидел тот поток, который должен был делать полезную работу, но не делал (лишнее вырезано за ненадобностью): Интересной является верхушка стектрейса. Из неё видно, что мы застали поток с идентификатором 0x346c за вызовом метода System.Diagnostics.Tracing.EventListener.Dispose(), после которого случился вызов System.Threading.Monitor.ReliableEnter, а это, как многим известно, конструкция lock. Заметим, что это происходит внутри .NET библиотеки System.Diagnostics.Tracing, а значит, мы можем найти это место в исходниках на github, благо в дампе есть указание нужного файла и даже строки: У потока не получается захватить этот лок, а значит, какой-то другой поток его уже держит. Но какой? Почему так долго не отпускает? Неужели внутри .NET случился дедлок? Для упрощения дальнейшего повествования дадим читаемое имя потоку 0x346c = EventListenerThread и попробуем разобраться с возникшими вопросами. Чтобы узнать, кто держит этот лок, можно воспользоваться командой syncblk, которая покажет нам информацию о всех блокировках, которые были на момент создания дампа: Внимательнее посмотрим на вторую строчку, ведь там содержатся уже знакомые слова про System.Diagnostics.Tracing.EventSource. Интересны колонки Thread Info и SyncBlock. Читать это можно так: "поток 0x2868 держит блокировку на объекте по адресу 000001ed376841c8". Видно, что это другой поток, с другим идентификатором. Появляется гипотеза, что EventListenerThread пытается взять лок как раз на этом объекте. Подтвердим или опровергнем это. Для этого, переключимся на EventListenerThread командой setthread -t 0x346c (напомню, это его идентификатор) и выполним команду clrstack -a, которая покажет подробную информацию о локальных переменных и аргументах функций потока: В секции LOCALS у метода System.Diagnostics.Tracing.EventListener.Dispose() видим этот объект 0x000001ed376841c8, гипотеза подтвердилась. Зафиксируем промежуточный итог — EventListenerThread пытается взять лок на объекте, которым уже владеет поток 0x2868. Теперь нужно понять, почему поток 0x2868 не отпускает этот лок. Для этого посмотрим на его стектрейс, уже знакомыми командами setthread -t 0x2868 и clrstack -a: А он и сам пытается захватить лок, но не может! Об этом нам говорит последний вызов метода System.Threading.Monitor.ReliableEnter. Поймём, кто мешает этому потоку успешно захватить лок. В секции LOCALS видно объект с адресом 0x000001edb76f3308. Вспомним вывод команды syncblk: И видим в первой строчке в точности этот объект, которым, владеет пока ещё незнакомый нам поток 0x33d0. Для упрощения дальнейшего повествования дадим читаемое имя потоку 0x2868 = CounterGroupThread и зафиксируем промежуточный итог — EventListenerThread ожидает взятие лока на 0x000001ed376841c8, но им уже владеет CounterGroupThread и не отпускает, потому что ожидает взятие лока на 0x000001edb76f3308, который тоже занят потоком 0x33d0. Тут пока ещё нет дедлока и нам ничего не остаётся, как выяснить, чем занимается поток 0x33d0 и почему он не отпускает блокировку. Может быть там дедлок? А может мы спустимся еще на уровень ниже, а потом ещё и ещё? Выполняем уже родные нам команды setthread -t 0x33d0 и clrtstack: Стоп, что? Видим, что этот поток мы застали за созданием TimeSpan через конструктор. Но ведь там нет никаких локов, в чём дело? Теперь проще всего будет заглянуть в код, чтобы понять, что происходит, благо в дампе есть указание всех классов и строчек в них. Замечание! Не стоит смотреть исходный код .NET через декомпиляцию в своей IDE, ведь ваша версия SDK может быть не такая, под какой был запущен исходный код. Чтобы узнать нужную версию, выполняем команду eeversion: В моём случае это был рантайм .NET 5.0.7 и его исходники мы посмотрим на github: Что же мы видим? Под локом выполняется цикл, из которого мы выходим, когда _nextPollingTimeStamp станет больше, чем now, который чуть раньше выставляется в DateTime.UtcNow. Вроде бы, ничего криминального, но понятно, что где-то здесь и скрывается корень всей проблемы, ведь дальше уже некуда копать. Посмотрев код всё того же класса, можно увидеть, что _pollingIntervalInMilliseconds может оказаться равен нулю и в таком случае, мы никогда не выйдем из цикла. Проверим, наш это случай или нет. Вызываем clrstack -a, чтобы получить адрес объекта этого класса: В this мы видим адрес 0x000001ebb76a7278, вызовем команду do 0x000001ebb76a7278, чтобы посмотреть поля этого объекта: В колонке Value у поля _pollingIntervalInMilliseconds видим значение 0, что и требовалось доказать. Таким образом, мы получили бесконечный цикл, а лок, который держит этот поток, никогда не будет освобождён. Подведём окончательный итог. EventListenerThread завис в попытках взять лок на 0x000001ed376841c8, который держит CounterGroupThread. В свою очередь, CounterGroupThread завис в попытках взять лок на 0x000001edb76f3308, который держит поток 0x33d0. А поток 0x33d0 крутится в вечном цикле. Взаимной блокировки нет, но тем не менее, потоки никогда не достигнут прогресса. .NET пишут люди, поэтому там тоже случаются баги. Данный баг уже пофиксили в .NET 5.0.8. В этом PR описаны ситуации, когда _pollingIntervalInMilliseconds может стать равен нулю, одна из них — гонка внутри .NET, которая и случилась в этот раз. Хороший повод регулярно ставить патчи с обновлением рантайма. Забавно, что до 5 версии .NET в этой же библиотеке, в окрестностях классов, которые были рассмотрены в этой статье и вправду был дедлок, так что кто знает, сколько ещё таких статей придётся написать :) Не оставляйте в стороне такой инструмент как анализ дампов, пользуйтесь им. В данном случае можно было бы прибить процесс насильно и всё, ведь баг воспроизводился редко. Но анализировать дампы увлекательно, а главное, появляется понимание корня проблемы, что позволяет от неё избавиться. .NET разработчик