현재 우리는 Concurrent Server를 구축하는 3가지 방법론에 대해 논하고 있다. 지난 포스팅에서 우린 Process-based와 Event-based의 디테일과 차이점에 대해 알아보았다. 오늘은 마지막 방법론인 Thread-based Server에 대해 알아보는 시간을 가질 것이다.
우리가 프로그램을 구동한다고 해보자. 하드웨어에는 CPU, 메인 메모리(DRAM)가 있다. 알다시피, 메인 메모리 DRAM은, Power가 Charging되는 상태여야 메모리 셀의 데이터를 유지하는 Volatile Memory이다.
한편, 이때 프로세스 Pa가 생기면, 메인 메모리에 해당 프로그램의 'Sequence of Instructions'가 적재된다. 적재될 때, 프로세스는 'Code-Data-Stack-Heap'이라는 영역으로 구분되어 적재된다.
동적인 메모리들이 Stack과, Heap에 들어가는데, Stack은 Local Variable이나 Function Return Address를, Heap은 Dynamic Allocation Variable을 담는다. Stack과 Heap은 서로 마주보면서 나아가는 방향으로 공간을 차지한다. 메모리 주소 번지값이 High에서 Low로 가는 방향으로 Code, Data, Stack, Heap이 순서대로 자리한다(이는 다를 수 있다).
앞서 말했듯이, 프로세스는 'Object Code / Machine Code / Instruction의 Set'이다.
한편, CPU 내부를 보자. 안에는 Register Set이 있다. 그 안에는 PC/IP(Program Counter, Instruction Pointer), IR(Instruction Register), SP(Stack Pointer), 범용 레지스터(x86 기준 EAX..) 등이 있다. 이 Registers와 더불어, ALU(Arithmetic and Logical Unit), CU(Control Unit)가 함께 CPU에 존재한다.
메인 메모리에 연결되어 있는 Bus에는 Address Bus, Control Bus, Data Bus가 있다. 이 세 가지 Bus를 통해서 CPU와 Main Memory가 데이터를 교환한다.
이때, CPU가 구동되면, 'Instruction Fetch -> Instruction Decode -> Execution -> Write Back'의 과정이 반복적으로 수행된다. PC를 Increment해가면서 말이다. CPU 입장에선, Fetch를 위해 Pa의 Code 메모리 영역 내에 저장된 명령어를 가져와야한다. 첫 번째 명령어부터 말이다.
여기까지가 하드웨어 관점에서의 프로그램 실행 루틴이자, Execution Flow이다.
이러한 Execution Flow는 프로세스 하나마다 하나씩 존재한다. 논리적인, Conceptual한 관점에서 말이다. 실행해야하는 명령어들의 Sequence가 곧 Execution Flow인 것이다.
이때, Pa가 fork하여 Pb가 만들어졌다고 해보자. 동일하게 메인 메모리 공간에 Code-Data-Stack-Heap 영역이 적재된다. CPU는 하나이고, 프로세스는 두 개인 상황이다. 알다시피, CPU는 Time Quantam을 기준으로 Pa와 Pb를 왔다 갔다 하면서 수행한다.
fork 후 exec을 하지 않았다고 해보자. 그렇다면, Pb는 Pa와 동일한 일을 하고 있다. 동일한 일을 하지만, 프로세스는 두 개이기 때문에 Execution Flow는 두 개인 상황이다.
Process-based Concurrent Server를 구현한다고 하면, Pa와 Pb의 메인 메모리 적재 공간 위치는 서로 Overlap되지 않은 별개의 Address Space이다. 그렇기 때문에, 이전 포스팅에서 언급한 것처럼, Process-based Server는 Overhead가 심하다고 했다. 이때, 우리는 다음과 같은 생각을 해볼 수 있다.
복수의 별도 Execution Flow를, 굳이 fork를 띄워서 만들지 말고, 즉, 프로세스를 추가로 생성하지 말고, 어차피 서버 구축 상황에서는 Pa나 Pb나 둘 다 같은 코드를 기반으로 한 Execution Flow인데, 이를 더 효율적으로 처리할 수 있는 방법이 있지 않을까? ★
이런 아이디어로 등장한 개념이 Thread이다.
N Thread Process : 하나의 프로세스에 대해 여러 개의 Execution Flow를 띄울 수 있는 프로세스. 새로 프로세스를 생성하지 않고, 동일한 일을 하는 Execution Flow를 새로 만드는 것이다.
Process에 대한 Traditional View
Process = Process Context + Code/Data/Stack (Heap은 생략)
Process = Process Context(Program Context + Kernel Context) + Code/Data/Stack
Process Context
Program Context
Kernel Context
Code/Data/Stack/(Heap) : 메인메모리에 적재된 프로세스의 영역들
Process에 대한 Alternative View
Process = Thread + Code/Data/KernelContext
Process = Thread(Program Context + Stack) + Code/Data + Kernel Context
앞선, 전통적인 Process View에서, Stack을 Process Context로 가져오고, 거기서 Kernel Context를 빼서 Code/Data에 붙여준다.
변화된 것은 하나도 없다. 오로지 '관점(View)'만 바뀐 것이다. ★★★
Traditional View of Process에서 프로세스를 fork하면, 그대로 양쪽 모두, 즉, Process Context와 Code/Data/Stack이 모두 복제되어 Child Process가 생성된다. ★
반면, Alternative View of Process에서 프로세스를 복제하면(여기선 fork라 하지 않음), Code/Data/KernelContext는 그대로 유지하고, 오로지 Thread(Stack + Program Context)만 복제한다. ★
Thread와 Process의 공통점
Thread와 Process의 차이점
Thread는 Local Variable을 위한 Stack을 제외하고는 모두 공유한다.
Thread 관리는 Process 관리에 비해 Overhead가 상대적으로 덜하다.
프로세스 하나에는 여러 개의 Thread를 만들 수 있다.
하나의 프로세스(스레드)에서 스레드를 새로 생성하면, 이를 'Peer Thread'라고 부른다.
따라서, 위의 그림을 보면, T1 & T2, T1 & T3가 서로 Concurrent Flow 관계이다. ★
한편, T2 & T3는 각 Thread의 시작과 끝이 겹치지 않으므로 Sequential Flow 관계이다. ★
우리는 아래와 같은 POSIX 제공 Interface를 이용해 Thread를 다룰 것이다.
pthread_create() // 프로세스의 fork같은 개념
pthread_join() // 프로세스의 wait같은 개념
pthread_self() // TID를 알아낸다.
pthread_cancel() // Thread 종료
pthread_exit() // Thread 종료
exit() // Thread 종료
Thread Interface를 이용해서 간단한 프로그램을 아래와 같이 만들어볼 수 있다. Stevens Style의 Wrapper를 씌웠다.
/* 생성될 Thread가 수행할 Routine */
void *whatToDo(void *vargp) {
printf("친구들아 안녕? 나는 Thread라고 해!\n");
return NULL;
}
int main(void) {
pthread_t tid;
Pthread_create(&tid, NULL, whatToDo, NULL); // Thread 생성
Pthread_join(tid, NULL); // Thread Reaping
return 0;
}
Main Thread(=Process)가 있고, pthread_create를 이용해 새로운 Thread를 Create하면, Peer Thread가 생성된다.
pthread_join을 이용해 생성된 Thread가 종료되는 것을 기다린다.
pthread_create(TID 변수 주소값, 일반적으로 NULL, Thread Routine, Routine의 Arguments);
pthread_join(TID 변수값, 리턴값);
아래와 같은 흐름을 가진다.
Thread-based Server : 기본적으로 Process-based Server 방법론과 매우 유사하다. 단지, Process 대신 Thread를 이용하는 것이다.
Thread 개념을 이해했다면, Thread-based가 Process-based보다 시공간적 Overhead가 상대적으로 적다는 것을 바로 알 수 있다. ★
반면에, 앞서 Thread는 특성상 다른 Thread의 Stack 참조를 막을 수 없어 보안적 측면에서는 취약하다는 것도 알 수 있다.
이론적 설명은 앞선 'Thread의 이해'에서 충분히 진행했으므로, 바로 Code-Level Analysis를 해보자. 프로세스 기반 서버 코드와 매우 유사하기 때문에 어렵지 않게 이해할 수 있다.
/* Routine of Peer Thread */
void *thread(void *vargp); // 후술
/* Thread-based Concurrent Server */
int main(int argc, char **argv) {
int listenfd, *connfdp;
pthread_t tid;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
listenfd = Open_listenfd(argv[1]); // listen까지의 작업 수행
while (1) {
clientlen=sizeof(struct sockaddr_storage); // 늘 말하듯이 중요한 과정
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen);// Accept!
Pthread_create(&tid, NULL, thread, connfdp); // Peer Thread로 만들자!
}
}
connfdp라는 포인터변수를 만든다. Main Thread의 Stack 공간에 포인터변수로서 존재한다. Heap 공간에 있는 Word Size(ex. 4Bytes) 공간을 가리킨다.
Main Thread가 pthread_create를 하면, Peer Thread에게 이 포인터변수가 가리키는 Heap 공간을 넘긴다.
그래서, Process-based에서는 Non-Pointer를 사용해서 Close하던 것과 다르게, 따로 Close하지는 않는다. ★★★
listenfd 자체는 닫을 필요도 없다. 왜냐면, 별도의 공간에 있는 것이 아니니까. ★
물론, 후술할 것이지만, 이 malloc 방식은 좋지 않은 방식이다. 일단은 이어가자.
/* Routine of Peer Thread */
void *thread(void *vargp) {
int connfd = *((int *)vargp); // 넘겨받은 Heap 공간값을 connfd가 가리킨다.
Pthread_detach(pthread_self()); // 아래에서 설명할 것! (Reaping 관련)
Free(vargp); // 값을 추출했으므로, 힙공간은 해제하자.
echo(connfd); // connfd에 대해서 Service를 제공한다!
Close(connfd); // 서비스 끝나면 디스크립터를 닫자!
return NULL; // pthread_create 함수에게 NULL을 넘김(관습)
}
각 Client는 개별의 Peer Thread에 의해 핸들링된다.
각 Thread는 TID를 제외한 모든 Process(Main Thread) State를 공유한다. ★
Thread-based Server 구현 시 주의점1
앞서, pthread_join이 Process 관점에서의 wait 역할이라 하지 않았나? 근데 왜 pthread_detach를 사용하는가?
pthread_join은 Main Thread 뿐만 아니라, Peer Thread에서도 언제 어디서든 호출하여 다른 Thread를 Reaping할 수 있다. (자유 접근 문제)
반면, pthread_detach를 호출하면, 다른 Peer Thread에서는 해당 Thread를 건들지 못하면서 동시에 OS Kernel이 알아서 Reaping해준다. ★
Thread-based Server 구현 시 주의점2
'Unintended Sharing' 문제도 주의해야한다.
Thread Programming의 어찌보면 가장 큰 위험이다. Thread끼리 하나의 Heap/Data/Code 등의 영역을 공유하면, 의도치 않은 데이터 공유 및 Corruption 문제가 발생할 수 있다.
대표적이 예시가 바로 위의 예시 서버 코드이다. Pthread_create함수에 connfdp라는 포인터 변수를 넘기고 있는데, 만약, Context Switch가 일어나고, 다른 Thread가 Accept해버리면, 매우 심각한 문제가 발생한다. 서비스 제공이 이상하게 돌아갈 수 있는 것이다.
이러한 문제들을 해결하기 위해, 과거 Process를 다룰 때 'Async-Signal-Safety'가 중요했던 것처럼, 'Thread-Safe'한 함수들을 사용해야한다. 이를 다음 포스팅에서 소개하겠다.
장점
Thread끼리 자료구조의 공유가 용이하다. IPC같은 것이 필요없으므로!
Process-based에 비해 확실히 성능이 우수하다. (Efficient)
단점
금일 포스팅은 여기까지이다. Concurrent Programming 개념의 막바지에 다다르고 있다. 어서 더 열심히 공부해보자.