вторник, 19 октября 2010 г.

Анализ дампов Delphi-приложений. CPU spike (Episode #1)

   Бывают такие моменты, когда приложение вдруг ни с того ни сего начинает проявлять гиперактивность - активно загружает процессор (CPU usage spike). Как правило в такой ситуации "особо старается" один их потоков процесса. На одноядерных (однопроцессорных) машинах это не трудно заметить по ухудшению комфортности работы за машиной.
   В особо тяжелых случаях загружать процессор могут сразу несколько потоков.


   Загрузка процессора может быть временной (когда она появляется иногда и через некоторое время пропадает) и перманентная (один раз начавшись не прекращается до момента убиения "виновного" процесса).

   Проблемы такого рода в приложении подлежат искоренению :) Дабы не нервировать лишний раз пользователей. Здесь будет описан один такой случай из практики. И процесс поиска ответа на вопрос "Кто виноват и что делать?" (с).

   Итак, с чего началась наша история: в какой-то момент приложение начало активно загружает процессор работой, чем ощутимо мешало нормальной работе компьютера (тем более, что последний имеет один одноядерный процессор). По прибытию на место происшествия, был снят дамп с "виновника" [1]. После чего процесс был пристрелен - для восстановления возможности нормальной работы за машиной.

   Уже в другом месте, в спокойной обстановке, начинаем анализировать дамп. Для начала найдем "поток-эгоист", который выделяется среди других аппетитом по отношению к времени процессора:
0:000> !runaway
 User Mode Time
  Thread       Time
  25:8a8       0 days 0:07:40.625
  17:f4c       0 days 0:00:13.140
   0:9d0       0 days 0:00:10.343
  16:f3c       0 days 0:00:04.656
   9:d9c       0 days 0:00:00.734
  19:f60       0 days 0:00:00.718
  12:e40       0 days 0:00:00.687
  26:8d4       0 days 0:00:00.500
  43:e2c       0 days 0:00:00.437
  32:984       0 days 0:00:00.359
  24:854       0 days 0:00:00.281
  34:974       0 days 0:00:00.156
  14:ec4       0 days 0:00:00.140
  ...

   Тут явно видно, что поток под номером 25 (TID=0x8a8) по параметру "User Mode Time" оставил далеко позади себя остальных. Вот таким бесхитростным образом был найден поток-виновник.


   Возможна такая ситуация, когда команда runaway ругнется "Unable to get thread times" и ничего не покажет. В этом случае придется вручную просматривать стеки всех потоков в поисках "аномалий" - потоков, которые не находятся в режиме ожидания какого-либо события. Но лучше сразу на месте, где снимается дамп, в Process Explorer-e [2] подсмотреть ID гиперактивного потока.


   Посмотрим на его стек (символой найти не удалось, так что анализ будет немного затруднен :)):
0:025> k
ChildEBP RetAddr  
WARNING: Stack unwind information not available. Following frames may be wrong.
0380ff68 2115acfe pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode+0x11
0380ffa0 2000794a libEvents!RegisterEvents+0x4bae
0380ffb4 7c80b713 rtl100!SystemAssert$qqrx17SystemAnsiStringt1i+0x4a
0380ffec 00000000 kernel32!BaseThreadStart+0x37

   Что же мы видим? Что во время снятия дампа, процесс был пойман в методе DRedBlackTree.RBIncrement (библиотеке DeCAL). Посмотрим на него:
procedure DRedBlackTree.RBincrement(var node : DTreeNode);
var
  y: DTreeNode;
begin
  if node.right <> nil_node then
    begin
      node := node.right;
      while node.left <> nil_node do
        node := node.left;
    end
  else
    begin
      y := node.parent;
      while node = y.right do
        begin
          node := y;
          y := y.parent;
        end;
      if node.right <> y then
        node := y;
    end;
end;

   Сам по себе этот метод не сложный, так что вероятность при снятии дампа споймать поток именно в нем очень маленькая. Но тем не менее поток именно в нем и был пойман. Так что есть большое подозрение, что именно там поток и залип. Это косвенно подтверждается наличием двух циклов в DRedBlackTree.RBincrement - есть формальный повод зависнуть в одном из них :) Но вполне закономерно возникает вопрос - почему?

   Попробуем это выяснить. Судя по стеку, поток запущен каким-то потомком TThread. Чтобы найти, каким именно, посмотрим, ссылки на какие объекты есть в стеке потока (т.к. Delphi по умолчанию использует соглашение вызова register, то ссылка на объект при вызове метода передается в регистре eax, а при вызове других методов того же объекта или методов других объектов, или при вызове обычных процедур, эта ссылка может сохраняться в стеке).
0:025> !stackobj
  Range: 0380ff2c - 03810000 (length = 0xd4)
0380ff58 018f88b0 "DMap"
0380ff5c 018f1cf8 "DRedBlackTree"
0380ff9c 00b025a0 "TEvents"

   Так-так-так, кое-какие концы мы нашли :) TEvents - это потомок TThread. Проведя небольшое детективное расследование (то бишь банальный поиск по коду), был найден такой код (вызывается из TEvents.Execute):
procedure TEvents.Process;
var
  iter: DIterator;
begin
  iter := fMap.start;  // fMap: DMap
  while not atEnd(iter) do
    begin
      // Полезная нагрузка
      ...

      advance(iter);
    end;
