Why should I know this?

Linux의 thread local storage 파헤치기 본문

Knowledge/Linux

Linux의 thread local storage 파헤치기

die4taoam 2019. 1. 17. 16:37

 


Thread Local Storage 파헤치기

 

0. 개요

Thread Local StorageMulti-Thread 프로그램을 작성하는데 거의 필수로 사용되는 기능이다.

Linux에서의 Thread Local Storage에 대하 자세히 다뤄보고자 한다.

 

이 글에서 다루는 모든 소스코드와 예제는 github 저장소에 존재한다.

https://github.com/ParkHanbum/study_tls.git

 

 

0-0. 배경

-. 프로그래밍 공학시간에 배웠듯, 프로그래밍 언어에서 지역변수는 Stack, 전역변수는 Data 영역에 보관된다.

-. 모든 프로세스에는 아키텍처에 가용한 최대한의 메모리가 가상 메모리로 제공된다. 이것을 Flat 혹은 Linear 메모리 모델이라고 부른다.

 

0-1. Thread ?

먼저 Thread를 간단히 정의하자면, Process가 자원 할당의 단위라고 한다면 Thread는 작업 처리의 단위이다.

 

0-2. Thread Local Storage(TLS)

메모리의 할당은 Process 단위로 이뤄지게 되는데, 이런 구조 상에서 Thread는 동일한 메모리 주소를 공유하게 된다. Thread가 한 Process 하에서 동일 메모리를 공유하기 때문에 Thread 들은 Data 영역을 공유하게 된다. 이것을 다른 말로 표현하면 Process의 전역 변수는 모든 Thread가 공유하게 되는 것이다.

 

하지만 Process와 마찬가지로 Thread들도 각자의 고유한 전역변수가 필요한 경우가 있을 것이다. 때문에 Stack과 마찬가지로 Thread 별로 Data 영역처럼 고유의 영역을 제공하는데, 이를 Thread Local Storage(=이하 TLS)라고 부른다.

 

 

1. TLS에 사용되는 네 개의 모델

TLS에 사용되는 네 개의 모델을 순서대로 살펴보기 전에, 먼저 TLS가 생성되는 기본 메커니즘을 알아야 한다. linux에서는 __thread 와 같은 키워드를 사용하여 TLS 변수를 선언할 수 있다.

 

초기화 된 전역 변수가 .bss 섹션에, 초기화 되지 않는 전역 변수가 .data 섹션에 저장되는 것처럼, TLS 변수는 각각 .tbss, .tdata 섹션에 저장된다.

 

하지만 전역변수와는 다르게 TLS 변수는 thread 별로 고유하게 할당되는 것이 목적이므로 TLS 변수는 Section에 바로 할당되지 않고 Loader에 의해 적재되는 런타임에 주소가 정해지게 되며, 이 때 비로소 메모리에 할당 & 복사된다.

 

이 차이를 유념하도록 하자

 

 

1-1. Local Exec

실행파일의 경우, Linking 시에 정적으로 할당되는 TLS 주소를 계산할 수 있다.

로더는 Thread를 관리하기 위한 구조체 struct pthread의 주소를 FS레지스터에 기록해논다. struct pthread 구조체는 glibcnptl/descr.h 에 선언되어 있다.

여기서는 TCB(Thread Control Block)으로 줄여 부르고자 한다.

 

로더는 TCB를 위한 메모리를 할당하면서 동시에 실행 전에 필요한 모든 TLS 공간을 메모리에 할당&복사하는 과정을 통해 실행을 준비를 한다. Local Exec 모델의 경우 TLS가 할당되는 위치가 TCB 주소에서 상대적으로 항상 고정되기 때문에 TCB의 주소를 기반으로 정적 참조가 가능하다.

 

github에서 받은 저장소에서 make를 실행하면 readtlsinfo라는 실행파일이 생성될 것이다. 이를 실행해보면 다음과 같은 결과를 볼 수 있다.

 

TLS variable address : 0x7f434e946b68

tcb : 0x7f434e946b80

dtv : 0x7f434e9474d0

 

위는 다음 코드를 실행한 결과이다.

 

        struct tls *test = &find_me;

        printf("Print DTV information after refer tls variable\n");

        printf("TLS variable address : %p\n", test);

 

