← 문제로

43. thread_local 저장 기간의 초기화·소멸 시점과 순서

난이도 중
내 리뷰 · C++
해설 · C++

해설 — thread_local 저장 기간의 초기화·소멸 시점과 순서

난이도: 중상

요약

thread_local 객체의 동적 초기화는 그 스레드에서 처음 사용될 때(lazy) 일어나고, 소멸은 그 스레드가 끝날 때, 초기화된 역순으로 일어난다. "스레드가 시작할 때 다 만들어진다"거나 "스레드마다 무조건 하나씩 생긴다"는 흔한 오해다. 한 번도 건드리지 않은 thread_local 은 그 스레드에서 아예 만들어지지 않는다.

이 lazy 초기화 때문에 컴파일러는 접근할 때마다 "이미 초기화됐는가"를 확인하는 가드 코드를 넣고, 소멸 순서는 소스 코드 순서가 아니라 런타임의 첫 접근 순서로 결정된다. 결과적으로 소멸자에서 다른 thread_local 을 만지는 코드의 안전성은 함수 호출 순서에 따라 조용히 뒤집힌다.

핵심 개념

저장 기간(storage duration) 네 가지

저장 기간 인스턴스 개수 초기화 시점 소멸 시점
static 프로그램당 1 정적 초기화 → 동적 초기화(main 이전 또는 첫 사용) main 종료 후, 초기화 역순
thread 스레드당 1 정적 초기화 → 첫 odr-use 시 스레드 종료 시, 초기화 역순
automatic 진입마다 1 선언 도달 시 스코프 이탈 시
dynamic 명시적 new delete

thread_local 은 "스레드 스코프의 static" 이라고 이해하면 대부분 맞는다. 차이는 인스턴스가 스레드마다 하나이고, 소멸이 main 종료가 아니라 각 스레드 종료 시점이라는 것.

두 종류의 초기화

  1. 정적 초기화(zero/constant initialization) — 상수식으로 초기화되는 스칼라는 TLS 블록 이미지에 그대로 박히며 런타임 비용이 0 이다. thread_local int counter = 0; 이 여기 해당한다.
  2. 동적 초기화 — 생성자를 부르거나 상수식이 아닌 초기화자를 쓰면 런타임에 실행된다. Tracer, std::mt19937 이 여기 해당하며, 이때만 가드와 소멸자 등록이 필요하다.

static thread_local 과 thread_local 의 차이

함수 안에서는 차이가 없다. thread_local 은 이미 저장 기간을 결정하므로 static 은 중복이며 무시된다(문법상 허용될 뿐이다). 문제 코드의 LogBuffer 와 ObjectPool 은 static 유무만 다를 뿐 완전히 동일하게 동작한다.

의미가 갈리는 곳은 클래스 멤버다. 비정적 멤버는 thread_local 이 될 수 없고, static thread_local 멤버만 허용된다(스레드마다 하나인 클래스 정적 변수가 된다). 네임스페이스 스코프에서는 static thread_local 이 내부 링키지를 뜻한다.

단계별 내부 동작

실제 출력

    [ctor] static  : g_staticGlobal
main: begin
main: spawn Worker1
  Worker1: begin
    [ctor] tl:func : ObjectPool
    [ctor] tl:func : LogBuffer
    [ctor] tl:ns   : g_tlNamespace
  Worker1: end
    [dtor] tl:ns   : g_tlNamespace
    [dtor] tl:func : DumpOnExit -> touching LogBuffer
    [dtor] tl:func : LogBuffer
    [dtor] tl:func : ObjectPool
main: spawn Worker2
  Worker2: begin
    [ctor] tl:func : LogBuffer
  Worker2/taskA: counter=1 roll=90
  Worker2/taskB: counter=2 roll=81
  Worker2: end
    [dtor] tl:func : LogBuffer
main: touch LogBuffer on main thread
    [ctor] tl:func : LogBuffer
main: leaked ptr name = tl:func : LogBuffer
main: end
    [dtor] tl:func : LogBuffer
    [dtor] static  : g_staticGlobal

1) g_staticGlobal 이 main: begin 보다 먼저

