43. thread_local 저장 기간의 초기화·소멸 시점과 순서
난이도 중해설 — 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 종료가 아니라 각 스레드 종료 시점이라는 것.
두 종류의 초기화
- 정적 초기화(zero/constant initialization) — 상수식으로 초기화되는 스칼라는
TLS 블록 이미지에 그대로 박히며 런타임 비용이 0 이다.
thread_local int counter = 0;이 여기 해당한다. - 동적 초기화 — 생성자를 부르거나 상수식이 아닌 초기화자를 쓰면 런타임에 실행된다.
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 스레드는 이 보장을 받지 못한다.