end;

   Обычный обход всех элементов DMap. Как видно в этом методе тоже есть цикл while, а значит повляется еще одна версия зацикливания потока - в методе TEvents.Process.
   Истина где-то рядом (с).

   Путь к DRedBlackTree.RBIncrement из TEvents.Process здесь возможен только из advance. Таким образом мы можем восстановить "правильный" стек вызовов в момент снятия дампа:
pkUtils!Decal.DRedBlackTree.RBIncrement
pkUtils!Decal.DInternalMap.iadvance
pkUtils!Decal.advance
libEvents!TEvents.Process
libEvents!TEvents.Execute
rtl100!System.ThreadWrapper
kernel32!BaseThreadStart

   Так-с. До окончательного и бесповоротного выяснения сермяжной правды осталось совсем немного. Мы уже практически на финишной прямой. Продолжим.

   Для начала дизассемблируем начало метода DRedBlackTree.RBIncrement и помедитируем:
0:025> uf pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode
; eax - ссылка на объект DRedBlackTree
; edx - ссылка на объект DTreeNode
pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode:
2040a250 8b02            mov     eax,dword ptr [edx]               ; node -> eax
2040a252 8b4018          mov     eax,dword ptr [eax+18h]           ; node.right -> eax
2040a255 3b052c904520    cmp     eax,dword ptr [pkUtils!pkutilsinitialization$qqrv+0x408 (2045902c)]  ; node.right <> nil_node ?
2040a25b 7414            je      pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode+0x21 (2040a271)

pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode+0xd: 
2040a25d 8902            mov     dword ptr [edx],eax               ; node := node.right
2040a25f eb02            jmp     pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode+0x13 (2040a263)

pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode+0x11:
2040a261 8902            mov     dword ptr [edx],eax               ; <- Вот тут поток был пойман при снятии дампа

pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode+0x13:
2040a263 8b02            mov     eax,dword ptr [edx]               ; node -> eax
2040a265 8b4014          mov     eax,dword ptr [eax+14h]           ; node := node.left

2040a268 3b052c904520    cmp     eax,dword ptr [pkUtils!pkutilsinitialization$qqrv+0x408 (2045902c)]  
2040a26e 75f1            jne     pkUtils!DecalDRedBlackTreeRBIncrement$qqrrp15DecalDTreeNode+0x11 (2040a261)
...

   В процессе медитации выясняется, что в момент снятия дампа в регистре edx находится адрес переменной, которая хранит ссылку на объект DTreeNode (а если конкретно, то edx = @iter.treeNode, где iter - локальная переменная, объявленная в TEvents.Process):

   Но проверка показывает, что это не так:
0:000> !object poi(edx)
 Delphi object not found

   Вспомним матчасть.
   Поле DIterator.treeNode хранит адрес ноды (DTreeNode) красно-черного дерева (DRedBlackTree). При переборе всех элементов, хранящихся в DMap-e, это поле используется для хранения ссылки на текущий элемент и модифицируется процедурой advance при переходе к следующему элементу.

   Но если это поле не указывает на объект типа DTreeNode, то указывать оно может на какую-то левую память. И тогда "путешествие" по полям left/right/parent DTreeNode (используемое в частности в DRedBlackTree.RBIncrement) может в конце концов закончится ошибкой доступа к памяти. Но этого на самом деле не наблюдалось - наоборот поток проявлял повышенную активность. Мистика какая-то...

   Ну что же - попробуем пролить свет на этот феномен.

   Из дизассемблерного листинга начала функции DRedBlackTree.RBIncrement видно, что в момент снятия дампа поток крутился в цикле:
...
  while node.left <> nil_node do
    node := node.left;
...

   Текущее значение переменной node мы знаем - в этот момент оно хранится в регистре eax (здесь же покажем значение переменной nil_node, оно нам пригодится):
0:025> r eax
eax=08374cb0

0:025> dp 2045902c l1       ; nil_node (2045902c - адрес переменной, хранящей nil_node)
2045902c  00b52090

   Но хранящееся в нем значение не является ссылкой на валидный объект:
0:025> !object eax
 Delphi object not found
   Ну не является, так не является - в конце-концов это не уголовно-наказуемо.

   Проверяем, чему равно значение поля node.left
0:025> dp 08374cb0+14 l1       ; 0x14 - смещение поля left в объекте DTreeNode
08374cc4  2007d0bc

   Условние node.left <> nil_node не выполняется поэтому можно мысленно продолжить цикл и посмотреть, что их этого получится. Итак, node становится равным 2007d0bc.

   node.left:
0:025> dp 2007d0bc+14 l1   
08374cc4  08374cb0

   Снова получили 08374cb0. Вернулись туда, откуда начали. А это означает, что цикл никогла не закончится. Место зацикливание локализовано, причина залипания выяснена.

   А что же относительно главного вопроса - как такое могло получиться?
   В данном ситуации ошибка заключается в отсутствии синхронизации доступа к объекту DMap при обходе его элементов. Т.к. вполне возможна ситуация, когда во время такого обхода из других потоков будет вызван метод Register/Unregister (первый из них додавляет элемент, а второй, соответственно, - удаляет). В самих методах Register/Unregister доступ к DMap синхронизируется - в том плане, что не может быть одновренно нескольких операций вставки/удаления. Но, как показал данный случай, перебор всех элементов тоже нуждается в защите.

   Остается только удивляться, с какой "ювелирной" точностью был "поломан" итератор в TEvents.Process - что он привел к зацикливанию потока, а не к ошибке типа Access Violation :)


Ссылки
[1] Как создать дамп процесса
[2] Process Explorer
[3] Delphi WinDbg Extension Dll

Комментариев нет:

Отправить комментарий