65.9K
CodeProject 正在变化。 阅读更多。
Home

调试教程第七部分:锁和同步对象

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (25投票s)

2004 年 8 月 8 日

12分钟阅读

viewsIcon

145290

学习调试死锁和其他问题的基本知识。

引言

欢迎来到 Debug Tutorial 系列的第七部分。在本节中,我们将学习 Windows 中的锁和同步对象。在本教程中,我们将做一些稍微不同的事情,我们将同时使用用户模式调试器和内核模式调试器,因为我已经介绍了这两种调试器。这样,我们就可以获得两全其美的效果。

什么是死锁?

这是多线程中最基本的问题之一。当一个线程由于导致对象无限期“锁定”的情况而永远无法获取某个资源、关键部分、锁或某种类型的同步对象时,就会发生死锁。最简单的例子就是两个线程和两个锁。假设锁 A 用于从数据库读取,锁 B 用于向数据库写入。

线程 1 获取锁 A 并从数据库读取。同时,线程 2 获取锁 B 并向数据库写入。现在,如果不释放锁,线程 1 需要锁 B 进行写入,而线程 2 需要锁 A 进行读取。由于它们都想要对方持有的资源,因此没有任何一个线程会完成。这只是一个例子。

这并非“死锁”的唯一形式。也许一个线程在执行某些操作(甚至清理)之前等待一个事件被信号化。假设应用程序的所有线程都必须退出,进程才能退出。信号化事件的线程在未发出清理信号的情况下退出。技术上来说,进程处于死锁状态,因为它永远不会有最后一个线程被信号化。

另一个例子是,持有锁的线程被终止(使用 TerminateThread)。这会导致线程无限期地死锁,因为持有对象的线程已不存在,因此永远无法释放它。

这些只是问题的变体。解决此问题的基本步骤如下。

  1. 找出线程想要获取的资源的所有者。
  2. 找出拥有线程是否在等待某个资源(是,转到 1)。
  3. 找出当前线程拥有的资源。

您基本上需要映射出哪些线程拥有哪些资源,以及哪些线程正在等待获取这些资源。一旦您绘制了这些图,就可以简单地将它们连接起来。

用户模式可用的对象

用户模式中有哪些对象可用?在本节中,我们将探索它们,并了解如何使用用户模式调试器处理它们。

事件

事件只是一个可以被信号化的对象。没有人“拥有”事件,但它可以用于同步。事件可以命名,这意味着它们是“全局”的,并且许多进程可以通过名称打开它们。它们也可以不命名,因此只能在该进程空间内看到。

事件可以是手动重置或自动重置。自动重置意味着一旦等待的线程被信号化,系统就会自动将事件重置为“未信号化”。如果事件是手动重置的,那么线程或任何线程最终都必须重置该事件,以便它变成非信号化状态。内核使用“KeInitializeEvent”创建事件,事件可以是“通知”类型或“同步”类型,您在查看事件句柄信息时可能会看到此类型。“通知”类型是手动重置,“同步”类型是自动重置。有关事件的更多信息,请在 MSDN 中查找“CreateEvent”和“KeInitializeEvent”。

与用户模式中的大多数对象一样,事件只是一个句柄,因此您可以使用 !handle

0:001> !handle 7e4 ff
Handle 7e4
  Type          Event
  Attributes    0
  GrantedAccess 0x1f0003:
         Delete,ReadControl,WriteDac,WriteOwner,Synch
         QueryState,ModifyState
  HandleCount   2
  PointerCount  4
  Name          <NONE>
  Object Specific Information
    Event Type Auto Reset
    Event is Set

如您所见,此事件已设置,它是一个自动重置事件。事件可用于同步的实际生活示例是“DBGVIEW”,以及“OutputDebugString”如何与“DBGVIEW”一起工作。

Application
1. Acquire Mutex
2. Open Memory Mapped File
3. Wait for Buffer is Ready event
4. Write to Memory Mapped File
5. Signal Buffer Data Available event
6. Close Handles

DbgView
1. Wait for Buffer Data Available event
2. Read Buffer Data.
3. Signal Buffer is ready event
4. Goto 1