네임스페이스 스코프의 정적 객체는 main 진입 전 동적 초기화 단계에서 생성된다. 소멸은 main 이 반환한 뒤 __cxa_atexit 등록의 역순으로 이뤄지므로 출력 맨 마지막에 나온다.

2) g_tlNamespace 는 Worker1 에서 단 한 번만 생성된다

네임스페이스 스코프의 thread_local 이라도 동적 초기화라면 첫 odr-use 전까지 미뤄진다. 표준은 "블록 스코프가 아닌 thread_local 의 동적 초기화는 unsequenced 또는 indeterminately sequenced 이지만, 같은 번역 단위 안에서 그 변수를 처음 odr-use 하기 전에 완료된다"고 규정한다.

GCC/Clang(Itanium ABI)은 이를 TLS wrapper 함수로 구현한다. g_tlNamespace 를 참조하는 모든 코드는 실제로는 _ZTW13g_tlNamespace 를 호출하고, 그 안에서 초기화 여부를 확인한 뒤 주소를 돌려준다.

결과적으로:

  • Worker1 은 g_tlNamespace.Touch() 를 호출하므로 그 시점에 생성된다. ObjectPool, LogBuffer 보다 나중에 만들어졌다는 점에 주목하라. 선언은 파일 맨 위에 있지만 실행 순서가 수명을 결정한다.
  • Worker2 와 main 스레드는 g_tlNamespace 를 한 번도 건드리지 않는다. 그래서 두 스레드에서는 생성자도 소멸자도 전혀 실행되지 않는다. "스레드마다 하나씩 생긴다"가 아니라 "쓴 스레드에서만 생긴다" 이다.

3) 함수 로컬 thread_local 의 가드

LogBuffer() 는 대략 이렇게 컴파일된다.

LogBuffer():
    mov  rax, fs:[__tls_guard@tpoff]     ; 스레드 로컬 1바이트 가드
    test al, al
    jne  .Lalready_initialized           ; 대부분의 호출은 여기로 빠진다
    mov  byte ptr fs:[__tls_guard@tpoff], 1
    lea  rdi, [buf@tpoff]
    call Tracer::Tracer(std::string)
    lea  rdi, [buf@tpoff]
    lea  rsi, [Tracer::~Tracer]
    call __cxa_thread_atexit             ; 소멸자를 이 스레드의 정리 목록에 등록
.Lalready_initialized:
    lea  rax, [fs:buf@tpoff]
    ret

핵심은 세 가지다.

  • 가드는 스레드 로컬이라 락이 필요 없다. 함수 로컬 static(비 thread_local)의 초기화는 스레드 안전을 위해 __cxa_guard_acquire 로 원자 연산 + 경합 시 뮤텍스를 쓰지만, thread_local 은 그 스레드만 접근하므로 평범한 1 바이트 로드/테스트로 끝난다. 비용은 사실상 예측 잘 되는 분기 하나이며, 캐시에 항상 떠 있어 수 사이클 수준이다.

  • 그럼에도 0 은 아니다. 접근 자체는 fs: 세그먼트 기반 상대 주소라 전역 변수만큼 빠르지만, 동적 초기화가 있으면 매 접근에 가드 검사가 붙고 인라인 최적화를 방해한다. 뜨거운 루프 안에서는 함수 진입 시 참조를 한 번 캐싱하는 것이 관례다.

    void HotLoop(const std::vector<Packet>& pkts) {
        auto& buf = LogBuffer();          // 가드 검사 1회
        for (const auto& p : pkts) buf.Append(p);
    }
    
  • 동적 초기화 없이 상수 초기화되는 thread_local int counter = 0;(RequestCounter)에는 가드가 아예 생성되지 않는다. 소멸자도 없으므로 __cxa_thread_atexit 등록도 없다. 같은 thread_local 이라도 타입에 따라 비용이 완전히 다르다.

    공유 라이브러리에서 접근하면 초기 실행 모델(initial-exec)을 못 쓰고 general-dynamic 모델이 되어 __tls_get_addr 함수 호출이 붙는다. -ftls-model=initial-exec 이나 실행 파일 직접 링크로 이 비용을 줄인다.

