SP - 5.4 Thread Safety & Reentrancy

hyeok's Log·2022년 6월 7일
2

SystemProgramming

목록 보기
18/29

  이제부터 우리는 'Thread-Safety' 개념에 대해 알아볼 것이다. 이전에 다루었던 'Async-Signal-Safety'와 유사하게 바라보면 된다.


Thread-Safe Functions

Thread-Safe : 여러 Thread가 Concurrent하게 특정 함수를 반복적으로 호출할 때, 항상 Correct한 결과가 나오는 함수를 'Thread-Safe Function'이라 한다.

Thread 내에서 호출되는 함수는 반드시 Thread-Safe해야한다.

  • 이때, 'Correct하다'는 것은 무엇일까?
    • 내가 Expect한 결과가 나오는 것을 말한다.

  • 이게 다인가? 그렇다. 놀랍게도, Thread-Safety 개념은 이게 다이다. 위의 정의가 '정식' 정의이다. 하지만, 포스팅을 계속 읽다보면, Thread-Safety에 대해 더 잘 이해할 수 있을 것이니 지금은 걱정하지 말자.

Thread-Unsafe Functions

  우리는 Thread-Unsafe한 함수들을 4가지 Class로 나누어서 설명한다. 이 카테고리에 포함되는 함수들이 바로 'Thread-Safe하지 않은' 함수인 것이다. 이 Case들을 잘 기억해두면, 자연스럽게 Thread-Safe한 함수가 무엇인지 알 수 있을 것이다.

  • Class 1: Functions that do not protect shared variables
  • Class 2: Functions that keep state across multiple invocations
  • Class 3: Functions that return a pointer to a static variable
  • Class 4: Functions that call thread-unsafe functions J

  이제부터 이들에 대해 자세히 알아보자.


Class1 : NOT Protect Shared Variables

  이전에 다루었던 Count 예제를 떠올려보자. 두 개의 Thread가 각각 cnt라는 전역 변수를 Increment하는 상황이다. 입력받은 인자 정수 값만큼 각각 Increment한다. 즉, 최종적으로 인자의 2배가 되는 값이 나오길 기대하는 상황이다.

  • 이때, 우리는 Binary Semaphore를 사용해 Mutex Lock을 걸어서 Concurrency Issue를 해결한 바 있다.
    • Critical Section인 "cnt++;" 부분의 수행을 직렬화하여 문제를 해결했다.

그렇다. 프로그램의 전역 변수를 접근(및 변형)하는 Thread에 Sychronization을 적용하면 Thread-Safe, 그렇지 않으면 Thread-Unsafe인 것이다.

  • 전역 변수를 보호하지 않는 Thread Function이 바로 Thread-Unsafe인 것이다.
    • P, V Semaphore Operation을 통해 간단히 해결할 수 있다.

Class2 : State across Multiple Invocations

  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가 srand()를 호출했다고 해보자.
      • 즉, 중간에 갑자기 seed값(next)이 바뀐 것이다.

    내(Thread)가 예상하던 Sequence(1,3,5,7,9,..)가 갑자기 중간에 바뀌는 것이다. 누군가 'Sequence를 결정하는 Global Static State(seed)'를 바꿨기 때문이다.

    즉, Static State에 의존하는 Function은 Thread-Unsafe한 함수이다. ★★

    프로그래머가 State를 기반으로 한 예상이 예상대로 흐르지 않기 때문!


  • 이를 Thread-Safe하게 수정하는 방법은 무엇일까?

    "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;
}
  • 그렇다. 위의 코드처럼, Global Variable을 사용하지 않고, 함수 Parameter로 'State'를 넘기게 만들어 문제를 해결할 수 있다. ★
    • State 자체를 Function 내에서 변형하기 위해선 Pointer Variable이 적절할 것!