正如您所见,互斥体只是为了保护该线程免受其自身和其他进程中其他线程向内存映射文件写入数据。但是,双事件用于同步应用程序和 DBGVIEW 之间对文件的读/写访问。

互斥体

互斥体是一种同步对象,通过指定名称也可以全局使用。这意味着多个应用程序也可以使用同一个互斥体,因此它同样存在于内核中。互斥体一次只允许一个线程获取它。

0:005> !handle 2c0 ff
Handle 2c0
  Type          Mutant
  Attributes    0
  GrantedAccess 0x1f0001:
         Delete,ReadControl,WriteDac,WriteOwner,Synch
         QueryState
  HandleCount   2
  PointerCount  3
  Name          <NONE>
  Object Specific Information
    Mutex is Free
0:005> !handle 2b0 ff
Handle 2b0
  Type          Mutant
  Attributes    0
  GrantedAccess 0x120001:
         ReadControl,Synch
         QueryState
  HandleCount   17
  PointerCount  19
  Name          \BaseNamedObjects\ShimCacheMutex
  Object Specific Information
    Mutex is Free

互斥体被列为“Mutant”类型。这些互斥体是空闲的。一个已命名,另一个未命名。

请注意,Windows 2003 显示的比 Window 2000 更多的句柄信息,并且由于某些问题(如死锁),在查询信息时,并非所有信息都会始终显示。因此,可以使用内核调试器或像 handle.exeoh.exe 这样使用驱动程序读取内核对象的程序来获取更多信息。有关句柄的更多信息,请参阅“Debug Tutorial Part 5”。

那么,当互斥体被使用时它是什么样的?可能会出现两种情况。互斥体被您进程中的线程使用,或者互斥体被其他进程中的线程使用。

0:005> !handle 50 ff
Handle 50
  Type          Mutant
  Attributes    0
  GrantedAccess 0x1f0001:
         Delete,ReadControl,WriteDac,WriteOwner,Synch
         QueryState
  HandleCount   2
  PointerCount  3
  Name          <NONE>
  Object Specific Information
    Mutex is Owned

这是从同一进程内获取的。它简单地显示“Mutex is Owned”。进程中还有其他没有对象特定信息的互斥体。对于那些互斥体,您不知道它们是已拥有还是空闲。那么我们如何找出谁拥有这个互斥体呢?

看来即使是 handle.exe 在尝试定位所有者时也无法满足我们。我们可能需要进入内核。

所以,我创建了一个名为“WaitForSingleObject”的互斥体。然后我在内核中设置了一个断点在“NtWaitForSingleObject”上并单步执行。单步执行所有代码后,它会将句柄映射到一个对象并调用“KeWaitForSingleObject”。此函数检查它是否已被拥有,然后我们进入两段神奇的代码。

eax=00000001 ebx=fcc724a0 ecx=00000000 edx=00000000 esi=fcd19860 edi=fcd198cc
eip=8042d697 esp=fb72bcc0 ebp=fb72bce0 iopl=0         ov up ei ng nz na pe cy
cs=0008  ss=0010  ds=0023  es=0023  fs=0030  gs=0000             efl=00000a83
nt!KeWaitForSingleObject+0x1d5:
8042d697 ff4b04           dec   dword ptr [ebx+0x4] ds:0023:fcc724a4=00000001
...
eax=00000000 ebx=fcc724a0 ecx=00000000 edx=00000000 esi=fcd19860 edi=fcd198cc
eip=8042d6aa esp=fb72bcc0 ebp=fb72bce0 iopl=0         nv up ei ng nz ac po cy
cs=0008  ss=0010  ds=0023  es=0023  fs=0030  gs=0000             efl=00000297
nt!KeWaitForSingleObject+0x1e8:
8042d6aa 897318           mov     [ebx+0x18],esi    ds:0023:fcc724b8=00000000
...
kd>; !object fcc724b8
Object: fcc724b8  Type: (fcc724a8) 
    ObjectHeader: fcc724a0
    HandleCount: 0  PointerCount: 524290
    Directory Object: 00000001  Name: (*** Name not accessable ***)
