지난 11장에서 구현했던 iterative server는 한 번에 하나의 요청만 처리할 수 있기 때문에, 둘 이상의 클라이언트가 존재할 때 한 클라이언트의 요청을 처리하는 동안 다른 클라이언트는 끝날 때까지 기다려야 하는 문제가 있었다. 여러 클라이언트를 동시에 처리해야 할 때는 concurrent server를 사용하는 것이 더 적절하다.
- Process 기반
- 커널이 자동적으로 다중 논리 흐름을 끼워넣는다.
- 각 흐름은 각각의 사적 주소 공간을 가진다.
- Event 기반
- 개발자가 직접 다중 논리 흐름을 끼워넣는다.
- 모든 흐름은 같은 주소 공간을 공유한다.
- I/O 다중화라는 기술을 사용한다.
- Thread 기반
- 커널이 자동적으로 다중 논리 흐름을 끼워넣는다.
- 각 흐름은 같은 주소 공간을 공유한다.
- 프로세스 기반 + 이벤트 기반
12.1 프로세스를 이용한 동시성 프로그래밍




- 각 클라이언트를 위해 분리된 프로세스를 둔다.

💻 Process 기반 동시성 에코 서버 코드
void sigchld_handler(int sig) { /* Reap all zombie children */
while (waitpid(-1, 0, WNOHANG) > 0)
;
return;
}
int main(int argc, char **argv) {
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
Signal(SIGCHLD, sigchld_handler);
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
if (Fork() == 0) {
Close(listenfd); /* Child closes its listening socket */
echo(connfd); /* Child services client */
Close(connfd); /* Child closes connection with client */
exit(0); /* Child exits */
}
Close(connfd); /* Parent closes connected socket (important!) */
}
}
🔹 동시성 서버에서 accept가 이뤄지는 과정
- 서버가 accept를 호출하고 리스팅 디스크립터 listenfd로의 연결 요청을 기다린다.
- 클라이언트가 connect를 호출함으로써 연결 요청을 생성한다.
- 서버는 accept로부터 connfd를 리턴한다. 클라이언트를 다루기 위해 자식 프로세스를 fork한다. 이제 연결은 clientfd와 connfd 사이에서 성립되었다.

🔹 프로세스 기반 서버 실행 모델
- 각 클라이언트는 독립적인 자식 프로세스에 의해 다뤄진다.
- 자식 프로세스들 사이에서 공유되는 상태는 없다.
- 부모&자식 프로세스는 listenfd와 connfd의 복사본을 가지고 있다.
- 부모는 connfd를 close해야 한다.
- 자식은 listenfd를 close해야 한다.

🔹 프로세스 기반 서버의 이슈들
- 리스닝 서버 프로세스는 치명적인 메모리 누설을 피하기 위해 좀비 프로세스를 제거해야 한다.
- 부모 프로세스는 그것의 connfd의 복사본을 close해야 한다.
- 커널은 각 소켓 및 열린 파일에 대해 참조 카운트를 유지한다.
- fork 이후 refcnt(connfd) = 2
- 연결은 refcnt(connfd) = 0가 되기 전까지는 close되지 않는다.
🔹 프로세스 기반 서버의 장단점
- 장점
- 여러 연결들을 동시에 다룰 수 있다.
- clean한 공유 모델 (디스크립터 ❌, 파일 테이블 ⭕, 전역 변수 ❌)
- 단순하고 직관적임
- 단점
- 프로세스 컨트롤에 추가적인 오버헤드 발생
- 프로세스 간에 데이터 공유가 어려움 → IPC 메커니즘을 사용해야 함 (named pipes, system V shared memory, semaphores...)
12.2 I/O 다중화를 이용한 동시성 프로그래밍
🔹 이벤트 기반 서버
- 서버는 connfd 배열로 활성 연결의 세트를 유지한다.
- 아래 동작들을 반복한다:
- connfd / listenfd 중 어떤 디스크립터가 보류 중인 입력들(pending inputs)을 가질 지 결정한다.
- (예) select나 epoll 함수 사용
- 펜딩 입력의 도착을 이벤트라 한다.
- listenfd가 입력을 받았으면 연결을 accept하고 새 connfd를 배열에 추가한다.
- 모든 connfd를 펜딩 입력을 가지고 서비스한다.
- connfd / listenfd 중 어떤 디스크립터가 보류 중인 입력들(pending inputs)을 가질 지 결정한다.
🔹 I/O 다중 이벤트 프로세싱