Class3 : Return pointer to Static Variable

  ctime 함수를 생각해보자. ctime 함수는 시간을 나타내는 특정 구조체(Static Variable)에 '현재 시간(Pointer)'을 가져와 기록하는 함수이다. 이 함수는 일반적으로 전역 변수를 사용해 기록한다.

  • 여러 Thread가 ctime 함수를 호출한다고 해보자. 내 Thread가 ctime을 호출했을 때, 과연 '진짜로 정확히 호출한 그 시각'이 구조체에 기록될까?

    • 그렇지 않을 수 있다.

    • 우연치 않게 ctime 함수가 값을 Return하기 직전에 Timer Interrupt가 걸렸다고 해보자.

      • ctime 함수는 전역 변수(구조체 변수)에 현재 시각을 기록해준다.
      • A라는 Thread가 ctime을 호출해놓고, 리턴받기 직전에 Interrupt가 걸렸다.
      • 이어서 B라는 Thread가 수행되는데, 여기서도 ctime을 호출한다. 여기선 Interrupt 없이 Return까지 받는다.
      • 이어서 다시 A의 차례가 돌아왔다. 이때, A에게 보여지는 시간은 'B가 ctime을 호출한 시각'이 된다. ★★

그렇다. 함수의 Result를 Static Variable(전역 변수나 Static 변수)에 Pointer 형식으로 기록하는 함수, 즉, 전역 변수의 값을 함수 내에서 간접적으로 변형하는 함수도 Thread-Unsafe 함수이다. ★★


  • 해결 방법은 무엇일까?
    • Lock & Copy 기법이 가능하다.

      • ctime 함수 호출에 Lock을 적용시키는 것이다. 그리고 ctime의 결과 값을 Thread 내부에 Copy해놓는 것이다. (말그대로 Lock을 적용하고 Copy한다는 것!)

        • Caller에서만 수정을 가하면 된다.
      • 아래의 새로운 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하고,...


Class4 : Calling Thread-Unsafe Functions

  말장난 같지만, 당연하게도 함수 내부에서 Thread-Unsafe Function을 호출하는 함수는 Thread-Unsafe하다.

함수 내에서 사소한 부분이라도, 단 한 번이라도 Thread-Unsafe 함수를 호출하면 그 함수 전체가 Thread-Unsafe해진다. ★

당연히, Thread-Safe함수를 호출하도록 변형해주어야 한다.


Examples & Insights

  다행스럽게도, 우리가 사용하는 기본 제공 표준 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가 나온다.


Reentrant Functions

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하게 만들어주는 것 뿐이다.


Real Meaning of 'Reentrant'

  • Reentrant의 정의에서, '접근(Access)'이라 하면, 조금 Tricky한 설명이지만, 함수 내부에서 'Shared Variable'을 "이용"해서 무언가 일을 하는 것을 의미한다. ★★★★★
    • 즉, 함수 내부에서 Right Value로서 'Shared Variable'을 사용하면, 조금 더 엄밀히 말하면, 함수 결과(Return값)에 영향을 주도록 사용하면, Reentrant하지 않을 확률이 높아지는 것이다. ★★★

  • 왜?

    • Reentrant를 다르게 정의하면 다음과 같다.

      복수의 Thread에서 특정 함수를 호출했을 때, 호출 순서와 상관없이, 그리고 Timer Interrupt에 의한 Interleaving과 상관없이, 해당 함수가 마치 독립적으로(동시에) 흐른 것처럼, 각 Thread에서 모두 Correct한 결과를 나타내는 것을 의미한다.

    • Thread-Safe의 정의는 다음과 같았다.

      여러 Thread가 Concurrent하게 특정 함수를 반복적으로 호출할 때, 항상 Correct한 결과가 나오는 함수를 'Thread-Safe Function'이라 한다.

    • 차이가 보이는가? Thread-Safe의 정의가 좀 더 포괄적이다. Reentrant에는, Thread-Safe의 정의에 추가로 '동시에 흐른다'는 뉘앙스가 들어간다. ★★★

      • 그래서 Thread-Safe가 더 큰 개념인 것이다.

  • 이 '뉘앙스 차이'를 염두하고, 아래의 네 가지 유형을 통해 Reentrant와 Thread-Safe의 관계를 정립해보자. 집중하자.

Case1 : Thread-Unsafe, NOT Reentrant

int temp;

