Windows名前付きセマフォでサーバー/クライアントを自動判別する正しい実装|CreateSemaphoreWとGetLastError徹底解説

Windowsの名前付きセマフォで「最初に起動したプロセス=サーバー、2回目以降=クライアント」を自動で切り替えたい――実装は簡単そうに見えますが、CreateSemaphoreW の挙動を誤解すると、常にサーバー側コードが動いてしまいます。本記事では原因と正しい判定フロー、堅牢なコード例、設計・運用での落とし穴まで徹底解説します。明日から安全に動く同期処理へ置き換えましょう。

目次

問題の全体像と症状

「同名の名前付きセマフォ MyDownloadSemaphore を作る。最初に作れたプロセスをサーバー、それ以外をクライアントにする」――この要件は Win32 の代表的なパターンです。しかし次のような現象が起きがちです。

  • 2回目以降の起動でもサーバー処理(初期化、スレッド生成、listen 等)が実行される
  • クライアント処理(既存サーバーへ接続、ダウンロード枠の消費 等)が一切動かない

原因はただ一つ。CreateSemaphoreW は「既存でもハンドルを返す」ため、戻り値だけで新規作成か既存かを判定できない点にあります。正解は直後の GetLastError を見ることです。

CreateSemaphoreW の正しい理解

CreateSemaphoreW は名前付きセマフォを「作成 or 既存のオープン」のどちらでも成功扱いにします。つまり戻り値 HANDLE が NULL かどうかは「成功/失敗」の判定にのみ使え、新規作成か既存かは区別できません。区別には直後の GetLastError を必ず確認します。

API 戻り値GetLastError意味分岐
h != NULL0 (ERROR_SUCCESS)新規作成サーバー処理
h != NULLERROR_ALREADY_EXISTS既存オブジェクトをオープンクライアント処理
h == NULLエラーコード作成失敗異常終了/再試行

注意点は以下です。

  • GetLastError はスレッドローカルですが、他 API 呼び出しで上書きされうるため、CreateSemaphoreW の直後に読み取ってください。
  • コンソール出力 (printf, wprintf)、ログライブラリ等でも内部で Win32 API を呼ぶことがあります。安全のため判定→分岐→ログの順にしましょう。

最小の正解コード(自動判別)

// コンセプト実装(エラーハンドリング簡略化)
// 役割: GetLastError の値でサーバー/クライアントを自動判定

#include 
#include 

void RunServer();
void RunClient();

int wmain() {
SECURITY_ATTRIBUTES sa{};
sa.nLength = sizeof(sa);
sa.bInheritHandle = FALSE; // 子プロセスへ継承しない(必要に応じて TRUE でも可)
const LONG kMaxParallel = 3; // 同時ダウンロード枠
SetLastError(0);
HANDLE hSemaphore = CreateSemaphoreW(
    &sa,                // 省略可:NULL でも良い
    kMaxParallel,       // 初期カウント
    kMaxParallel,       // 最大カウント
    L"MyDownloadSemaphore"  // ※名前は後述の命名規約を参照
);

if (!hSemaphore) {
    std::printf("CreateSemaphoreW failed: %lu\n", GetLastError());
    return 1;
}

DWORD last = GetLastError(); // 直後に読む!
if (last == ERROR_ALREADY_EXISTS) {
    std::puts("Semaphore client started");
    RunClient();
} else {
    std::puts("Semaphore server started");
    RunServer();
}

CloseHandle(hSemaphore);
return 0;

}

// ダミー
void RunServer(){ /* サーバー初期化やワーカースレッド起動など */ }
void RunClient(){ /* 既存サーバーへの接続やリクエスト送出など */ } 

「役割固定方式」でさらに明瞭にする

自動判別は便利ですが、責務を分けるとテストや運用が簡単になります。具体的には、

  • サーバー: CreateSemaphoreW(新規でも既存でも OK)
  • クライアント: OpenSemaphoreW(存在必須・なければ失敗)

と API を分けるやり方です。

