【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_PEAKGR_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 黒ペンで引かれる。作成関数の戻り値をその場で見るのが唯一の気づき方。
  • 上限中は読み取り系の値も食い違う。全解放してハンドル数が戻るかどうかで、上限が原因かを判定する。
目次