생각해 봅시다.
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
* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하여 내용을 정리하였습니다.
'L C++ > Concurrency' 카테고리의 다른 글
[Concurrency] 다양한 mutex 소개 (2) | 2023.12.19 |
---|---|
[Concurrency] std::call_once - 중복 초기화 해소 기술 (0) | 2023.12.12 |
[Concurrency] std::jthread - join 없이 사용하기📌 (0) | 2023.11.28 |
[Concurrency] std::async - 기존 함수 그대로 thread에 적용(2) (2) | 2023.11.21 |
[Concurrency] packaged_task - 기존 함수 그대로 thread에 적용(1) (0) | 2023.11.14 |