// 役割固定方式
// サーバー側
HANDLE StartServerSemaphore(LPCWSTR name, LONG initialCount, LONG maxCount) {
    HANDLE h = CreateSemaphoreW(nullptr, initialCount, maxCount, name);
    if (!h) return nullptr;
    return h; // 既存でもOK。GetLastErrorはここでは使わない。
}

// クライアント側
HANDLE ConnectServerSemaphore(LPCWSTR name) {
    // 同期+カウント操作が必要
    DWORD access = SYNCHRONIZE | SEMAPHORE_MODIFY_STATE;
    HANDLE h = OpenSemaphoreW(access, FALSE, name);
    return h; // 存在しなければNULL
}

「なぜそうなるのか」を仕組みから理解する

名前付きカーネルオブジェクトのルール

  • 同名のオブジェクトは1インスタンスだけ存在します(同名なら全プロセスで共有)。
  • 最初の Create* が作成者。それ以降の Create* は作成者ではないが成功し、ERROR_ALREADY_EXISTS を返します。
  • 最後のハンドルが CloseHandle された瞬間、オブジェクトは破棄されます(永続ではない)。

セッション / 名前空間

Terminal Services(リモートデスクトップなど)環境では、名前空間が「Global\」「Local\」に分かれます。サービスとユーザーアプリが共有したいなら Global\MyDownloadSemaphore のように Global プレフィックスを付けます。デスクトップ間で共有不要なら無指定か Local\ で十分です。

アクセス権

OpenSemaphoreW の dwDesiredAccess には、待機に必要な SYNCHRONIZE と、ReleaseSemaphore を行うための SEMAPHORE_MODIFY_STATE を指定します。片方でも不足すると実行時に失敗します。

堅牢な実装テンプレート(並列ダウンロード制御)

以下は「最大3並列でダウンロード」するサーバー/クライアントの完全テンプレートです。
要点は、枠の取得に WaitForSingleObject、完了で ReleaseSemaphore を徹底すること。

#include <windows.h>
#include <vector>
#include <string>
#include <thread>
#include <cstdio>

struct Handle {
    HANDLE h{};
    ~Handle(){ if(h) CloseHandle(h); }
    operator HANDLE() const { return h; }
};

static const wchar_t* kName = L"Global\\MyDownloadSemaphore"; // 必要に応じて Global/Local を調整
static const LONG kMaxParallel = 3;

// ダウンロードの疑似処理
void DownloadTask(int id, HANDLE semaphore) {
    // 枠の取得(待機)
    DWORD dw = WaitForSingleObject(semaphore, INFINITE);
    if (dw != WAIT_OBJECT_0) {
        std::printf("[T%02d] Wait failed: %lu\n", id, GetLastError());
        return;
    }

    std::printf("[T%02d] slot acquired\n", id);
    // 実仕事(疑似)
    Sleep(800 + (id % 3) * 200);

    // 枠の返却
    if (!ReleaseSemaphore(semaphore, 1, nullptr)) {
        std::printf("[T%02d] ReleaseSemaphore failed: %lu\n", id, GetLastError());
    } else {
        std::printf("[T%02d] slot released\n", id);
    }
}

void RunServer() {
    std::puts("[Server] starting worker threads...");
    std::vector<std::thread> th;
    for (int i=0;i<5;i++) { // 5本投げるが、常時3本までが実行される
        th.emplace_back(DownloadTask, i, OpenSemaphoreW(SYNCHRONIZE|SEMAPHORE_MODIFY_STATE, FALSE, kName));
    }
    for (auto& t: th) t.join();
    std::puts("[Server] all tasks done");
}

void RunClient() {
    std::puts("[Client] sending 2 jobs...");
    std::thread t1(DownloadTask, 101, OpenSemaphoreW(SYNCHRONIZE|SEMAPHORE_MODIFY_STATE, FALSE, kName));
    std::thread t2(DownloadTask, 102, OpenSemaphoreW(SYNCHRONIZE|SEMAPHORE_MODIFY_STATE, FALSE, kName));
    t1.join(); t2.join();
}

