【MFC】GDI ハンドル上限:CreatePen が NULL を返し始めた瞬間の数を実機で数える

【MFC】GDI ハンドル上限:CreatePen が NULL を返し始めた瞬間の数を実機で数える

長く動かしているMFCアプリで、線や枠が急に描かれなくなることがあります。GDIオブジェクトを解放しないまま作り続けるとプロセスあたりのハンドル数が上限に達し、そこから先のCreatePenは例外もエラーダイアログも出さずにNULLを返します。


目次

最初に見る値は、自分のプロセスのGDIハンドル数

描画が壊れたときに見る場所は症状で決まります。落ちていないのに描かれない、という状態が続いているなら、次の順で値を取ります。

症状見る値読み方
線・枠・文字が描かれない。落ちてはいないGetGuiResources(GR_GDIOBJECTS)数千から1万に張り付いていれば、ハンドル上限を疑う
動かし続けると症状が出る。再起動で直る現在値とGR_GDIOBJECTS_PEAK両方高ければ解放漏れ。ピークだけ高いなら一時的な大量作成
特定の描画だけ見た目が変わるCreatePen / CreateSolidBrushの戻り値NULLが返っていれば上限に達している
アプリを止めずに外から見たいタスクマネージャーの[詳細]タブ列の追加で「GDIオブジェクト」を出すと、プロセス単位の現在値が見える

アプリ側から数えるときはGetGuiResourcesを使います。ヘッダーはwinuser.h、リンクはUser32.libです。自分のプロセスを見るだけならGetCurrentProcess() をそのまま渡せます。

// タイマーで定期的に自分のプロセスを見張り、しきい値を超えたら記録する。
// 現場で「いつの間にか描画が壊れている」を再現待ちするときは、この1行が効く。
void CMainFrame::OnTimer(UINT_PTR nIDEvent)
{
    if (nIDEvent == m_gdiWatchTimer)
    {
        const HANDLE self = ::GetCurrentProcess();
        const DWORD gdi  = ::GetGuiResources(self, GR_GDIOBJECTS);
        const DWORD peak = ::GetGuiResources(self, GR_GDIOBJECTS_PEAK);
        const DWORD user = ::GetGuiResources(self, GR_USEROBJECTS);

        if (gdi > m_gdiWarnLevel)   // 起動直後の値 + 2000 程度から始める
        {
            CString log;
            log.Format(_T("GDI=%lu peak=%lu USER=%lu"), gdi, peak, user);
            TRACE(_T("%s\n"), (LPCTSTR)log);   // 実運用ではファイルへ残す
        }
    }
    CFrameWnd::OnTimer(nIDEvent);
}

GR_GDIOBJECTS_PEAKとGR_USEROBJECTS_PEAKはWindows 7 / Windows Server 2008 R2以降で使えます。渡すハンドルにはPROCESS_QUERY_LIMITED_INFORMATIONが要り(GetCurrentProcess() の擬似ハンドルは満たしています)、対象は同じセッションのプロセスに限られます。


上限に達した瞬間のGDIハンドル数を実機で数える

解放漏れと同じ状態は、その場で作れます。CreatePenを解放せずに呼び続け、NULLが返った時点で数を読むだけです。Windows 11 + Visual Studio 2026 / x64 Debugで測った値が次の画面です。

GDIハンドルを解放せずに作り続け、9986本目でCreatePenがNULLを返した時点のGDIハンドル数10000とGetLastError 0を表示した実行結果
9986本目でCreatePenがNULL。GDIハンドル数は10000で止まり、GetLastErrorは0のまま
起動直後の GDI ハンドル数      : 14
起動直後の USER ハンドル数     : 20

作成できた HPEN の本数         : 9986
NULL が返ったか                : はい
失敗直前の GDI ハンドル数      : 10000
失敗時の GDI ハンドル数        : 10000
GR_GDIOBJECTS_PEAK             : 10000
NULL 時の GetLastError         : 0

全解放後の GDI ハンドル数      : 14

14 + 9986 = 10000です。上限が数えているのは自分が作った本数ではなく、プロセスが今持っているGDIハンドルの総数で、ウィンドウやコントロールが起動時から持っている分もそこに含まれます。10000はこの環境で観測した値で、あとの節のとおりレジストリで変わります。

上限に届くまでの時間は、同じ環境で3回走らせて172 ms / 406 ms / 1063 msでした。桁は動きますが、いずれもユーザーが操作している最中の一瞬です。ループで作り続ければ一気に届き、1回のイベントで数本ずつ漏らす作りなら数日かけて届きます。


CreatePenがNULLでも線は引かれる。既定のペンで

