Linux Programmer

C언어:reentrant (재진입성) 함수와 쓰레드안전(MultiThread-safe) 본문

컴퓨터 관련/C언어

C언어:reentrant (재진입성) 함수와 쓰레드안전(MultiThread-safe)

sunyzero 2011. 4. 10. 23:39

* 이 글은 C언어 시스템 프로그램에서 주로 사용되는 reentrant(재진입성) 함수와 멀티쓰레드 안전(Multi-threads-safety)의 차이와 기능에 대해서 알아보는 글입니다. (참고: "멀티쓰레드 안전"은 매뉴얼 표기시 MT-safe, MT-safety, Thread-safe라고 표기합니다.)

우선 책에는 애매모호하게 짧게 적어놔서 문의 메일을 여러 차례 받았습니다. 매번 같은 내용을 설명하는 것도 힘들고, 자세히 설명드리지도 못해 죄송한 마음에 미리 적어두고 해당 링크만 참조시켜드릴 요령으로 작성하게 되었습니다.

이렇게 짧은 내용으로 적어둔 이유를 굳이 변명하자면 "Advanced 리눅스 시스템 네트워크 프로그래밍"책은 Advanced라는 제목이 표현하듯이 중급 프로그래머를 타켓으로 저술되다보니 기초적 설명은 부실한 편입니다. 그래서 반성하는 태도로 아주 자세히 설명하도록 하겠습니다.(이 글에서도 이해가 안되면 댓글이나 메일로 주시기 바랍니다. 물론 둘 다 답변하는 시간은 느릴 수 있습니다. -_-;)

그러면 reentrant에 대해 질문 메일을 던지시는 분들은 크게 2가지를 물어보시는데.

질문1. reentrant와 MT-safety의 정확한 차이는 무엇인가?
질문2. 기존의 라이브러리나 새로 작성하는 코드를 reentrant코드로 변경하거나 MT-safety로 작성하려면 어떻게 해야 하는가?

이 2가지의 질문에 대한 답은 간단하지만 여기서도 불친절하게 몇 줄로 끝내버리면 저녁에 밤길을 조심해야 할 상황이 생길지도 모르니 각종 예제와 그림으로 자세한 설명을 하도록 하겠습니다.

답변1. reentrant와 MT-safety의 가장 큰 차이는 코드가 어떠한 경우에도 병렬 실행을 보장할 수 있느냐의 여부입니다.

Reentrant function : "A function whose effect, when called by two or more threads, is guaranteed to be as if the threads each executed the function one after another in an undefined order, even if the actual execution is interleaved." - Single UNIX Specification version 3

