SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++/Concurrency

[Concurrency] thread_local - thread 전용 변수 ✔

보리남편 김 주부 2023. 12. 5. 09:00

생각해 봅시다.


Process 끼리는 메모리 할당이 서로 접근할 수 없게 독립적으로 분리되어 있다. 이런 콘셉트로 스레드끼리도 메모리를 독립적으로 쓸 수 있는 방법은 없을까?  🙄


필요성을 좀 더 느끼기 위해 아래 예제를 봅시다.

A, B 스레드 각각 독립적으로 1씩 증가하여 3을 만드는 프로그램을 짜는데 지역변수를 사용하면 원하는 결과가 안 나온다. n을 어떻게 선언하면 이 문제를 해결할 수 있을까?

지역 변수 n

예제 1
A, B 스레드 각각 독립적으로 1씩 증가를 지역변수 n을 이용한 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <string>

//thread_local int n = 0;

int increase()
{
	int n = 0;		     // 지역변수, 스택 사용
	//static int n = 0;
	n = n + 1;
	return n;
}

void foo(const std::string& name)
{
	std::cout << name << " : " << increase() << std::endl;
	std::cout << name << " : " << increase() << std::endl;
	std::cout << name << " : " << increase() << std::endl;
}
int main()
{
	std::thread t1(foo, "A");
	std::thread t2(foo, "\tB");

	t1.join();
	t2.join();

	foo("\t\tmain");
}

 

결과
A :     B : 11
A : 1
A : 1

        B : 1
        B : 1
                main : 1           
                main : 1           
                main : 1           


지역변수는 stack에 쌓이고 스레드당 별도의 공간에 사용되지만 함수 종료 시 파괴되어 값이 유지가 안된다.

* 메모리 구조 그림 참고 글 : 2022.12.21 - [언어(공통)] - [C/C++] #define과 const 차이

 

전역 변수 n

그러면 static으로 n을 사용하면 어떤 결과가 될까?

예제 1의 코드에서 아래 as-is를 to-be로 수정하고 실행해 보자.

//as-is
int n = 0;		     // 지역변수, 스택 사용
//static int n = 0;	

//-----------------------------------------------------
//to-be
//int n = 0;
static int n = 0; // data 메모리, 모든 스레드가 공유
결과
A :     B : 21
A : 3
A : 4

        B : 5
        B : 6
                main : 7
                main : 8
                main : 9

 

static 변수는 data 메모리에 올라가서 함수가 종료되어도 파괴되지 않지만 다른 스레드에도 동시에 공유가 되어 스레드끼리 독립적으로 값 유지가 안 된다.

 

이런 문제에 사용하기 위해 만들어진 키워드가 thread_local이고 표준 C++11부터 지원한다.🤗

참고. C++11 이전에는 OS 마다 다른 방법으로 사용했어야 했다.
linux ( gcc ) _thread static int x
windows ( cl ) __declspec(thread)

 

키워드 thread_local


thread_local n

그럼 표준C++에 키워드 thread_local를 이용하여 문제를 풀어보자.

예제 1의 코드에서 아래 as-is를 to-be로 수정하고 실행하자.

//as-is
//thread_local int n = 0;
int n = 0;		     // 지역변수, 스택 사용
//static int n = 0;
    
//------------------------------------------------------
 
//to-be
thread_local int n = 0;
//int n = 0;
//static int n = 0;

 

결과
A :     B : 11
        B : 2
        B : 3
A : 2
A : 3
                main : 1
                main : 2
                main : 3

 

같은 n을 사용했지만, 스레드 A, B 그리고 주 스레드까지도 n의 값이 독립적으로 실행됨을 확인할 수 있다.

 

키워드 thread_local를 정리해 보면

  • 스레드당 한 개의 data 메모리를 가진다.
  • static을 표기하지 않아도 '암시적으로 static 변수'이다.

이것을 Thread local Storage(TLS) 또는 Thread Specific Storage(TSS)라고 한다.

 

정말 data 메모리에 올라가나?🤔


ASM 파일로 확인

n이 data 메모리에 올라가는지 예제 1의 파일을 asm 파일로 생성해서 확인해 보자.

asm 파일 생성 방법
g++ -S xxx.cpp 하면 asm 파일이 생성된다.

 