4) 소멸 순서 — 초기화 역순, 그리고 그 "초기화"는 런타임 순서다

__cxa_thread_atexit 는 소멸자를 스레드별 단일 연결 리스트에 push 한다. 스레드가 끝날 때 이 리스트를 앞에서부터 pop 하며 실행하므로 결과는 등록 역순(LIFO) 이다.

Worker1 의 등록 순서와 실행 순서:

등록 순서 객체 소멸 순서
1 ObjectPool 4
2 LogBuffer 3
3 DumpOnExit (Dumper 호출 시) 2
4 g_tlNamespace 1

출력이 정확히 g_tlNamespace → DumpOnExit → LogBuffer → ObjectPool 로 나온 이유다.

중요한 것은 이 순서가 선언 순서가 아니라 Worker1 본문의 호출 순서라는 점이다. Worker1 안에서 ObjectPool() 과 LogBuffer() 의 호출 순서를 바꾸면 소멸 순서도 바뀐다. 전역 정적 객체의 "static initialization order fiasco" 가 thread_local 에서는 호출 경로에 따라 매 실행 달라질 수 있는 형태로 나타난다.

5) DumpOnExit 은 이번에는 안전하다 — 하지만 우연이다

DumpOnExit 소멸자는 LogBuffer() 를 만진다. Worker1 에서 안전한 이유는 오직 하나, LogBuffer 가 DumpOnExit 보다 먼저 등록되어 나중에 파괴되기 때문이다.

호출 순서를 뒤집으면 바로 깨진다. 아래는 Dumper() 를 LogBuffer() 보다 먼저 부른 실측이다.

[ctor] LogBuffer
  touch LogBuffer
[dtor] LogBuffer                       ← 먼저 파괴됨
[dtor] DumpOnExit -> touch LogBuffer
  touch LogBuffer                      ← 이미 파괴된 객체를 만졌다
main end

크래시하지 않았지만 이는 파괴된 std::string 을 읽은 UB 다. 문자열이 SSO 버퍼에 있었고 소멸자가 메모리를 덮어쓰지 않아 우연히 옛 값이 남아 있었을 뿐이다. 힙 할당된 긴 문자열이었다면 free 된 메모리를 읽는다.

여기서 반드시 알아야 할 함정이 하나 더 있다. 함수 로컬 static(비 thread_local)은 소멸 후 재접근하면 표준이 "다시 생성하지 않는다"고 규정하며, thread_local 도 마찬가지다. 위 출력에서 두 번째 touch 앞에 [ctor] 가 없는 것이 그 증거다. 가드 플래그가 이미 1 로 남아 있어 생성자를 다시 부르지 않고 죽은 객체의 주소를 그대로 준다.

원칙: thread_local/static 객체의 소멸자에서 다른 thread_local/static 객체를 참조하지 마라. 꼭 필요하면 Nifty Counter 나 leaky singleton(아래) 로 수명을 명시적으로 관리한다.

6) main 스레드 — 정적 객체와의 소멸 경쟁

main 스레드의 LogBuffer 는 main: end 직후에 파괴되고, 그 다음에 g_staticGlobal 이 파괴됐다.

이는 표준이 정한 순서다. main 이 반환하면 먼저 main 스레드의 thread_local 소멸자들이 스레드 종료 처리로 실행되고, 그 뒤에 정적 저장 기간 객체의 소멸자가 실행된다.

문제는 main 스레드가 아닌 스레드다. 워커 스레드가 main 종료 후까지 살아 있으면 (detach 했거나 종료를 못 기다렸을 때) 그 스레드의 thread_local 소멸자는 이미 파괴된 전역 로거·전역 할당자를 만질 수 있다. 종료 시 크래시의 흔한 원인이며, std::cout 조차 정적 객체이므로 안전하지 않다(std::ios_base::Init 덕에 보통 살아남지만 보장은 아니다).

실무 처방은 모든 워커 스레드를 main 종료 전에 반드시 join 하는 것이다. 이 프로그램은 std::thread(Worker1).join() 으로 그렇게 하고 있어 안전하다.