kd>; dd fcc724b8 l1
fcc724b8  fcd19860
kd>; !thread fcd19860 1
THREAD fcd19860  Cid 214.418  Teb: 7ffdc000  Win32Thread: 00000000 RUNNING

EBX 指向对象头。因此,我们知道 ObjectHeader + 4 = Free count,ObjectHeader + 18 = Owner Thread。

信号量

与其余对象一样,这些实际上是内核对象。信号量和互斥体之间唯一的真正区别在于,信号量可以有一个大于 1 的计数。而互斥体只允许一个所有者尝试访问,信号量可以分配一个数字并允许“x”个线程访问。

0:002> !handle 0 f semaphore
Handle 90
  Type          Semaphore
  Attributes    0
  GrantedAccess 0x1f0003:
         Delete,ReadControl,WriteDac,WriteOwner,Synch
         QueryState,ModifyState
  HandleCount   2
  PointerCount  3
  Name          <NONE>
  Object Specific Information
    Semaphore Count 15
    Semaphore Limit 16
1 handles of type Semaphore

因此,从用户模式调试器中,我们可以找到有多少信号量是打开的以及限制是多少。请记住,值为 0 表示它们都已被占用。用户模式调试器还将信号量和互斥体视为“单独”的对象。无论如何,让我们看看在对象中能找到什么。

kd> !handle 90 ff fccde020
processor number 0
PROCESS fccde020  SessionId: 0  Cid: 03cc    Peb: 7ffdf000  ParentCid: 03d8
    DirBase: 00e7b000  ObjectTable: fccbfae8  TableSize:  36.
    Image: mspaint.exe

Handle Table at e1e62000 with 36 Entries in use
0090: Object: fcec1680  GrantedAccess: 001f0003
Object: fcec1680  Type: (fcebd620) Semaphore
    ObjectHeader: fcec1668
        HandleCount: 1  PointerCount: 1

kd> dd fcec1668 
fcec1668  00000001 00000001 fcebd620 00000000
fcec1678  fcc75888 00000000 00050005 00000010
fcec1688  fcec1688 fcec1688 00000010 7ffddfff
fcec1698  00000000 00000000 03018002 644c6d4d
fcec16a8  fcec19a8 fce73b48 00650074 0052006d
fcec16b8  006f006f 005c0074 fc8f5000 fc90b87c
fcec16c8  00025000 00500050 e134a588 00160016
fcec16d8  fcec87a8 09104000 004b0002 ffffffff

上面是我们调用以获取对象之前的对象头的转储。现在,下面我们将获取该对象并查看会发生什么。

kd> !handle 90 ff fccde020
processor number 0
PROCESS fccde020  SessionId: 0  Cid: 03cc    Peb: 7ffdf000  ParentCid: 03d8
    DirBase: 00e7b000  ObjectTable: fccbfae8  TableSize:  36.
    Image: mspaint.exe

Handle Table at e1e62000 with 36 Entries in use
0090: Object: fcec1680  GrantedAccess: 001f0003
Object: fcec1680  Type: (fcebd620) Semaphore
    ObjectHeader: fcec1668
        HandleCount: 1  PointerCount: 1

kd> dd fcec1668
fcec1668  00000001 00000001 fcebd620 00000000
fcec1678  fcc75888 00000000 00050005 0000000f
fcec1688  fcec1688 fcec1688 00000010 7ffddfff
fcec1698  00000000 00000000 03018002 644c6d4d
fcec16a8  fcec19a8 fce73b48 00650074 0052006d
fcec16b8  006f006f 005c0074 fc8f5000 fc90b87c
fcec16c8  00025000 00500050 e134a588 00160016
fcec16d8  fcec87a8 09104000 004b0002 ffffffff

计数已减一,所以我们现在知道信号量限制和计数存储在哪里。另一个问题是我们不知道哪个线程获取了信号量。我们如何找出这一点?

经过一些调试,我们发现信号量没有采取相同的代码路径,该地址也不包含拥有线程,线程的上下文也没有保存。不幸的是,信号量不被线程或多个线程“拥有”,它们基于计数工作。