leaq	__emutls_v.n(%rip), %rax
movq	%rax, %rcx
call	__emutls_get_address

#중략

.globl __emutls_v.n

 

그리고 static 변수처럼 globl(global) 되어 누구나 접근할 수 있는 영역에 있으나(Data 섹션) 이름에서도 유추할 수 있듯이 n이 emutls_v.n로 생성되었으며, __emutls_get_address를 통해 스레드 별 별도로 n의 주소를 가지기 때문에 스레드 간 공유가 되지 않는다.

 

 

참고 : 
asm 심볼 정의 : http://korea.gnu.org/manual/release/as/as-ko_7.html#SEC90
 __emutls_get_address 함수가 어떻게 구현되어 있는지 궁금하다면 : https://android.googlesource.com/toolchain/gcc/+/master/gcc-4.9/libgcc/emutls.c

 

리눅스 nm 명령으로 확인

리눅스 nm 명령
: 심볼 테이블 검색 nm은 다수의 최신 유닉스와 유닉스 계열 운영 체제에 포함되어 있는 명령어이다. nm은 라이브러리, 컴파일된 오브젝트 모듈, 공유 오브젝트 파일, 독립 실행파일등의 바이너리 파일을 검사해서 그 파일 들에 저장된 내용 또는 메타 정보를 표시한다. nm은 디버깅 과정에서 이름 겹침과 C++ 이름 맹글링 문제를 해결하거나 툴체인의 다른 부분을 확인하는 데 사용된다.
object 파일 생성 방법
g++ -c xxx.cpp 

심볼 테이블 출력
nm -C xxx.o

 

# 출력된 simbol 중 확인하고자 하는 n의 정보
0000000000000000 D __emutls_v.n


[ 심볼 값 ] [ 심볼 클래스 ] [ 심볼명이 출력 ] 순으로 출력이 되며 Data 섹션에 있는 것을 확인할 수 있다.

 

심볼 설명 : https://sourceware.org/binutils/docs/binutils/nm.html

 

 

Data 용량이 클 때 크기 제한이 있는 TLS(TSS) 활용 방법은?🤔


주의 🛑

대부분 OS에서 TLS(TSS)의 크기제한이 있어 Data 메모리를 무한정 쓸 수 없다. 

windows : sizeof(int) * 1088 

 

스레드 내에 많은 data 공간이 필요할 때는 TLS에는 주소만 보관하고 실제 data는 힙에 할당하여 사용하면 된다.

 

예제 2
TLS는 pdata 포인트로 주소만 보관하고, data는 new로 힙공간을 사용한 프로그램 코드이다. 
#include <iostream>
#include <thread>
#include <string>

//int* pdata = nullptr;

thread_local int* pdata = nullptr;

void init_thread()
{
    pdata = new int[1000];
}
void clean() { delete[] pdata; }

void thread_main()
{
    init_thread();

    // pdata 사용
	
    clean();
}

int main()
{
	std::thread t1(thread_main);
	std::thread t2(thread_main);

	t1.join();
	t2.join();
}

 

이렇게 하면 스레드 내에 많은 data 공간을 사용할 수 있다.

 

thread_local 대신 전역 int* 를 쓰는 것과 뭐가 다를까?

// as-is
//int* pdata = nullptr;
thread_local int* pdata = nullptr;

//------------------------------------------------
// to-be
int* pdata = nullptr;
//thread_local int* pdata = nullptr;

 

두 개의 스레드에서 pdata라는 변수에 각각 동적 메모리를 할당을 하고 해제를 하면서 다른 스레드에서 사용 중인 data에 접근 혹은 다른 스레드에서 삭제한 pdata를 접근하면서 예외가 발생할 수 있다.

 

참고


* 메모리 구조 그림 참고 글 :

 2022.12.21 - [언어(공통)] - [C/C++] #define과 const 차이

 

* asm 심볼 정의 : http://korea.gnu.org/manual/release/as/as-ko_7.html#SEC90

 

* __emutls_get_address 함수가 어떻게 구현되어 있는지 궁금하다면 : https://android.googlesource.com/toolchain/gcc/+/master/gcc-4.9/libgcc/emutls.c

 

* 심볼 설명 : https://sourceware.org/binutils/docs/binutils/nm.html

 

* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하여 내용을 정리하였습니다.

 

728x90