7) Worker2 — 스레드 로컬 상태의 요청 간 오염

  Worker2/taskA: counter=1 roll=90
  Worker2/taskB: counter=2 roll=81

RequestCounter() 의 counter 는 상수 초기화라 스레드 시작 시 0 이지만, 같은 스레드에서 두 번째 작업을 처리할 때는 이전 작업의 값이 그대로 남는다. Rng() 도 12345 로 한 번만 시드되고, 두 번째 호출은 엔진 상태가 이어져 다른 값이 나온다.

이 프로그램은 스레드를 매번 새로 만들지만, 실제 서버는 스레드 풀을 쓴다. 스레드 풀에서는 스레드가 죽지 않으므로 thread_local 소멸자가 영원히 호출되지 않고, 한 요청이 남긴 상태가 다음 요청으로 그대로 넘어간다. 실제 사고 사례:

  • 스레드 로컬 로그 버퍼에 A 유저의 컨텍스트(계정 ID, IP)가 남아 B 유저의 로그에 섞여 찍힌다.
  • 스레드 로컬 트랜잭션 컨텍스트가 정리되지 않아 다음 요청이 남의 트랜잭션에 붙는다.
  • 스레드 로컬 오브젝트 풀이 계속 커져 스레드 수 × 풀 크기 만큼 메모리를 잡는다. 스레드가 종료되지 않으니 반환도 되지 않는다.
  • 스레드 로컬 캐시가 무효화되지 않아 유저가 옛 아이템 정보를 본다.

처방은 두 가지다.

// (1) 작업 경계에서 명시적으로 리셋한다 — RAII 로 강제
struct TaskScope {
    TaskScope()  { LogBuffer().Reset(); RequestCounter() = 0; }
    ~TaskScope() { LogBuffer().Flush(); LogBuffer().Reset(); }
};

void HandleRequest(const Request& r) {
    TaskScope scope;          // 진입/이탈에서 항상 정리
    ...
}
// (2) 애초에 thread_local 을 쓰지 않고 컨텍스트를 명시적으로 넘긴다
void HandleRequest(RequestContext& ctx, const Request& r);

(2) 가 근본적이다. thread_local 은 "인자를 넘기기 귀찮아서 쓰는 전역 변수"가 되기 쉽고, 코루틴이 도입되면 한 논리적 작업이 여러 스레드를 오가므로 완전히 무너진다. C++20 코루틴이나 비동기 런타임을 쓴다면 thread_local 기반 컨텍스트는 즉시 버그가 된다.

8) LeakPointer — 스레드 밖으로 나간 주소

LeakPointer() 는 &LogBuffer() 를 반환한다. 이 주소는 호출한 스레드의 TLS 블록 안을 가리킨다.

main: leaked ptr name = tl:func : LogBuffer

같은 스레드에서 쓰는 동안은 문제없다. 하지만 이 포인터를 다른 스레드에 넘기면:

  • 그 스레드의 TLS 를 가리키는 것이 아니다. 원래 스레드의 메모리를 그대로 가리킨다. 즉 thread_local 이 보장하는 "스레드 격리"가 깨지고 동기화 없는 공유 접근이 된다.
  • 원래 스레드가 종료되면 소멸자가 돌고 TLS 블록이 해제된다. 이후 접근은 use-after-free 다. 스레드 풀에서는 그 블록이 재사용되어 전혀 다른 스레드의 데이터를 덮어쓴다.
  • 이 dangling 은 원래 스레드가 살아 있는 동안에는 절대 재현되지 않아, 종료 경로에서만 간헐적으로 터진다. 가장 진단하기 어려운 형태다.

원칙: thread_local 객체의 주소는 그 스레드 밖으로 나가면 안 된다. 다른 스레드에 넘겨야 하는 데이터라면 thread_local 이 아니라 값 복사나 shared_ptr 로 소유권을 명시하는 자료여야 한다.