事实上,我们注意到如果您在同一线程上调用“WaitForSingleObject”几次,资源计数就会减少。这意味着您在编程时必须负责任,因为即使您没有减少信号量的计数,您也可能会随机释放它。这也意味着递归线程不会像使用互斥体或关键部分那样被保存。

临界区

这些实际上是用户模式数据结构。它们可以防止多个线程从单个进程中尝试访问单个资源。它们存在于用户模式这一事实使得使用用户模式调试器非常容易进行调试。

这很好,因为如前所述,我们可以在用户模式下找出所有者是谁。您还可以使用 !locks!locks -v 来获取有关您自己进程中关键部分的信息。

0:000> !critsec ntdll!LdrpLoaderLock

CritSec ntdll!LdrpLoaderLock+0 at 77FC1774
LockCount          0
RecursionCount     1
OwningThread       9ac
EntryCount         0
ContentionCount    0
*** Locked

Windows 中最著名的锁是加载器锁。在加载库、创建或销毁线程等操作期间,它会被锁定。正如我们所见,加载器锁现在已被锁定。我们使用了 !critsec 命令,并指定了锁的地址,以获取此信息。谁是拥有线程?

0:001> ~*
   0  Id: e28.9ac Suspend: 1 Teb: 7ffde000 Unfrozen
      Start: notepad!WinMainCRTStartup (01006ae0)
      Priority: 0  Priority class: 32
.  1  Id: e28.aa0 Suspend: 1 Teb: 7ffdd000 Unfrozen
      Start: ntdll!DbgUiRemoteBreakin (77f5f2f2)
      Priority: 0  Priority class: 32

虽然进程中只有两个线程,但您可以看到如何将线程与锁匹配。

0:001> !critsec ntdll!LdrpLoaderLock

CritSec ntdll!LdrpLoaderLock+0 at 77FC1774
LockCount          NOT LOCKED
RecursionCount     0
OwningThread       0
EntryCount         0
ContentionCount    0

这是关键部分未锁定时输出。关键部分比互斥体还有哪些优势?由于它们存在于用户模式,因此它们不需要进入内核即可锁定,也不会与其他进程发生争用。这使它们更快,而且正如您所见,在调试用户模式应用程序时更容易使用。对地址执行“dd”命令,您还会看到数据结构多么简单,以及调试器显示的信息来自何处。

如果线程不再存在,那么显然它要么已被终止(TerminateThread),要么自行退出,而未释放锁。

内核模式可用的对象

我实际上已经介绍了一些内核模式可用的对象。事件、信号量、互斥体都可以在内核模式下使用。事实上,用户模式应用程序只是调用内核来创建相同的对象,使用内核创建它们所用的相同函数。不过,还有一些其他类型的对象我想在内核中讨论。它们是自旋锁和 ERESOURCE。

自旋锁

自旋锁是一种同步对象,用于防止多处理器系统同时访问同一资源。自旋锁与关键部分之间的区别在于,第二个处理器将在此锁上自旋,直到能够获取它为止,而不是允许其他线程被调度运行。

在单处理器系统上,自旋锁只会提高 IRQL 级别,这样在该代码执行期间就不会调度任何其他进程。这意味着您无法访问可分页内存,并且应仅执行少量操作,因为您不想占用处理器。

hal!KfAcquireSpinLock:
80069850 33c0             xor     eax,eax
80069852 a024f0dfff       mov     al,[ffdff024]
80069857 c60524f0dfff02   mov     byte ptr [ffdff024],0x2
8006985e c3               ret

该地址是一个全局变量,指向 IRQL 级别。调用 KeAcquireSpinLock 只会将 IRQL 级别设置为 2。然后,它会简单地将旧的 IRQL 级别保存在传递给函数的自旋锁数据结构中。当调用 KeReleaseSpinLock 时,将使用此信息来恢复之前的级别。

IRQL 是操作系统运行的 IRQ 级别。下面是 NTDDK 头文件中定义的级别。

#define PASSIVE_LEVEL 0             // Passive release level
#define LOW_LEVEL 0                 // Lowest interrupt level
#define APC_LEVEL 1                 // APC interrupt level
#define DISPATCH_LEVEL 2            // Dispatcher level