上限に達したまま、太さ5・青のペンで線を引こうとしたときの実測です。

呼び出し結果
CreatePen(PS_SOLID, 5, RGB(0,0,255))NULL
そのときのGetLastError0
CreateSolidBrushNULL
SelectObject(NULLのペンを選択)失敗(NULLが返る)
実際に引かれた線太さ1 px・黒
FillSolidRectの矩形指定どおり塗られた

例外は飛びません。SelectObjectが失敗するとDCには直前のペン、既定では1 pxの黒いペンが残ったままなので、そのペンで線が引かれます。色も太さも指定と違うのに、どの関数も失敗を知らせません。新しいハンドルを作らないFillSolidRectは上限中でも通るため、画面には「一部だけ描けている」状態が残ります。

気づけるようにするには、作成の戻り値をその場で見ます。MFCならCPen::CreatePenのBOOL、Win32 APIならHPENです。

void CChartView::DrawTrendLine(CDC* pDC, const CPoint& from, const CPoint& to)
{
    CPen pen;
    if (!pen.CreatePen(PS_SOLID, 5, RGB(0, 0, 255)))
    {
        // 上限に達している。黙って既定のペンで描かせない。
        const DWORD gdi = ::GetGuiResources(::GetCurrentProcess(), GR_GDIOBJECTS);
        TRACE(_T("CreatePen failed. GDI handles = %lu\n"), gdi);
        return;
    }

    CPen* pOldPen = pDC->SelectObject(&pen);
    pDC->MoveTo(from);
    pDC->LineTo(to);
    pDC->SelectObject(pOldPen);   // 選択されたままのオブジェクトは削除できない

    pen.DeleteObject();
}

元のペンへ戻す1行を省くと、そのペンはDCに選択されたままになります。MicrosoftのCGdiObjectクラスのリファレンスにも、デバイスコンテキストに選択されている状態でDeleteObjectを呼んではいけない、と書かれています。戻さないまま削除しようとした分がそのまま漏れて、上の10000に近づいていきます。DCの種類ごとの寿命はCPaintDCとCClientDCの記事で扱っています。


上限に達した状態で読んだ値は、そのまま信じない

切り分けが難しくなるのは、上限中は測定側も正しく動かないからです。実測で2つ出ました。

ひとつはGetLastErrorです。CreatePenがNULLを返した直後でも0のままでした。呼び出し前にSetLastError(0) を入れて測った値なので、他のAPIの残りかすではありません。エラーコードから原因へ辿る手は、この症状では最初から使えません。

もうひとつは読み取り系です。描いた線をGetPixelで走査したところ、上限中は縦31 px全部が「白以外」と返ってきました。全ハンドルを解放してから同じ画素を読み直すと1 pxです。ビットマップの中身は解放では変わらないので、壊れていたのは描画ではなく読み取りのほうでした。上限中の測定値で結論を出さず、まず解放して数が戻るかを見ます。今回は解放後に14へ戻り、太さ5の青い線も描けています。


上限値はレジストリで決まる。既定値は公式に書かれていない

MicrosoftのGDI Objectsは、1セッションあたり65,536個という理論上の上限があること、プロセスごとの既定の上限が別にあること、そしてそれを変えるレジストリ値を挙げています。既定値そのものの数字は書かれていません。

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows
    GDIProcessHandleQuota    ; 256 〜 65,536 の範囲で設定できる
    USERProcessHandleQuota   ; 200 〜 18,000 の範囲で設定できる

USERオブジェクト側の値と範囲はUser Objectsに載っています。範囲がGDIとUSERで違う点は、両方を上げようとしたときに効いてきます。上で測った10000はこの環境の実測値で、配布先のPCが同じ値とは限りません。稼働中の環境で確かめるなら、このレジストリ値とGetGuiResourcesの実測を突き合わせます。

クォータを引き上げても、漏れている限り到達が先送りされるだけです。漏れている場所を先に見つけます。なおCRTのメモリリーク検出はGDIハンドルを数えません。_CrtSetDbgFlagのレポートが空でもGDIハンドルは漏れ続けるので、CRTのリーク検出とは別に数える必要があります。


まとめ

  • 描画が壊れたのに落ちていないときは、GetGuiResources(GR_GDIOBJECTS) で自分のプロセスの現在値とピーク値を取る。実測では10000でCreatePenがNULLを返した。
  • 上限に達しても例外は飛ばずGetLastErrorも0のままで、線は既定の1 px黒ペンで引かれる。作成関数の戻り値をその場で見るのが唯一の気づき方。
  • 上限中は読み取り系の値も食い違う。全解放してハンドル数が戻るかどうかで、上限が原因かを判定する。
目次