Chapter4까지 Concurrent Server에 대해 다루었다. 간단히 요약하면 다음과 같다.
Process-based : Client로부터 Server에게 Connection Request가 전송될 때마다, Server는 이를 받고, fork를 띄워 Connected File Descriptor를 Child Server Process에게 넘긴다. 해당 Child Process는 Client와 Connection을 형성한다.
Event-based : 단일 프로세스 안에서 이벤트(Pending Input 여부)를 File Descriptor를 이용해 체킹 후, 각 이벤트를 핸들링하는 방식이다. 하나의 프로세스가 빠르게 작업을 반복적으로 수행한다. Execution Flow가 하나이기 때문에 Concurrent Issue가 특별히 없다.
Thread-based : 하나의 프로세스가 여러 개의 Thread를 띄워서 서버를 운영한다. 이들이 Process 안의 여러 Context를 Share할 수 있었다.
우리는, 이번 Chapter에서 Thread-based Server, 나아가 Thread Programming을 함에 있어서 발생할 수 있는 여러 Concurrent Issue와, 그에 대한 Handling 방법에 대해 다룰 것이다.
Thread는 별도의, Private한 Stack을 가진다고 했다. 하지만, 이것이 '다른 Thread로부터의 해당 Thread Stack Sharing을 막는다는 의미는 아니다. ★★
Private란, 각 Thread가 자신의 Stack을 가졌음을 의미할 뿐, 다른 Thread로부터의 침범이 방지된다는 의미는 아니다.
따라서, 통념상으론 Global Variable만 Share되고 Stack Variable은 Share되지 않을 것이라 생각하지만, 아니라는 것이다.
Thread Programming 시, 각 Thread의 Stack Variable도 Sharing의 대상이 될 수 있다.
변수 A가 Shared라는 것은, 복수의 Thread가 해당 A의 인스턴스를 참조한다는 것을 말한다.
여러 Thread는 결국 하나의 단일 프로세스 위에서 돌아간다.
한편, Thread는 자신만의 Context도 가진다. (Thread Context)
이론적인 시선에서 Thread와 Process의 관계는 다음과 같다.
복수의 Thread가 하나의 Process Context를 토대로 돌아간다.
각 Thread는 별도의 Separate된 Thread Context를 가진다.
모든 Thread는 '유지되는 Process Context'를 Share한다.
Code, Data, Heap 영역과, 프로세스의 Virtual Memory Space 상의 다양한 라이브러리 조각들을 공유한다.
또한, File Descriptor Table과, 설치된 Signal Handler들도 공유한다. ★
하지만, 실제로 Thread Programming을 수행, 구현하면, 다음과 같은 상황을 어렵지 않게 확인할 수 있다.
어떤 Thread가 다른 Thread를 Read & Write 할 수 있다.
즉, 실제로는, 이론과 다르게, 한 Thread가 다른 Thread의 Stack Variable도 접근할 수 있다.
~> 즉, 이론과 실재가 미스매치하기 때문에, 여기서 혼란을 겪는 상황이 많다.
아래의 예시 프로그램은 대표적인 Thread의 데이터 공유 문제를 보여준다. 주석과 함께 간단히 분석해보자. main Process(Thread)에 두 개의 문자열을 지역변수로 두고, 별개로 두 개의 Thread를 띄운 다음, 각 Thread가 자기 Stack에 있지 않은 main의 문자열을 띄우는 프로그램이다.
void *thread(void *vargp); // Thread Routine
char **ptr; // 모든 Thread가 다 같이 접근 가능할 수 있는 전역변수
int main(void) {
pthread_t tid;
char *msgs[2] = { // 이들은 모두 main Thread의 Local Variable들!
"안녕, 난 foo라고 해.\n", // 즉, main의 Stack에 존재한다.
"안녕, 난 bar라고 해.\n" // 그말은 즉, Peer Thread들이 접근하지 못해야한다.
}; // (Conceptual 관점에서 말이다)
ptr = msgs; // 전역변수 ptr이 msgs라는 Local Variable의 주소를 담는다. ★
for (long i = 0; i < 2; i++) // i는 0 아니면 1이고,
Pthread_create(&tid, NULL, thread, (void *)i); // i가 Argument로 넘어간다.
Pthread_exit(NULL); // 여긴 Thread Reaping하는 상황
}
void *thread(void *vargp) { // Thread 0번과 1번이 이를 같이 수행한다.
long myid = (long)vargp;
static int cnt = 0; // Local Variable이지만, static이므로 Data부에 들어간다.
// (cnt의 Lifetime은 thread함수의 시작과 끝이다.)
printf("[%ld]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt); // myid는 0 또는 1일 것
// ptr은 전역변수이고!
return NULL;
}
// ~> 따라서, i가 0인 Thread는 foo를, 1인 Thread는 bar를 출력할 것이다.
~> 각 Thread가 Data 영역에 있는 ptr을 통해서 main Thread의 Stack 영역에 있는 Local Variable인 msgs 배열에 접근할 수 있는 상황이다.
=> 단순히 접근뿐만 아니라, 충분히 수정 및 변경도 할 수 있다.
이것이 바로 Stack Variable Sharing 상황이다.
Peer threads reference main thread's stack indirectly through global ptr variable.
이러한 상황을 'Indirection'이라 한다. 전역 변수를 이용해 '우회적으로 접근'하므로.
Global Variable
Local Variable
Local Variable with 'static'
정의 : static Keyword와 함께 선언된 지역 변수
각 static 변수에 대해, Virtual Memory 상에서 오직 하나의 Instance만 존재한다.
전역변수처럼 말이다! (전역 변수와 Local Static Variable은 Data Segment에 존재)
Lifetime은 각 Thread 내에서만 존재하지만, 그 데이터 메모리 자체는 Data Segment에 존재한다. 딱 하나의 Instance로만 말이다.
즉, static 변수 선언이 포함된 Thread Routine을 두 번 수행할 시, 최초 Thread Routine 수행에서만 Data부에 변수를 선언한다.
Lifetime이 끝나도, 그 데이터는 Data Segment Memory에 그대로 남아있는다. 언제까지? Main Thread(Program)가 종료될 때까지!
ptr은 Data Segment에 있다.
tid, msgs, i(for문)는 Main Thread의 Stack에 있다.
myid는 각 Thread의 Stack에 있다.
cnt는 Data Segment에 있다.
Thread는 총 3개(Main, T0, T1)가 있는데,
결론적으로 보면, ptr, cnt, msgs 변수는 Shared이고, tid, i, myid는 Not Shared이다.
우리는 이제 Thread끼리 Indirection을 통해 Stack Variable을 공유할 수 있음을 알고 있다. 그것은 좋다. 그런데, 이렇게 Variable Sharing을 할 경우, 우리는 Synchronization Error를 맞이할 수 있다. 이에 대해 알아보자. 아래의 프로그램을 보자. 주석을 함께 읽자.
void *thread(void *vargp); // Thread Routine
volatile long cnt = 0; // Global shared variable : Counter (in Data)
// volatile 선언은 하단에서 설명한다.
int main(int argc, char **argv) { // 이 프로그램은 Argument를 받는다.
pthread_t tid1, tid2; // 예를 들어, ./example 10000이란 명령으로
long niters = atoi(argv[1]); // 프로그램을 수행했다고 가정하자.
Pthread_create(&tid1, NULL, thread, &niters); // Thread를 두 개 띄운다.
Pthread_create(&tid2, NULL, thread, &niters); // (T0, T1)
Pthread_join(tid1, NULL); // Join한다. (Reaping)
Pthread_join(tid2, NULL); // Joinable Threads
if (cnt != (2 * niters)) // 결과가 20000이 아니면 잘못된 상황이다.
printf("Shit! cnt is %ld\n", cnt);
else // 20000이 되길 기대하는 상황
printf("Yeah! cnt is %ld\n", cnt); // 아래의 루틴을 보면 이해 가능
exit(0);
}
void *thread(void *vargp) { // Thread Routine
long i, niters = *((long *)vargp); // 넘어온 Iteration Number에 대해,
// 가정에 따르면 10000이다.
for (i = 0; i < niters; i++) // 10000번을 돌면서 Increment!
cnt++;
return NULL;
}
(출력)
> ./example 10000
Yeah! cnt is 20000
> ./example 10000
Shit! cnt is 13051
~> 어떤 때는 의도한 결과가 나오는데, 어떤 때는 잘못된 결과가 나오고 있다. ★
여기서 발생한 문제는 무엇일까?
그것은 바로, Thread Routine에서 "cnt++;"이란 명령이 Atomic하다고 생각한 것이 문제의 원인이다.
전역변수 cnt를 각 Thread가 함께 접근하고 있는데, 그 cnt를 접근하는 명령이 Atomic하다고 착각한 것이다. ★
Atomic하지 않다는 것은 무슨말이고, Atomic하지 않은게 왜 문제일까? 이를 좀 더 자세히 이해하기 위해, Main Thread의 Loop문을 Assembly Code Level로 분석해보자.
for (i = 0; i < niters; i++)
cnt++;
(C Source Code -> ASM Source Code)
movq (%rdi), %rcx
testq %rcx,%rcx
jle .L2
movl $0, %eax
.L3:
movq cnt(%rip),%rdx // Load (여기는 cnt++; 부분)
addq $1, %rdx // Update (여기는 cnt++; 부분)
movq %rdx, cnt(%rip) // Store (여기는 cnt++; 부분)
addq $1, %rax
cmpq %rcx, %rax
jne .L3
.L2:
~> 어셈블리어 단위로 보니, cnt++;이라는 명령어가 실제로는 3개의 기계어 명령으로 구성되어 있었다!!
=> 즉, "cnt++;"은 Atomic하지 않은, 3개의 Instruction의 Sequence인 것이다. ★
Atomic하지 않은 명령이 왜 문제인가? 아래를 보자.
두 개의 Thread가 Concurrent Flow 관계로 돌아가고 있다고 하자.
~> 초록색은 Thread0의 명령 시퀀스, 보라색은 Thread1의 명령 시퀀스이다. 그리고, 초록과 보라 사이의 흰색 칸은 Context Switch를 의미한다. ★
=> (운이 좋게도) 문제가 없는 상황이다.
~> 이번엔 Incorrect Ordering 상황이다. 각 Thread의 "cnt++;" 수행이 다 끝마쳐지기 전에, 도중에 Context Switch가 일어났다. ★
~~> 좌측을 보자. T0에서 Load하고, Update까지 해놓았다. 그러다 도중에 Context Switch가 일어났다. 이제 T1의 차례이다. T1이 cnt를 자신의 rdx Register에 Load한다. 그 다음 곧바로 다시 Context Switch가 일어났다. 이제 T0의 Store가 일어난다. 그러고 다시 곧바로 Context Switch가 일어났다. 이번엔 T1이 Update해야한다. 자신의 rdx를 Increment한다. 이때, 이 값은? 그렇다. 1이다. 따라서 1을 다시 cnt(현재 T0에 의해 1이 된)를 덮어씌운다. 최종적으로 cnt가 2가 아니라 1인 상황이다. ★★
~~> 우측도 보자(그림이 약간 잘못되었다). cnt가 0인 상황에서, T0의 L1이 수행된다. 이어서 Switch가 일어나고, T1의 L-U-S가 모두 수행된다. 당근 cnt는 1일 것이다. 이어서 Switch가 일어나고, T0의 나머지 U-S가 수행되는데, 이때, T0의 rdx에는 0이라는 값이 있었으므로, 1을 cnt에 덮어씌운다. 즉, 역시나 최종적으로 cnt가 2가 아니라 1인 상황이다. ★★
=> 즉, Sequentially Consistent Interleaving 상황에 따라, 의도와는 동 떨어진 결과가 나타날 수 있는 것이다.
Shared Variable을 다루는 Thread의 Routine은 Atomic해야한다. 그렇지 않으면, 위와 같이 수행 도중의 Context Switch에 의해 의도가 왜곡될 수 있다.
※ cnt 전역 변수를 volatile로 선언하는 것의 효과 : Main Memory DRAM의 Object와 (CPU 옆의) Cache에 있는 Object(DRAM에서 가져온)가 서로 동기화되도록 volatile 선언을 한다.
~> 즉, Main Memory와 Cache의 Version 차이를 방지하는 것이다.
~> 무슨말이냐 하면, Concurrent Programming 시에는 어느 Thread(Process)가 먼저 해당 전역 변수에 접근할지 모르기 때문에, Cache와 실제 DRAM 상의 Variable Object Value가 서로 다를 수 있다. volatile 선언을 하면, 사실상 Cache가 없는 효과를 내어, Version & Concurrency Issue를 막는 것이다. ★
우리는 이러한 상황을, 'Progress Graph'로 좀 더 자세히 분석할 수 있다. 연재 초반에 다룬 Process Graph와는 다른 것이다. 주의하라.
Trajectory : State Transition의 Sequence를 우리는 'Trajectory'라고 부른다.
현재, 이 예시에서, 각 Thread는 'Header->Load->Update->Store->Tail'의 과정을 거친다.
이 중, 'Load->Update->Store'의 과정이 Atomic해야, 우리가 의도하는 프로그래밍이 되는 것이다.
이러한, '프로그래머의 의도에 맞는 수행이 이뤄지기 위해 Atomic해야하는 명령 시퀀스'를 'Critical Section'이라고 부른다. ★★
두 Critical Section이 이루는 '(교차)영역'을 우리는 'Unsafe Region'이라고 한다. 침범이 일어나면 안되는 지역이기 때문에 이러한 이름이 붙었다. ★★
Instructions in Critical Section should not be interleaved!!
즉, 이 Critical Section을 보호해주는 장치가 필요하다.
이를 '(Thread) Synchronization'이라 한다.
금일 포스팅은 여기까지이다. 우리는 다음 포스팅에서 설명한 동기화 문제에 대한 해결책에 대해 알아볼 것이다.