因此,自旋锁会将操作系统提升到 DISPATCH_LEVEL。有关 IRQL 的更多信息,请参阅 MSDN 文档。

正如您在下面看到的,在多处理器机器上,自旋锁函数的工作方式略有不同。它们在一个字节上“自旋”尝试“锁定”它,并继续这样做,直到它被“锁定”。

基本上,为了描述下面的汇编代码,“LOCK”指令会锁定总线,以防止多个处理器读取或写入同一内存区域。“BTS”指令(指定 0)会将位 0 移动到进位标志,然后将位 0 设置为 1。

所以,“JB”会在进位标志为 1 时跳转,这意味着它以前为 1。然后它会测试位 0 是否为 1。如果位 0 不为 1,它会跳转回并重试。如果位 0 为 1,则表示它仍被占用,因此在重试之前会执行“pause”。

hal!KfAcquireSpinLock:
80065420 8b158000feff     mov     edx,[fffe0080]
80065426 c7058000feff41000000 mov dword ptr [fffe0080],0x41
80065430 c1ea04           shr     edx,0x4
80065433 0fb68280a30680   movzx   eax,byte ptr [edx+0x8006a380]
8006543a f00fba2900       lock    bts dword ptr [ecx],0x0
8006543f 7203             jb      hal!KfAcquireSpinLock+0x24 (80065444)
80065441 c3               ret
80065442 8bff             mov     edi,edi
0: kd>; u
hal!KfAcquireSpinLock+0x24:
80065444 f70101000000     test    dword ptr [ecx],0x1
8006544a 74ee             jz      hal!KfAcquireSpinLock+0x1a (8006543a)
8006544c f390             pause
8006544e ebf4             jmp     hal!KfAcquireSpinLock+0x24 (80065444)

这就是自旋锁工作的本质,它们并不复杂,一般来说,大多数应用程序永远不需要自旋锁。在大多数情况下,信号量或互斥体应该可以正常工作。如果您想使用自旋锁,请阅读 MSDN 中的文档。还有“排队”自旋锁,据说它们可以提供更好的性能。

ERESOURCE

我将对 ERESOURCE 的解释尽量简化,因为

  1. 您可以在 MSDN 上阅读它们,我不想重复信息,并且
  2. 如果您不知道它们是什么,您可能不会使用它们,也不需要调试它们。这是一篇关于调试的文章,而不是编程。

但是,我会给出一个简短的概述。ERESOURCE 是您可以在内核中使用的 A 数据结构,它允许共享或独占访问。共享意味着许多线程可以获取它,而独占意味着显然只有一个线程可以获取它。

需要注意的一点是,ERESOURCE 存在于非分页内存的全局链接列表中。这意味着如果您在删除资源之前释放此内存或覆盖此数据结构,您将损坏此列表。

在内核调试器中,您可以使用名为“!locks”的命令来转储系统上的所有锁。

kd> !locks
**** DUMP OF ALL RESOURCE OBJECTS ****
KD: Scanning for held locks.....................................

Resource @ 0xfceba0c0    Shared 1 owning threads
     Threads: fcebeda3-01<*> *** Actual Thread FCEBEDA0
KD: Scanning for held locks.
1814 total locks, 1 locks currently held

执行此操作时,它将显示拥有锁的线程,并且还将显示等待锁的线程列表。您还可以执行 !locks <ADDRESS> 来获取信息。还有一些可以使用的标志,例如 !locks -v

这些锁的优点是 !locks 将列出拥有锁的 ETHREAD 地址以及等待锁的 ETHREAD 地址,因此调试争用变得非常简单。上面的锁只有一个所有者,没有列出等待者。我相信在 Windows XP/2003 中,您甚至可以在数据上执行 dt _ERESOURCE 来显示所有字段。

结论

我们已经涵盖了 Windows 用户模式和内核中常用的同步对象。还有其他方法,例如 InterlockedDecrement、文件字节锁定(LockFile)以及程序员自己可以用来实现自己的同步的其他操作/方法。这些将超出了本教程的范围,因为如何调试它们将取决于实现。

© . All rights reserved.