43. 백그라운드 워커의 수명 관리 부재 (Dispose 후 잔존 작업) (C#)
난이도 상해설 — 백그라운드 워커의 수명 관리 부재 (Dispose 후 잔존 작업) (C#)
난이도: 상
요약
생성자에서 Task.Run(RunLoop) 로 띄운 백그라운드 루프 (A) 는 종료 조건도
취소 토큰도 없고, Dispose (B) 는 그 작업에 정지 신호를 보내거나 완료를
기다리지 않고 _stats.Clear() 만 한다. 결과적으로 루프는 Dispose 이후에도 계속
돌며 이미 정리된(그리고 동기화 없이 공유되는) _stats 를 수정한다. 게다가 버려진
Task 의 예외는 관측되지 않아 조용히 사라진다.
문제점
- [분류: 작업 수명 관리 부재 · fire-and-forget · Dispose 이후 접근 · 데이터 레이스]
- 증상: Dispose 후에도 CPU/타이머가 계속 돌고,
_stats가 정리됐다가 다시 채워지며, 백그라운드 스레드와 Dispose 스레드가_stats를 동시 접근해Dictionary손상/InvalidOperationException. 루프 내 예외는 미관측으로 은폐. - 재현: 매니저를 Dispose →
Clear()직후 루프가_stats[0] = _tick실행 → "정리했는데 살아있는" 상태. 여러 매니저를 생성/폐기 반복하면 좀비 루프가 누적돼 누수처럼 보인다. - 근본 원인: 백그라운드 작업의 시작만 있고 정지·합류(await)가 없다. 소유 객체의 수명과 작업의 수명이 분리돼, 객체가 사라져도 작업이 살아남는다.
- 증상: Dispose 후에도 CPU/타이머가 계속 돌고,
수정안
취소 토큰으로 루프를 멈추고, Task 핸들을 보관해 Dispose 에서 취소 후 완료를 대기한다. 공유 상태 접근도 동기화한다.
public sealed class ZoneManager : IAsyncDisposable
{
private readonly CancellationTokenSource _cts = new();
private readonly Task _loop;
private long _tick;
public ZoneManager() => _loop = Task.Run(() => RunLoop(_cts.Token));
private async Task RunLoop(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
Interlocked.Increment(ref _tick); // 동기화된 갱신
await Task.Delay(10, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { /* 정상 종료 */ }
// 그 외 예외는 _loop 를 통해 관측된다(삼켜지지 않음)
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try { await _loop.ConfigureAwait(false); } // 루프 종료를 기다린 뒤 정리
catch (OperationCanceledException) { }
_cts.Dispose();
}
}
핵심: (1) CancellationToken 으로 종료 조건 제공, (2) Task 를 보관해 Dispose 에서
await(동기 문맥이면 Wait())로 완료 대기 → 정리 시점에 잔존 작업이 없음,
(3) 완료 대기가 예외를 관측하게 함, (4) 공유 상태는 원자/락으로 보호.
더 나은 설계
- 표준
BackgroundService/IHostedService(StartAsync/StopAsync)로 시작·정지 수명을 프레임워크에 위임한다. - 생성자에서 무거운 작업을 시작하지 말고 명시적
StartAsync로 분리(생성자에서this캡처 후 즉시 실행은 초기화 미완 객체 노출 위험). - 트레이드오프: 취소·대기·
IAsyncDisposable로 코드가 늘지만, 결정적 종료와 누수·경합 제거를 얻는다.
면접 포인트
- fire-and-forget
Task.Run의 위험(정지 불가·예외 은폐·수명 분리). - Dispose 가 취소 신호+완료 대기까지 해야 하는 이유,
IAsyncDisposable. - 생성자에서
this캡처 백그라운드 시작의 위험.
해설 — 백그라운드 워커의 수명 관리 부재 (파괴 후 UAF) (C++)
난이도: 상
요약
생성자에서 this 를 캡처해 띄운 스레드 (A) 는 종료 조건이 없고, 소멸자 (B) 는
worker_.detach() 로 스레드를 떼어낸다. 그 결과 detach 된 스레드는 ZoneManager
객체가 파괴된 뒤에도 계속 돌며 이미 소멸된 멤버(tick_, stats_)를 참조한다 →
use-after-free(미정의 동작). 또한 stats_/tick_ 을 스레드가 무보호로 접근한다.
문제점
- [분류: 객체 수명 vs 스레드 수명 분리 · use-after-free · 데이터 레이스]
- 증상: 소멸 직후 간헐적 크래시/힙 손상, 쓰레기 값 갱신. detach 대신 join 을
깜빡하면 소멸 시 스레드가 joinable 상태라
std::terminate로 프로세스 강제 종료. - 재현:
ZoneManager스택/힙 객체가 스코프를 벗어나 소멸 →~ZoneManager가detach()→ 멤버 파괴 → 그러나 detach 된 스레드는 다음 루프에서tick_++,stats_[0]=...실행 → 해제된 메모리 접근(UAF). 루프에 정지 조건이 없어 join 을 해도 영영 끝나지 않는다는 점이 근본 문제. - 근본 원인: 스레드에 정지 신호가 없고, 소멸자가 스레드 종료를 기다리지 않는다(detach 는 기다림을 포기하는 것). 객체 수명과 스레드 수명이 어긋난다.
- 증상: 소멸 직후 간헐적 크래시/힙 손상, 쓰레기 값 갱신. detach 대신 join 을
깜빡하면 소멸 시 스레드가 joinable 상태라
수정안
원자 정지 플래그로 루프를 종료시키고, 소멸자에서 신호 후 join 으로 합류한다. 공유 상태는 락으로 보호한다.
#include <atomic>
#include <mutex>
class ZoneManager {
std::thread worker_;
std::atomic<bool> stop_{false};
std::mutex mtx_;
std::unordered_map<int, long> stats_;
long tick_ = 0;
void Run() {
while (!stop_.load(std::memory_order_acquire)) { // 정지 조건
{
std::lock_guard<std::mutex> lk(mtx_);
tick_++;
stats_[0] = tick_;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
public:
ZoneManager() { worker_ = std::thread([this] { Run(); }); }
~ZoneManager() {
stop_.store(true, std::memory_order_release); // 정지 신호
if (worker_.joinable()) worker_.join(); // 종료를 기다린 뒤 파괴
}
ZoneManager(const ZoneManager&) = delete; // this 캡처 → 복사/이동 금지
ZoneManager& operator=(const ZoneManager&) = delete;
};
핵심: (1) stop_ 원자 플래그로 루프에 종료 경로 제공, (2) 소멸자에서 신호 후
join() — detach 가 아니라 합류로 "스레드가 멤버 접근을 끝냈음"을 보장한 뒤
파괴, (3) 공유 멤버 접근 동기화, (4) this 를 캡처하므로 복사/이동 금지(주소가
바뀌면 dangling).
더 나은 설계
- C++20
std::jthread는 소멸 시 자동request_stop()+join()을 해줘 이 패턴을 구조적으로 안전화한다(stop_token으로 루프 종료). - 생성자에서
this캡처 스레드를 띄우는 대신 별도start()/stop()로 수명 분리. - 트레이드오프: join 대기가 소멸을 블로킹하지만, UAF/terminate 를 제거한다.
면접 포인트
- joinable 스레드를 파괴하면 왜
std::terminate인가, detach 가 왜 UAF 를 부르는가. - "정지 신호 + join" 이 세트인 이유(루프 종료 경로가 없으면 join 도 무한 대기).
this캡처 스레드에서 복사/이동 금지,std::jthread/stop_token로의 개선.