TLS 변수인 testTCB의 주소에서 24를 뺀 주소에 위치하는 것을 알 수 있다. 이 주소가 항상 고정되어 있다는 것을 기계어 코드를 보면 확인할 수 있다.

 

   0x00000000004012ef <+84>:    mov    %fs:0x0,%rax

   0x00000000004012f8 <+93>:    add    $0xffffffffffffffe8,%rax

   0x00000000004012fe <+99>:    mov    %rax,-0x10(%rbp)

   0x0000000000401302 <+103>:   mov    $0x4015e8,%edi

   0x0000000000401307 <+108>:   callq  0x400a10 <puts@plt>

 

FS+0TCB의 주소이므로 TCB 주소에서 -24 된 주소를 출력해주도록 기계어가 생성되어 있는 것을 확인할 수 있다.

 

Local Exec model 정리

-. 실행파일의 TLS는 고정된 위치에 적재된다.

-. 실행파일의 TLSTCB 주소 기반으로 고정위치에 정해진다.

-. 컴파일러는 TCB를 기반으로 고정된 위치를 참조하도록 기계어를 생성한다.

 

 

1-2. Initial Exec

앞서 Local Exec Model에서 실행파일의 TLS가 고정된 위치에 적재된다고 설명한 바 있다. 로더는 TLS 영역을 할당하고 가장 먼저는 TCB에서 Fixed offset에 실행파일의 TLS을 복사한다. 여기서 로더가 고정적으로 할당하는 메모리 영역을 Static TLS Block이라고 부른다.

 

Initial Exec modelLocal Exec model처럼 Static TLS Block에 복사되고, 별도의 메모리를 할당받지 않는다. 해당 모델은 주로 glibc와 같은 실행파일이 실행되는데 필수적으로 필요한 의존성 공유 오브젝트들이 사용하게 된다.

 

우리에게 친숙한 errno 이 해당 model을 사용한다.

 

실행파일과는 다르게 이 model에서 할당되는 메모리 공간은 Runtime에 결정되므로 Local Exec model처럼 Linking시에 참조할 주소를 결정할 수 없다. 때문에 로더가 최종적으로 할당한 Static TLS Block의 주소 값을 약속된 공간인 GOT에 기록하도록 약속하고 컴파일러는 GOT에서 주소를 참조하는 방식을 취한다.

 

Make를 실행했다면, bench_tls 라는 이름의 실행파일이 생겼을 것이다. ldd로 의존성을 확인하면 다음과 같은 결과를 볼 수 있다.

 

$ ldd bench_tls

        linux-vdso.so.1 (0x00007ffdfd322000)

        libtls.so => not found

        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f6fa9d04000)

        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6fa9913000)

        /lib64/ld-linux-x86-64.so.2 (0x00007f6fa9f23000)

 

libtls.so가 존재하지만, library path에서 찾을 수 없기 때문에 libtls.sonot found로 나온다. 때문에 다음의 명령을 실행해야 한다.

 

$ export LD_LIBRARY_PATH=`pwd`

 

이제 정상적으로 library를 찾을 수 있을 테니, bench_tls를 실행해보자.

 

$ ./bench_tls

[1770555136] 0x7f1a698886fc = 500001

Check accessability of address : 500001

[1804125952] 0x7f1a6b88c6fc = 500001

Check accessability of address : 500001

[1795733248] 0x7f1a6b08b6fc = 500001

Check accessability of address : 500001

[1778947840] 0x7f1a6a0896fc = 500001

Check accessability of address : 500001

[1787340544] 0x7f1a6a88a6fc = 500001

Check accessability of address : 500001

 

bench_tls에서는 5 개의 Thread를 생성하여 initial-exec model로 할당된 변수 tls_variable500000 까지 1 씩 증가시키는 로직을 작성했다.

 

use_tls.c 의 최상단에 관련 코드가 있다.

 

__thread int tls_variable __attribute__((tls_model("initial-exec")));

 

int *__tlsvar_location(void)

{

        return &tls_variable;

}

 

위 코드는 initial exec modeltls_variable을 생성하고, 그의 주소를 반환하는 __tlsvar_location 함수이다. tls_variableTLS 변수이기 때문에 컴파일 타임에 주소가 결정되지 않는다. 그렇기 때문에 export로 참조가 불가능하다. 그래서 __tlsvar_location 함수를 통해 runtime에 결정된 tls_variable 주소를 참조하는게 가능하도록 한다.

 

정확히 동일한 구조로 errno과 같은 TLS 변수가 export 된다.

 

