WinINet でファイルを落としつつ、別スレッドで 4KB ごとに進捗を出したい――実装してみたら開始メッセージだけ表示して固まる。この症状は SRWLock の使い方と終了判定まわりの定番ミスが重なると高確率で起きます。本稿では「なぜ止まるのか」を具体的に分解し、最小修正から堅牢化、デバッグ手順、設計指針まで一気通貫で解説します。コピペで使えるサンプルとチェックリストも付けました。
症状の再現イメージと前提
以下のような開始メッセージが表示された後、ダウンロードや進捗が前に進まず停止したように見える状態です。
Downloading http://127.0.0.1:8000/a-guide-to-kernel-exploitation.pdf to downloaded_file.pdf...
- ダウンローダ(生産者)スレッド:
InternetReadFile→WriteFile→ 共有カウンタ更新 - 進捗表示(消費者)スレッド:一定間隔で共有カウンタを読んで表示
- 共有状態の保護:
SRWLock(更新:Exclusive、参照:Shared)を想定
根本原因と対処(要約表)
ハングの主因になったパターンと修正要点を表にまとめます。どれか 1 つでも該当すると止まりますが、現場では複数同時発生が定番です。
| 主な不具合 | 具体的症状 | 修正ポイント |
|---|---|---|
| 状態フラグのバッファ不足 | g_status が 6 文字しかなく COMPLETE 書込みでバッファオーバーラン。 | WCHAR g_status[16] = L"START"; など充分に確保するか、std::atomic_bool done{false}; に置換。 |
| 文字列比較の誤り | while (g_status == L"COMPLETE") はポインタ比較で常に false。 | wcscmp を使う:while (wcscmp(g_status, L"COMPLETE") != 0)。 |
| ロックの取り外し漏れ | 読む側がロック取得のままループを抜け、書く側が永久待機。 | Acquire* と Release* を必ず対に。例:AcquireSRWLockShared/ReleaseSRWLockShared。 |
| TryAcquire 後の再取得 | TryAcquireSRWLockExclusive 成功後に AcquireSRWLockExclusive を続けて呼び二重取得。 | 成功したらそのまま処理。失敗時だけリトライやスリープ。 |
| 排他/共有ロックの混在 | 読み取りでも排他ロックを取ってしまいスループット低下、ひどいと飢餓・ハング風。 | 更新側は Exclusive、読取側は Shared を厳守。 |
| スレッド関数シグネチャ誤り | DWORD WINAPI ReadSharedData() ではなく、無理なキャスト。 | DWORD WINAPI ReadSharedData(LPVOID) に修正。 |
| ロック外で共有データ操作 | WriteFile 後にロック無しで global_shared_data を加算・出力。 | 共有値の加算・読み出し前に必ず適切なロックまたは atomic を使用。 |
| スピン+Sleep の設計ミス | 進捗側が while (!done) { TryAcquire...; Sleep(1); } で無駄に回し続ける。 | 素直に Acquire* でブロック or atomic でロックレス、通知は CONDITION_VARIABLE や Event を使う。 |
最小構成の修正版(安全に動く雛形)
「とりあえず確実に動かす」ための最小構成です。ブロッキング I/O(InternetReadFile)の前後でロックしない点が肝です。共有カウンタは短時間だけロックで守り、終了は std::atomic_bool で伝えます。
// 最小構成(Unicode / マルチバイトどちらでもOK)
#include <windows.h>
#include <wininet.h>
#include <cstdio>
#include <cstdint>
#include <atomic>
#pragma comment(lib, "wininet.lib")
static SRWLOCK g_lock = SRWLOCK_INIT;
static std::atomic_bool g_finished{false};
static unsigned long long g_totalBytes = 0;
static HINTERNET g_hInet = nullptr;
static HINTERNET g_hUrl = nullptr;
static HANDLE g_hFile = INVALID_HANDLE_VALUE;
DWORD WINAPI Downloader(LPVOID) {
BYTE buf[4096];
DWORD read = 0, written = 0;
for (;;) {
if (!InternetReadFile(g_hUrl, buf, sizeof buf, &read)) break;
if (read == 0) break; // EOF
if (!WriteFile(g_hFile, buf, read, &written, nullptr)) break;
AcquireSRWLockExclusive(&g_lock);
g_totalBytes += written; // 共有値の更新は短く
ReleaseSRWLockExclusive(&g_lock);
}
g_finished.store(true, std::memory_order_release);
return 0;
}
DWORD WINAPI ProgressPrinter(LPVOID) {
unsigned long long lastPrinted = 0;
for (;;) {
if (g_finished.load(std::memory_order_acquire)) {
AcquireSRWLockShared(&g_lock);
auto total = g_totalBytes;
ReleaseSRWLockShared(&g_lock);
std::printf("Downloaded %llu bytes (final)\r\n", total);
return 0;
}
AcquireSRWLockShared(&g_lock);
auto total = g_totalBytes;
ReleaseSRWLockShared(&g_lock);
if (total - lastPrinted >= 4096) {
std::printf("Downloaded %llu bytes\r\n", total);
lastPrinted = total;
}
Sleep(100); // 表示は 10Hz 程度で十分
}
}
int wmain(int, wchar_t**) {
const wchar_t* url = L"http://127.0.0.1:8000/a-guide-to-kernel-exploitation.pdf";
const wchar_t* path = L"downloaded_file.pdf";
std::wprintf(L"Downloading %s to %s...\n", url, path);
g_hInet = InternetOpenW(L"Downloader/1.0", INTERNET_OPEN_TYPE_PRECONFIG, nullptr, nullptr, 0);
if (!g_hInet) return 1;
g_hUrl = InternetOpenUrlW(g_hInet, url, nullptr, 0, INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE, 0);
if (!g_hUrl) { InternetCloseHandle(g_hInet); return 1; }
g_hFile = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (g_hFile == INVALID_HANDLE_VALUE) { InternetCloseHandle(g_hUrl); InternetCloseHandle(g_hInet); return 1; }
HANDLE th1 = CreateThread(nullptr, 0, Downloader, nullptr, 0, nullptr);
HANDLE th2 = CreateThread(nullptr, 0, ProgressPrinter, nullptr, 0, nullptr);
HANDLE ths[2] = { th1, th2 };
WaitForMultipleObjects(2, ths, TRUE, INFINITE);
CloseHandle(th1);
CloseHandle(th2);
CloseHandle(g_hFile);
InternetCloseHandle(g_hUrl);
InternetCloseHandle(g_hInet);
return 0;
}
上記は以下を満たします。
- ブロック中にロックを保持しない:
InternetReadFile/WriteFileはロック外。共有カウンタ更新のみ保護。 - 終了通知は
atomic:安全・明瞭でデッドロックの絡みを排除。 - 4KB 単位での出力:前回値との差分で出力頻度を制御。
なぜフリーズに見えるのか(内部で起きていること)
SRWLock は再入不可です。同じスレッドが AcquireSRWLockExclusive を二度呼ぶと自分にブロックされ進めません。また、共有ロックを取りっぱなしでループを抜けると、書き手が永遠に Exclusive を取れません。さらに、状態フラグのバッファオーバーランはヒープ破壊を誘発し、たまたま SRWLock の内部状態やヒープメタデータが壊れて 「ロック待ちに見える」 ことも多いです。最後にポインタ比較誤用により進捗スレッドの終了条件が満たされず、永遠に回り続けます。これらが重なると、開始ログ以降が静止画のようにフリーズします。
設計指針:シンプルで止まらない構成
- 共有データはできる限り
std::atomic:単一のカウンタやフラグはロックレス化。 - ロックのスコープは最小化:「加算・読み取り」の瞬間だけ。I/O はロック外。
- TryAcquire は乱用しない:本当に他の仕事がある時だけ。単純な生産者−消費者は素直に
Acquire*。 - 終了条件は 1 箇所で明確化:
finishedを原則に、例外的なエラーも同じ経路で伝播。 - 出力は制限:4KB・100ms など閾値で抑制。標準出力は遅い。
ロックレス(atomic)版:さらに簡潔
カウンタを std::atomic<unsigned long long> にすれば SRWLock を消せます。二値の整合性が不要なら最有力です。
static std::atomic<unsigned long long> g_total{0};
static std::atomic_bool g_finished{false};
// 書き手
// ... WriteFile 後
g_total.fetch_add(written, std::memory_order_relaxed);
// 読み手
auto now = g_finished.load(std::memory_order_acquire);
auto t = g_total.load(std::memory_order_relaxed);
メモ:単一カウンタなら InterlockedAdd64 でも可。C++ なら atomic が可読性・移植性に優れます。
通知まで欲しい場合:CONDITION_VARIABLE で待機・起床
「1KB 増えるたびに即時表示したい」「ウエイクアップ遅延を減らしたい」なら、SRWLOCK+CONDITION_VARIABLE の組み合わせが有効です。
static SRWLOCK g_lock = SRWLOCK_INIT;
static CONDITION_VARIABLE g_cv;
static unsigned long long g_total = 0;
static bool g_done = false;
// 書き手:加算後に通知
AcquireSRWLockExclusive(&g_lock);
g_total += written;
WakeConditionVariable(&g_cv);
ReleaseSRWLockExclusive(&g_lock);
// 読み手:条件待ち
AcquireSRWLockShared(&g_lock);
while (!g_done) {
SleepConditionVariableSRW(&g_cv, &g_lock, 100 /*timeout*/, CONDITION_VARIABLE_LOCKMODE_SHARED);
std::printf("%llu\r\n", g_total);
}
ReleaseSRWLockShared(&g_lock);
Shared ロックモードのまま SleepConditionVariableSRW できるため、読み取り多・書き込み少のワークロードでスループットが高くなります。
アンチパターンを具体的に潰す
- ロックを持ったまま I/O:最悪。ネットワーク遅延がロック遅延に直結します。
- 二重取得・取り忘れ:関数冒頭と異常系
return前でRelease*を忘れがち。早期リターン禁止 or スコープガードで回避。 - 状態文字列:フラグは文字列で持たない。
enumorbooloratomic。 - 出力のし過ぎ:1 行出力はミリ秒単位で重い。閾値でまとめて出す。
デバッグ:詰まりを見破る 7 ステップ
- Wait Chain を見る:タスクマネージャの「分析」または WCT API でどのスレッドがどの待機オブジェクトを掴んでいるか確認。
- Application Verifier:ロック検証・ヒープ検証を有効化。二重取得・解放忘れ・オーバーランを早期検知。
- Page Heap:ヒープ破壊を再現・即死に。
COMPLETE書込みで止まるなら高確率で検出できます。 - 最小再現を作る:I/O を
ReadFile/WriteFileのメモリパイプに置換しても止まるなら、ロック設計の問題。 - ログに時刻とスレッド ID:
GetTickCount64()とGetCurrentThreadId()を各イベントで出す。 - ガード構文:RAII(スコープガード)で取得/解放の対応を機械化。
- タイムアウトを入れる:デバッグビルドだけ
Acquire*の前後にウォッチドッグを入れてハング位置を特定。
4KB ごとの進捗設計のコツ
- 差分トリガ:現在−前回 が 4KB を超えたら出力。端数は次回へ持ち越し。
- レート制限:高速回線では 4KB/行でもログが膨大。100ms のスロットリングを併用。
- UI 連携:GUI なら 描画は UI スレッドに投げる。ロックを持ったまま
SendMessageしない。
よくある質問(FAQ)
Q. SRWLock と CRITICAL_SECTION はどちらがよい?
A. 読み取り多・書き込み少なら SRWLock が有利(Shared が効く)。単純な独占保護や古い環境互換なら CRITICAL_SECTION も選択肢。ただし再入不可なのは同じです。
Q. std::mutex との違いは?
A. std::mutex は C++ 標準で移植性が高い一方、読取専用の共有モードがありません(代替は std::shared_mutex)。Win32 API と混在させるなら SRWLock の方が少し軽量。
Q. ハングに見えるが CPU 使用率が 0% のまま。
A. ロック待ちや I/O 待ちの可能性。Wait Chain とスレッドスタックを確認。I/O 中はロックを保持しない設計に。
Q. 進捗が昔の値のまま更新されない。
A. 可視化の問題。atomic のロードに memory_order_acquire、ストアに memory_order_release を使うか、デフォルトの seq_cst を使えばまず安全です。
チェックリスト(貼って使える)
- 共有カウンタは
atomicか、短いスコープの SRWLock で保護している - ロック中に
InternetReadFile/WriteFileを呼んでいない - 終了条件は
atomic<bool>で一元化している Acquire*とRelease*は 1:1 の対応を保ち、例外経路も漏れがないTryAcquire*に成功した場合は再度Acquire*しない- 進捗出力は差分 4KB または 100ms スロットリングで抑制している
- 状態文字列・バッファは十分な長さを確保(あるいは廃止)
「止まるコード」を「止まらないコード」に直す着眼点
- 終了通知を文字列から
atomic<bool>へ:曖昧さとオーバーランの芽を摘む。 - カウンタはロックレス or 極小スコープ:ロック時間を数十ナノ秒~数百ナノ秒に抑える。
- ロック中に 待たない:ネットワーク、ディスク、UI、
Sleepをロック内に入れない。 - 観測可能性:ログに TID と時刻を必ず記録。次に Wait Chain。
付録:ロバスト版(エラー伝搬と通知を整備)
実戦投入を意識し、エラー通知・条件変数・タイムアウトを加えた例です。
struct Shared {
SRWLOCK lock = SRWLOCK_INIT;
CONDITION_VARIABLE cv{};
unsigned long long total = 0;
bool finished = false;
DWORD lastError = ERROR_SUCCESS;
Shared() { InitializeConditionVariable(&cv); }
};
static Shared g;
static HINTERNET g_hInet = nullptr, g_hUrl = nullptr;
static HANDLE g_hFile = INVALID_HANDLE_VALUE;
DWORD WINAPI Downloader2(LPVOID) {
BYTE buf[64 * 1024];
DWORD read = 0, written = 0;
for (;;) {
if (!InternetReadFile(g_hUrl, buf, sizeof buf, &read)) { g.lastError = GetLastError(); break; }
if (read == 0) break;
if (!WriteFile(g_hFile, buf, read, &written, nullptr)) { g.lastError = GetLastError(); break; }
```
AcquireSRWLockExclusive(&g.lock);
g.total += written;
WakeConditionVariable(&g.cv);
ReleaseSRWLockExclusive(&g.lock);
}
AcquireSRWLockExclusive(&g.lock);
g.finished = true;
WakeAllConditionVariable(&g.cv);
ReleaseSRWLockExclusive(&g.lock);
return 0;
```
}
DWORD WINAPI Progress2(LPVOID) {
unsigned long long printed = 0;
AcquireSRWLockShared(&g.lock);
for (;;) {
// 100ms でタイムアウトしつつ、通知があればすぐ起きる
SleepConditionVariableSRW(&g.cv, &g.lock, 100, CONDITION_VARIABLE_LOCKMODE_SHARED);
```
auto t = g.total;
bool fin = g.finished;
DWORD err = g.lastError;
ReleaseSRWLockShared(&g.lock);
if (t - printed >= 4096) {
std::printf("Downloaded %llu bytes\r\n", t);
printed = t;
}
if (fin) {
if (err != ERROR_SUCCESS) std::printf("Download failed: %lu\r\n", err);
else std::printf("Download complete: %llu bytes\r\n", t);
break;
}
AcquireSRWLockShared(&g.lock); // 次の周回のため再取得
}
return 0;
```
}
まとめ
- フリーズの正体は「ロックの二重取得・解放漏れ」「終了条件の誤実装」「バッファ破壊」が 3 大要因。
- 回避の第一歩は、終了は
atomic、カウンタはatomicか極小スコープの SRWLock。 - ロックを持ったまま I/O しない・
TryAcquireを乱用しない・出力を絞る。 - デバッグは Wait Chain → Verifier → Page Heap → 最小再現の順で詰める。
実装スニペット(使い回し用)
// スレッド関数は必ず WINAPI / LPVOID で
DWORD WINAPI ThreadProc(LPVOID) { /* ... */ return 0; }
// SRWLock:共有読み / 排他書き
AcquireSRWLockShared(&lock); /* read */ ReleaseSRWLockShared(&lock);
AcquireSRWLockExclusive(&lock);/* write */ ReleaseSRWLockExclusive(&lock);
// atomic:終了フラグと単一カウンタに最適
std::atomic_bool done{false};
std::atomic total{0};
この記事の方針通りに実装すれば、進捗表示が止まる・動作が鈍る・終了しないといった「不自然な静止画状態」は解消できます。まずは最小修正版で確実に動かし、次に通知やロギングを整えて、最後にパフォーマンスをチューニングする――この順で取り組むのが最短経路です。

コメント