int wmain() {
    SetLastError(0);
    Handle sem;
    sem.h = CreateSemaphoreW(nullptr, kMaxParallel, kMaxParallel, kName);
    if (!sem.h) {
        std::printf("CreateSemaphoreW failed: %lu\n", GetLastError());
        return 1;
    }

    DWORD last = GetLastError();
    if (last == ERROR_ALREADY_EXISTS) {
        std::puts("Semaphore client started");
        RunClient();
    } else {
        std::puts("Semaphore server started");
        RunServer();
    }
    return 0;
}

ポイント

  • 各スレッド(または各クライアント)は OpenSemaphoreW で同じ名前のハンドルを開いています。これによりプロセス/スレッドを跨いで1つのカウントを共有できます。
  • WaitForSingleObject が枠の取得、ReleaseSemaphore が返却です。値を増減させるのは ReleaseSemaphore だけに統一してください。
  • セマフォには「放棄(abandoned)」の概念はありません。プロセスが異常終了すると枠は自動返却されませんが、ReleaseSemaphore が呼ばれなかった分だけカウントが不足し、以後の Wait がブロックし続けます。長時間のブロック監視やウォッチドッグを別途設けるのが実務では安全です。

よくある落とし穴と対処

GetLastError を遅れて読む

判定前に他の Win32 API を呼ぶと、GetLastError が上書きされる可能性があります。必ず CreateSemaphoreW の直後に読みましょう。

名前の衝突・命名規約

プロセスが多いシステムでは、短い名前は他アプリと衝突しやすく危険です。以下の規約を推奨します。

  • Global\CorpName\Product\Feature\SemaphoreName
  • 環境ごとに GUID を埋める:Global\Corp\Product\{B1C2-...}\MyDownloadSemaphore
  • テスト用/本番用で接尾辞を変える:...MyDownloadSemaphore_Prod, ..._Dev

アクセス権不足

OpenSemaphoreW のアクセス権が不足すると NULL になります。待機・解放の双方が必要なら SYNCHRONIZE | SEMAPHORE_MODIFY_STATE を必ず指定しましょう。

セッションを跨げない

サービス(Session 0)とユーザー(Session 1+)で共有したい場合は、Global\ を付けます。ユーザー内で閉じた用途なら Local\ または無指定で十分です。

ハンドルリーク

CreateSemaphoreW / OpenSemaphoreW / CreateThread 等で得たハンドルは必ず CloseHandle。RAII(スコープ終了で自動解放)を使えばリークを防げます。

初期値と最大値の設計ミス

初期カウント == 最大カウント == 並列上限が基本です。
例:3並列にしたい → 初期=3、最大=3、生成するスレッドは上限以上でも良いが、Wait で自然に流量制御されます。

判定フローを視覚化する

ステップ条件処理備考
1—SetLastError(0)古い値の混入防止(必須ではないが可読性UP)
2—h = CreateSemaphoreW(..., name)失敗なら NULL
3h == NULLエラー処理ログ→終了 or 再試行
4h != NULLlast = GetLastError()直後に読む!
5last == ERROR_ALREADY_EXISTSクライアント処理既存に参加
6last == 0サーバー処理初期化・ワーカ起動

テスト戦略(再現と確認)

  1. アプリを2つ起動:先に起動した方が「Server started」、後の方が「Client started」と出るか。
  2. 同時起動テスト:ほぼ同時にクリックしても、片方だけがサーバーになること(もう片方は ERROR_ALREADY_EXISTS)。
  3. 並列枠の検証:サーバー側で5タスク投げ、slot acquired のログが常に3本以内に収まるか。
  4. 異常系:OpenSemaphoreW でわざと別名にして失敗ログが出るか(アクセス権・名前空間の誤り検知)。

運用と監視の実務Tips

  • ログに GetLastError の数値だけでなく、どの API の直後かを必ず記録。
  • 長時間同一タスクが枠を保持し続ける場合に備え、タイムアウト待機(WaitForSingleObject(..., ms))+リトライ設計を用意。
  • インストール単位でセマフォ名にテナントIDやGUIDを含め、マルチインスタンス運用の衝突を予防。
  • 障害時は Process Explorer / Handle 等でセマフォのハンドル保持プロセスを特定(ツール名は伏せますが観点として)。

