Pull to refresh

История о потоке UI, зависавшем при вызове ядра

Reading time5 min
Views1.9K
Original author: Raymond Chen

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

0: kd> !thread 0xffffd18b976ec080 7
THREAD ffffd18b976ec080  Cid 79a0.7f18  Teb: 0000003d7ca28000
    Win32Thread: ffffd18b89a8f170 WAIT: (Suspended) KernelMode Non-Alertable
SuspendCount 1
    ffffd18b976ec360  NotificationEvent
Not impersonating
DeviceMap                 ffffad897944d640
Owning Process            ffffd18bcf9ec080       Image:         contoso.exe
Attached Process          N/A            Image:         N/A
Wait Start TickCount      14112735       Ticks: 1235580 (0:05:21:45.937)
Context Switch Count      1442664        IdealProcessor: 2             
UserTime                  00:02:46.015
KernelTime                00:01:11.515

 nt!KiSwapContext+0x76
 nt!KiSwapThread+0x928
 nt!KiCommitThreadWait+0x370
 nt!KeWaitForSingleObject+0x7a4
 nt!KiSchedulerApc+0xec
 nt!KiDeliverApc+0x5f9
 nt!KiCheckForKernelApcDelivery+0x34
 nt!MiUnlockAndDereferenceVad+0x8d
 nt!MmProtectVirtualMemory+0x312
 nt!NtProtectVirtualMemory+0x1d9
 nt!KiSystemServiceCopyEnd+0x25 (TrapFrame @ ffff8707`a9bef3a0)
 ntdll!ZwProtectVirtualMemory+0x14
 [end of stack trace]

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

Обратите внимание: проблемный поток находится в состоянии «Suspended» (приостановлен). Судя по всему, он провёл в таком состоянии более пяти часов.

THREAD ffffd18b976ec080  Cid 79a0.7f18  Teb: 0000003d7ca28000
    Win32Thread: ffffd18b89a8f170 WAIT: (Suspended) KernelMode Non-Alertable
SuspendCount 1
    ffffd18b976ec360  NotificationEvent
Not impersonating
DeviceMap                 ffffad897944d640
Owning Process            ffffd18bcf9ec080       Image:         contoso.exe
Attached Process          N/A            Image:         N/A
Wait Start TickCount      14112735       Ticks: 1235580 (0:05:21:45.937)

Естественно, если поток пользовательского интерфейса приостанавливает работу, это выглядит как зависание.

Такие функции, как Suspend­Thread , предназначены преимущественно для применения совместно с отладчиком. Поэтому мы спросили пострадавших коллег, был ли у них прикреплён к этому процессу отладчик, когда снимался дамп ядра. Они ответили, что нет.

Итак, что же приостанавливало поток, и почему?

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

Стек сторожевого потока выглядит так:

ntdll!ZwWaitForAlertByThreadId(void)+0x14
ntdll!RtlpAcquireSRWLockSharedContended+0x15a
ntdll!RtlpxLookupFunctionTable+0x180
ntdll!RtlLookupFunctionEntry+0x4d
contoso!GetStackTrace+0x72
contoso!GetStackTraceOfUIThread+0x127
...

Ладно, мы убедились, что сторожевой поток пытается получить стектрейс потока UI, но зависает внутри вызова Rtl­Lookup­Function­Entry, ожидающего, пока освободится блокировка.

Спорим, я знаю, кто её удерживает?

Это поток пользовательского интерфейса.

Работа которого приостановлена.

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

Считайте это практическим экзаменом на тему: Почему никогда не следует приостанавливать поток.

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

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

Бонус: в этом стеке ядра отчётливо видно, что поток Suspend­Thread работает асинхронно. Когда сторожевой поток вызывает Suspend­Thread с целью приостановить поток пользовательского интерфейса, сам поток пользовательского интерфейса как раз работает в ядре, меняя параметры защиты памяти. Поток останавливается не сразу, а сначала дожидается, пока ядро справится с этой работой. Затем ядро перед передачей управления в пользовательский режим проверяет Check­For­Kernel­Apc­Delivery – посмотреть, нет ли сейчас каких-нибудь ожидающих запросов. Оно выбирает запрос, который следует приостановить, и только после этого поток фактически приостанавливает работу.

Ядро не приостанавливает поток немедленно, поскольку он может удерживать блокировки, связанные с внутренним функционированием ядра. Если приостановить поток в тот момент, когда он удерживает блокировку ядра (например, такую, которая синхронизирует доступ к страницам памяти), то ядро намертво заблокирует само себя!  

Бонус к бонусу: «А что, если ядро воздержалось от приостановки потока потому, что он удерживал какие-либо блокировки из пользовательского режима? Не решается ли эта проблема таким путём?»

Для начала — откуда ядру вообще знать, удерживает ли поток какие-то блокировки, относящиеся к пользовательскому режиму? У блокировки из пользовательского режима нет никакой узнаваемой сигнатуры. В конце концов, в пользовательском режиме поток может заблокировать любой байт памяти, используя его в качестве спинлока. Во-вторых, даже если ядру каким-то образом удастся выяснить, удерживает ли данный поток блокировку из пользовательского режима, не хочется, чтобы этот фактор влиял на порядок приостановки потоков, так как в таком случае приостановку работы программы станет невозможно контролировать! Просто вызывайте AcquireSRWLockShared(some_global_srwlock) и ни в коем случае не вызывайте соответствующую функцию Release. Поздравляю: теперь поток вечно удерживает глобальную блокировку в разделяемом режиме, и поэтому полностью невосприимчив к попыткам его приостановить.

Tags:
Hubs:
Total votes 8: ↑6 and ↓2+10
Comments2

Articles