💻 I/O 다중화 기반 iterative 에코 서버 코드
#include "csapp.h"
void echo(int connfd);
void command(void);
int main(int argc, char **argv) {
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
fd_set read_set, ready_set;
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
FD_ZERO(&read_set); /* Clear read set */
FD_SET(STDIN_FILENO, &read_set); /* Add stdin to read set */
FD_SET(listenfd, &read_set); /* Add listenfd to read set */
while (1) {
ready_set = read_set;
Select(listenfd+1, &ready_set, NULL, NULL, NULL);
if (FD_ISSET(STDIN_FILENO, &ready_set))
command(); /* Read command line from stdin */
if (FD_ISSET(listenfd, &ready_set)) {
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
echo(connfd); /* Echo client input until EOF */
Close(connfd);
}
}
}
void command(void) {
char buf[MAXLINE];
if (!Fgets(buf, MAXLINE, stdin))
exit(0); /* EOF */
printf("%s", buf); /* Process the input command */
}
🔹 이벤트 기반 서버의 장단점
- 장점
- 한 개의 논리적 제어 흐름과 주소 공간 사용
- 디버거로 single-step 가능
- 프로세스/쓰레드 컨트롤 오버헤드 없음
- 단점
- 프로세스/쓰레드 기반 디자인보다 코드가 복잡하다.
- Fine-grained한 동시성 제공이 어렵다.
- 멀티코어의 장점을 이용하기기 어렵다 (단일 쓰레드 사용)
💡 이러한 단점들에도 불구하고 Node.js, nginx, Tornado 같은 현대의 고성능 서버들은 (I/O 다중화 기반) 이벤트 기반 프로그래밍을 이용하는데, 그 이유는 주로 프로세스와 쓰레드에 비해 엄청난 성능 때문이다.
12.3 쓰레드 실행 모델
프로세스 기반 모델과 비슷하지만, 프로세스 대신 쓰레드를 사용한다는 점에서 차이가 있다.
🔹 고전적 관점의 프로세스
- 프로세스 = 프로세스 컨텍스트 + 코드, 데이터, 스택

🔹 다른 관점의 프로세스
- 프로세스 = 쓰레드 + 코드, 데이터, 커널 컨텍스트

🔹 다중 쓰레드를 가진 프로세스
- 각 쓰레드는 각각의 논리적 제어흐름을 가지고 있다.
- 각 쓰레드는 같은 코드, 데이터, 커널 컨텍스트를 공유한다.
- 각 쓰레드는 각각의 지역변수를 위한 스택을 가진다. (다른 쓰레드들로부터 보호되지는 않는다)
- 각 쓰레드는 각각의 TID(쓰레드 id)를 가진다.

🔹 쓰레드의 논리적 관점
한 프로세스와 관련된 쓰레드들은 (트리 계층구조를 형성하는 프로세스들과는 다르게) pool of peers를 형성한다.

🔹 동시성 쓰레드
- 두 쓰레드의 흐름이 어떤 시간에 겹친다면 동시적concurrent이라고 한다.

- 단일 코어 프로세스는 타임 슬라이싱을 통해 병렬성parallelism을 구현한다.

- 멀티 코어 프로세스는 진짜로 병렬성을 가질 수 있다.

