句柄、消息与窗口过程
环境:Windows 11 x64,GCC 13.1.0(MinGW-w64,
D:/Qt/Tools/mingw1310_64/bin/gcc.exe),-std=gnu11 -O2 -Wall -lgdi32 -luser32 -lpsapi。计时一律 7 轮 × 3000 次取中位数并给区间。对系统的观察全部只读(EnumProcesses+GetProcessHandleCount+NtQuerySystemInformation),没动服务、注册表和网络配置;建的临时文件与命名对象都在探针退出时清掉。
一、句柄不是指针,是进程私有表的下标
空控制台程序起点 96 个句柄,创建 5 个内核对象:
基线(进 main) 句柄数=96 USER=1 GDI=0
创建 5 个内核对象 句柄数=101 USER=1 GDI=0
句柄值 ev=00000000000000d0 mu=...d4 sem=...d8 tm=...dc mm=...e0
差值 mu-ev=4 sem-mu=4 tm-sem=4 mm-tm=4
全部 CloseHandle 后 句柄数=97 USER=1 GDI=0
- 相邻句柄值差 4:句柄是句柄表索引,表项 4 字节,x64 上高 32 位恒为 0。所以
0xd0这种”小地址”是合法句柄——它从来不是地址。 - 5 个对象 = +5 句柄,线性;全部关闭后回到 97 而不是 96,因为中途
LoadLibraryW(ntdll)/FormatMessage自己会开句柄。判断泄漏要看趋势,不能要求”回到基线”。
关掉句柄后槽位立刻复用:连续建 3 个事件得到 0xec 0xf0 0xf4,关掉 0xf0,下一个新建又拿到 0xf0。这是 Win32 最难查的一类 bug 的根源——悬空句柄不保证失败,同一个值可能已经指向别人的新对象。失败时能拿到的信息也只有错误码:
CloseHandle(0x1234) 返回=FALSE lasterr=6 FormatMessage="句柄无效。"
已关闭的句柄再 CloseHandle:返回=FALSE lasterr=6 FormatMessage="句柄无效。"
OpenProcess(pid=4, PROCESS_VM_WRITE) 返回=NULL lasterr=5 FormatMessage="拒绝访问。"
同名第二次 CreateEventW -> h=0e4(第一次 0xd0)lasterr=183 FormatMessage="当文件已存在时,无法创建该文件。"
最后一条:CreateEventW 撞上同名对象不失败,返回新句柄 + ERROR_ALREADY_EXISTS(183)。而 183 的系统文本是”当文件已存在时……”——FormatMessage 是按错误码查通用消息表,语义要自己翻译。
二、一个内核对象上的两层计数
NtQueryObject(ObjectBasicInformation) 读对象自身(sizeof(OBJECT_BASIC_INFORMATION)=56,实测 写入=56):
Event(ev) h=d0 HandleCount=1 granted=0x001f0003 类型=Event(系统对象数=69793 句柄数=74828)
Mutex(mu) h=d4 HandleCount=1 granted=0x001f0001 类型=Mutant(系统对象数=3119 句柄数=25446)
Semaphore(sem) h=d8 HandleCount=1 类型=Semaphore(系统对象数=12536 句柄数=12635)
TimerEx(tm) h=dc HandleCount=1 类型=IRTimer(系统对象数=4571 句柄数=4571)
Section(mm) h=e0 HandleCount=1 类型=Section(系统对象数=30568 句柄数=24172)
Event(ev) 2个句柄 h=d0 HandleCount=2 ← 同名再开
Event(ev) +dup h=d0 HandleCount=3 ← DuplicateHandle 给了 h=0xec
Event(ev) -dup h=d0 HandleCount=2 ← 关掉 0xec
HandleCount 是句柄表引用数,随 DuplicateHandle/CloseHandle 精确变动。内核类型名和 Win32 叫法对不上:互斥体叫 Mutant、内存映射叫 Section、CreateWaitableTimerEx 叫 IRTimer。造 1000 个事件验证:
建 1000 个事件:Event 对象数 70153 -> 71153,句柄数 75312 -> 76312
关掉 1000 个后:Event 对象数=70153 句柄数=75312
同增同减、立刻归零:最后一个句柄关闭时对象就没了,没有延迟回收。granted 那列说明句柄可以只带部分权限(事件 0x1f0003=EVENT_ALL_ACCESS,互斥体 0x1f0001 只有 SYNCHRONIZE + 标准 rights)。
三、系统级盘点:need 不能信,均值更不能信
NtQuerySystemInformation(SystemExtendedHandleInformation=64) 免特权可跑,但第一次探长度返回的 need 是坑:
探长度:st=0xc0000004 need=56
第2次:缓冲区=37170296 字节 st=0000000000 返回 need=37170296
系统句柄总数=929257 本进程=94 每项40字节 → 该数组占 35 MB
56 只是头大小,真实需要 37 MB,必须翻倍重试。免特权看别人更简单的办法是 EnumProcesses + GetProcessHandleCount:
EnumProcesses 进程数=561 可查句柄数=367 句柄合计=824126 平均=2319.7
top0 pid=4340 句柄=558379 C:\ProgramData\UDisplay\UDisplay.exe
top1 pid=17940 句柄=41864 C:\Windows\System32\audiodg.exe
top2 pid=21564 句柄=21862 C:\Program Files\Adobe\...\node.exe
top3 pid=3084 句柄=13731 C:\Windows\System32\Taskmgr.exe
平均值 2319 是骗人的:扣掉 UDisplay.exe 的 55.8 万,剩下 366 个进程合计 26.6 万,人均 726。一个第三方进程占了整机 68% 的句柄,它就是在泄漏。查句柄先看离群值。 另外 本进程=94 与 GetProcessHandleCount 在不同运行里差过 1–2,枚举有快照时延,别要求两者严格相等。
四、USER / GDI 是另一套配额:各 10000,且 GDI 不报错
把窗口建到失败,再把画笔画刷建到失败:
起点(1 个窗口类) 内核句柄=100 USER=1 GDI=0
CreateWindowExW 第 1 次失败: lasterr=1158 "当前程序已使用了 Window 管理器对象的系统允许的所有句柄。"
成功创建窗口 9998 个
窗口建满后 内核句柄=133 USER=10000 GDI=0 → 销毁一半:USER=5001,又能建 4999 个
CreatePen 第 1 次失败: lasterr=0 "操作成功完成。"
成功创建 GDI 画笔 9999 支
GDI 建满后 内核句柄=197 USER=10000 GDI=10000 → 全部 DeleteObject 后 GDI=1,又能建 9999 支
USER 与 GDI 各 10000/进程,GetGuiResources 读的就是这两张表;DestroyWindow/DeleteObject 归还立刻可见。最反直觉的一条:GDI 耗尽时 CreatePen 返回 NULL,而 GetLastError() 是 0(“操作成功完成”)——if(!h) err(GetLastError()) 这条标准写法对 GDI 完全失效,只能靠返回值判 NULL。另一条:建 9998 个窗口内核句柄只从 100 涨到 133,建 9999 支画笔涨到 197,USER/GDI 基本不占内核句柄,所以”句柄数正常”排除不了 UI 资源泄漏,三个数要分开盯(这就是任务管理器”句柄数/对象数”两列的来源)。GDI 对象的泄漏与 DC 成本第二节细量。
五、CreateWindowExW 期间 WndProc 已被回调 4 次
把 printf 塞进 WndProc,不跑任何消息循环,只调 CreateWindowExW(WS_OVERLAPPED):
#0 +0.0 us WM_GETMINMAXINFO lp=000000202dbfeee0 W=01373872 tid=96064
#1 +44.8 us WM_NCCREATE lp=000000202dbfee90
#2 +3908.0 us WM_NCCALCSIZE lp=000000202dbfef00
#3 +4865.5 us WM_CREATE lp=000000202dbfee80
这 4 条没进过任何队列,USER32 在 CreateWindowExW 的调用栈上直接回调你的 WndProc。ShowWindow(SW_SHOW) 触发 16 条,顺序是实测:
WM_SHOWWINDOW → WM_WINDOWPOSCHANGING → WM_WINDOWPOSCHANGING → WM_ACTIVATEAPP → WM_NCACTIVATE
→ WM_ACTIVATE → WM_IME_SETCONTEXT → WM_IME_NOTIFY → WM_GETOBJECT → WM_GETOBJECT
→ WM_SETFOCUS → WM_NCPAINT → WM_ERASEBKGND → WM_WINDOWPOSCHANGED → WM_SIZE → WM_MOVE
WM_WINDOWPOSCHANGING 连发两次,WM_SIZE/WM_MOVE 排在最后——“先收到 SIZE 再显示”的假设不成立。WM_IME_* 每次激活都来,WM_GETOBJECT 是无障碍/UIA 在问话(本机每次 ShowWindow 固定 2 条)。UpdateWindow 只给 1 条 WM_PAINT(在调用栈上,BeginPaint 报 rcPaint=(0,0)-(394,271) erase=0);InvalidateRect 交给消息循环时是 5 条(多了 WM_DWMNCRENDERINGCHANGED、一个未公开的 0xc378、以及 WM_GETICON)。DestroyWindow 给 3 条:0x0090(+0.0µs,MinGW winuser.h 的 251 条 WM_ 名字表里查不到,是 UAH 内部通知)→ WM_DESTROY(+468.6µs)→ WM_NCDESTROY(+9161.9µs),后两者之间是释放本窗口 GDI 对象的最后时机。
六、Post 与 Send:56 纳秒 vs 27.8 微秒
| 操作 | 中位单价 | 区间 |
|---|---|---|
SendMessageW 同线程窗口 | 56.4 ns | 56.1 – 58.6 |
PostMessageW 同线程窗口 | 1463 ns | 1167 – 6574 |
PeekMessageW(PM_REMOVE) 取一条 | 1204 ns | 984 – 7034 |
PostThreadMessageW 到别的线程 | 16640 ns | 15425 – 30013 |
PostMessageW 跨线程窗口 | 17053 ns | 15723 – 33133 |
SendMessageW 跨线程窗口 | 27818 ns | 16835 – 42312 |
- 同线程
SendMessage就是一次直接函数调用(56ns,与std::mutex无竞争加锁同量级),不入队。 - 跨线程
SendMessage= 唤醒成本 + 等回音,是同线程的 493 倍,与《线程与进程模型》量的”一次条件变量唤醒交接 11.35µs”同源。 - 同线程
PostMessage也要 1.4µs:队列是 USER32 与 win32k 共享的结构,入队从来不免费。
PostMessageW(NULL,...) 是线程消息,实测进调用线程自己的队列(成功 9999 次 lasterr=1816,A 线程排空 10000 条);而 SendMessageW(NULL,...) 直接失败:返回=0 lasterr=1400,两个线程的 WndProc 一次都没被调用。这个区别救过一次误判——我最初版探针忘了给事件句柄赋值,WaitForSingleObject 返回 4294967295(WAIT_FAILED)却没检查,主线程于是读到工作线程的 hwnd 仍是 0,“跨线程 SendMessage 不重入”的假结论全部来自那次静默失败。先打印同步原语自身的返回值,再谈 API 行为。
七、队列配额 10000 条,和 SendMessage 的重入
同一个窗口:成功投递 10000 条后 PostMessageW 开始失败,lasterr=1816 "配额不足,无法处理此命令。"
紧接着往同线程的另一个窗口投递:成功 0 条,lasterr=1816
先排空,再投 5 条 WM_NULL + 1 条 WM_USER+9;PeekMessage 取到 6 条,其中 WM_NULL 5 条
PM_NOREMOVE 两次=1,1;PM_REMOVE 两次=1,0
空队列时 GetQueueStatus(QS_ALLINPUT)=0x00100000 低16=0 高16=0x10
上限是每线程 10000 条,与窗口无关:UI 线程被长任务卡死时,别人只能投进 1 万条,之后 PostMessage 静默失败——这就是”后台通知 UI、UI 卡住后通知丢了”的机制,要可靠就换事件/IOCP 而不是消息队列。WM_NULL 会被 PeekMessage 正常取出(“系统吞掉 WM_NULL”在 Peek 路径上不成立);PM_NOREMOVE 真的不摘;GetQueueStatus 低 16 位是”请求且已置位”的 flags,高 16 位是队列里出现过的类别并集。重入实测:A Send(hB,100) → B 的 handler Send(hA,101) → A 的 handler 再 Send(hB,200):
[B] 收到 A 的 SendMessage(WM_USER+100),深度=1 → 我回手 SendMessage(A, WM_USER+101)
[A] WndProc 深度=1(A 此刻其实卡在 SendMessage(WM_USER+100) 内部)
[B] ★重入★ 上一个 WM_USER+100 还没返回,WndProc 又被调用(WM_USER+200),深度=2
A 的 WndProc 最大深度=1 B 的 WndProc 最大深度=2
跨线程 SendMessage 能把 WndProc 叠成任意深的嵌套,栈上同时摆着若干”做了一半”的状态——这就是”WndProc 里不要等锁、不要干重活”的物理原因。超时与”未响应”判定:
B 正在 WndProc 里 Sleep(3000):IsHungAppWindow=0 lasterr=1816
SMTO_ABORTIFHUNG + 500ms:返回=FALSE 用时=513.2 ms lasterr=1460 "由于超时时间已过,该操作返回。"
再发一次 uTimeout=0(无限等):返回=TRUE 用时=2450.6 ms lasterr=1460
IsHungAppWindow 对”忙 3 秒”不敏感(它看的是窗口是否响应系统消息),SMTO_ABORTIFHUNG 于是退化成”等满 500ms”(实测 513.2ms,多出来的是定时器粒度)。最后一行还带两个陷阱:成功后 lasterr 仍是上一次的 1460,uTimeout=0 是无限等而不是立刻。
八、A/W 两套 API:导出表里没有”不带后缀”的名字
同一份源码编译两次,(void*)CreateEvent 指向不同函数:
带 -DUNICODE :CreateEvent==CreateEventW ? 1 ==CreateEventA ? 0 sizeof(TCHAR)=2
不带 -DUNICODE:CreateEvent==CreateEventW ? 0 ==CreateEventA ? 1 sizeof(TCHAR)=1
GetProcAddress("CreateEvent ") = 0 GetProcAddress("CreateEventW") = 00007ffa31436e80
GetProcAddress("CreateWindowEx") = 0 GetProcAddress("CreateWindowExW") = 00007ffa3209c110
UNICODE 是纯编译期宏。手工解析 PE 导出表:
kernel32 导出 1697 个具名函数:A 结尾 264,W 结尾 293,其余 1140;A/W 成对 264 组
user32 导出 1050 个具名函数:A 结尾 149,W 结尾 152,其余 749;A/W 成对 147 组
只有 W 版、没有 A 版的 27 个里含:FindFirstStreamW FindFirstFileNameW FindNextStreamW
GetVolumeInformationByHandleW DnsHostnameToComputerNameExW …
FindFirstStreamW(枚举 NTFS 备用数据流)这类从来没有 ANSI 版本,写 FindFirstStreamA 只会得到 undefined reference。转换本身极便宜,贵的是别的(7 轮中位):
MultiByteToWideChar(CP_ACP) 14 字节 43.8 ns(39.4–92.1) WideCharToMultiByte 9 字符 32.1 ns(31.3–35.8)
SetWindowTextW(隐藏,ASCII)187.3 µs(104.5–348.6) SetWindowTextA(隐藏,ASCII)253.2 µs(173.9–351.3)
SetWindowText 的单价是转换的 4000 倍,A/W 差值完全被重绘噪声吃掉(区间大幅重叠)——要优化的是调用次数,不是字符集。代码页语义(本机 ACP=936),以及把 activeCodePage=UTF-8 写进 exe 的资源 ID 24(RT_MANIFEST)重链之后——没动任何系统设置:
ACP=936 :U+2603→"3f 20"(UsedDefaultChar=1,雪人被换成 '?')
CreateFileW(中文)=0x68 ok CreateFileA(GBK 字节名)=0x68 ok CreateFileA(UTF-8 字节名)=INVALID lasterr=2
ACP=65001:U+2603→"e2 98 83 20"(UsedDefaultChar=0,真 UTF-8) 控制台输出 CP 仍 936
CreateFileA(UTF-8 字节名)=0x64 ok CreateFileA(GBK 字节名)=INVALID lasterr=2
两条 A 版 API 的行为整个反过来。同一轮实验里 CreateActCtxW+ActivateActCtx 返回成功(=1)但 GetACP() 仍是 936:ACP 覆盖只在进程加载时读嵌入 manifest 生效。控制台 CP 不跟着变,所以第一次跑 FormatMessage 我拿到的是乱码,之后所有探针都改成 WideCharToMultiByte(CP_UTF8,…)+fputs。MinGW 另有一个坑:IDC_ARROW/MAKEINTRESOURCE 也随 UNICODE 展开,不带 -DUNICODE 时它是 MAKEINTRESOURCEA,传给 LoadCursorW 直接报 incompatible pointer types。
GetWindowText* 的缓冲区语义是本节最危险的发现。标题”中文标题 café”(9 字符 / CP936 下 14 字节):
A 版:nMax=2 →返回2 前2字节 d6 d0 无终止符 nMax=4 →返回6 前4字节 d6 d0 ce c4 无终止符
nMax=8 →返回11 d6 d0 ce c4 b1 ea cc e2 无终止符 nMax=14 →返回14 无终止符 nMax=15 →返回14 有终止符
W 版:nMax=2 →返回1(4e2d 0000) nMax=9 →返回8 nMax=10 →返回9 三种都有终止符
W 版规矩:nMax 含终止符、返回值=实际拷贝字符数、永远补 0。A 版三条全违反:返回值能大于写进缓冲区的字节数(nMax=4 却返回 6)、不保证补终止符、nMax 在 DBCS 下按字符还是按字节也不透明。char buf[N]; GetWindowTextA(hwnd,buf,N); use(buf); 一定在读越界。
小结
- 句柄 = 4 字节步进的进程私有表索引,值关掉立刻复用;
CloseHandle只能报 6(句柄无效),双关闭不崩,悬空句柄更可能”关到别人的对象”;命名对象二次打开返回成功 + 183。 - 对象上有句柄计数与引用计数两层,最后一个句柄关闭即销毁(1000 个事件:70153→71153→70153)。全机 93 万个句柄免特权可数,本机一个进程占 68%——查泄漏看离群值不看均值;
NtQuerySystemInformation首次need只是头大小(56 vs 真实 37MB),必须翻倍重试。 - USER 与 GDI 各 10000/进程且几乎不占内核句柄;窗口耗尽报 1158,GDI 耗尽
GetLastError()=0。 - 创建期 4 条 / ShowWindow 16 条 / Destroy 3 条消息都是调用栈上直接回调,不经过队列;同线程
SendMessage56ns,跨线程 27.8µs(493 倍),且跨线程SendMessage实测把 WndProc 叠到深度 2。 - 队列配额 10000 条/线程(不是每窗口),投满后 1816 静默丢消息;
WM_NULL会被正常取出;IsHungAppWindow对”忙 3 秒”不敏感,SMTO_ABORTIFHUNG只是超时器。 - A/W 是编译期宏,导出表里没有无后缀名字(kernel32 有 27 个 W-only);转换 32–44ns 而
SetWindowText190–250µs,优化调用次数而不是字符集;RT_MANIFEST 的activeCodePage=UTF-8能把 A 版语义整体翻转,ActivateActCtx做不到。而GetWindowTextA不保证终止符、返回值不可信,GetWindowTextW两者都保证——窗口文本一律走 W 版。