C++ thread_local: 스레드별 카운터·버퍼, 초기화 시점과 소멸 순서
이 글의 핵심
여러 스레드가 같은 전역 카운터나 난수 생성기를 쓰면 뮤텍스가 병목이 되거나 데이터 레이스가 납니다. 이 글은 thread_local로 스레드마다 사본을 두는 방법과 전역 변수와의 차이, 스레드가 많을 때 초기화 비용과 메모리가 늘어나는 문제, 종료 시 소멸 순서 때문에 생기는 버그를 예제로 설명합니다.
들어가며
C++11의 thread_local은 각 스레드마다 독립적인 저장소를 제공하여 스레드 안전한 코드를 작성할 수 있게 합니다. 멀티스레드 환경에서 동기화 없이 스레드별 데이터를 관리할 수 있습니다.
thread_local 개념과 기본 사용
thread_local은 변수 선언 앞에 붙이는 저장 기간 지정자로, 이 키워드가 붙은 변수는 프로그램 전체에 하나만 존재하는 것이 아니라 각 스레드마다 독립적인 사본이 하나씩 만들어집니다. 일반 전역 변수를 여러 스레드가 공유하면 동시 접근으로 인한 데이터 레이스를 막기 위해 뮤텍스 같은 동기화 장치가 필요하지만, thread_local 변수는 애초에 스레드끼리 공유되지 않으므로 동기화 없이도 각 스레드가 안전하게 자신만의 값을 읽고 쓸 수 있습니다.
개념
아래 예시에서 thread_local int counter = 0;으로 선언된 counter는 이름은 하나지만 실제로는 각 스레드마다 별도의 메모리 공간에 존재하는 완전히 다른 변수입니다. t1과 t2 두 스레드가 동시에 func()를 호출해 counter++를 실행해도, 서로 다른 스레드의 counter는 물리적으로 분리되어 있으므로 한쪽의 증가가 다른 쪽에 영향을 주지 않고 각자 독립적으로 0에서 시작해 증가합니다.
#include <thread>
#include <iostream>
thread_local int counter = 0;
void func() {
counter++;
std::cout << "스레드 " << std::this_thread::get_id()
<< ": " << counter << std::endl;
}
int main() {
std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();
}
실행하면 두 스레드 모두 1을 출력합니다. 다만 두 줄이 섞여 나올 수 있는데, std::cout의 개별 << 호출은 데이터 레이스는 일으키지 않지만 여러 <<를 묶은 한 문장이 원자적으로 출력된다는 보장은 없기 때문입니다. 출력이 깔끔하게 나오길 원하면 C++20의 std::osyncstream을 쓰거나 문자열을 먼저 만든 뒤 한 번에 출력합니다.
내부적으로 thread_local 변수는 운영체제와 런타임이 스레드마다 마련하는 TLS(Thread-Local Storage) 블록에 놓이고, 접근할 때는 “현재 스레드의 TLS 기준 주소 + 오프셋”으로 계산됩니다. x86-64 Linux에서는 fs 세그먼트 레지스터가 이 기준 주소를 가리켜서, 실행 파일 안의 thread_local 변수 접근은 일반 전역 변수와 거의 같은 비용입니다. 반면 dlopen으로 늦게 불러온 공유 라이브러리 안의 thread_local은 __tls_get_addr 함수 호출을 거치는 경우가 있어 조금 더 비쌉니다. 뜨거운 루프 안에서 thread_local 접근이 병목으로 보인다면 지역 변수로 한 번 복사해 쓰고 마지막에 되돌려 놓는 방식이 도움이 됩니다.
기본 사용
앞선 예시와 거의 동일한 구조지만, 변수명을 x로 바꿔 thread_local이 특정 이름이나 타입에 국한된 기능이 아니라 어떤 변수 선언에도 자유롭게 적용할 수 있는 범용적인 저장 기간 지정자라는 점을 보여줍니다. 실무에서는 이런 단순 카운터보다 스레드별로 독립적인 상태를 유지해야 하는 더 복잡한 자료구조(캐시, 버퍼, 난수 생성기 등)에 thread_local을 적용하는 경우가 많으며, 뒤에서 이런 실전 활용 예제를 차례로 다룹니다.
#include <thread>
#include <iostream>
thread_local int x = 0;
void worker() {
x++;
std::cout << "스레드 " << std::this_thread::get_id()
<< ": " << x << std::endl;
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}
스레드별 카운터·버퍼·난수 생성기
thread_local의 진가는 여러 스레드가 각자 독립적인 상태를 유지해야 하는 실전 상황에서 드러납니다. 아래 세 가지 예제는 스레드별 요청 카운터, 버퍼링된 로그 처리, 그리고 스레드 안전한 난수 생성기라는 대표적인 활용 사례를 다룹니다.
스레드별 카운터
서버가 여러 워커 스레드로 요청을 처리할 때, 각 스레드가 자신이 처리한 요청 수를 세고 싶다면 thread_local이 가장 간단한 해법입니다. 아래 requestCount는 스레드마다 독립적으로 0부터 시작해 증가하므로, 별도의 뮤텍스나 원자적 연산 없이도 “이 스레드가 지금까지 몇 건을 처리했는가”를 정확하게 추적할 수 있습니다. 만약 이 카운터가 일반 전역 변수였다면 여러 스레드가 동시에 증가시킬 때 락이 필요했겠지만, thread_local을 쓰면 애초에 경쟁이 발생할 여지 자체가 없어집니다.
#include <thread>
#include <vector>
#include <iostream>
thread_local size_t requestCount = 0;
void handleRequest() {
requestCount++;
std::cout << "스레드 " << std::this_thread::get_id()
<< " 요청: " << requestCount << std::endl;
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; i++) {
threads.emplace_back([] {
for (int j = 0; j < 3; j++) {
handleRequest();
}
});
}
for (auto& t : threads) {
t.join();
}
}
스레드별 버퍼
로그나 이벤트 데이터를 즉시 기록하지 않고 일정량 모았다가 한꺼번에 처리하는 배칭(batching) 패턴에서도 thread_local이 유용합니다. 아래 예시에서 각 스레드는 자신만의 buffer에 값을 계속 채워 넣다가 100개가 쌓이면 flush()로 한 번에 내보내는데, 이 버퍼가 스레드마다 독립적이기 때문에 여러 스레드가 동시에 process()를 호출해도 버퍼에 값을 추가하는 과정에서 락을 걸 필요가 전혀 없습니다. 이런 패턴은 로깅 시스템처럼 쓰기 빈도는 높지만 스레드 간 순서가 중요하지 않은 데이터를 다룰 때 성능을 크게 개선할 수 있습니다.
#include <thread>
#include <vector>
#include <iostream>
thread_local std::vector<int> buffer;
void flush(const std::vector<int>& buf) {
std::cout << "Flush: " << buf.size() << " items" << std::endl;
}
void process(int value) {
buffer.push_back(value);
if (buffer.size() >= 100) {
flush(buffer);
buffer.clear();
}
}
int main() {
std::thread t1([] {
for (int i = 0; i < 150; i++) {
process(i);
}
});
t1.join();
}
난수 생성기
std::mt19937 같은 난수 생성기 엔진은 내부 상태를 계속 갱신하며 다음 난수를 만들어내는 구조라서, 여러 스레드가 하나의 엔진 인스턴스를 동시에 사용하면 내부 상태가 손상되는 데이터 레이스가 발생합니다. 이 문제를 뮤텍스로 해결하면 매 호출마다 락 경쟁이 발생해 성능이 크게 떨어지지만, 아래처럼 thread_local로 엔진 자체를 선언하면 각 스레드가 자신만의 독립된 난수 생성기를 갖게 되어 동기화 비용 없이 병렬로 난수를 생성할 수 있습니다. 이런 이유로 멀티스레드 환경에서 난수를 다룰 때는 전역 난수 엔진 하나를 공유하기보다 thread_local로 스레드마다 별도의 엔진을 두는 것이 사실상 표준적인 관행입니다.
시드를 줄 때 조심할 점이 있습니다. std::random_device는 구현에 따라 진짜 난수 장치가 아닐 수 있어서, 예전 MinGW 같은 일부 환경에서는 매번 같은 값을 돌려줘 모든 스레드가 같은 난수 수열을 만들었습니다. 스레드마다 다른 수열이 필요하다면 random_device 값에 스레드 ID 해시나 카운터를 섞어 시드를 만드는 것이 안전합니다. 반대로 시뮬레이션처럼 결과를 재현해야 하는 경우에는 random_device를 쓰지 말고 “기본 시드 + 스레드 번호”처럼 결정적인 시드를 주어야 같은 입력에서 같은 결과를 얻을 수 있습니다. 또 mt19937은 상태가 약 2.5KB라서 스레드마다 하나씩 두는 비용은 무시할 만하지만, random_device 생성은 시스템 호출을 동반하므로 함수 안에서 매번 새로 만드는 코드는 피해야 합니다.
#include <random>
#include <thread>
#include <iostream>
thread_local std::mt19937 rng(std::random_device{}());
int getRandomNumber() {
std::uniform_int_distribution<int> dist(1, 100);
return dist(rng);
}
int main() {
std::thread t1([] {
for (int i = 0; i < 5; i++) {
std::cout << "스레드 1: " << getRandomNumber() << std::endl;
}
});
std::thread t2([] {
for (int i = 0; i < 5; i++) {
std::cout << "스레드 2: " << getRandomNumber() << std::endl;
}
});
t1.join();
t2.join();
}
thread_local 변수는 언제 초기화되는가
thread_local 변수가 정확히 언제 초기화되는지 아는 것은 예상치 못한 부작용을 피하는 데 중요합니다. 초기화 시점은 변수가 네임스페이스 스코프에 선언되었는지, 함수 내부의 지역 변수로 선언되었는지에 따라 달라지며, 아래 두 예제가 이 차이를 각각 보여줍니다.
스레드 시작 시
네임스페이스 스코프에서 thread_local int x = 10;처럼 상수로 초기화된 변수는 스레드가 만들어질 때 TLS 블록이 초기 이미지로 채워지면서 이미 값이 들어 있습니다. 아래 예시에서 t1과 t2는 각자 10으로 채워진 x를 갖고 시작하므로, worker() 안에서 x를 출력하면 두 스레드 모두 항상 10을 봅니다.
생성자 호출처럼 동적 초기화가 필요한 네임스페이스 스코프 thread_local은 사정이 다릅니다. 표준은 이 초기화를 “그 스레드에서 변수가 처음 사용되기 전”까지 미룰 수 있게 허용하고, GCC와 Clang은 실제로 변수에 접근할 때마다 초기화 여부를 확인하는 래퍼 함수를 거쳐 첫 접근 시 초기화합니다. 그래서 스레드를 만들 때마다 모든 thread_local 객체가 생성되는 것은 아니지만, 대신 접근할 때마다 작은 검사 비용이 붙고, 생성자에 로그나 등록 같은 부작용을 넣었다면 그 부작용이 “스레드 시작”이 아니라 “첫 접근” 시점에 일어난다는 점을 알아 둬야 합니다.
#include <thread>
#include <iostream>
thread_local int x = 10;
void worker() {
std::cout << "x = " << x << std::endl;
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}
첫 사용 시
반면 함수 내부의 지역 변수로 선언된 thread_local 변수는 스레드가 시작될 때가 아니라, 그 함수가 처음 호출되어 해당 변수 선언문을 처음 실행하는 시점에 지연 초기화(lazy initialization)됩니다. 아래 예시에서 func()를 같은 스레드 안에서 두 번 호출하면 compute()가 호출되었다는 메시지는 첫 번째 호출에서만 출력되는데, 이는 thread_local int y = compute();가 각 스레드마다 딱 한 번만, 즉 그 스레드에서 처음 func()에 진입할 때만 실행되기 때문입니다. 이런 지연 초기화 방식은 초기화 비용이 크지만 모든 스레드가 그 함수를 반드시 호출하는 것은 아닌 경우에, 실제로 필요한 스레드에서만 초기화 비용을 지불하게 해주는 유용한 최적화입니다.
#include <thread>
#include <iostream>
int compute() {
std::cout << "compute() 호출" << std::endl;
return 42;
}
void func() {
thread_local int y = compute();
std::cout << "y = " << y << std::endl;
}
int main() {
std::thread t1([] {
func();
func();
});
t1.join();
}
소멸 순서·클래스 멤버·초기화 비용 문제
thread_local은 사용법 자체는 단순하지만, 스레드의 생명주기와 얽히면서 몇 가지 미묘한 함정을 만들어 냅니다. 아래 네 가지는 실무에서 특히 자주 마주치는 문제들입니다.
소멸 순서
thread_local 객체는 해당 스레드가 종료될 때 자동으로 소멸자가 호출되며, 이는 스레드가 프로그램 전체보다 먼저 끝나든 나중에 끝나든 상관없이 성립합니다. 아래 예시에서 Resource 타입의 thread_local 변수 r은 t1 스레드가 실행을 마치고 종료되는 시점에 소멸자가 호출되어 “Resource 소멸”이 출력되는데, 이 소멸 시점이 main 함수가 끝나는 시점이나 다른 전역 변수의 소멸 시점과 정확히 어떤 순서로 일어나는지는 프로그램 구조에 따라 달라질 수 있습니다. 여러 개의 thread_local 객체가 서로 의존 관계를 가지고 있다면, 이 소멸 순서의 불확실성이 예상치 못한 버그로 이어질 수 있으므로 각 객체의 소멸자가 다른 thread_local 객체에 의존하지 않도록 설계하는 것이 안전합니다.
#include <thread>
#include <iostream>
struct Resource {
Resource() { std::cout << "Resource 생성" << std::endl; }
~Resource() {
std::cout << "Resource 소멸" << std::endl;
}
void use() {}
};
thread_local Resource r;
void func() {
r.use(); // 이 스레드에서 r을 실제로 사용해야 생성·소멸이 보장됨
std::cout << "func() 실행" << std::endl;
}
int main() {
std::thread t1(func);
t1.join();
}
func() 안에서 r.use()를 호출한 데는 이유가 있습니다. 앞에서 본 것처럼 GCC·Clang은 동적 초기화가 필요한 thread_local을 첫 접근 시 생성하므로, 스레드가 r을 한 번도 건드리지 않으면 r은 그 스레드에서 생성되지도 소멸되지도 않습니다. “소멸자에 정리 코드를 넣었는데 호출되지 않는다”는 질문의 상당수가 이 경우입니다. 실제 출력 순서는 t1 안에서 “Resource 생성” → “func() 실행” → 스레드 종료 시 “Resource 소멸”입니다.
소멸 시점과 관련해 알아 둘 규칙이 두 가지 더 있습니다. 첫째, 한 스레드의 thread_local 객체들은 그 스레드의 정적 저장 기간 객체보다 먼저 소멸합니다. 메인 스레드라면 main이 반환된 뒤 thread_local이 먼저 정리되고 그다음 전역 객체가 정리됩니다. 둘째, detach한 스레드가 프로세스 종료 시점까지 실행 중이면 그 스레드의 thread_local 소멸자는 호출되지 않습니다. 또 std::exit는 호출한 스레드의 thread_local만 정리하고, std::quick_exit나 _exit는 아무것도 정리하지 않습니다. 소멸자에서 파일을 flush하는 스레드별 로그 버퍼가 종료 시 마지막 몇 줄을 잃는 버그가 대부분 이 규칙들 때문입니다.
클래스 멤버
클래스의 정적 멤버 변수도 thread_local로 선언할 수 있지만, 일반 정적 멤버와 마찬가지로 클래스 정의 안의 선언과 클래스 정의 밖의 실제 정의(및 초기화)를 분리해서 작성해야 합니다. 아래 예시에서 class MyClass 안에는 static thread_local int x;로 선언만 하고, 클래스 밖에서 thread_local int MyClass::x = 0;로 실제 저장 공간을 할당하고 초기값을 지정하고 있습니다. 이 문법을 놓치고 클래스 안에서 바로 초기화하려 하면 컴파일 에러가 발생하므로, 정적 멤버 변수의 일반적인 정의 규칙에 thread_local 키워드만 추가된 것이라고 이해하면 헷갈리지 않습니다. C++17부터는 static inline thread_local int x = 0;으로 클래스 안에서 선언과 정의를 한 번에 할 수 있습니다. 비정적 멤버 변수에는 thread_local을 붙일 수 없는데, 객체마다 존재하는 멤버를 “스레드마다” 또 나눈다는 개념이 저장 기간 모델과 맞지 않기 때문입니다. 객체별·스레드별 값이 모두 필요하다면 멤버로 std::unordered_map<std::thread::id, T>를 두고 락으로 보호하는 식으로 직접 구현해야 합니다.
#include <iostream>
class MyClass {
public:
static thread_local int x;
};
thread_local int MyClass::x = 0;
int main() {
MyClass::x = 42;
std::cout << MyClass::x << std::endl; // 42
}
초기화 비용
초기화 비용이 큰 객체를 thread_local로 즉시 초기화하도록 선언하면, 해당 변수를 실제로 사용하지 않는 스레드에서도 불필요하게 초기화 비용을 지불하게 됩니다. 아래 예시는 이런 낭비를 피하기 위해 thread_local std::unique_ptr<ExpensiveObject> obj;로 스마트 포인터만 즉시 초기화(비용이 거의 없는 nullptr 초기화)해 두고, func()가 처음 호출될 때만 if (!obj) 검사를 거쳐 실제 객체를 지연 생성하는 패턴을 보여줍니다. 이런 지연 초기화 패턴은 앞서 다룬 “첫 사용 시” 초기화와 함께 활용하면, 비용이 큰 자원을 실제로 필요한 스레드에서만, 필요한 시점에만 생성하도록 세밀하게 제어할 수 있습니다.
#include <memory>
#include <iostream>
struct ExpensiveObject {
ExpensiveObject() {
std::cout << "ExpensiveObject 생성" << std::endl;
}
};
thread_local std::unique_ptr<ExpensiveObject> obj;
void func() {
if (!obj) {
obj = std::make_unique<ExpensiveObject>();
}
}
int main() {
func();
func();
}
사실 GCC·Clang에서는 thread_local ExpensiveObject obj;로 직접 선언해도 첫 접근 시 생성되므로, 이 unique_ptr 패턴이 없어도 사용하지 않는 스레드는 비용을 치르지 않습니다. 그래도 이 패턴이 쓰이는 이유는 초기화 시점을 코드에서 명시적으로 드러내고, 구현에 따른 차이 없이 동일하게 동작하게 하며, 필요하면 obj.reset()으로 스레드가 살아 있는 동안에도 자원을 돌려줄 수 있기 때문입니다. 함수 안에서만 쓰는 객체라면 void func() { thread_local ExpensiveObject obj; ... }처럼 함수 내부 thread_local로 두는 것이 가장 간단한 지연 초기화입니다.
메모리 사용
thread_local 변수의 메모리는 스레드마다 각각 별도로 할당되므로, 전체 메모리 사용량은 변수 하나의 크기에 스레드 개수를 곱한 만큼 늘어납니다. 아래 예시처럼 100만 개의 int를 담는 큰 벡터를 thread_local로 선언하면, 스레드를 10개만 생성해도 이 벡터 하나로만 대략 40MB(int 4바이트 기준)에 가까운 메모리가 소비되며, 스레드 풀을 사용하는 서버 애플리케이션에서 스레드 수가 늘어날수록 이 부담은 선형으로 커집니다. 그래서 큰 자료구조를 thread_local로 선언하기 전에는 실제로 필요한 스레드 수를 고려해 전체 메모리 사용량을 미리 계산해 보고, 필요하다면 스레드 풀의 크기를 제한하거나 자료구조 크기를 줄이는 것을 함께 검토해야 합니다.
#include <vector>
#include <thread>
#include <iostream>
thread_local std::vector<int> largeBuffer(1000000);
void worker() {
std::cout << "Buffer size: " << largeBuffer.size() << std::endl;
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
}
스레드별 캐시와 통계 수집
지금까지 다룬 개념과 주의사항을 정리하면, 실무에서 thread_local은 크게 두 가지 패턴으로 반복해서 등장합니다. 아래 두 패턴은 각각 스레드별 캐시와 스레드별 통계 수집으로, 두 경우 모두 “스레드마다 독립적이어야 하고, 다른 스레드와 공유할 필요가 없는 상태”라는 공통점을 가지고 있습니다.
스레드별 캐시
계산 비용이 큰 값을 반복해서 요청받는 함수는 캐시를 두어 같은 입력에 대한 재계산을 피하는 것이 일반적인데, 이 캐시를 여러 스레드가 공유하면 동기화 오버헤드가 발생합니다. 아래 예시처럼 thread_local std::unordered_map<std::string, int> cache;로 캐시 자체를 스레드마다 독립적으로 두면, 각 스레드가 자신만의 캐시를 락 없이 자유롭게 읽고 쓸 수 있어 동기화 비용이 완전히 사라집니다. 다만 이 방식은 스레드마다 같은 값을 중복 계산하고 중복 저장하게 되므로, 스레드 수가 많고 캐시 항목이 크다면 전체 메모리 사용량과 캐시 적중률 사이의 트레이드오프를 함께 고려해야 합니다. 스레드 풀의 스레드는 프로세스와 수명을 같이하는 경우가 많아서, 크기 제한이 없는 thread_local 캐시는 사실상 스레드 수만큼 복제된 메모리 누수처럼 계속 자랍니다. 항목 수 상한이나 LRU 정책을 두어야 하고, 원본 데이터가 바뀌었을 때 모든 스레드의 캐시를 무효화할 방법(예: 전역 버전 번호를 두고 각 캐시가 조회 때 비교)도 설계에 포함해야 합니다. 예제 코드의 find 후 cache[key]는 해시 조회를 두 번 하므로, 실제로는 find가 돌려준 반복자의 ->second를 쓰는 편이 낫습니다.
#include <unordered_map>
#include <string>
thread_local std::unordered_map<std::string, int> cache;
int getValue(const std::string& key) {
if (cache.find(key) != cache.end()) {
return cache[key];
}
int value = computeValue(key);
cache[key] = value;
return value;
}
스레드별 통계
요청 처리 건수나 에러 발생 횟수 같은 통계를 여러 스레드가 함께 집계해야 할 때도, 매번 원자적 연산이나 뮤텍스로 하나의 공유 카운터를 갱신하는 대신 스레드별로 독립된 통계 구조체를 두는 방식이 흔히 쓰입니다. 아래 Statistics 구조체를 thread_local로 선언하면 각 스레드가 자신의 처리 건수와 에러 건수를 락 없이 빠르게 누적할 수 있고, 프로그램 종료 시점이나 주기적인 리포팅 시점에 모든 스레드의 통계를 한 곳에 모아 합산하는 방식으로 전체 통계를 얻을 수 있습니다. 이 접근은 통계 갱신 자체는 최대한 빠르게 하고, 상대적으로 드물게 일어나는 집계 시점에만 스레드 간 조율 비용을 지불한다는 점에서 효율적인 설계입니다.
#include <iostream>
struct Statistics {
size_t count = 0;
size_t errors = 0;
void print() {
std::cout << "Count: " << count << ", Errors: " << errors << std::endl;
}
};
thread_local Statistics stats;
void processRequest() {
stats.count++;
}
위 코드는 “모든 스레드의 통계를 모아 합산”하는 부분을 생략했는데, 실제로는 이 부분이 가장 까다롭습니다. thread_local 변수는 다른 스레드에서 이름으로 접근할 수 없으므로, 집계하려면 각 스레드가 자기 stats의 주소를 뮤텍스로 보호되는 전역 목록에 등록해 두어야 합니다. 이때 두 가지를 챙겨야 합니다. 첫째, 스레드가 종료되면 그 stats는 소멸하므로 등록한 포인터가 댕글링이 됩니다. Statistics의 소멸자에서 자기 값을 전역 누적값에 더한 뒤 목록에서 빠지도록 해야 합니다. 둘째, 집계 스레드가 다른 스레드의 count를 읽는 순간 그 스레드는 계속 쓰고 있으므로, 평범한 size_t면 데이터 레이스입니다. 필드를 std::atomic<size_t>로 두고 쓰는 쪽은 memory_order_relaxed로 증가시키면, 경쟁이 없는 캐시 라인에 대한 원자적 증가라 비용은 거의 그대로이면서 집계가 안전해집니다.
마지막으로, 요청 ID나 트랜잭션 문맥 같은 값을 thread_local에 두는 설계는 동기 코드에서만 성립합니다. 코루틴이나 비동기 실행기에서는 한 요청이 여러 스레드를 옮겨 다니므로, co_await 전후로 같은 thread_local 변수가 다른 스레드의 사본을 가리킵니다. 이런 코드를 비동기로 옮길 때 로그의 요청 ID가 다른 요청과 뒤섞이는 버그가 자주 생기는데, 컴파일러도 새니타이저도 잡아 주지 않아 찾기 어렵습니다.
thread_local 요약
핵심 요약
- thread_local: 스레드별 독립 변수
- 초기화: 상수 초기화는 스레드 생성 시, 동적 초기화는 대개 첫 사용 시
- 용도: 캐시, 통계, 난수 생성기
- 성능: 접근 빠름, 초기화 비용 있음
- 메모리: 스레드 수 × 변수 크기
thread_local vs 전역 변수
| 특성 | thread_local | 전역 변수 |
|---|---|---|
| 스레드 안전 | O | X |
| 동기화 필요 | X | O |
| 메모리 | 스레드당 | 1개 |
| 성능 | 빠름 | 동기화 필요 시 느림 |
실전 팁
- 스레드별 캐시에 활용
- 난수 생성기는 thread_local 사용
- 초기화 비용 고려
- 메모리 사용량 주의
다음 단계
- C++ jthread
- C++ random_device
- C++ Mutex
같이 보면 좋은 글
- C++ async & launch
- C++20 std::jthread: 자동 join과 stop_token으로 스레드 협조적 중단하기
- C++17 constexpr 람다: 암시적 constexpr 조건과 컴파일 타임 룩업 테이블
- C++20 템플릿 람다: auto 람다와 차이, Concepts로 타입 제한하기
자주 묻는 질문 (FAQ)
Q. 클래스 멤버를 thread_local로 선언하려면 어떻게 해야 하나요?
A. thread_local은 정적 멤버에만 붙일 수 있으며, 일반 비정적 멤버 변수에는 사용할 수 없습니다. 클래스 안에서 static thread_local int x;로 선언하고 클래스 밖에서 thread_local int MyClass::x = 0;처럼 정의하는 것이 기본 형태이며, C++17 이후라면 static inline thread_local로 클래스 안에서 바로 정의할 수도 있습니다. 객체마다가 아니라 스레드마다 하나씩 존재하는 값이라는 점을 염두에 두고 설계해야 합니다.