// stdlib/errno.h:37

/* The error code set by various library functions.  */                                                                                                                                                            

extern int *__errno_location (void) __THROW __attribute_const__;

# define errno (*__errno_location ())

 

// csu/errno.c:31

__thread int errno;

 

// csu/errno-loc.c:24

int *

__errno_location (void)

{

  return &errno;

}

 

먼저 많이 사용되는 errno의 예제 코드를 한번 살펴보자.

 

extern int errno;

int main()

{

        int test = errno;

}

 

위의 코드는 다음처럼 기계어로 번역된다.

 

   0x0000000000400611 <+47>:    callq  0x4004c0 <__errno_location@plt>

   0x0000000000400616 <+52>:    mov    (%rax),%eax

   0x0000000000400618 <+54>:    mov    %eax,%edi

   0x000000000040061a <+56>:    callq  0x4004f0 <strerror@plt>

  

보다시피 errno라는 변수의 참조는 사실은 __errno_location의 호출이며, 이는 위에서 설명했듯 TLS변수를 export하는 것이 불가능하기 때문에 런타임에 결정되는 TLS 변수의 주소를 반환하는 함수로 PREDEFINE하여 사용하게끔 export하고 있는 것이다. 여기까지 errno을 통해 initial exec model로 선언된 TLS 변수의 활용법에 대해 살펴봤다.

 

다시 __tls_location 함수로 돌아와, initial exec modelTLS 변수가 어떻게 참조되는지 기계어를 통해 살펴보자.

 

0000000000000835 <__tlsvar_location>:

 835:   55                      push   %rbp

 836:   48 89 e5                mov    %rsp,%rbp

 83f:   64 48 8b 14 25 00 00    mov    %fs:0x0,%rdx

 846:   00 00

 848:   48 8b 05 89 07 20 00    mov    0x200789(%rip),%rax

 84f:   48 01 d0                add    %rdx,%rax

 852:   5d                      pop    %rbp

 853:   c3                      retq

 

FS레지스터를 참조해 TCB의 주소를 읽어들이는 것은 Local exec와 동일하다.

다음이 핵심인데, "mov    0x200789(%rip),%rax" 구문으로 이곳이 바로 로더가 써주기로 약속된 Static TLS Block Offset이다.

 

readelf를 통해 libtls.so의 정보를 조회하면

$ readelf -a libtls.so

...

Relocation section '.rela.dyn' at offset 0x5b8 contains 10 entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

000000200dc8  000000000008 R_X86_64_RELATIVE                    830

000000200dd0  000000000008 R_X86_64_RELATIVE                    7f0

000000201038  000000000008 R_X86_64_RELATIVE                    201038

000000200fc8  000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0

000000200fd0  000400000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0

000000200fd8  000a00000012 R_X86_64_TPOFF64  0000000000000000 tls_variable + 0

000000200fe0  000500000006 R_X86_64_GLOB_DAT 0000000000000000 mcount@GLIBC_2.2.5 + 0

000000200fe8  000700000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0

000000200ff0  001100000006 R_X86_64_GLOB_DAT 0000000000000854 do_stuff + 0

000000200ff8  000800000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

...

 

 

보이는가?

848(program counter) + 0x200789 + 7(instruction size) = 0x200fd8 이고

이는 .rela.dyn 섹션에 tls_variableR_X86_64_TPOFF64을 의미한다.

앞서 설명했듯 이 OFFSET 값은 로더가 정해주는 Static TLS Block 내의 offset을 뜻하며 런타임에 결정된다.

 

, 다음의 한 줄로 선언한 initial exec model 변수는

 

__thread int tls_variable __attribute__((tls_model("initial-exec")));

 

실제로는 TCB Address + tls_variable(OFFSET)의 주소를 갖게 되는 것이며,

 

 83f:   64 48 8b 14 25 00 00    mov    %fs:0x0,%rdx

 846:   00 00

 848:   48 8b 05 89 07 20 00    mov    0x200789(%rip),%rax

 84f:   48 01 d0                add    %rdx,%rax

 

위의 기계어로 번역되어 참조되는 것이다.

 

반복해서 설명하자면, 이처럼 tls_variable은 우리는 변수로 선언하고 사용하지만 컴파일러는 변수를 참조하는 코드로 번역해 넣으므로, 외부에서는 참조할 수가 없다. 때문에 앞서 errno처럼 이를 참조할 수 있도록 별도의 함수를 추가하는 것이다.

 