FAQ

printf の後に GetLastError を読んでも大丈夫? 安全とは限りません。直前に別の Win32 API を呼ぶ実装に変わる可能性もあります。直後に読むのが唯一確実です。 ミューテックスでも同じ判定はできる? はい。CreateMutex も既存の場合は ERROR_ALREADY_EXISTS を返します。ただしミューテックスは「排他」。並列制御が目的ならセマフォが適切です。 リブート後に「サーバー」が変わるのは問題? 名前付きセマフォは最後のハンドルが閉じられると消えるため、永続的な「所有者」概念はありません。常に起動順で決まる設計にしましょう。 初期値を 0 にしても良い? 可能です。初期値 0 は「全スレッド待機から開始」になります。サーバー初期化後に ReleaseSemaphore で枠を開放していくパターンです。 CreateSemaphoreEx を使うメリットは? 拡張フラグや明示的なアクセス権指定が可能です。高度な制御や将来互換性を重視するなら選択肢になります。

チェックリスト(コピペ前の最終確認)

  • 直後の GetLastError を読んで分岐しているか?
  • セマフォ名は衝突しない命名規約になっているか?(Global\Corp\Product\... など)
  • クライアントで OpenSemaphoreW(SYNCHRONIZE | SEMAPHORE_MODIFY_STATE) を使っているか?
  • 枠取得は WaitForSingleObject、返却は ReleaseSemaphore に統一しているか?
  • 全ハンドルを CloseHandle しているか? RAII 化できているか?

まとめ

「2回目以降のプロセスでもサーバー側のコードが動く」問題は、CreateSemaphoreW の仕様理解ミスが原因でした。戻り値ではなく直後の GetLastError で ERROR_ALREADY_EXISTS を判定すれば、サーバー/クライアントの自動振り分けは正確に動作します。さらに役割固定方式(サーバーは Create、クライアントは Open)を採用すれば、テスト容易性・運用性も向上します。
命名・アクセス権・名前空間・枠設計・ハンドル解放という基本原則を守り、確実にスケールする同期処理へ刷新しましょう。

付録:ミニ実装断片集

不要キャストの撤廃

// NG: (LPTHREAD_START_ROUTINE) のキャストで型不一致を隠す
// CreateThread(..., (LPTHREAD_START_ROUTINE)func, ...);

// OK: シグネチャを合わせる
DWORD WINAPI WorkerThread(LPVOID p) { /* ... */ return 0; }
CreateThread(nullptr, 0, WorkerThread, nullptr, 0, nullptr); 

エラー文字列を素早く可視化

void LogLastError(const char* api, DWORD code) {
    LPSTR msg = nullptr;
    FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
                   nullptr, code, 0, (LPSTR)&msg, 0, nullptr);
    std::printf("%s failed: %lu (%s)\n", api, code, msg ? msg : "n/a");
    if (msg) LocalFree(msg);
}

コマンドラインで役割を手動切替(デバッグ用)

// 例: app.exe server / app.exe client / app.exe (自動判別)
int wmain(int argc, wchar_t** argv) {
    if (argc > 1) {
        if (_wcsicmp(argv[1], L"server") == 0) { RunServer(); return 0; }
        if (_wcsicmp(argv[1], L"client") == 0) { RunClient(); return 0; }
    }
    // 自動判別にフォールバック
    // ...(前述の CreateSemaphoreW & GetLastError 判定)
}

セマフォとミューテックスの使い分け早見表

用途推奨オブジェクト理由
同時実行数の上限制御(N本)セマフォカウントで同時実行本数を自然制御
共有リソースの単一占有ミューテックス排他が必要。所有権と放棄検出がある
イベント通知(1→N)イベント状態遷移の通知に向く(手動/自動リセット)

プロセス間でハンドルを共有したい場合

名前付きオブジェクトが使えない/使いたくない場合は、bInheritHandle=TRUE+DuplicateHandle で子プロセスにハンドルを明示的に渡します(セキュリティ属性・アクセス権に注意)。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次