* 이 글은 C언어 시스템 프로그램에서 주로 사용되는 reentrant(재진입성) 함수와 멀티쓰레드 안전(Multi-threads-safety)의 차이와 기능에 대해서 알아보는 글입니다. (참고: "멀티쓰레드 안전"은 매뉴얼 표기시 MT-safe, MT-safety, Thread-safe라고 표기합니다.)
우선 책에 애매모호하게 짧게 나와서 이를 종종 질문해오시는 분들이 많아서 이렇게 긴글을 쓰게 되었습니다. 매번 설명하는것도 힘들고 해서 다음부터는 해당 링크만 참조시켜드릴 요령으로 작성하게 되었습니다. 그럼 우선 논란이 된 "Advanced 리눅스 시스템 프로그래밍"책의 해당 내용을 적어보도록 하겠습니다.
* 원자성과 쓰레드 - 안전, 재진입성에 대해서
원자성(atomicity)이 보장되는 코드(or 함수)는 일단 해당 코드 부분이 시작하면, 종료되기 전에는 다른 코드 실행부(자기 자신 포함)가 끼어들지 못한다는 것입니다. 일반적인 동기적 프로그래밍은 순차적으로 모든 함수가 진행되므로 한 개의 함수가 다른 함수에 의해서 인터럽트 되는 경우는 발생할 수 없지만, 비동기적 요소인 쓰레드나 시그널이 도입되는 경우에는 그렇지 않으므로 다른 함수에 의해서 인터럽트 되는 것에 대해서 생각해야 합니다.
이와 비슷하게 쓰레드-안전(thread-safety)이나 재진입성(reentrant)도 중요한 요소입니다. 이는 뒷부분의 쓰레드 부분에서 다시 다루게 됩니다. 다만 여기서 간단하게 언급하면 쓰레드-안전은 쓰레드 간에 서로 동시에 함수를 호출해도 문제가 발생하지 않도록 디자인되었다는 것이며(이것은 함수와 라이브러리나 프로그램 부분에서 모두 적용 가능한 범위를 가지게 됩니다.), 재진입성은 동일한 함수가 병렬적으로 호출되었을 때 서로 다른 공간을 가지므로 서로 크리티컬 섹션 문제와 같은 오염에 대한 걱정을 할 필요가 없는 경우의 환경으로 디자인 된 것을 의미합니다.
의미상으로 보면 둘은 같은 의미로도 사용됩니다. 허나 엄밀하게 말하면 재진입성은 쓰레드 뿐 아니라 각종 비동기적인 환경(시그널과 같은)에서도 정상 작동하는 것을 의미하므로 조금 더 포괄적이라고 할 수 있습니다.
- "Advanced 리눅스 시스템 네트워크 프로그래밍" (가메출판사, 2006) p.52 발췌
이렇게 짧은 내용으로 적어둔 이유를 굳이 변명하자면 "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 표준안에 의하면 "reentrant 함수는 둘 이상의 쓰레드에 의해서 호출될 경우에 순서에 구애받지 않고, 서로 동일한 코드가 겹쳐서 실행되어도 작동이 보장되어야 함"을 말하고 있습니다.
이를 다른 말로 설명하면 reentrant 함수는 재귀호출을 포함한 병렬실행을 보장하는 코드를 의미합니다. 즉 쓰레드에서도 마음껏 사용해서 병렬적으로 실행되도 작동되고, 자신이 재귀호출되어도 안전하다는 뜻입니다.
그러면 비교대상인 Thread-safe는 어떻게 쓰여 있을까요? 이를 찾아보면 SUSv3에서는 이렇게 적어두고 있습니다. - 참고: 표준안에서는 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과 같은 메모리 객체)가 뮤텍스 락과 같은 매커니즘으로 보호되어야 함을 의미합니다. 정적공간(static storage)을 사용해도 단지 lock으로 보호해주면 thread-safe조건은 만족한다는 것입니다. 여기서 lock을 사용한다는 것은 병렬이 아닌 직렬 실행 구간을 의미하므로, 진입순서에 따라서 실행결과가 달라질 수 있는 것이 큰 차이입니다. 따라서 Thread-safe는 쓰레드에서 죽지 않고 실행되는 것을 의미하고, 병렬로 순서가 섞여서 실행되는 것은 만족하지 않아도 된다는 것을 말합니다.
그렇다면 모든 reentrant 함수는 thread-safe하다고 말할 수 있게 되었습니다. 하지만 그 역은 성립하지 않습니다. 그리고 가장 중요한 것으로 reentrant 함수는 static storage를 사용하지 않음으로서 그 기능을 만들 수 있다는 것을 눈치가 빠른 분들은 꿰고 있을겁니다.
이제 여러분이 어떤 함수를 만들었는데 병렬실행가능하고 static storage를 사용하지 않는다면 reentrant 함수라고 말할 수 있고, static storage를 사용하는데 lock으로 보호해두었다면 reentrant가 아니라 Thread-safe한 함수라고 말할 수 있다는 것입니다.(주석1)
간혹 옛날에는 "reentrant == MT-safety"였다고 말씀하시는 80년대 학번의 분들이 있는데, 맞습니다. 과거에는 병렬코드의 실행여부를 크리티컬하게 따지지 않았기 때문에 둘의 의미가 같았던 시절이 있었습니다. 실제로 Thread가 표준화 된 것이 1995년도의 IEEE std 1003.1c-1995이므로 1995년도 이전에는 둘을 뚜렷하게 구분하지 않았습니다. 그래서 심지어 과거 몇몇 유닉스 시스템에서는 "#define _REENTRANT"가 "#define _THREAD_SAFE"하고 같은 의미로 사용되기도 했습니다. 하지만 이제는 둘을 구분하고 있으니 그 차이에 대해서 숙지하고 있어야 합니다.(현재 _THREAD_SAFE 선언은 사용하지 않기를 권장하고 있습니다.)
* 참고: reentrant 함수의 선언
reentrant 함수의 프로토타입을 검사하도록 컴파일러에게 지시하려면 "#define _REENTRANT"를 선언해주어야 합니다. 그래야만 문법 검사가 제대로 이뤄져서 implicit function warning 메시지를 제거할 수 있습니다.
그러면 이제 함수를 reentrant 코드로 만드는 것과 MT-safety로 만드는 방법을 알아보겠습니다.
답변2. 기존의 라이브러리나 새로 작성하는 코드를 reentrant코드로 변경하거나 MT-safety로 작성하려면 어떻게 해야 하는가?
이 부분을 설명하기 전에 우선 간단한 예제코드를 하나 소개하도록 하겠습니다. 이 예제는 sum_strnum()이라는 함수가 등장합니다. sum_strnum()은 문자열로 된 숫자를 2개를 받아서 덧셈한 뒤에 다시 문자열로 리턴하는 함수입니다.
(여기에 쓰이는 코드는 예제로서 복잡한 에러처리는 하지 않도록 하겠습니다.)
- 예제 파일 1 : sum_strnum.c
sum_strnum.c를 컴파일하여 실행하면 다음과 같은 결과가 나옵니다.
이제 코드에 대한 이해는 끝났지요? 이 코드는 순차적으로 sum_strnum()함수를 3번 실행하고 리턴값은 BSS(Block Started by Symbol) 메모리에 있는 static변수에 저장해서 리턴합니다. 과거의 함수들은 내부적으로 한번 쓰는 버퍼를 BSS영역에 할당하여 복잡한 메모리 할당/해제를 하지 않는 경우가 많았었습니다.(실제로 gethostbyname()같은 함수가 이런 구조로 되어있습니다.) 자 그러면 이 코드의 실행과정을 그림으로 표현해보도록 하겠습니다.
그런데 이런 BSS 영역의 메모리를 함수에서 사용하면 병렬 실행시 심각한 문제를 발생합니다. 이는 병렬 실행시 해당 함수가 한 개 밖에 없는 BSS영역의 메모리를 겹쳐서 사용하기 때문에 병렬로 실행된 함수중에 하나가 마지막으로 메모리를 덮어 쓰게 되면 원치 않는 데이터가 출력될 수 있습니다. 이 현상을 관찰하기 위해 POSIX thread를 이용해서 해당 함수가 병렬로 실행되도록 변경했습니다. (참고:이 예제 쓰레드 프로그램은 복잡한 에러처리는 하지 않았습니다.)
그러나 이런 현상은 해당 함수를 수없이 병렬로 호출하면 랜덤하게 발생하므로 여기서는 고의적으로 발생시키기 위해 시간차를 주는 코드를 넣어두었씁니다. 그래서 sleep, usleep을 이용해서 함수내에 지연시간을 두었고, sum_strnum()함수에도 sleep 1초를 넣어 두었습니다. 자 그러면 소스코드 나갑니다.
- 예제 파일 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이 출력됩니다. 진짜로 실행해보도록 하겠습니다.
그러면 이를 제대로 작동하도록 하려면 어떻게 해야 할까요? 앞서 언급했듯이 reentrant함수로 바꾸던가 아니면 lock을 도입하여 직렬실행하는 코드로 바꾸는 방법을 사용하면 됩니다. 하지만 lock을 쓰는 것은 쉽지만 그 만큼 성능을 떨어뜨리게 됩니다. 따라서 여기서는 reentrant 함수로 바꾸는 방법을 실행해보겠습니다.
우선 reentrant 함수로 바꾸기 위한 규칙부터 정리하겠습니다.
규칙1. data 저장 부분은 개별 공간을 사용하도록 한다.(i.e. 전역 공간이나 static 영역은 사용하지 않는다는 의미)
규칙2. return 값의 타입은 int로 만들고 성공시 0, 실패시 -1을 리턴하도록 한다.(규제는 아니지만 관습적으로 이렇게 디자인한다.)
규칙3. 함수이름의 맨 뒤에 _r 을 붙여서 만든다.(이것도 규제는 아니지만 관습적으로 이렇게 디자인합니다.)
이 세가지 규칙에 맞춰서 앞의 sum_strnum()함수를 변경해보도록 하겠습니다.
그림에서 볼 수 있듯이 기존의 static char buf_sum[16]부분은 더이상 사용하지 말아야 합니다. 그러나 return 값에서 static 변수의 주소를 리턴했기 때문에 이를 외부 데이터 공간으로 빼려면 return 타입인 char * 가 함수 인수(argument) 리스트로 들어가야 합니다. 그런데 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차원 배열을 구현할 때 더블포인터를 써서 구현할 수도 있다는 것 뿐입니다. 잘못 배우신 분들은 여기서 더블 포인터에 대한 개념을 다시 정립하고 넘어가시기 바랍니다.)
자 그러면 전체 소스코드도 같이 첨부해서 보도록 하겠습니다.
- 예제 파일 3 : sum_strnum_reent.c
컴파일하고 실행해보면 정상적으로 4, 8, 6이 나오는 것을 볼 수 있을 겁니다.
마지막으로 왜 reentrant 를 잘 알아두어야 하는가를 설명하겠습니다.
최근 5~6년 부터는 대부분의 CPU가 멀티코어로 나오고 있지요? 이는 실리콘 소자로는 CPU 클럭 주파수를 4~5GHz이상 올리지 못하기 때문에 클럭 주파수를 올리는 성능개선보다는 병렬 처리를 중점으로 두는 방향으로 선회하기 때문입니다.(실제로 5GHz까지 올라가면 엄청난 열이 발생하고, 50%가 넘는 전력이 누수됩니다.) 실제로 최근의 데스크탑 PC는 dual이나 quad-core를 사용하는 경우가 많고 앞으로는 octet-core도 나올지 모르니 병렬처리는 성능향상에 필수요소가 될 것입니다.
그런데 멀티코어를 제대로 사용하려면 쓰레드를 이용한 병렬 처리를 해야 하는데 reentrant 함수가 아닌 경우에는 제대로 작동하지 않기 때문에 앞으로는 필히 reentrant 코드를 사용하는 것이 좋습니다.(강제 사항은 아닙니다. 다만 권고사항일 뿐이지요. ^^)
주석1. 물론 reentrant나 MT-safety는 실행후 결과값이 정확하게 나와야 합니다. 만일 병렬실행이 가능하지만 결과값은 개판이다. 이러면 제대로된 코드라고 볼 수 없습니다. 프로그램을 설마 이렇게 이해하는 분은 안계시겠지요? 따라서 멀티쓰레드에서 해당 함수는 절대 죽지 않도록 설계되었지만 결과값은 엉망이 나올 수도 있다면, MT-safety라는 말을 붙이기 이전에 제대로 된 코드라고 볼 수 없습니다. 즉 애초에 함수 결과값을 보장하지 않고 만든다면 한마디로 정신나간 프로그래머입니다.
긴 글이 도움이 되기를 바라면서... 이 글은 저작자와 출처만 표시하신다면 마음대로 복사, 발췌하셔도 괜찮습니다. (굳이 몰라서 출처 표시 안해도 고소하지 않으니 안심하시길...^^)
2009.07.24 reentrant 설명에 SUSv3 내용 첨부
2009.07.19 예제에 vim html 형식 사용 (TOhtml 기능 사용)
2009.07.17 처음 글 씀
우선 책에 애매모호하게 짧게 나와서 이를 종종 질문해오시는 분들이 많아서 이렇게 긴글을 쓰게 되었습니다. 매번 설명하는것도 힘들고 해서 다음부터는 해당 링크만 참조시켜드릴 요령으로 작성하게 되었습니다. 그럼 우선 논란이 된 "Advanced 리눅스 시스템 프로그래밍"책의 해당 내용을 적어보도록 하겠습니다.
* 원자성과 쓰레드 - 안전, 재진입성에 대해서
원자성(atomicity)이 보장되는 코드(or 함수)는 일단 해당 코드 부분이 시작하면, 종료되기 전에는 다른 코드 실행부(자기 자신 포함)가 끼어들지 못한다는 것입니다. 일반적인 동기적 프로그래밍은 순차적으로 모든 함수가 진행되므로 한 개의 함수가 다른 함수에 의해서 인터럽트 되는 경우는 발생할 수 없지만, 비동기적 요소인 쓰레드나 시그널이 도입되는 경우에는 그렇지 않으므로 다른 함수에 의해서 인터럽트 되는 것에 대해서 생각해야 합니다.
이와 비슷하게 쓰레드-안전(thread-safety)이나 재진입성(reentrant)도 중요한 요소입니다. 이는 뒷부분의 쓰레드 부분에서 다시 다루게 됩니다. 다만 여기서 간단하게 언급하면 쓰레드-안전은 쓰레드 간에 서로 동시에 함수를 호출해도 문제가 발생하지 않도록 디자인되었다는 것이며(이것은 함수와 라이브러리나 프로그램 부분에서 모두 적용 가능한 범위를 가지게 됩니다.), 재진입성은 동일한 함수가 병렬적으로 호출되었을 때 서로 다른 공간을 가지므로 서로 크리티컬 섹션 문제와 같은 오염에 대한 걱정을 할 필요가 없는 경우의 환경으로 디자인 된 것을 의미합니다.
의미상으로 보면 둘은 같은 의미로도 사용됩니다. 허나 엄밀하게 말하면 재진입성은 쓰레드 뿐 아니라 각종 비동기적인 환경(시그널과 같은)에서도 정상 작동하는 것을 의미하므로 조금 더 포괄적이라고 할 수 있습니다.
- "Advanced 리눅스 시스템 네트워크 프로그래밍" (가메출판사, 2006) p.52 발췌
이렇게 짧은 내용으로 적어둔 이유를 굳이 변명하자면 "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 표준안에 의하면 "reentrant 함수는 둘 이상의 쓰레드에 의해서 호출될 경우에 순서에 구애받지 않고, 서로 동일한 코드가 겹쳐서 실행되어도 작동이 보장되어야 함"을 말하고 있습니다.
이를 다른 말로 설명하면 reentrant 함수는 재귀호출을 포함한 병렬실행을 보장하는 코드를 의미합니다. 즉 쓰레드에서도 마음껏 사용해서 병렬적으로 실행되도 작동되고, 자신이 재귀호출되어도 안전하다는 뜻입니다.
그러면 비교대상인 Thread-safe는 어떻게 쓰여 있을까요? 이를 찾아보면 SUSv3에서는 이렇게 적어두고 있습니다. - 참고: 표준안에서는 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과 같은 메모리 객체)가 뮤텍스 락과 같은 매커니즘으로 보호되어야 함을 의미합니다. 정적공간(static storage)을 사용해도 단지 lock으로 보호해주면 thread-safe조건은 만족한다는 것입니다. 여기서 lock을 사용한다는 것은 병렬이 아닌 직렬 실행 구간을 의미하므로, 진입순서에 따라서 실행결과가 달라질 수 있는 것이 큰 차이입니다. 따라서 Thread-safe는 쓰레드에서 죽지 않고 실행되는 것을 의미하고, 병렬로 순서가 섞여서 실행되는 것은 만족하지 않아도 된다는 것을 말합니다.
그렇다면 모든 reentrant 함수는 thread-safe하다고 말할 수 있게 되었습니다. 하지만 그 역은 성립하지 않습니다. 그리고 가장 중요한 것으로 reentrant 함수는 static storage를 사용하지 않음으로서 그 기능을 만들 수 있다는 것을 눈치가 빠른 분들은 꿰고 있을겁니다.
이제 여러분이 어떤 함수를 만들었는데 병렬실행가능하고 static storage를 사용하지 않는다면 reentrant 함수라고 말할 수 있고, static storage를 사용하는데 lock으로 보호해두었다면 reentrant가 아니라 Thread-safe한 함수라고 말할 수 있다는 것입니다.(주석1)
간혹 옛날에는 "reentrant == MT-safety"였다고 말씀하시는 80년대 학번의 분들이 있는데, 맞습니다. 과거에는 병렬코드의 실행여부를 크리티컬하게 따지지 않았기 때문에 둘의 의미가 같았던 시절이 있었습니다. 실제로 Thread가 표준화 된 것이 1995년도의 IEEE std 1003.1c-1995이므로 1995년도 이전에는 둘을 뚜렷하게 구분하지 않았습니다. 그래서 심지어 과거 몇몇 유닉스 시스템에서는 "#define _REENTRANT"가 "#define _THREAD_SAFE"하고 같은 의미로 사용되기도 했습니다. 하지만 이제는 둘을 구분하고 있으니 그 차이에 대해서 숙지하고 있어야 합니다.(현재 _THREAD_SAFE 선언은 사용하지 않기를 권장하고 있습니다.)
* 참고: reentrant 함수의 선언
reentrant 함수의 프로토타입을 검사하도록 컴파일러에게 지시하려면 "#define _REENTRANT"를 선언해주어야 합니다. 그래야만 문법 검사가 제대로 이뤄져서 implicit function warning 메시지를 제거할 수 있습니다.
그러면 이제 함수를 reentrant 코드로 만드는 것과 MT-safety로 만드는 방법을 알아보겠습니다.
답변2. 기존의 라이브러리나 새로 작성하는 코드를 reentrant코드로 변경하거나 MT-safety로 작성하려면 어떻게 해야 하는가?
이 부분을 설명하기 전에 우선 간단한 예제코드를 하나 소개하도록 하겠습니다. 이 예제는 sum_strnum()이라는 함수가 등장합니다. sum_strnum()은 문자열로 된 숫자를 2개를 받아서 덧셈한 뒤에 다시 문자열로 리턴하는 함수입니다.
(여기에 쓰이는 코드는 예제로서 복잡한 에러처리는 하지 않도록 하겠습니다.)
#include <stdio.h> #include <stdlib.h> char *sum_strnum(char *s1, 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()함수를 3번 실행하고 리턴값은 BSS(Block Started by Symbol) 메모리에 있는 static변수에 저장해서 리턴합니다. 과거의 함수들은 내부적으로 한번 쓰는 버퍼를 BSS영역에 할당하여 복잡한 메모리 할당/해제를 하지 않는 경우가 많았었습니다.(실제로 gethostbyname()같은 함수가 이런 구조로 되어있습니다.) 자 그러면 이 코드의 실행과정을 그림으로 표현해보도록 하겠습니다.
그런데 이런 BSS 영역의 메모리를 함수에서 사용하면 병렬 실행시 심각한 문제를 발생합니다. 이는 병렬 실행시 해당 함수가 한 개 밖에 없는 BSS영역의 메모리를 겹쳐서 사용하기 때문에 병렬로 실행된 함수중에 하나가 마지막으로 메모리를 덮어 쓰게 되면 원치 않는 데이터가 출력될 수 있습니다. 이 현상을 관찰하기 위해 POSIX thread를 이용해서 해당 함수가 병렬로 실행되도록 변경했습니다. (참고:이 예제 쓰레드 프로그램은 복잡한 에러처리는 하지 않았습니다.)
그러나 이런 현상은 해당 함수를 수없이 병렬로 호출하면 랜덤하게 발생하므로 여기서는 고의적으로 발생시키기 위해 시간차를 주는 코드를 넣어두었씁니다. 그래서 sleep, usleep을 이용해서 함수내에 지연시간을 두었고, sum_strnum()함수에도 sleep 1초를 넣어 두었습니다. 자 그러면 소스코드 나갑니다.
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> char *sum_strnum(char *s1, 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 will be '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을 쓰는 것은 쉽지만 그 만큼 성능을 떨어뜨리게 됩니다. 따라서 여기서는 reentrant 함수로 바꾸는 방법을 실행해보겠습니다.
우선 reentrant 함수로 바꾸기 위한 규칙부터 정리하겠습니다.
규칙1. data 저장 부분은 개별 공간을 사용하도록 한다.(i.e. 전역 공간이나 static 영역은 사용하지 않는다는 의미)
규칙2. return 값의 타입은 int로 만들고 성공시 0, 실패시 -1을 리턴하도록 한다.(규제는 아니지만 관습적으로 이렇게 디자인한다.)
규칙3. 함수이름의 맨 뒤에 _r 을 붙여서 만든다.(이것도 규제는 아니지만 관습적으로 이렇게 디자인합니다.)
이 세가지 규칙에 맞춰서 앞의 sum_strnum()함수를 변경해보도록 하겠습니다.
그림에서 볼 수 있듯이 기존의 static char buf_sum[16]부분은 더이상 사용하지 말아야 합니다. 그러나 return 값에서 static 변수의 주소를 리턴했기 때문에 이를 외부 데이터 공간으로 빼려면 return 타입인 char * 가 함수 인수(argument) 리스트로 들어가야 합니다. 그런데 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차원 배열을 구현할 때 더블포인터를 써서 구현할 수도 있다는 것 뿐입니다. 잘못 배우신 분들은 여기서 더블 포인터에 대한 개념을 다시 정립하고 넘어가시기 바랍니다.)
자 그러면 전체 소스코드도 같이 첨부해서 보도록 하겠습니다.
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #define SZ_BUF_SUM 16 int sum_strnum_r(char **buf_sum, size_t sz_buf, char *s1, char *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); 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 will be '8'. */ 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이 나오는 것을 볼 수 있을 겁니다.
마지막으로 왜 reentrant 를 잘 알아두어야 하는가를 설명하겠습니다.
최근 5~6년 부터는 대부분의 CPU가 멀티코어로 나오고 있지요? 이는 실리콘 소자로는 CPU 클럭 주파수를 4~5GHz이상 올리지 못하기 때문에 클럭 주파수를 올리는 성능개선보다는 병렬 처리를 중점으로 두는 방향으로 선회하기 때문입니다.(실제로 5GHz까지 올라가면 엄청난 열이 발생하고, 50%가 넘는 전력이 누수됩니다.) 실제로 최근의 데스크탑 PC는 dual이나 quad-core를 사용하는 경우가 많고 앞으로는 octet-core도 나올지 모르니 병렬처리는 성능향상에 필수요소가 될 것입니다.
그런데 멀티코어를 제대로 사용하려면 쓰레드를 이용한 병렬 처리를 해야 하는데 reentrant 함수가 아닌 경우에는 제대로 작동하지 않기 때문에 앞으로는 필히 reentrant 코드를 사용하는 것이 좋습니다.(강제 사항은 아닙니다. 다만 권고사항일 뿐이지요. ^^)
주석1. 물론 reentrant나 MT-safety는 실행후 결과값이 정확하게 나와야 합니다. 만일 병렬실행이 가능하지만 결과값은 개판이다. 이러면 제대로된 코드라고 볼 수 없습니다. 프로그램을 설마 이렇게 이해하는 분은 안계시겠지요? 따라서 멀티쓰레드에서 해당 함수는 절대 죽지 않도록 설계되었지만 결과값은 엉망이 나올 수도 있다면, MT-safety라는 말을 붙이기 이전에 제대로 된 코드라고 볼 수 없습니다. 즉 애초에 함수 결과값을 보장하지 않고 만든다면 한마디로 정신나간 프로그래머입니다.
긴 글이 도움이 되기를 바라면서... 이 글은 저작자와 출처만 표시하신다면 마음대로 복사, 발췌하셔도 괜찮습니다. (굳이 몰라서 출처 표시 안해도 고소하지 않으니 안심하시길...^^)
2009.07.24 reentrant 설명에 SUSv3 내용 첨부
2009.07.19 예제에 vim html 형식 사용 (TOhtml 기능 사용)
2009.07.17 처음 글 씀