Initial Exec model 정리

-. Local Exec model과 동일한 Static TLS Block에 로더에 의해 적재된다.

-. 로더는 적재된 TLS주소를 TCB기준 offset을 계산하여 TPOFF typeGOT에 기록한다.

-. 컴파일러는 TCB 주소 + GOT에 기록된 offset 값을 통해 변수를 참조하도록 기계어를 생성한다.

 

 

1-3. General Dynamic

 

해당 모델은 가장 범용적으로 사용되며, 동적으로 로드하려는 공유 오브젝트들이 많이 활용하는 방식이다.

 

앞서 두 모델을 살펴봤는데, 이들은 실행파일과 실행에 필요한 의존성이 있는 공유 오브젝트들이 실행되기 전에 적재되면서 생성되는 Static TLS Block을 점유한다고 설명했다. Initial Exec model을 사용하는 공유 오브젝트는 Static TLS Block이 가득차면 dlopen등을 활용한 동적 로드가 불가할 수가 있다.

 

'cannot allocate memory in static TLS block'

> Initial Exec model을 사용한 공유 오브젝트를 동적 로드하면 이런 에러 메시지를 보여줄때가 있다.

 

General Dynamic model은 동적으로 TLS Block을 할당하고 해제할 수 있도록 동적 관리를 하게 되는데, 그 절차와 과정이 다소 복잡하므로 모든 과정을 상세히 다루지는 않고 할당되는 과정만 다루고자 한다.

 

먼저 생성된 dlopen_common을 다음처럼 실행해보자.

 

 $ ./dlopen_common `pwd`/libreadtlsinfo.so

 

그럼 다음과 같은 결과가 보일 것이다.

 

...

[64] [2] : /home/m/git/study_tls/libreadtlsinfo.so       location at 0x7ffab2892000

[PT_TLS] Virtual Address : 201d98 - 18

0x2 0x1 0xf 0xe 0xd 0xc 0xb 0xa 0x6 0x5 0x4 0x3 0xa 0x9 0x8 0x7 0xe 0xd 0xc 0xb 0x0 0x0 0x0 0x0

...

 

MODULE ID       :[2]

Print DTV information before refer tls variable

tcb : 0x7ffab3286740

dtv : 0x7ffab32870a0

Total dtv count : 1

dtv [0] : 1 - 0

dtv [1] : 7ffab32866b0 - 0

dtv [2] : 0 - 0

not allocated yet

TLS ALLOC : 0x1732910

Print DTV information after refer tls variable

TLS variable address : 0x1732910

tcb : 0x7ffab3286740

dtv : 0x7ffab32870a0

Total dtv count : 2

dtv [0] : 2 - 0

dtv [1] : 7ffab32866b0 - 0

dtv [2] : 1732910 - 1732910

Module value located at 0x1732910, value : a0b0c0d0e0f0102

Print DTV information after allocate

tcb : 0x7ffab3286740

dtv : 0x7ffab32870a0

Total dtv count : 2

dtv [0] : 2 - 0

dtv [1] : 7ffab32866b0 - 0

dtv [2] : 1732910 - 1732910

Module value located at 0x1732910, value : a0b0c0d0e0f0001

 

read_tls_info.c의 최상단에 다음처럼 TLS 변수를 선언해놨다.

 

struct tls {

        long d;

        int a;

        int b;

        int c;

};

 

__thread struct tls find_me = {

        .d = 0x0a0b0c0d0e0f0102L,

        .a = 0x03040506,

        .b = 0x0708090a,

        .c = 0x0b0c0d0e,

};

 

그리고 Main에서는 다음처럼 DTV를 출력하는 코드가 있다.

 

printf("MODULE ID\t:[%d]\n", moduleid);

printf("Print DTV information before refer tls variable\n");

show_dtv();

 

 

TCB 구조체에는 DTV라는 배열이 존재한다. DTVDynamic Thread Vector의 약자이며, 동적으로 로드되는 모듈은 moduleidDTVindex를 부여받게 된다.

 

DTV는 다음과 같은 구조를 가지고 있다.

 

// sysdeps/generic/dl-dtv.h:29

/* Type for the dtv.  */

typedef union dtv

{

  size_t counter;

  struct dtv_pointer pointer;

} dtv_t;

 

