references:
- https://github.com/wqreytuk/windows_internal/blob/main/books/part2.pdf--page_61
- https://asp-blogs.azurewebsites.net/kennykerr/parallel-programming-with-c-part-1-asynchronous-procedure-calls
- https://learn.microsoft.com/zh-cn/windows/win32/ipc/named-pipe-server-using-completion-routines
为何要使用APC
响应式应用的黄金准则就是避免阻塞线程调用,阻塞式的函数调用将会中断线程的消息循环从而导致窗口无响应
常规的解决方案是使用worker线程来调用阻塞式函数,缺点是开销太大
一种简单高效的替代方案就是alertable IO & APC
APC原理概述
Windows为每个线程维护一个APC队列,该队列允许用户和内核模式的代码插入一个函数,并在将来的某个时刻被调用
这个特性允许你创建出单线程的响应式程序,下面介绍一下APC工作原理
APC分为内核和用户模式,内核APC由驱动代码插入,之后内核会发起一个软件中断让APC有机会在相关联的线程上下文中运行,内核APC的主要作用是提供一种内核代码访问用户内存的方式
用户APC与内核的不同点在于他不会主动运行,而是在关联线程进入到alertable状态时才会运行,此时线程会处理队列中所有的APC(FIFO),可以在内核模式插入存在于用户内存中的函数这一特性对IO操作很有用
比方说你手头有一个文件句柄,需要从里面读取数据,这个文件可能在本地磁盘,也有可能在文件服务器中,也有可能这个句柄的背后并不是一个真实的文件,而一个socket或者pipe,只不过对于IO管理器而言,这些都是一样的,所有这些东西都被他抽象成了虚拟文件
另外一方面就是,无论什么时候,读取文件都要比直接读取内存慢得多,也就是说读操作在执行的时候处理器实际上是可以去做其他的工作的
从Windows底层的角度来看,IO管理器找到IO请求对应的设备栈,并提交IRP,最终,目标设备会定位到指定的数据并通知IO管理器完成IO请求,此时IO管理器需要一种通知程序IO请求已完成的方法,而APC正好能够完成这一任务
APC会在线程进入alertable状态时执行,而进入该状态即意味着线程被挂起,Windows提供了很多API来使线程进入这种状态
实例
假设现在有一个通过命名管道提供数据流的服务程序
下面的客户端代码为ATL project
pipe.Attach(::CreateFile(L"\\\\.\\pipe\\TestServer",
FILE_READ_DATA,
0,
0,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED,
0));
if (INVALID_HANDLE_VALUE == pipe) {
printf("pip not accessible, error: 0x%p\n", GetLastError());
exit(-1);
}
CHandle是ATL提供的一个类,他是Handle的wrapper,他可以在合适的时机自动关闭句柄,可以看到倒数第二个参数启用了FILE_FLAG_OVERLAPPED
标志位,此标志位意味着我们将要使用异步IO
Void CALLBACK ReadFileCompleted(DWORD errorCode,
DWORD bytesCopied,
OVERLAPPED* overlapped);
DWORD buffer = 0;
OVERLAPPED overlapped = { 0 };
if (!::ReadFileEx(pipe, &buffer, sizeof(buffer), &overlapped, ReadFileCompleted)) {
printf("ReadFileEx call failed, error: 0x%p\n", GetLastError());
exit(-1);
}
异步读操作,可以看到传入了一个回调函数作为参数,这个函数会被插入到APC中并在合适的时机被调用,上面代码中的overlapped参数我们直接初始化为空了,它可以用来标识哪一个IO操作完成了,因为它会被作为参数传递给回调函数
调用SleepEx函数可以使当前线程进入alertable状态
const DWORD sleepResult = ::SleepEx(INFINITE, TRUE);
assert(WAIT_IO_COMPLETION == sleepResult);
SleepEx的第二个参数表示线程在挂起期间应进入alertable状态,否则无法处理APC
如果APC队列不为空,那么在调用SleepEx之后线程不会进入挂起状态,而是会立即开始处理队列中的APC
如果APC队列为空,那么在调用之后,线程会进入挂起状态直到APC队列不为空,此时线程会再次被调度获得CPU时间片,处理队列中的APC直到队列为空,然后SleepEx函数会返回
不过挂起线程对于响应式程序而言并不太好,我们可以把第一个参数设置为0来flush掉APC队列中的APC,SleepEx会立刻返回,不会导致线程被挂起,同时APC队列中如果有任何APC也会被处理并被移除队列
如果你想要避免使用全局变量,那么你可以自定义一个结构体,然后通过OVERLAPPED结构体的Pointer字段保存我们自定义结构体的地址,之后在回调函数中进行强转
完整项目文件及运行效果
后记
线程的APC队列存在于KTHREAD的ApcState
字段中
在内核调试器中,运行!process 0 7 consoleapplication1.exe
可以看到consoleapplication1.exe
进程的所有线程
起始地址为mainCRTStartup
即为主线程
从上图中可以看到KTHREAD结构体地址为0xffffa3851a48d080
dt _kthread 0xffffa3851a48d080
可以得到ApcState1
字段的地址为0xffffa3851a48d118
0: kd> dt _KAPC_STATE 0xffffa3851a48d118
ntdll!_KAPC_STATE
+0x000 ApcListHead : [2] _LIST_ENTRY [ 0xffffa385`1a48d118 - 0xffffa385`1a48d118 ]
+0x020 Process : 0xffffa385`1e890080 _KPROCESS
+0x028 InProgressFlags : 0 ''
+0x028 KernelApcInProgress : 0y0
+0x028 SpecialApcInProgress : 0y0
+0x029 KernelApcPending : 0 ''
+0x02a UserApcPendingAll : 0 ''
+0x02a SpecialUserApcPending : 0y0
+0x02a UserApcPending : 0y0
APC队列就是ApcListHead
标识的双向链表,用于链接KPAC
结构体,可以看到此时队列为空,头尾指针指向同一个地方,另外就是ApcListHead
字段是一个数组,里面包含了两个ListEntry
,为什么是两个链表我不太清楚,但是我猜测一个是用于用户APC,另一个是用于内核APC(根据后面的windbg调用栈,我的猜测应该是对的,[0]->kernel,[1]->user)
0: kd> dq /c 1 0xffffa3851a48d118 L4
ffffa385`1a48d118 ffffa385`1a48d118
ffffa385`1a48d120 ffffa385`1a48d118
ffffa385`1a48d128 ffffa385`1a48d128
ffffa385`1a48d130 ffffa385`1a48d128
可以看到这两个链表都是空链表,我们可以在两个链表的Flink字段下内存写入断点,因为要插入节点,头节点的Flink一定会发生变化
0: kd> ba w8 ffffa385`1a48d118
0: kd> ba w8 ffffa385`1a48d128
放行程序后其中一个断点会被触发,此时调用栈如下:
0: kd> k
# Child-SP RetAddr Call Site
00 fffffa84`be333680 fffff804`1463d3a3 nt!KeInsertQueueApc+0x11e
01 fffffa84`be333720 fffff804`14a45317 nt!IopCompleteRequest+0x373
02 fffffa84`be3337f0 fffff804`149cf0a8 nt!IopSynchronousServiceTail+0x3b7
03 fffffa84`be333890 fffff804`14a0bc08 nt!IopReadFile+0x7cc
04 fffffa84`be333980 fffff804`14811235 nt!NtReadFile+0x8a8
05 fffffa84`be333a90 00007ffd`5264d0a4 nt!KiSystemServiceCopyEnd+0x25
06 00000018`3f79f678 00007ffd`4fea9018 ntdll!NtReadFile+0x14
07 00000018`3f79f680 00007ff7`45da2df9 KERNELBASE!ReadFileEx+0xa8
08 00000018`3f79f700 0000013c`b68d6580 ConsoleApplication1!main+0x129 [C:\Users\x\RPC_Study\ConsoleApplication1\ConsoleApplication1\ConsoleApplication1.cpp @ 51]
09 00000018`3f79f708 00000000`00000000 0x0000013c`b68d6580
可以看到第二个断点被触发,Flink被写入新的地址,节点插入,那么新插入的KAPC节点的ApcListEntry
地址就是0xffffa3851e589848
``
2: kd> !list ffffa385
1a48d128
ffffa3851a48d128 ffffa385
1e589848 ffffa3851a48d128
ffffa385
1a48d138 ffffa3851e890080 00000003
08000000
ffffa3851a48d148 00000000
00000000 ffffa3851a48d1c0
ffffa385
1a48d158 fffff8040f701a90 fffff804
0f701a90
ffffa3851a48d168 00000000
00000000 0000000923d52000
ffffa385
1a48d178 0000000000000000 00000000
005e0008
ffffa3851a48d188 ffffa385
1a48d250 ffffa3851a48d250
ffffa385
1a48d198 00000002e57b2a43 fffff804
0f6ff888
ffffa3851e589848 ffffa385
1a48d128 ffffa3851a48d128
ffffa385
1e589858 fffff80414b207c0 fffff804
14b207c0
ffffa3851e589868 00007ffd
4feaf7b0 00007ff745da2e50
ffffa385
1e589878 0000012596030770 00000000
00000000
ffffa3851e589888 00000000
00010100 0000000000000000
ffffa385
1e589898 0000000000000000 00000000
00000000
ffffa3851e5898a8 00000000
00000000 0000000000000000
ffffa385
1e5898b8 0000000000000000 00000000
00000000
```
根据LIST_ENTRY定位结构体的方法
2: kd> dt _kapc
ntdll!_KAPC
+0x000 Type : UChar
+0x001 AllFlags : UChar
+0x001 CallbackDataContext : Pos 0, 1 Bit
+0x001 Unused : Pos 1, 7 Bits
+0x002 Size : UChar
+0x003 SpareByte1 : UChar
+0x004 SpareLong0 : Uint4B
+0x008 Thread : Ptr64 _KTHREAD
+0x010 ApcListEntry : _LIST_ENTRY
+0x020 KernelRoutine : Ptr64 void
+0x028 RundownRoutine : Ptr64 void
+0x030 NormalRoutine : Ptr64 void
+0x020 Reserved : [3] Ptr64 Void
+0x038 NormalContext : Ptr64 Void
+0x040 SystemArgument1 : Ptr64 Void
+0x048 SystemArgument2 : Ptr64 Void
+0x050 ApcStateIndex : Char
+0x051 ApcMode : Char
+0x052 Inserted : UChar
我们可以计算出,KAPC结构体的地址为0xffffa3851e589848-0x10
2: kd> dt _kapc 0xffffa3851e589848-0x10
ntdll!_KAPC
+0x000 Type : 0x12 ''
+0x001 AllFlags : 0 ''
+0x001 CallbackDataContext : 0y0
+0x001 Unused : 0y0000000 (0)
+0x002 Size : 0x58 'X'
+0x003 SpareByte1 : 0 ''
+0x004 SpareLong0 : 0
+0x008 Thread : 0xffffa385`1a48d080 _KTHREAD
+0x010 ApcListEntry : _LIST_ENTRY [ 0xffffa385`1a48d128 - 0xffffa385`1a48d128 ]
+0x020 KernelRoutine : 0xfffff804`14b207c0 void nt!IopUserCompletion+0
+0x028 RundownRoutine : 0xfffff804`14b207c0 void nt!IopUserCompletion+0
+0x030 NormalRoutine : 0x00007ffd`4feaf7b0 void KERNELBASE!BasepIoCompletionSimple+0
+0x020 Reserved : [3] 0xfffff804`14b207c0 Void
+0x038 NormalContext : 0x00007ff7`45da2e50 Void
+0x040 SystemArgument1 : 0x00000125`96030770 Void
+0x048 SystemArgument2 : (null)
+0x050 ApcStateIndex : 0 ''
+0x051 ApcMode : 1 ''
+0x052 Inserted : 0x1 ''
这里面的NormalContext
字段就是我们的回调函数的地址
而SystemArgument1
就是OVERLAPPED
2: kd> dt _overlapped 0x00000125`96030770
ConsoleApplication1!_OVERLAPPED
+0x000 Internal : 0x00000125`00000000
+0x008 InternalHigh : 4
+0x010 Offset : 0x96037ce0
+0x014 OffsetHigh : 0x125
+0x010 Pointer : 0x00000125`96037ce0 Void
+0x018 hEvent : 0x0000d7cb`0d00000d Void
我们使用了OVERLAPPED结构体的Pointer字段指向了我们自定义的结构体
typedef struct _selfDefinedStruct {
DWORD status;// = ERROR_SUCCESS;
DWORD buffer;// = ERROR_SUCCESS;v
HANDLE pipe;//;
} MyStruct;
2: kd> dc 0x00000125`96037ce0
00000125`96037ce0 00000000 61636f77 0000009c 00000000 ....woca........
0是状态码,woca
是服务端pipe中的buffer的字符形式,0x9c
是客户端的pipe句柄
放行之后第二个断点再次被触发,此时调用栈为:
2: kd> k
# Child-SP RetAddr Call Site
00 fffffa84`bd95d900 fffff804`14802c60 nt!KiDeliverApc+0x686
01 fffffa84`bd95d9c0 fffff804`148112df nt!KiInitiateUserApc+0x70
02 fffffa84`bd95db00 00007ffd`5264d664 nt!KiSystemServiceExit+0x9f
03 00000009`23eff938 00007ffd`4fe7b62e ntdll!NtDelayExecution+0x14
04 00000009`23eff940 00007ff7`45da2e37 KERNELBASE!SleepEx+0x9e
05 00000009`23eff9e0 00000125`96036580 ConsoleApplication1!main+0x167 [C:\Users\x\RPC_Study\ConsoleApplication1\ConsoleApplication1\ConsoleApplication1.cpp @ 57]
06 00000009`23eff9e8 00000000`00000001 0x00000125`96036580
07 00000009`23eff9f0 00000009`c00000bb 0x1
08 00000009`23eff9f8 00000000`00000000 0x00000009`c00000bb
APC被处理,节点被删除
2: kd> !list ffffa385`1a48d128
ffffa385`1a48d128 ffffa385`1a48d128 ffffa385`1e589848
ffffa385`1a48d138 ffffa385`1e890080 00000003`08000000
ffffa385`1a48d148 00000000`00000000 ffffa385`1a48d1c0
ffffa385`1a48d158 fffff804`0f701a90 fffff804`0f701a90
ffffa385`1a48d168 00000000`00000000 00000009`23d52000
ffffa385`1a48d178 00000000`00000000 00000000`005e0008
ffffa385`1a48d188 ffffa385`1a48d250 ffffa385`1a48d250
ffffa385`1a48d198 00000002`e57b2a43 fffff804`0f6ff888
再次放行,第一个断点被触发
0: kd> k
# Child-SP RetAddr Call Site
00 fffffa84`bdbcd6d0 fffff804`1465d0e7 nt!IopfCompleteRequest+0xc14
01 fffffa84`bdbcd7b0 fffff804`2b418d0a nt!IofCompleteRequest+0x17
02 fffffa84`bdbcd7e0 fffff804`2b418939 condrv!CdCompleteIo+0x26a
03 fffffa84`bdbcd840 fffff804`2b41adcd condrv!CdpServerFastIoctl+0x69
04 fffffa84`bdbcd880 fffff804`14a4497f condrv!CdpFastIoDeviceControl+0x6d
05 fffffa84`bdbcd8d0 fffff804`14a441d6 nt!IopXxxControlFile+0x78f
06 fffffa84`bdbcda20 fffff804`14811235 nt!NtDeviceIoControlFile+0x56
07 fffffa84`bdbcda90 00007ffd`5264d0c4 nt!KiSystemServiceCopyEnd+0x25
08 0000007a`8d07fbc8 00007ffd`4fe6591b ntdll!NtDeviceIoControlFile+0x14
09 0000007a`8d07fbd0 00007ffd`50fb5921 KERNELBASE!DeviceIoControl+0x6b
0a 0000007a`8d07fc40 00007ff6`525c0fa0 KERNEL32!DeviceIoControlImplementation+0x81
0b 0000007a`8d07fc90 0000007a`8d07fcf0 0x00007ff6`525c0fa0
0c 0000007a`8d07fc98 00000000`00000000 0x0000007a`8d07fcf0
IopfCompleteRequest
函数在第一个链表中插入了一个APC,具体含义未知,不过从KAPC的Thread字段可以看到相关线程并非主线程
0: kd> !list ffffa385`1a48d118
ffffa385`1a48d118 ffffa385`1e589848 ffffa385`1e589848
ffffa385`1a48d128 ffffa385`1a48d128 ffffa385`1a48d128
ffffa385`1a48d138 ffffa385`1e890080 00000003`08000000
ffffa385`1a48d148 00000000`00000000 ffffa385`1a48d1c0
ffffa385`1a48d158 fffff804`0f701a90 fffff804`0f701a90
ffffa385`1a48d168 00000000`00000000 00000009`23d52000
ffffa385`1a48d178 00000000`00000000 00000000`005e0008
ffffa385`1a48d188 ffffa385`1a48d250 ffffa385`1a48d250
ffffa385`1e589848 ffffa385`1a48d118 ffffa385`1a48d118
ffffa385`1e589858 fffff804`1463d030 fffff804`14c935d0
ffffa385`1e589868 00000000`00000000 00000000`00000000
ffffa385`1e589878 ffffa385`1f39f5d0 00000000`00000000
ffffa385`1e589888 00000000`00010000 00000000`0000000f
ffffa385`1e589898 00000000`00000000 00000000`00000000
ffffa385`1e5898a8 00000000`00000000 00000000`00000000
ffffa385`1e5898b8 00000000`00000000 00000000`00000000
0: kd> dt _kapc 0xffffa3851e589848
ntdll!_KAPC
+0x000 Type : 0x18 ''
+0x001 AllFlags : 0xd1 ''
+0x001 CallbackDataContext : 0y1
+0x001 Unused : 0y1101000 (0x68)
+0x002 Size : 0x48 'H'
+0x003 SpareByte1 : 0x1a ''
+0x004 SpareLong0 : 0xffffa385
+0x008 Thread : 0xffffa385`1a48d118 _KTHREAD
+0x010 ApcListEntry : _LIST_ENTRY [ 0xfffff804`1463d030 - 0xfffff804`14c935d0 ]
+0x020 KernelRoutine : (null)
+0x028 RundownRoutine : (null)
+0x030 NormalRoutine : 0xffffa385`1f39f5d0 void +ffffa3851f39f5d0
+0x020 Reserved : [3] (null)
+0x038 NormalContext : (null)
+0x040 SystemArgument1 : 0x00000000`00010000 Void
+0x048 SystemArgument2 : 0x00000000`0000000f Void
+0x050 ApcStateIndex : 0 ''
+0x051 ApcMode : 0 ''
+0x052 Inserted : 0 ''
再次放行,第一个断点被触发,APC移除
0: kd> !list ffffa385`1a48d118
ffffa385`1a48d118 ffffa385`1a48d118 ffffa385`1e589848
ffffa385`1a48d128 ffffa385`1a48d128 ffffa385`1a48d128
ffffa385`1a48d138 ffffa385`1e890080 00000003`08000000
ffffa385`1a48d148 00000000`00000100 ffffa385`1a48d1c0
ffffa385`1a48d158 fffff804`0f701a90 fffff804`0f701a90
ffffa385`1a48d168 00000000`00000000 00000009`23d52000
ffffa385`1a48d178 00000000`00000000 00000000`005e0008
ffffa385`1a48d188 ffffa385`1a48d250 ffffa385`1a48d250
0: kd> k
# Child-SP RetAddr Call Site
00 fffffa84`bd95d490 fffff804`14641657 nt!KiDeliverApc+0x1da
01 fffffa84`bd95d550 fffff804`1464085f nt!KiSwapThread+0x827
02 fffffa84`bd95d600 fffff804`14640103 nt!KiCommitThreadWait+0x14f
03 fffffa84`bd95d6a0 fffff804`147f15e4 nt!KeWaitForSingleObject+0x233
04 fffffa84`bd95d790 fffff804`14a4546b nt!IopWaitForSynchronousIoEvent+0x50
05 fffffa84`bd95d7d0 fffff804`149cf379 nt!IopSynchronousServiceTail+0x50b
06 fffffa84`bd95d870 fffff804`14a0af76 nt!IopWriteFile+0x23d
07 fffffa84`bd95d970 fffff804`14811235 nt!NtWriteFile+0x996
08 fffffa84`bd95da90 00007ffd`5264d0e4 nt!KiSystemServiceCopyEnd+0x25
09 00000009`23efd6e8 00007ffd`4fe65326 ntdll!NtWriteFile+0x14
0a 00000009`23efd6f0 00007ff7`45dbb3b5 KERNELBASE!WriteFile+0x76
0b 00000009`23efd760 00000125`96031f1e ConsoleApplication1!write_text_ansi_nolock+0xb9 [minkernel\crts\ucrt\src\appcrt\lowio\write.cpp @ 443]
0c 00000009`23efd768 00000000`00000015 0x00000125`96031f1e
0d 00000009`23efd770 00000125`9603b014 0x15
0e 00000009`23efd778 00000009`23efd790 0x00000125`9603b014
0f 00000009`23efd780 00000000`00000000 0x00000009`23efd790