SUSv3 표준안(http://pubs.opengroup.org/onlinepubs/009695399/)에 의하면 "reentrant 함수는 둘 이상의 쓰레드에 의해서 호출될 경우에 순서에 구애받지 않고, 서로 동일한 코드가 겹쳐서 실행되어도 작동이 보장되어야 함"을 말하고 있습니다.

이를 다른 말로 풀어서 설명하면 reentrant function은 재귀호출을 포함한 병렬 실행을 완벽히 보장하는 코드를 의미합니다. 즉 쓰레드나 시그널 핸들러에서 마음껏 사용해도 괜찮으며, 심지어 재귀 호출코드에 들어 넣어도 안전하다는 뜻입니다.

그러면 비교대상인 Thread-safe는 어떻게 쓰여 있을까요? SUSv3 (POSIX.1-2001)에서 찾아보면 아래와 같이 적어두고 있습니다. - 참고: 표준안에서는 Thread-safe한 함수를 TSF(Thread-Safe-Function)이라고도 부릅니다.

Thread-safe (Thread-safety) : "A function that may be safely invoked concurrently by multiple threads. Each function defined in the System Interfaces volume of IEEE Std 1003.1-2001 is thread-safe unless explicitly stated otherwise. Examples are any "pure" function, a function which holds a mutex locked while it is accessing static storage, or objects shared among threads."

Thread-safe도 복수의 쓰레드에서 호출 될 수 있고 병렬로 실행 될 수도 있습니다. 다만 완벽한 병렬을 보장하지는 않고 정적 공간(static storage = 전역변수, BSS 메모리 등)이나 공유 객체(heap과 같은 메모리 객체)가 있다면 MUTEX와 같은 매커니즘으로 보호해야만 합니다. 그리고 뮤텍스 락으로 보호되는 서브 루틴은 직렬로 실행됩니다.

따라서 only 쓰레드 안전을 만족하는 코드라면, 정적공간이나 공유 객체를 사용하면 진입순서에 따라 실행 결과가 달라질 수 있습니다.

그렇다면 모든 reentrant 함수는 thread-safe를 만족한다고 말할 수 있을 겁니다. 하지만 그 역은 성립하지 않습니다. 그리고 눈치가 빠른 분들은 reentrant 함수는 static storage를 사용하지 않아야 한다는 것을 알 수 있을 것입니다.

이제 여러분이 어떤 함수를 만들었는데 병렬 실행이 가능하고 static storage를 사용하지 않는 구조라면 reentrant 함수라고 말할 수 있고, 병렬 실행이 가능하지만 서브 루틴에서 static storage나 shared object를 사용하며 lock으로 보호해두었다면 Thread-safe만 만족하는 함수라고 말할 수 있다는 것입니다.


답변2. 기존의 라이브러리나 새로 작성하는 코드를 reentrant코드로 변경하거나 MT-safety로 작성하려면 어떻게 해야 하는가?

이 부분을 설명하기 위해 예제코드를 하나 보겠습니다. 이 예제에는 sum_strnum이라는 함수가 등장합니다. sum_strnum은 문자열로 된 숫자 2개를 받아서 덧셈한 뒤에 다시 문자열로 리턴하는 함수입니다.

(여기에 쓰이는 코드는 예제로서 복잡한 에러처리는 하지 않도록 하겠습니다.)

 
#include <stdio.h>
#include <stdlib.h>

char *sum_strnum(const char *s1, const char *s2)
{
    static char buf_sum[16];
    snprintf(buf_sum, sizeof(buf_sum), "%d", atoi(s1) + atoi(s2));
    return buf_sum;
}

int main()
{
    char *p_str;

    p_str = sum_strnum("1", "3");
    printf("1 + 3 = %s\n", p_str);
    p_str = sum_strnum("4", "4");
    printf("4 + 4 = %s\n", p_str);
    p_str = sum_strnum("1", "5");
    printf("1 + 5 = %s\n", p_str);
    return EXIT_SUCCESS;
}

- 예제 파일 1 : sum_strnum.c
sum_strnum.c를 컴파일하여 실행하면 다음과 같은 결과가 나옵니다.

[sunyzero@localhost work]$ make sum_strnum
cc -Wall sum_strnum.c -o sum_strnum
[sunyzero@localhost work]$ ./sum_strnum
1 + 3 = 4
4 + 4 = 8
1 + 5 = 6
[sunyzero@localhost work]$

이제 코드에 대한 이해는 끝났지요? sum_strnum.c 코드는 순차적으로 sum_strnum()함수를 3번 실행합니다. sum_strnum 함수는 리턴값을 BSS(Block Started by Symbol) 메모리에 있는 static변수에 저장하여 리턴합니다. 


옛날 유닉스에서부터 내려온 표준 함수중에는 이처럼 static 변수를 리턴하는 형태가 꽤 많은데, 이는 내부적으로 한번 쓰는 버퍼를 BSS영역에 할당하면 메모리 할당/해제를 하지 않아 편하다는 이유가 있었습니다.(실제로 gethostbyname()같은 함수도 이런 구조로 되어있습니다.)

자 그러면 이해를 돕기 위해, 위 코드의 실행과정을 그림으로 표현해보도록 하겠습니다.

그림으로 보면 이해가 쉽죠?

그런데 위의 sum_strnum 코드가 병렬 실행되면 어떻게 될까요? 당연히 문제가 생기겠죠? 왜냐하면 BSS영역의 메모리는 정적으로 프로세스에 1개만 존재하기 때문에, 병렬로 실행되면 마지막으로 실행된 코드가 메모리를 덮어쓰게 됩니다. 따라서 원치 않는 데이터가 출력되거나 프로그램이 중단될 수 있습니다. 

이런 현상을 관찰하기 위해 POSIX thread를 이용해서 해당 함수가 병렬로 실행되도록 변경해봤습니다. (참고:이 예제 쓰레드 프로그램은 복잡한 에러처리는 하지 않았습니다.) sum_strnum이 서로 겹쳐서 실행될 수 있도록 고의적으로 sleep, usleep을 이용해 함수의 리턴을 지연시켰습니다. 만일 이렇게 강제로 지연시키지 않으면, CPU가 워낙 빠르게 처리하기 때문에 자연적으로 병렬 처리되는 상황을 만들기가 어렵기 때문입니다.


#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> char *sum_strnum(const char *s1, const char *s2) { static char buf_sum[16]; /* BSS memory */ snprintf(buf_sum, sizeof(buf_sum), "%d", atoi(s1) + atoi(s2)); sleep(1); return buf_sum; } pthread_t t_id; /* thread ID */ void *start_tfunc(void *arg) { char *p_str; pthread_detach(pthread_self()); sleep(1); p_str = sum_strnum("4", "4"); printf("4 + 4 = %s\n", p_str); return NULL; } int main() { char *p_str; if (pthread_create(&t_id, NULL, start_tfunc, NULL) != 0) { exit(1); } p_str = sum_strnum("1", "3"); usleep(500000); /* sleep 0.5sec. */ printf("1 + 3 = %s\n", p_str); /* p_str = 8 */ p_str = sum_strnum("1", "5"); printf("1 + 5 = %s\n", p_str); return EXIT_SUCCESS; }

- 예제 파일 2 : sum_strnum_thread.c 

소스코드를 실행하기 전에 이해를 돕기 위해 time flow를 도식으로 그려보도록 하겠습니다.
이 예제에서는 sleep을 이용해 억지로 지연을 주기 때문에 쓰레드가 죽지 않고 실행되지만, 실제 환경에서 메모리 쓰기가 겹칠 경우에는 프로그램이 비정상 종료를 할 가능성도 있으니 주의하시기 바랍니다.


편의상 main thread가 생성하는 쓰레드를 thread 1번이라고 하겠습니다.

main thread는 실행하자마자 즉시 thread 1을 생성합니다. 그리고 thread 1은 1초를 쉬었다가 sum_strnum("4", "4")를 호출합니다. 1초의 지연을 주는 이유는 main thread의 sum_strnum("1", "3")이 먼저 실행되도록 하기 위함 입니다.
그러고나서 main thread는 0.5초를 쉬고 이전에 실행했던 1+3의 결과를 출력해봅니다. 하지만 이 때는 이미 thread 1이 static 변수인 buf_sum의 값을 4+4의 결과로 덮어쓰기 했을 겁니다. 그래서 원하는 결과인 4가 출력되지 않고 8이 출력됩니다. 진짜로 예상과 같은지 실행해 보겠습니다.

[sunyzero@localhost work]$ gcc -Wall -o sum_strnum_thread -lpthread sum_strnum_thread.c
[sunyzero@localhost work]$ ./sum_strnum_thread
1 + 3 = 8
4 + 4 = 8
1 + 5 = 6
[sunyzero@localhost work]$

그러면 이런 문제를 해결하려면 어떻게 할까요? 앞서 언급했듯이 reentrant함수로 바꾸던가 아니면 lock을 도입하여 직렬 실행하는 코드로 바꾸면 됩니다. 그러나 lock을 쓰면 문제해결은 간단해지지만 위와 같은 구조에서는 호출 시점과 사용되는 시점까지 모두 보호하거나 리턴하기 전에 사용자 개별 버퍼로 복사해주는 구조로 만들어야 합니다. 결국 lock을 이용하면 위와 같은 구조는 심각한 성능문제를 유발하게 됩니다. 따라서 여기서는 reentrant 함수로 바꾸는 방법을 실행해보겠습니다.

우선 reentrant 함수로 바꾸기 위한 규칙부터 정리하겠습니다.

규칙1. 전역 공간이나 static 영역, 공유 객체는 사용하지 않는다.
규칙2. 리턴값은 int 타입을 사용하고, 성공시 0, 실패시 -1을 리턴한다. (강제성은 없습니다만 관습적으로 이렇게 디자인한다.)
         원래 함수의 리턴값은 인수 리스트의 포인터 변수 형태로 대체한다.
규칙3. 함수이름의 맨 뒤에 _r 을 붙여서 만든다.(이것도 규제는 아니지만 관습적으로 이렇게 디자인합니다.)

이 세가지 규칙에 맞춰서 앞의 sum_strnum()함수를 변경해보도록 하겠습니다.


위 그림에서 볼 수 있듯이, 기존의 static char buf_sum[16]부분은 더이상 사용하지 않게 됩니다. 그러나 return 값에서 static 변수의 주소를 리턴했기 때문에 이를 외부 인수 리스트 공간으로 빼려면 return 타입인 char *가 출력 가능한 포인터 타입, 즉 반환 주소가 되어야 합니다. 

풀어서 말하면 char * 형의 리턴 주소를 매개변수 리스트로 전달하려면 char ** 로 변해야 한다는 것이죠. 예를 들어 int를 인수 리스트 영역에서 리턴받는다고 생각해보세요. myfunc(int *result, ...) 방식으로 선언하지요? 마찬가지입니다. char *를 인수에서 참조 리턴받으려면 char **이 되어야 합니다.

왜 더블 포인터 형인 char **로 들어가야 하는지에 대해서 더 자세히 말씀드리면, sum_strnum_r(char *buf_sum, size_t sz_buf, char *s1, char *s2)로 사용하는 경우에 복잡한 코드가 들어있어서 내부적으로 메모리 연산을 하다가 buf_sum 값에 NULL을 넣는 코드를 사용하면 리턴되는 원래 주소를 잃어버리기 때문에 코드 자체에 문제가 발생합니다. 그래서 return값이 인수 리스트로 들어가게 되면 주소 연산(*)이 붙게 되는 것입니다.

간혹 "더블포인터==2차원 배열"이라고 기계적으로 알고 있으신 분들이 많은데, 원래 더블 포인터는 2차원 배열과는 관계가 없습니다. 그냥 주소의 주소를 나타내는 값일 뿐입니다. 다만 2차원 배열을 구현할 때 더블포인터를 사용해서 구현할 수도 있다는 것 뿐이죠. 만일 기계적으로 더블포인터가 2차원 배열이라고 외웠거나 잘못 배우신 분들은 여기서 더블 포인터에 대한 개념을 다시 정립하고 넘어가시기 바랍니다.

자 그러면 전체 소스코드도 같이 첨부해서 보도록 하겠습니다.

#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #define SZ_BUF_SUM 16 int sum_strnum_r(char ** restrict buf_sum, size_t sz_buf, const char * restrict s1, const char * restrict s2) { snprintf(*buf_sum, sz_buf, "%d", atoi(s1) + atoi(s2)); sleep(1); return 0; } pthread_t t_id; /* thread ID */ void *start_tfunc(void *arg) { char *p_str; if ( (p_str = calloc(SZ_BUF_SUM, sizeof(char))) == NULL) { exit(1); } pthread_detach(pthread_self()); sleep(1); if (sum_strnum_r(&p_str, SZ_BUF_SUM, "4", "4") == -1) { /* error */ } printf("4 + 4 = %s\n", p_str); free(p_str); return NULL; } int main() { char *p_str; if ( (p_str = calloc(SZ_BUF_SUM, sizeof(char))) == NULL) { exit(1); } if (pthread_create(&t_id, NULL, start_tfunc, NULL) != 0) { exit(1); } if (sum_strnum_r(&p_str, SZ_BUF_SUM, "1", "3") == -1) { /* error */ } usleep(500000); /* sleep 0.5sec. */ printf("1 + 3 = %s\n", p_str); /* p_str = 4 */ if (sum_strnum_r(&p_str, SZ_BUF_SUM, "1", "5") == -1) { /* error */ } printf("1 + 5 = %s\n", p_str); return EXIT_SUCCESS; }

- 예제 파일 3 : sum_strnum_reent.c 

컴파일하고 실행해보면 정상적으로 4, 8, 6이 나오는 것을 볼 수 있을 겁니다. 참고로 위 예제에서는 코드를 줄이기 위해 main함수에서 p_str을 재사용하고 있지만 재진입되는 변수들은 되도록이면 restrict로 선언하여 재사용되거나 에일리어싱 되지 않도록 하는 것이 일반적입니다. 여러분이 실습하실 때는 이 부분을 염두에 두고 작성하시면 좋겠습니다.



마지막으로 왜 reentrant를 잘 알아두어야 하는가를 설명하겠습니다.

최근 5~6년전 부터는 대부분의 CPU가 멀티코어로 나오고 있지요? 그러면 왜 멀티코어가 나오게 되었을까요?

반도체 기술은 날로 발전하여 좀 더 작게 만들수 있게 되었습니다. 하지만 단일 성능을 올리는 클럭수는 4~5GHz에서 정체되고 있습니다. 이는 현재 사용하는 실리콘 소자 반도체는 이 이상 올리면 엄청난 에너지 손실이 생기기 때문입니다. 따라서 반도체 기술 발전으로 인해 남는 공간을 병렬처리를 위해 복수개의 CPU 코어를 만드는 방향으로 선회하게 됩니다.


그래서 최근의 데스크탑 PC는 Dual-core나 Quad-core를 사용하는 경우가 많고 앞으로는 Octet-core도 나올것입니다. 즉 개별 CPU의 성능 향상보다는 멀티 코어를 통한 병렬 처리로 방향을 바꾼 것이죠. 따라서 소프트웨어도 멀티 코어 하드웨어를 최대한 사용하는 방향으로 가야 합니다. (하지만 멀티 프로세스로 원하는 성능을 충족한다면 굳이 어려운 멀티 쓰레드는 지양하는 것도 좋습니다.)


그리고 병렬 처리를 위해서 사용해야 하는 프로그래밍 기술 중에 가장 만만한 것은 바로 쓰레드 프로그래밍이 됩니다. 그러나 쓰레드에서는 reentrant 함수나 쓰레드 안전을 만족하는 함수만 사용해야 합니다. 물론 성능을 생각한다면 reentrant 함수쪽이 더 낫기 때문에 서브 루틴도 재진입성을 염두에 두고 개발하는 것이 좋습니다.



주석1. 물론 reentrant나 MT-safety는 실행후 결과값이 정확하게 나와야 합니다. 

만일 병렬실행이 가능하지만 결과값은 개판이다. 이러면 제대로된 코드라고 볼 수 없습니다. 프로그램을 설마 이렇게 이해하는 분은 안계시겠지요? 따라서 멀티쓰레드에서 해당 함수는 절대 죽지 않도록 설계되었지만 결과값은 엉망이 나올 수도 있다면, MT-safety라는 말을 붙이기 이전에 제대로 된 코드라고 볼 수 없습니다. 애초에 함수 결과값을 보장하지 않고 만든다면 한마디로 정신나간 프로그래머입니다.

긴 글이 도움이 되기를 바라면서... 이 글은 저작자와 출처만 표시하신다면 마음대로 복사, 발췌하셔도 괜찮습니다. 

(굳이 몰라서 출처 표시 안해도 고소하지 않으니 안심하시길...^^)

다만 제 글을 퍼가신다면 꼭 다른 사람도 쉽게 발췌하거나 복사할 수 있도록 해주시기 바랍니다. 오른쪽 마우스 금지, 스크랩 금지 혹은 드래그 금지를 해두는 것은 좋지 않다고 생각합니다. 다른 사람에게 지식을 나눠준다고 해서 본인의 지식이 줄어들지 않으니, 남에게 지식을 주는 것에 인색하지 않았으면 좋겠군요.


길 잃고 방황하는 자에게

친절하게 길을 가르쳐 주는 사람은

마치 자신의 등불로 다른 사람의 등에

불을 붙여 주는 것과 같도다.

그런데 남에게 불을 붙여 주었다고 해서

자신의 불빛이 덜 빛나는 것은 아니니라. - 키케로

 

* 용어정리
MT-safe, MT-safety : 멀티 쓰레드 안전
TSF : Thread-Safe-Function (MT-safe 함수)
SUS : Single UNIX Specification (단일 유닉스 표준) - http://www.opengroup.org 에서 보실 수 있습니다.
XPG : X/Open Portability Guide (X/Open 표준 가이드라인: SUS로 대체됨)

* 히스토리
2011.04.13 namhyung님의 지적 반영함 (쓰레드 안전은 병렬 실행을 못하는 것 같은 늬앙스를 수정)
2011.04.11 egloos블로그에 있는 내용을 옮겨옴(문장의 내용을 약간 다듬음)
2009.07.24 reentrant 설명에 SUSv3 내용 첨부
2009.07.19 예제에 vim html 형식 사용 (TOhtml 기능 사용)
2009.07.17 처음 글 씀



반응형
Comments