DTVstruct pthread의 첫번째 멤버 tcbhead_t header의 두 번째 멤버이며

동적으로 생성되고 Vector의 특성에 따라 확장가능하다. 또한 벡터의 첫번째 요소는 반드시 전체 크기를 나타내는 counter이다.

 

// 결과 출력.

MODULE ID       :[2]

Print DTV information before refer tls variable

tcb : 0x7ffab3286740

dtv : 0x7ffab32870a0

Total dtv count : 1

dtv [0] : 1 - 0

dtv [1] : 7ffab32866b0 - 0

dtv [2] : 0 - 0

not allocated yet

 

이제 위의 결과가 무엇을 의미하는지 어느정도는 감이 올 것이다.

TCB에서 DTV의 주소를 읽어오고, DTV의 요소들을 읽어 출력을 해준 것이다.

 

[64] [2] : /home/m/git/study_tls/libreadtlsinfo.so

MODULE ID       :[2]

 

dl_iterate_phdr을 통해 로드된 ELFphdriterating하여 알아낸 libreadtlsinfo.somoduleid2인데, DTV 2 NULL 이다. 또한 DTVcount1 이다.

 

DTVLazy Load 되기 때문에, 실질적으로 TLS 변수에 접근하는 코드가 실행되기 전까지 DTV는 할당되지 않는다. 예제에서 선언한 TLS 변수 find_me를 참조하는 호출과정을 통해서 할당되게 된다.

 

그 과정을 간단히 설명하는 코드가 allocate_dtv 함수에 존재한다.

 

void allocate_dtv(void)

{

        void *tls_alloc = malloc(tls_size);

 

        printf("TLS ALLOC : %p\n", tls_alloc);

        if (tls_addr != 0)

                memcpy(tls_alloc, (void *)tls_addr, tls_size);

 

        dtv_t *dtv = get_dtv();

        dtv[0].counter += 1;

        dtv[moduleid].pointer.val = tls_alloc;

        dtv[moduleid].pointer.to_free = tls_alloc;

}

 

코드는 간단하다, 메모리를 할당하고 초기화된 변수인 경우 데이터를 복사한다.

그리고 DTV[0]에 위치하는 카운터를 증가시키고, 부여받은 moduleidindex에 할당받은 주소를 적는다.

다시 DTV를 조회하면 다음처럼 할당이 잘 이뤄진 것을 확인할 수 있을 것이다.

 

   dtv [0] : 2 - 0

   dtv [1] : 7ffab32866b0 - 0

   dtv [2] : 1732910 - 1732910

 

이제 find_me를 참조하는 경우 dtv[moduleid]를 참조하여 해당 메모리 주소를 찾아갈 수 있게 될 것이다.

 

기억을 환기시켜 주자면, Local Exec model에서 TLS 변수는

 

   0x00000000004012ef <+84>:    mov    %fs:0x0,%rax

   0x00000000004012f8 <+93>:    add    $0xffffffffffffffe8,%rax

  

TCB로부터 FixedOffset을 더해 Static TLS Block의 주소를 찾았고, 

Initial Exec model에서는

  

   83f:   64 48 8b 14 25 00 00    mov    %fs:0x0,%rdx

   846:   00 00

   848:   48 8b 05 89 07 20 00    mov    0x200789(%rip),%rax

   84f:   48 01 d0                add    %rdx,%rax

 

로더가 Static TLS Block에 적재후, TCB 기준 offset 값을 GOT에 기록하도록 약속해놓고,

컴파일러는 해당 GOT에 기록된 OffsetTCB의 주소를 더해 주소를 찾았다.

 

Dynamic model의 경우에는 moduleidoffset을 할당받게 된다.

이는 다음과 같은 구조를 가지고 있다.

 

typedef struct dl_tls_index

{

  unsigned long int ti_module;

  unsigned long int ti_offset;

} tls_index;

 

마찬가지로, ti_moduleti_offset은 로더에 의해 Runtime에 결정된다.

ti_moduleModuleidDTVindex이며 ti_offsetDTV[moduleid]에 기록된 할당된 메모리 주소로부터의 offset을 의미한다. find_me를 참조하는 코드는 이 값을 인자로 __tls_get_addr 함수를 호출하는 기계어로 번역되게 된다.

 

    struct tls *test = &find_me;

    # 201fe0 <find_me@@Base+0x201fe0>

    166a:       66 48 8d 3d 6e 09 20    data16 lea 0x20096e(%rip),%rdi       

    1671:       00

    1672:       66 66 48 e8 76 f7 ff    data16 data16 callq df0 <__tls_get_addr@plt>

    1679:       ff

 

   

