CreateCompatibleDC(IntPtr.Zero)为何返回IntPtr.Zero
结论
CreateCompatibleDC(IntPtr.Zero) 返回 IntPtr.Zero,说明 Windows 未能创建内存设备上下文。故障是在升级第三方组件后出现的,调用频繁,通常运行数小时后才失败,重启进程后大概率恢复。根据这些特征,应优先排查第三方组件是否存在 GDI 对象泄漏、句柄配额耗尽或并发使用错误。
这类故障可能与托管内存无关。即使 .NET GC 堆没有明显异常,未及时释放的 HDC、HFONT、HBITMAP 等原生 GDI 对象仍会不断累积,最终导致新的 GDI 对象创建失败。
一次 IntPtr.Zero 还不足以确认存在泄漏。需要同步记录 GDI 对象数量、进程内存、句柄数和失败现场,观察资源是否随请求量持续增长。
为什么高负载运行数小时后才出现
CreateCompatibleDC(IntPtr.Zero) 创建的是与当前屏幕兼容的内存 DC。每次成功调用都会占用 GDI 资源,使用完后应通过 DeleteDC 释放。
只要每次分配都能及时释放,调用一千次和十万次在资源生命周期上没有本质区别。问题通常出在每次请求泄漏一个或几个对象。低负载环境可能很久都不会触及限制,生产环境则可能在累计到相对固定的请求量后失败,于是表现为“每 3 到 4 小时出现一次”。
进程级 GDI 对象配额并非唯一原因。桌面会话资源、内核资源和系统内存压力也可能导致创建失败。如果同一台服务器上还有其他大量使用 GDI 的进程,也需要一并检查。
现有 Dispose 代码的风险点
这段释放逻辑调用了 SelectObject、DeleteObject 和 DeleteDC,但这些调用本身无法证明资源已经正确释放。
1. Dispose 是否真的被调用
只有持有 TextPageRenderer 的代码主动调用 Dispose(),资源才会及时释放。依赖终结器时,释放时间由 GC 决定,而 GDI 资源压力不会直接促使 GC 回收对象。
如果第三方组件在每次 TextSearch 中创建渲染器,却只通过终结器清理,GDI 对象可能在两次 GC 之间大量堆积。
2. 删除失败后不会重试
无论 DeleteObject 或 DeleteDC 是否成功,代码最后都会执行:
_isDisposed = true;
例如,DeleteDC 返回 false 时,_hdc 不会被清零,但对象已经标记为 disposed。以后再次调用 Dispose() 会直接返回,释放失败的资源也不会得到再次处理。
删除失败通常还说明对象状态、选择关系或并发访问可能有问题,不应直接忽略。
3. SelectObject 的结果没有完整验证
初始化代码如下:
_hFontOld = GDI32.SelectObject(_hdc, _hFont);
这段代码没有检查 _hFont 是否创建成功,也没有可靠判断 SelectObject 是否失败。如果字体仍被选入 DC,DeleteObject(_hFont) 可能会失败。
releaseOldBitmap() 的实现同样需要检查。位图、画笔、画刷和字体通常要先从 DC 中换出,之后才能删除。
4. 初始化和释放缺少同步
代码中的 _initialized、_isDisposed、_hdc 和字体句柄都没有锁保护。如果同一个实例会被多个请求或线程共享,可能出现以下情况:
- 两个线程重复初始化同一个对象;
- 一个线程正在使用 DC,另一个线程执行
DeleteDC; - 两个线程同时进入
Dispose(bool); - 初始化尚未完成,另一个线程已经开始释放。
这些竞态可能造成资源泄漏,也可能产生无效句柄和间歇性的 GDI 失败。需要向供应商确认 TextDataProvider、搜索结果及内部渲染器是否支持多线程共享。
微软还对 CreateCompatibleDC(NULL) 创建的 DC 说明了线程所有权问题。如果第三方组件将 DC 传递给其他线程,或者创建 DC 的线程提前退出,也应重点排查。
监控 GDI 对象数量
最直接的诊断方法是在生产服务器上观察进程的 GDI Objects。
可以使用以下工具:
- 在任务管理器的“详细信息”页添加“GDI 对象”和“用户对象”列;
- 使用 Process Explorer 查看进程的 GDI Objects、Handles 和 Private Bytes;
- 定时调用
GetGuiResources,将结果写入应用日志或监控系统。
示例:
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
internal static class NativeDiagnostics
{
private const uint GR_GDIOBJECTS = 0;
private const uint GR_USEROBJECTS = 1;
[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetGuiResources(
IntPtr hProcess,
uint uiFlags);
public static (uint GdiObjects, uint UserObjects) GetGuiObjectCounts()
{
using var process = Process.GetCurrentProcess();
return (
GetGuiResources(process.Handle, GR_GDIOBJECTS),
GetGuiResources(process.Handle, GR_USEROBJECTS)
);
}
}
建议按固定时间间隔记录一次,并在每处理固定数量的 TextSearch 后记录:
var counts = NativeDiagnostics.GetGuiObjectCounts();
logger.Info(
"GDI={0}, USER={1}, Handles={2}, PrivateMemory={3}",
counts.GdiObjects,
counts.UserObjects,
Process.GetCurrentProcess().HandleCount,
Process.GetCurrentProcess().PrivateMemorySize64);
观察时主要看以下趋势:
- GDI 对象数量随搜索次数持续单向增长,基本可以确认存在 GDI 泄漏;
- GDI 数量周期性上升,并在 GC 时明显下降,说明组件可能过度依赖终结器;
- GDI 数量稳定,但
PrivateMemorySize64或句柄数持续增长,需要继续检查其他原生资源; - 所有指标都稳定,应将排查重点转向并发、跨线程使用及服务器整体资源压力。
提高 GDI 配额不能作为正式修复。这样做只能延后故障,还可能让持续泄漏的进程消耗更多系统资源。
记录失败现场
P/Invoke 声明需要支持捕获 Win32 错误。调用返回后必须立即读取错误码,期间不能先调用其他本机函数:
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
internal static class Gdi32
{
[DllImport("gdi32.dll", SetLastError = true)]
internal static extern IntPtr CreateCompatibleDC(IntPtr hdc);
}
IntPtr hdc = Gdi32.CreateCompatibleDC(IntPtr.Zero);
int error = Marshal.GetLastWin32Error();
if (hdc == IntPtr.Zero)
{
throw new Win32Exception(
error,
$"CreateCompatibleDC failed. Win32Error={error}");
}
CreateCompatibleDC 的公开契约明确规定,失败时返回 NULL,但并不保证所有失败路径都会设置有意义的新错误码。因此,当错误码为 0 或与故障现象不符时,仍不能排除资源耗尽。记录错误码的同时,还应保存 GDI 对象数、进程句柄数、内存用量、线程 ID 和请求并发数。
如果无法修改第三方 DLL,可以在调用 TextSearch 的外层捕获异常,并记录这些指标。
检查调用方对象的生命周期
调用代码创建或取得了多个对象:
var textSearchOptions = new TextSearchOptions();
var docQuery = obApp.Core.CreateDocumentQuery();
var textProvider = obApp.Core.Retrieval.Text;
textSearchResults = textProvider.TextSearch(...);
需要查阅当前版本第三方 API 的文档或反编译后的类型声明,确认这些类型是否实现 IDisposable。如果实现,应按照供应商规定使用 using 或显式调用 Dispose()。
在所有权规则不明确时,不要随意释放 obApp.Core.Retrieval.Text。它可能是由 obApp 管理的共享对象,也可能需要调用方释放。升级后对象所有权规则是否发生变化,应由供应商明确说明。
可以用下面的诊断代码确认类型:
bool queryIsDisposable = docQuery is IDisposable;
bool providerIsDisposable = textProvider is IDisposable;
bool resultsAreDisposable = textSearchResults is IDisposable;
如果确认对象归调用方所有,可以采用类似写法:
using var docQuery = obApp.Core.CreateDocumentQuery();
// 只有供应商明确要求调用方释放时,才这样处理:
using var textSearchResults =
textProvider.TextSearch(
document.DefaultRenditionOfLatestRevision,
searchString,
textSearchOptions);
代码能否编译、哪些对象需要释放,取决于第三方组件的实际类型定义。
进行受控压力对比
测试环境无法复现,往往是因为请求量、并发度、文档类型或运行时间与生产环境不同。可以通过以下步骤缩小范围:
- 在隔离进程中连续执行与生产环境相同的
TextSearch。 - 使用相同的
.ctx文档、搜索字符串分布和并发度。 - 每处理 10 次或 100 次请求,记录一次 GDI 对象数。
- 分别测试升级前和升级后的版本。
- 分别测试单线程和生产环境的并发度。
- 记录异常发生前的累计调用数,不要只记录运行时间。
如果新版组件的 GDI 对象数随调用次数增加,而旧版保持稳定,这可以作为提交给供应商的直接证据。如果单线程运行正常,只有并发测试出现增长或失败,则更接近线程安全问题。
必要时,可以使用 Application Verifier、GDIView、Process Monitor、WinDbg 或进程转储继续分析。在生产环境启用这些工具前,应先评估性能影响,并在副本或压测环境中验证。
修正调用方的异常处理
下面的写法会重置异常堆栈:
catch (Exception ex)
{
throw ex;
}
应改为:
catch
{
throw;
}
如果 catch 中没有其他处理,也可以直接删除。完整堆栈有助于确认异常来自 TextSearch 的哪条内部调用路径。
当前方法已经在 try 中执行了 return Results;,因此末尾的另一个 return Results; 是不可达或多余代码,可以清理。它与 GDI 失败没有直接关系。
向第三方供应商提供的信息
故障从组件升级后开始,相关 GDI 代码又位于第三方 DLL,最终修复很可能需要供应商参与。提交问题时,建议提供:
- 升级前后组件的准确版本;
- 故障前累计执行的
TextSearch次数和并发数; - GDI Objects、USER Objects、Handle Count 和 Private Bytes 的时间序列;
- 完整异常堆栈和 Win32 错误码;
- 重启进程后是否立即恢复;
- 单线程与并发压力测试的差异;
- 失败前后的进程转储;
- 能稳定表现资源增长的最小复现程序。
从现有现象判断,应先排查 GDI 资源是否未及时释放,再检查新版组件的并发或跨线程生命周期问题。监控确认对象数量随调用次数增长后,才能将“可能存在资源泄漏”进一步定位为可复现的组件缺陷。
备注:内容仅供参考。
