Krafton Jungle/4. CSAPP

[Computer System] ⑫ 동시성 프로그래밍 (1)

munsik22 2025. 5. 5. 21:21

지난 11장에서 구현했던 iterative server는 한 번에 하나의 요청만 처리할 수 있기 때문에, 둘 이상의 클라이언트가 존재할 때 한 클라이언트의 요청을 처리하는 동안 다른 클라이언트는 끝날 때까지 기다려야 하는 문제가 있었다. 여러 클라이언트를 동시에 처리해야 할 때는 concurrent server를 사용하는 것이 더 적절하다.

  1. Process 기반
    • 커널이 자동적으로 다중 논리 흐름을 끼워넣는다.
    • 각 흐름은 각각의 사적 주소 공간을 가진다.
  2. Event 기반
    • 개발자가 직접 다중 논리 흐름을 끼워넣는다.
    • 모든 흐름은 같은 주소 공간을 공유한다.
    • I/O 다중화라는 기술을 사용한다.
  3. Thread 기반
    • 커널이 자동적으로 다중 논리 흐름을 끼워넣는다.
    • 각 흐름은 같은 주소 공간을 공유한다.
    • 프로세스 기반 + 이벤트 기반

12.1 프로세스를 이용한 동시성 프로그래밍

1단계: 서버는 클라이언트로부터의 연결 요청을 수락한다.
2단계: 서버는 자식 프로세스를 fork하고 클라이언트를 서비스한다.
3단계: 서버는 다른 연결 요청을 수락한다.
서버는 다른 자식을 fork해서 새 클라이언트를 서비스한다.

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

💻 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가 이뤄지는 과정

  1. 서버가 accept를 호출하고 리스팅 디스크립터 listenfd로의 연결 요청을 기다린다.
  2. 클라이언트가 connect를 호출함으로써 연결 요청을 생성한다.
  3. 서버는 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 배열로 활성 연결의 세트를 유지한다.
  • 아래 동작들을 반복한다:
    1. connfd / listenfd 중 어떤 디스크립터가 보류 중인 입력들(pending inputs)을 가질 지 결정한다.
      • (예) select나 epoll 함수 사용
      • 펜딩 입력의 도착을 이벤트라 한다.
    2. listenfd가 입력을 받았으면 연결을 accept하고 새 connfd를 배열에 추가한다.
    3. 모든 connfd를 펜딩 입력을 가지고 서비스한다.

🔹 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이라고 한다.

A와 B, A와 C는 concurrent하고, B와 C는 sequential하다.

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

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

2개 코어로 3개 쓰레드를 돌린다.

🔹 쓰레드 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해야 한다.

🔹 쓰레드 기반 디자인의 장단점

  • 장점
    • 쓰레드 간에 자료 구조 공유가 쉬움 (로깅 정보, 파일 캐시 등)
    • 쓰레드들이 프로세스보다 더 효율적임
  • 단점
    • 의도치 않은 공유가 미묘하고 재생산하기 어려운 에러를 유발한다. (추적하기 어려움!)