__tls_get_addr 함수를 호출하면서 넘겨주는 인자 값의 주소는 initial exec model과 유사하게 .rela.dyn에 위치한 GOT에 존재한다.

   

Relocation section '.rela.dyn' at offset 0x9e8 contains 11 entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

000000201db0  000000000008 R_X86_64_RELATIVE                    f70

000000201dc0  000000000008 R_X86_64_RELATIVE                    f30

0000002020d0  000000000008 R_X86_64_RELATIVE                    2020d0

000000201db8  001d00000001 R_X86_64_64       000000000000160f init + 0

000000201fc8  000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0

000000201fd0  000c00000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0

000000201fd8  001000000006 R_X86_64_GLOB_DAT 0000000000000000 mcount@GLIBC_2.2.5 + 0

000000201fe0  001b00000010 R_X86_64_DTPMOD64 0000000000000000 find_me + 0

000000201fe8  001b00000011 R_X86_64_DTPOFF64 0000000000000000 find_me + 0

000000201ff0  001500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0

000000201ff8  001600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

 

R_X86_64_DTPMOD64 = module id

R_X86_64_DTPOFF64 = module offset

 

여기서 module offset은 변수의 크기를 컴파일 시에 결정할 수 있으므로 컴파일 타임에 결정된다.

 

 

General Dynamic model 정리

-. 동적 로드되는 module은 로더에게 moduleid로 부여받으며 이는 DTVindex로 활용된다.

-. 로더는 TLS 변수들이 위치하는 TLS Section을 메모리에 적재한 뒤 각 변수의 module_id를 기록한다. 

-. 컴파일러는 TLS 변수에 참조하는 코드를 module_idmodule_offset을 인자로 __tls_get_addr 함수를 호출하는 기계어로 번역한다.

-. __tls_get_addr에서는 DTV[module_id]로 할당된 메모리 주소를 찾고 해당 주소에 module_offset을 더한 값을 넘겨준다.

 

[LAZY LOAD = TLS 변수를 참조하여 __tls_get_addr 함수가 호출될 때 이뤄지는 동작]

-. 로더는 moduleTLS Section을 복사해 메모리에 적재하고 적재된 주소를 DTV[moduleid]에 기록한다.

 

 

1-4. Local Dynamic

// 생략 //

 



2. TLS Model

Linux에서는 프로그램이 적재되는 과정을 크게 세 단계로 볼 수 있다.

1 단계, 실행하려는 주 프로그램이 적재되는 단계

2 단계, 실행하려는 주 프로그램이 실행되기 위해 필요한 의존 라이브러리를 적재하는 단계

3 단계, 실행하는 프로그램이 실행 간에 동적으로 필요한 라이브러리를 적재하는 단계

 

이 세 단계에 따라, TLS가 할당되는 모델도 크게 네 개로 분류된다.

1. Local-Exec

주 프로그램에 사용되는 모델로 ELF 해더에 TCBStatic TLS Block이 고정적으로 동일 위치에 할당되는데, TCB의 주소를 보관하는 FS로부터 상대 OFFSET값을 정적 계산하도록 기계어를 번역한다.

2. Initial-Exec

주 프로그램이 위존성을 갖는 라이브러리가 주로 사용하는 모델로 위 모델과 유사하지만, OFFSET의 값이 동적으로 정해지므로, 동적으로 설정된 OFFSETGOT에 기록하도록 약속해놓고 TCB의 주소를 보관하는 FS로부터 GOT에 기록된 OFFSET 값을 정적으로 계산하도록 기계어를 번역한다.

 

3. Local-Dynamic

4. Global-Dynamic

위 두 모델은 동적으로 필요한 라이브러리를 적재하는 과정에서 활용되므로 Static TLS Block의 사용이 불가능하기 때문에 로더에 의해 동적으로 메모리를 할당받는다. 로더는 동적으로 로드되는 모듈들에거 Module_id를 부여하고 해당 메모리 주소를 TCB에 있는 Dynamic Thread Vector(DTV)DTV[module_id]로 관리한다.

 

