이제부터 우리는 'Thread-Safety' 개념에 대해 알아볼 것이다. 이전에 다루었던 'Async-Signal-Safety'와 유사하게 바라보면 된다.
Thread-Safe : 여러 Thread가 Concurrent하게 특정 함수를 반복적으로 호출할 때, 항상 Correct한 결과가 나오는 함수를 'Thread-Safe Function'이라 한다.
Thread 내에서 호출되는 함수는 반드시 Thread-Safe해야한다.
우리는 Thread-Unsafe한 함수들을 4가지 Class로 나누어서 설명한다. 이 카테고리에 포함되는 함수들이 바로 'Thread-Safe하지 않은' 함수인 것이다. 이 Case들을 잘 기억해두면, 자연스럽게 Thread-Safe한 함수가 무엇인지 알 수 있을 것이다.
이제부터 이들에 대해 자세히 알아보자.
이전에 다루었던 Count 예제를 떠올려보자. 두 개의 Thread가 각각 cnt라는 전역 변수를 Increment하는 상황이다. 입력받은 인자 정수 값만큼 각각 Increment한다. 즉, 최종적으로 인자의 2배가 되는 값이 나오길 기대하는 상황이다.
그렇다. 프로그램의 전역 변수를 접근(및 변형)하는 Thread에 Sychronization을 적용하면 Thread-Safe, 그렇지 않으면 Thread-Unsafe인 것이다.
Static State에 의존하는 난수 발생기를 떠올려보자. 난수 발생기를 여러 Thread에서 공유하고 있다고 생각해보자. 아래 코드를 보자.
static unsigned int next = 1; // 여러 스레드가 함께 공유
/* rand(): Pseudo-Random한 난수를 발생시키는 함수이다. */
// 아래와 같이, 특정 공식을 기반으로 Deterministic하게 랜덤값을 발생시키고 있다.
int rand(void) {
next = next*1103515245 + 12345;
return (unsigned int)(next/65536) % 32768;
}
// ~> 매번 Invocation할 때마다, State를 유지(Keep)해야하는 함수이다. ★★
// ~> 그래야, Pseudo-Random의 정해진 Sequence를 유지할 수 있기 때문이다. ★★
// (아래 설명을 보면 이해할 수 있다)
/* srand(seed): rand()함수에 대한 seed를 배정하는 함수이다. */
// 이 함수를 통해 seed를 변경할 수 있다.
void srand(unsigned int seed) {
next = seed;
}
만약, 예를 들어, 위의 rand()함수에서 난수가 1, 3, 5, 7, 9,... 순서로 발생한다고 해보자. 특정 Thread에서 rand()함수를 주기적으로 호출하고 있는 상황이다.
내(Thread)가 예상하던 Sequence(1,3,5,7,9,..)가 갑자기 중간에 바뀌는 것이다. 누군가 'Sequence를 결정하는 Global Static State(seed)'를 바꿨기 때문이다.
즉, Static State에 의존하는 Function은 Thread-Unsafe한 함수이다. ★★
프로그래머가 State를 기반으로 한 예상이 예상대로 흐르지 않기 때문!
"Eliminate global state!"
/* rand_r - Pseudo-Random한 난수값을 Thread-Safe하게 반환하는 함수 */
// nextp라는 포인터를 통해, Thread가 자기만의 seed를 가지게 만들어준다. ★★
int rand_r(int *nextp) {
*nextp = *nextp * 1103515245 + 12345;
return (unsigned int)(*nextp/65536) % 32768;
}
ctime 함수를 생각해보자. ctime 함수는 시간을 나타내는 특정 구조체(Static Variable)에 '현재 시간(Pointer)'을 가져와 기록하는 함수이다. 이 함수는 일반적으로 전역 변수를 사용해 기록한다.
여러 Thread가 ctime 함수를 호출한다고 해보자. 내 Thread가 ctime을 호출했을 때, 과연 '진짜로 정확히 호출한 그 시각'이 구조체에 기록될까?
그렇지 않을 수 있다.
우연치 않게 ctime 함수가 값을 Return하기 직전에 Timer Interrupt가 걸렸다고 해보자.
그렇다. 함수의 Result를 Static Variable(전역 변수나 Static 변수)에 Pointer 형식으로 기록하는 함수, 즉, 전역 변수의 값을 함수 내에서 간접적으로 변형하는 함수도 Thread-Unsafe 함수이다. ★★
Lock & Copy 기법이 가능하다.
ctime 함수 호출에 Lock을 적용시키는 것이다. 그리고 ctime의 결과 값을 Thread 내부에 Copy해놓는 것이다. (말그대로 Lock을 적용하고 Copy한다는 것!)
아래의 새로운 ctime 함수를 보자.
/* Lock & Copy Function calling ctime */
char *ctime_ts(const time_t *timep, char *privatep) {
char *sharedp;
P(&mutex); // Lock을 잡아준다. (LOCK)
sharedp = ctime(timep); // ctime을 호출하고, 그 결과를 받고,
strcpy(privatep, sharedp); // privatep에 Copy해준다. (COPY)
V(&mutex); // Lock을 푼다.
return privatep;
}
~> 그렇다. 쉽게 말해서 Lock을 잡아서 해결한다는 것이다. 다른 Thread로부터 값이 오염되지 않도록 말이다. Lock을 잡고, Copy하고,...
말장난 같지만, 당연하게도 함수 내부에서 Thread-Unsafe Function을 호출하는 함수는 Thread-Unsafe하다.
함수 내에서 사소한 부분이라도, 단 한 번이라도 Thread-Unsafe 함수를 호출하면 그 함수 전체가 Thread-Unsafe해진다. ★
당연히, Thread-Safe함수를 호출하도록 변형해주어야 한다.
다행스럽게도, 우리가 사용하는 기본 제공 표준 C 라이브러리 함수들 중에선 아래와 같은 함수들을 제외하고는 대부분 'Thread-Safe'하다. malloc, free, printf, scanf 모두 Thread-Safe 함수들이다.
Thread_Unsafe_Function Class Reentrant_Version
asctime 3 asctime_r
ctime 3 ctime_r (Class3)
gethostbyaddr 3 gethostbyaddr_r
gethostbyname 3 gethostbyname_r
inet_ntoa 3 (none)
localtime 3 localtime_r
rand 2 rand_r (Class2)
~> Thread Programming 시 이들의 사용을 주의하자.
Q) 아니, 왜 명색의 표준 라이브러리 함수들 중에 Thread-Unsafe 함수와 같은 위험한 함수들이 있는 건가요?
A) 과거 C가 처음 개발될 적엔 Multithread Programming이라는 개념 자체가 없었다. 즉, 그때는 Thread-Safety를 고려할 생각 조차 하지 못했던 것이다. 나중에 Thread 개념이 정립되고 나서야 Reentrant(후술 예정) 개념이 등장하고, Thread-Safe 함수 설계가 이루어진 것이다.
여기서 우리가 얻어야하는 교훈은 무엇일까? 그렇다. 프로그래머로서 우리는 "내가 작성한 함수가 Thread-Safe할 것인가?"를 고민해보아야 하는 것이다.
위의 네 가지 Class를 보면 알 수 있듯이, 기본적으로 Global한, 여러 Thread끼리 공유 가능한 Data에 접근하는 함수들은 Thread-Safety가 보장될지 의심해보아야 한다. ★★
여기서 Reentrant에 대한 Idea가 나온다.
Multiple Threads에서 특정 함수를 호출할 때, 해당 함수가 Shared Variable에 대한 접근을 하지 않는 함수를 우리는 'Reentrant Function'이라 부른다.
Reentrant Function은 Thread-Safe Function의 부분집합이다. ★
즉, Thread-Safe Function이라고 반드시 Reentrant한 것은 아니다. 그 중 일부(대다수)가 Reentrant한 것이다.
반대로 말하면, Reentrant 함수는 모두 Thread-Safe하다.
Reentrant 함수는 Synchronization Operation을 필요로 하지 않는다.
'Class2 Function(Keeping State across Multiple Invocations)'을 Thread-Safe하게 수정하는 방법은 오직 Reentrant하게 만들어주는 것 뿐이다.
왜?
Reentrant를 다르게 정의하면 다음과 같다.
복수의 Thread에서 특정 함수를 호출했을 때, 호출 순서와 상관없이, 그리고 Timer Interrupt에 의한 Interleaving과 상관없이, 해당 함수가 마치 독립적으로(동시에) 흐른 것처럼, 각 Thread에서 모두 Correct한 결과를 나타내는 것을 의미한다.
Thread-Safe의 정의는 다음과 같았다.
여러 Thread가 Concurrent하게 특정 함수를 반복적으로 호출할 때, 항상 Correct한 결과가 나오는 함수를 'Thread-Safe Function'이라 한다.
차이가 보이는가? Thread-Safe의 정의가 좀 더 포괄적이다. Reentrant에는, Thread-Safe의 정의에 추가로 '동시에 흐른다'는 뉘앙스가 들어간다. ★★★
int temp;
int add(int a) {
temp = a;
return temp + 7;
}
전역 변수를 사용하고 있으므로 Thread-Unsafe이다.
전역 변수를 직접 이용해서 Return하고 있으므로 NOT Reentrant이다.
위의 그림에서 노란 영역이 시그널 핸들러 수행 영역이다. Thread의 Return 값이 바뀌어버린다. 즉, 이런 상황이 발생할 수 있기 때문에 NOT Reentrant인 것이다. (위 그림에서 9+7이 아니라 2+7이다. 오타)
이는 Thread-Unsafe와는 개념이 다르다. Thread-Unsafe는 '다른 Thread'에 의해 오염되는 것이고, NOT Reentrant는 Signal Handler에 의해 '함수의 Result'가 오염되는 것이다. ★★★
thread_local int temp;
int add(int a) {
temp = a;
return temp + 7;
}
전역 변수를 사용하고 있다. 그런데, Thread-Safe이다. 왜?
전역 변수를 직접 이용해서 Return하고 있으므로 NOT Reentrant이다.
int temp;
int add(int a) {
temp = a;
return a + 7;
}
전역 변수를 사용하고 있으므로 Thread-Unsafe이다.
전역 변수를 이용하지 않고 있으므로 Reentrant이다.
즉, Shared Variable이 각 명령의 Right Side에 없기 때문에 Reentrant이다.
상당히 Tricky할 것이다. 그런데, 위에서 언급한 내용을 그대로 적용해보면 어렵지 않다.
Reentrant 판단은 Signal Handler 중첩 시 오염이 발생할 것인지(Right Value나 Return에서 Shared Variable을 사용하는지)로, Thread-Safe 판단은 여러 Thread가 동시 수행 시 오염이 발생할 것인지(Shared Variable을 특정 조치 없이 그대로 함수 내에서 사용하는지)로 판단할 수 있다. ★★★★★★★
int add(int a) {
return a + 7;
}
전역 변수를 사용하고 있지 않으므로 Thread-Safe하다.
전역 변수를 이용하지 않고 있으므로 Reentrant이다.
"어떤 느낌인지 알겠는가?"
~> 이래서, Reentrant 개념은 Async-Signal-Safety 정의에 등장하고, Thread-Safety 정의에는 등장하지 않는 것이다.
금일 포스팅은 여기까지이다.