int add(int a) {
	temp = a;
    return temp + 7;
}
  • 전역 변수를 사용하고 있으므로 Thread-Unsafe이다.

    • Return 직전에 temp 값이 다른 Thread에 의해 바뀔 수 있기 때문이다.
  • 전역 변수를 직접 이용해서 Return하고 있으므로 NOT Reentrant이다.

    • add라는 함수를 사용하는 Signal Handler가 설치되어 있다고 해보자.
    • 어떤 Thread가 add 함수를 호출했는데, 그 함수가 Return하기 이전에, 즉, 수행 도중에 특정 Signal에 의해 Signal Handler가 동작했다고 해보자.

    • 위의 그림에서 노란 영역이 시그널 핸들러 수행 영역이다. Thread의 Return 값이 바뀌어버린다. 즉, 이런 상황이 발생할 수 있기 때문에 NOT Reentrant인 것이다. (위 그림에서 9+7이 아니라 2+7이다. 오타)

      • 그래서, Signal Handler의 중첩을 고려해서, Right Value에 Shared Variable이 오면 NOT Reentrant가 되는 것이다.
    • 이는 Thread-Unsafe와는 개념이 다르다. Thread-Unsafe는 '다른 Thread'에 의해 오염되는 것이고, NOT Reentrant는 Signal Handler에 의해 '함수의 Result'가 오염되는 것이다. ★★★


Case2 : Thread-Safe, NOT Reentrant

thread_local int temp;

int add(int a) {
	temp = a;
    return temp + 7;
}
  • 전역 변수를 사용하고 있다. 그런데, Thread-Safe이다. 왜?

    • 전역 변수가 'thread_local' 선언이 되어 있다.
      • thread_local 선언은 해당 Nonlocal Data에 대해 Lock & Copy를 시스템적으로 제공한다는 의미이다. (즉, Synchronization 보장)
    • 따라서, Lock이 제공되고 있으므로 Thread-Safe이다. 여러 Thread끼리 중첩적으로 이 함수를 호출하여도, temp에 대한 Lock이 제공되므로, 다른 Thread에 의한 Corruption이 일어나지 않을 것이기 때문이다. ★★★
  • 전역 변수를 직접 이용해서 Return하고 있으므로 NOT Reentrant이다.

    • Case1에서 본 것처럼, add를 사용하는 Signal Handler가 add 수행 도중 중첩된다고 해보자. 중간에 temp가 바뀌어버린다. 그리고 나아가 함수 결과도 바뀌어버린다. 즉, NOT Reentrant한 것이다.

Case3 : Thread-Unsafe, Reentrant

int temp;

int add(int a) {
	temp = a;
    return a + 7;
}
  • 전역 변수를 사용하고 있으므로 Thread-Unsafe이다.

    • Return 직전에 temp 값이 다른 Thread에 의해 바뀔 수 있기 때문이다.
  • 전역 변수를 이용하지 않고 있으므로 Reentrant이다.

    • 즉, Shared Variable이 각 명령의 Right Side에 없기 때문에 Reentrant이다.

    • 상당히 Tricky할 것이다. 그런데, 위에서 언급한 내용을 그대로 적용해보면 어렵지 않다.

      • add 함수를 사용하는 Signal Handler가, 특정 Thread에서 add 함수를 호출해 add 내부가 수행되던 중, Signal에 의해 중첩되었다고 해보자.

      • 즉, Signal Handler가 도중에 중첩되어도 Thread가 '그 전에' 호출했던 add 함수의 결과가 "마치 Handler 종료 이후 다시 Thread에서 add 함수에 Re-Enter해서 수행을 이어간 것처럼" 함수의 Result가 유지되고 있다. ★★★★★
        • 그렇다. 이것이 바로 Reentrant의 개념인 것이다.

          Reentrant 판단은 Signal Handler 중첩 시 오염이 발생할 것인지(Right Value나 Return에서 Shared Variable을 사용하는지)로, Thread-Safe 판단은 여러 Thread가 동시 수행 시 오염이 발생할 것인지(Shared Variable을 특정 조치 없이 그대로 함수 내에서 사용하는지)로 판단할 수 있다. ★★★★★★★


Case4 : Thread-Safe, Reentrant

int add(int a) {
    return a + 7;
}
  • 전역 변수를 사용하고 있지 않으므로 Thread-Safe하다.

    • 다른 Thread가 끼어들어도 문제가 없을 것이 자명
  • 전역 변수를 이용하지 않고 있으므로 Reentrant이다.

    • 핸들러 중첩되어도 문제가 없을 것이 자명

"어떤 느낌인지 알겠는가?"
~> 이래서, Reentrant 개념은 Async-Signal-Safety 정의에 등장하고, Thread-Safety 정의에는 등장하지 않는 것이다.


  금일 포스팅은 여기까지이다.

0개의 댓글