흔한 오해·함정

  • "thread_local 은 스레드 시작 시 전부 초기화된다" — 아니다. 동적 초기화는 첫 사용 시점이다. 한 번도 안 쓰면 그 스레드에서는 생성자조차 돌지 않는다(g_tlNamespace 와 Worker2 가 그 예).
  • "소멸 순서는 선언 순서의 역순이다" — 아니다. 첫 접근 순서의 역순이다. 선언 위치가 아니라 실행 경로가 순서를 결정한다.
  • "static thread_local 과 thread_local 은 다르다" — 함수 안에서는 완전히 같다. thread_local 이 이미 저장 기간을 정하므로 static 은 아무 효과가 없다. 차이가 나는 곳은 클래스 멤버(static thread_local 만 허용)와 네임스페이스 스코프(링키지)다.
  • "소멸된 thread_local 을 다시 쓰면 재생성된다" — 재생성되지 않는다. 가드가 이미 세워져 있어 죽은 객체의 주소를 그대로 돌려준다.
  • "thread_local 은 락이 없어서 항상 빠르다" — 접근은 빠르지만, 동적 초기화가 있으면 매 접근에 가드 검사가 붙고, 공유 라이브러리에서는 __tls_get_addr 호출까지 붙는다. 뜨거운 경로에서는 참조를 캐싱한다.
  • "스레드 풀이면 thread_local 로 요청 컨텍스트를 관리해도 된다" — 절대 아니다. 스레드가 죽지 않으니 소멸자가 안 돈다. 작업 경계에서 명시적으로 리셋하지 않으면 요청 간 데이터가 샌다. 코루틴이 들어오면 그마저도 성립하지 않는다.
  • detach() 한 스레드 + thread_local — main 종료 후 그 스레드의 thread_local 소멸자가 이미 파괴된 전역 객체를 만진다. 워커는 반드시 join 한다.
  • DLL/so 언로드 — dlclose 시점에 그 모듈의 thread_local 을 쓰고 있는 스레드가 있으면 TLS 블록이 사라져 정의되지 않은 동작이 된다. 플러그인 구조에서 조심할 지점이다.

면접 포인트

  • "thread_local 객체는 언제 생성되고 언제 파괴되는가?" — 동적 초기화는 그 스레드에서 첫 odr-use 시, 소멸은 스레드 종료 시 초기화 역순. 안 쓰면 안 만들어진다.
  • "함수 로컬 static 과 thread_local 의 가드 비용 차이는?" — static 은 스레드 간 경합이 있으므로 __cxa_guard_acquire(원자 연산, 경합 시 뮤텍스), thread_local 은 스레드 전용이므로 락 없는 1 바이트 플래그 검사. 후자가 훨씬 싸다.
  • "thread_local 소멸자에서 다른 thread_local 을 쓰면?" — 등록 순서에 따라 안전하기도, use-after-destruction 이기도 하다. 그리고 이미 파괴된 객체는 재생성되지 않는다. 의존이 있으면 소멸자에서 참조하지 말고 명시적 수명 관리로 바꾼다.
  • "스레드 풀에서 thread_local 을 쓸 때 주의점은?" — 스레드가 죽지 않아 소멸자가 안 돈다. 요청 간 상태 오염과 메모리 누적이 생기므로 작업 경계에서 RAII 로 리셋해야 한다. 더 나은 답은 "요청 컨텍스트를 인자로 명시적으로 넘긴다".
  • "thread_local 변수의 주소를 다른 스레드에 넘겨도 되나?" — 안 된다. 스레드 격리가 깨지고, 원래 스레드 종료 후에는 use-after-free 다.
  • "코루틴과 thread_local 은 왜 상성이 나쁜가?" — 코루틴은 재개 시 다른 스레드에서 실행될 수 있다. co_await 앞뒤로 thread_local 값이 조용히 바뀐다. 컨텍스트는 코루틴 프레임에 담아야 한다.
  • "main 스레드의 thread_local 과 전역 객체 중 뭐가 먼저 파괴되나?" — main 스레드의 thread_local 이 먼저(스레드 종료 처리), 그 다음 정적 저장 기간 객체. 단, main 종료 후까지 살아 있는 detach 스레드는 이 보장을 받지 못한다.