🔹 쓰레드 vs 프로세스
- 공통점
- 각각은 각각의 논리적 제어흐름을 가진다.
- 각각은 다른 것들과 동시적으로 실행될 수 있다.
- 각각은 문맥 전환된다.
- 차이점
- 쓰레드는 (전역 스택을 제외한) 모든 코드와 데이터를 공유하지만, 프로세스는 (일반적으로) 그렇지 않는다.
- 쓰레드는 프로세스보다 비용이 적다. (프로세스의 생성과 제거가 쓰레드에서 발생하는 비용의 2배)
🔹 Posix Threads (Pthreads) 인터페이스
Pthreads는 C 프로그램에서 쓰레드를 조작하는 표준 인터페이스다.
- 쓰레드의 생성과 제거
- pthread_create()
- pthread_join()
- 쓰레드 ID 결정
- pthread_self()
- 쓰레드 종료
- pthread_cancel()
- pthread_exit()
- exit() (모든 쓰레드 종료), RET (현재 쓰레드 종료)
- 쓰레드 분리
- pthread_detach()
- 쓰레드 초기화
- pthread_once()
- 공유 변수 접근 동기화
- pthread_mutex_init
- pthread_mutex_[un]lock
💻 Pthreads를 사용한 "hello, world" 프로그램
#include "csapp.h"
void *thread(void *vargp);
int main() {
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
Pthread_join(tid, NULL);
exit(0);
}
void *thread(void *vargp) /* thread routine */
{
printf("Hello, world!\n");
return NULL;
}

💻 쓰레드 기반 동시성 에코 서버
int main(int argc, char **argv)
{
int listenfd, *connfdp;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
pthread_t tid;
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen=sizeof(struct sockaddr_storage);
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, connfdp);
}
}
- 연결된 디스크립터의 malloc은 deadly race를 피하기 위해 필요하다.
/* Thread routine */
void *thread(void *vargp)
{
int connfd = *((int *)vargp);
Pthread_detach(pthread_self());
Free(vargp);
echo(connfd);
Close(connfd);
return NULL;
}
- 쓰레드를 분리된detached 모드에서 작동시킨다.
- 다른 쓰레드들과는 독립적으로 실행시킴
- 종료되면 (커널에 의해) 자동으로 제거됨
- connfd를 유지하기 위해 할당된 저장공간을 free함
- connfd를 close함 (중요!)
🔹 쓰레드 기반 서버 실행 모델

- 각 클라이언트는 각각의 동료peer 쓰레드에 의해 다뤄짐
- 쓰레드들은 TID를 제외한 모든 프로세스 상태를 공유함
- 각 쓰레드는 지역 변수를 위한 분리된 스택을 가짐
🔹 쓰레드 기반 서버의 이슈
- 메모리 누수를 피하기 위해 분리된 상태로 실행해야 한다.
- 언제나 쓰레드는 연결 가능하거나 분리되어 있다.
- 연결 가능joinable한 쓰레드는 다른 쓰레드에 의해 제거될 수 있다. → 메모리 자원을 free하려면 pthread_join으로 제거되어야 한다.)
- 분리된 쓰레드는 다른 쓰레드에 의해 제거될 수 없다. → 리소스는 종료 시에 자동으로 제거된다.
- 기본 상태는 연결 가능한 상태다. → 분리 상태로 만들려면 pthread_detach(pthread_self())를 써야 함
- 의도치 않은 공유를 피해야 한다.
- Pthread_create(&tid, NULL, thread, (void *)&connfd);
- 쓰레드에 의해 호출되는 모든 함수들은 쓰레드-안전thread-safe해야 한다.
🔹 쓰레드 기반 디자인의 장단점
- 장점
- 쓰레드 간에 자료 구조 공유가 쉬움 (로깅 정보, 파일 캐시 등)
- 쓰레드들이 프로세스보다 더 효율적임
- 단점
- 의도치 않은 공유가 미묘하고 재생산하기 어려운 에러를 유발한다. (추적하기 어려움!)
'Krafton Jungle > 4. CSAPP' 카테고리의 다른 글
| [Computer System] ⑫ 동시성 프로그래밍 (2) (0) | 2025.05.05 |
|---|---|
| [Computer System] ⑪ 네트워크 프로그래밍 (2) (0) | 2025.05.02 |
| [Computer System] ⑪ 네트워크 프로그래밍 (1) (0) | 2025.05.01 |
| [Computer System] ⑨ 가상메모리 (2) (0) | 2025.04.24 |
| [Computer System] ⑨ 가상메모리 (1) (0) | 2025.04.22 |