동적 모듈들은 각 TLS공간을 module_idmodule_offset을 인자로 _tls_get_addr 함수를 호출하여 반환된 주소 값을 통해 접근할 수 있다.

 

 

 

3. TLS 활용 방식

Linux에서 TLS를 활용하는 방식은 두 가지가 존재한다.

 

3-1. posix 라이브러리 활용

Linuxposix 라이브러리에서 제공하는 함수로 직접 TLS를 관리하는 방법이다.

 

-. thread 마다 고유한 moduleid를 만드는 pthread_key_create()

-. thread key마다 데이터 주소를 보관하는 pthread_setspecific()

-. thread 고유의 key로 보관된 데이터를 반환하는 pthread_getspecific()

 

예제는 bench_tls_so.c:47 이하에 있다.

 

 

static pthread_key_t __key;

 

void test_specific()

{

    pthread_setspecific(__key, malloc(sizeof(int)));

    int *local = pthread_getspecific(__key);

}

 

int main()

{

    pthread_key_create(&__key, NULL);

    test_specific();

}

 

 

하나의 정적 pthread_key_t key를 생성하면 이후 이 key를 통해 접근하는 메모리 주소는 모두 TLS를 가르키게 된다.

 

 

3-2. __thread 변수 선언

마찬가지로 Linuxposix 라이브러리에서 제공하지만 확장 기능으로 Linux의 실행파일 포맷 ELF와 컴파일러 그리고 로더의

지원으로 사용할 수 있게 확장된 기능으로 __thread 라는 Prefix를 추가하여 사용할 수 있다.

 

예제는 bench_tls_so.c:5 이하에 있다.

 

static __thread int test3 __attribute__ ((tls_model("initial-exec")));

 

void test_initial_exec()

{

    int *local = &test3;

}

 

 

앞서 설명했듯이 __thread Prefix와 함께 선언되는 TLS4 개의 Model을 선택할 수 있고, modelattribute

지정 가능하다. 지정되지 않을 경우 실행파일은 Local_exec, 공유객체는 General_dynamic이 기본이다.

 

 

3-3. 간단한 성능 측정

먼저 성능 측정을 위해 uftrace라는 도구를 사용하기 때문에 설치한다.

 

$ sudo apt install uftrace

 

이후 다운받은 예제가 위치하는 곳에서 다음의 명령으로 빌드를 한다.

 

$ make bench TRACE=1

 

bench_mainlibbenchtls.so 가 생성된 것을 확인하고 정상적으로 생성됐다면, 다음의 명령을 실행한다.

 

$ export LD_LIBRARY_PATH=`pwd`

$ uftrace ./bench_main

 

위 명령을 실행하면 다음과 같은 결과를 보게 될 것이다.

 

# DURATION     TID     FUNCTION

            [101335] | main() {

 671.054 us [101335] |   test_initial_exec();

   1.061 ms [101335] |   test_specific();

 797.407 us [101335] |   test_local_dynamic();

 780.200 us [101335] |   test_global_dynamic();

   1.045 ms [101335] |   test_specific();

 787.834 us [101335] |   test_local_dynamic();

 815.827 us [101335] |   test_global_dynamic();

 656.801 us [101335] |   test_initial_exec();

 784.145 us [101335] |   test_local_dynamic();

 786.621 us [101335] |   test_global_dynamic();

   1.025 ms [101335] |   test_initial_exec();

   1.140 ms [101335] |   test_specific();

 778.637 us [101335] |   test_global_dynamic();

 657.192 us [101335] |   test_initial_exec();

   1.057 ms [101335] |   test_specific();

 805.243 us [101335] |   test_local_dynamic();

  13.663 ms [101335] | } /* main */

 

몇 번의 테스트를 해보면 initial_exec > local_dynamic = global_dynamic > specific 에 수렴하는 결과를 보여준다. 몇 차례 실행 기록을 도표화 하면 다음과 같다.

 

[Ubuntu 18.04 LTS. glibc-2.28. gcc 8.1.0]

 

 

 

 

4. 결론

-. pthread_getspecific() 의 사용을 지양하고 __thread를 활용하는게 성능상 이점이 있다.

-. 실행파일과 의존성있는 공유객체에서는 initial-exec를 사용하면 성능상 이점이 있다.

 

initial_execdynamic 간의 성능 차이는 전 글에서 다뤘듯, 실행되는 기계어의 수가 차이나기 때문에 당연한 결과다.




Comments