본문 바로가기

[CSAPP] 12장 동시성 프로그래밍 (1/3) [1] Process [2] I/O Multiplexing

 

출처
Randal E. Bryant & David R. O'Hallaron. (2015). Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.

 
여러 논리 제어 흐름들이 시간적으로 겹칠 때 논리 제어 흐름이 동시적이라고 이야기한다. 동시성이라고 불리는 현상은 컴퓨터 시스템의 다양한 레벨에서 나타난다. 하드웨어 예외 핸들러, 프로세스, Linux 시그널 핸들러가 흔한 예시이다. 
지금까지 살펴본 동시성은 운영체제 커널이 여러 애플리케이션 프로그램을 실행시키기 위해 사용하는 메커니즘으로서의 동시성이었다. 하지만 동시성은 커널에만 국한된 것은 아니다. 애플리케이션 프로그램에서도 동시성은 중요한 역할을 한다. 예를 들어, Linux 시그널 핸들러는 애플리케이션이 Ctrl+C 입력이나 정의되지 않은 가상 메모리 영역에 접근하는 것과 같은 비동기 이벤트에 대응할 수 있게 해준다. 애플리케이션 레벨 동시성은 여러 면에서 유용하다.

  • 느린 I/O 장치에 접근하기
    애플리케이션이 디스크와 같은 느린 I/O 장치로부터 데이터가 도착하기를 기다릴 때 커널은 CPU가 놀지 않도록 다른 프로세스를 실행시킨다. 애플리케이션도 I/O 요청과 다른 작업을 같이 진행하는 식으로 동시성을 사용한다. 
  • 인간과 상호작용하기
    컴퓨터와 상호작용하는 사람은 한 번에 많은 일을 수행할 수 있어야 한다. 예를 들어, 문서를 출력하는 와중에 윈도우 크기를 조정할 수 있다. 현대의 윈도우 시스템은 이러한 일을 가능하게 하기 위해서 동시성을 사용한다. 사용자가 마우스 클릭과 같은 액션을 요청할 때마다 이러한 액션을 수행하기 위해 별개의 동시성 흐름이 생성된다. 
  • 작업을 지연시킴으로써 레이턴시 감소시키기
    때때로 애플리케이션은 어떤 작업의 레이턴시를 감소시키기 위해 다른 작업을 지연시키고 이들을 동시에 실행시킨다. 예를 들어, 동적 스토리지 할당기는 free 작업의 레이턴시를 감소시킨다. 낮은 우선순위에서 실행되는 동시적 “합병” 흐름을 지연시키고 CPU가 사용 가능해질 때 CPU를 사용한다.
  • 다수의 네트워크 클라이언트에게 서비스 제공하기
    앞에서 학습한 반복적인 네트워크 서버는 현실적이지 않다. 한 번에 하나의 클라이언트에게만 서비스를 제공할 수 있기 때문이다. 하나의 느린 클라이언트는 다른 모든 클라이언트에게 서비스해주는 것을 거부하게 만들 수 있다. 초당 수백 개, 수천 개의 클라이언트에게 서비스해줄 수 있어야 하는 서버는 그래서는 안 된다. 각각의 클라이언트를 위한 별개의 논리 흐름을 생성하는 동시 서버를 구축해야 한다. 이렇게 하면 여러 클라이언트에게 동시에 서비스를 제공할 수 있고 느린 클라이언트들이 서버를 독점하는 것을 막을 수 있다.
  • 멀티 코어 머신에서 병렬적으로 연산하기
    많은 현대 시스템은 여러 개의 cpu를 가진 멀티 코어 프로세서를 갖추고 있다. 동시 흐름으로 나누어진 애플리케이션은 단일 프로세서 머신에서보다 멀티 프로세서 머신에서 더 빨리 동작한다. 왜냐하면 흐름들이 교차되지 않고 병렬적으로 실행되기 때문이다.

애플리케이션 레벨의 동시성을 사용하는 애플리케이션을 동시성 프로그램 concurrent program이라고 한다. 현대 운영체제상에서 동시성 프로그램을 만드는 방법으로는 기본적으로 3가지가 있다.

  • 프로세스
    프로세스를 이용할 때, 각각의 논리 제어 흐름은 커널에 의해 스케줄되고 관리되는 프로세스가 된다. 프로세스는 각각 별도의 가상 주소 공간을 가지기 때문에 프로세스간 통신하고 싶다면 명시적인 IPC interprocess communication 메커니즘을 이용해야 한다.
  • I/O 멀티플렉싱
    애플리케이션이 단일 프로세스의 컨텍스트에서 자신의 논리 제어 흐름들을 명시적으로 스케줄링하는 형태의 동시성 프로그래밍 방식이다.
  • 스레드
    스레드는 싱글 프로세스의 컨텍스트에서 실행되고 커널에 의해 스케줄링되는 논리 흐름이다. 스레드는 위 두 개의 접근을 합친 것이라고 생각하면 된다. 스레드는 프로세스처럼 커널에 의해 스케줄링되며, I/O 멀티플렉싱처럼 같은 주소 공간을 공유한다.

이 챕터에서는 이 세 가지 동시성 프로그래밍 기술을 알아볼 것이다. 구체적인 논의를 위해 11장에서 학습했던 iterative echo server를 계속해서 예시로 사용할 것이다.

1. 프로세스를 이용한 동시 프로그래밍

동시성 프로그램을 만드는 가장 간단한 방법은 프로세스를 이용하는 것이다. 친숙한 함수들인 fork, exec, waitpid를 이용한다. 예를 들어, 동시성 서버를 만드는 자연스러운 접근은 부모 프로세스에서 클라이언트 커넥션 요청을 받고 각각의 클라이언트에게 서비스를 제공할 자식 프로세스를 생성하는 것이다.
이것이 어떻게 작동하는지 살펴보기 위해서 2개의 클라이언트와 listening descriptor (3번)에서 연결 요청을 listening 중인 1개의 서버가 있다고 해보겠다. 서버가 클라이언트 1의 연결 요청을 받아 connected descriptor (4번)를 반환했다고 해 보자.

서버는 커넥션 요청을 받은 후에 서버는 서버의 descriptor table의 완전한 복사본을 가지는 자식 프로세스를 fork한다. 자식 프로세스는 복사본에서 listening descriptor (3번)을 close하고 부모 프로세스는 connected descriptor (4번)을 close한다. 부모 프로세스와 자식 프로세스에 있는 connected descriptor가 모두 같은 파일 테이블 항목을 가리키고 있기 때문에 부모 프로세스가 connected descriptor를 close하는 것은 중요하다. 그렇지 않으면 connected descriptor (4번)은 영원히 해제되지 않을 것이고 메무리 누수가 발생할 것이다.

 
부모 프로세스가 클라이언트 1을 위해 자식 프로세스를 생성한 다음에 부모 프로세스가 클라이언트 2의 connection 요청을 받아서 새로운 connected descriptor (5번)을 반환했다고 해 보자. 부모 프로세스는 또 다른 자식 프로세스를 fork할 것이다. 이 자식 프로세스는 connected descriptor (5번)을 이용하여 클라이언트에게 서비스를 제공할 것이다. 

(1) 프로세스에 기반한 동시 서버

다음은 프로세스에 기반을 둔 동시성 에코 서버이다. 여기에는 중요한 포인트 몇 가지가 있다.

  • 첫째, 서버는 일반적으로 오랜 시간 동안 실행된다. 따라서 서버에 좀비 자식 프로세스를 거두는 SIGCHLD 핸들러를 포함시켜야 한다. SIGCHLD 시그널은 SIGCHLD 핸들러가 실행되는 동안 block되기 때문에, 그리고 Linux 시그널은 block되지 않기 때문에 SIGCHLD 핸들러는 여러 개의 자식 좀비 프로세스를 거둘 수 있도록 준비되어야 한다.
  • 둘째, 부모 프로세스와 자식 프로세스는 그들의 connfd의 복사본을 close해야 한다. 특히 부모 프로세스에서 close하는 것이 중요하다.
  • 마지막으로, 소켓의 파일 테이블 엔트리에 있는 참조 카운트 reference count로 인해 클라이언트와의 연결은 부모 프로세스와 자식 프로세스의 connfd가 모두 close될 때까지 종료되지 않을 것이다.

(2) 프로세스 방식의 장점과 단점

프로세스는 부모 프로세스와 자식 프로세스가 state 정보를 공유할 수 있는 좋은 모델이다. 파일 테이블은 공유되고, 사용자 주소 공간은 공유되지 않는다.
프로세스마다 별개의 주소 공간을 가진다는 것은 장점과 단점이 모두 있다. 장점은 하나의 프로세스가 다른 프로세스의 가상 메모리에 실수로 overwirte 하게 되는 상황이 불가능하다는 점이다. 이는 많은 혼란스러운 실패를 막아준다. 반면 단점은 별개의 주소 공간은 프로세스들이 state 정보를 공유하는 것을 까다롭게 만든다는 점이다. 프로세스간 정보를 공유하려면 이들은 명시적인 IPC를 이용해야 한다. 프로세스 기반 설계는 프로세스 컨트롤과 IPC의 오버헤드로 인해 느리다는 단점이 있다.
 
<프로세스 기반 동시성 에코 서버 코드>

#include "csapp.h"
void echo (int connfd);	/* Omit implementation of it */

void sigchld_handler (int sig)
{
    while (waitpid (-1, 0, WNOHANG) > 0)
    	;
    return;
}

int main (int argc, char **argv)
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    
    if (argc != 2) {
    	fprintf (stderr, "usage: %s <port>\n", argv[0]);
        exit (0);
    }
    
    Signal (SIGCHLD, sigchld_handler);
    listenfd = Open_listenfd (argv[1]);
    while (1) {
    	clientlen = sizeof (sturct 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!) */ 
}

 
<Unix IPC>
waitpid 함수와 시그널도 같은 호스트에 있는 프로세스가 프로세스에게 작은 메시지를 보낼 수 있는 primitive 메커니즘이다. 소켓 인터페이스는 서로 다른 호스트에 있는 프로세스들이 임의의 바이트 스트림을 교환할 수 있는 IPC이다. 하지만 Unix IPC는 일반적으로 같은 호스트에서 실행 중인 프로세스간 통신을 허용하는 hodegepodge of technique를 의미한다. 예로는 파이프, FIFO, System V shared memory, System V semaphore가 있다. Kerrisk의 책이 좋은 레퍼런스이다.

2. I/O 멀티플렉싱을 이용한 동시 프로그래밍

사용자가 표준 입력에 타이핑한 상호작용 명령어에 응답할 수 있는 에코 서버를 작성한다고 해 보자. 서버는 두 개의 독립적인 I/O 이벤트에 응답할 수 있어야 한다. (1) 네트워크 클라이언트의 연결 요청, (2) 사용자가 키보드에 타이핑한 명령어
 
어떤 이벤트를 먼저 기다려야 할까? 어떤 이벤트를 먼저 기다리든 나머지 이벤트에 응답하지 못하게 되는 문제가 생긴다. 이 딜레마에 대한 해결책 중 하나는 I/O 멀티플렉싱이다. 기본 아이디어는 커널에게 프로세스를 중단시켜달라고 요청하는 select 함수를 사용하는 것이다. 이렇게 하면 하나 이상의 I/O 이벤트가 발생한 후에만 애플리케이션으로 컨트롤을 리턴한다. 
다음은 예시이다.

  • 세트 (0, 4) 중 하나의 디스크립터라도 읽기 준비가 되면 리턴한다.
  • 세트 (0, 4) 중 하나의 디스크립터라도 쓰기 준비가 되면 리턴한다.
  • I/O 이벤트가 발생하기를 기다린 지 152.13 초가 지나면 타임아웃 처리한다.

Select는 다양한 사용 시나리오가 있는 복잡한 함수이다. 우리는 첫 번째 시나리오만 논의할 것이다: 디스크립터 세트가 읽기를 위한 준비가 되기를 기다리기 (다른 시나리오들이 궁금하다면 레퍼런스 [62, 110]을 참고하라.)

#include <sys/select.h>

/* Returns: nonzero count of ready descriptors, -1 on error */
int select (int n, fd_set *fdset, NULL, NULL, NULL);

/* Macros for manipulating descriptor sets */
FD_ZERO (fd_set *fdset);                /* Clear all bits in fdset */
FD_CLR (int fd, fd_set *fdset); 	/* Clear bit fd in fdset */
FD_SET (int fd, fd_set *fdset);		/* Turn on bit fd in fdset */
FD_ISSET (int fd, fd_set *fdset);	/* Is bit fd in fdset on? */

 
select 함수는 디스크립터 세트라고 알려진 fd_set 타입을 조작한다. 디스크립터 세트는 n개 크기의 비트 벡터이다. 각각의 비트 bk는 디스크립터 k에 해당한다. 디스크립터 k는 bk = 1일 때만 디스크립터 세트의 멤버이다. 디스크립터 세트에 대한 작업은 다음 3가지만 허용된다.
(1) 할당하기
(2) 이 타입의 변수를 다른 변수에 할당하기
(3) FD_ZERO, FD_SET, FD_CLR, FD_ISSET 매크로를 이용하여 이들을 수정하고 확인하기
 
우리의 목적을 위해 select 함수는 두 개의 입력을 받는다. 하나는 read set라고 불리는 디스크립터 세트이고 다른 하나는 read set의 cardinalitny (n)이다. (사실상 디스크립터 세트의 최대 차수이다.) select 함수는 read set에서 적어도 하나의 디스크립터가 읽기 준비가 될 때까지 block한다. 이때 디스크립터 k가 읽기에 준비가 되었다는 것은 그 디스크립터로부터 1바이트를 읽는 요청이 block되지 않을 때를 의미다. select 함수는 수행의 사이드 이펙트로 fd_set를 수정하여 read set의 부분 집합인, ready set을 설정한다. select 함수에 의해 리턴된 값은 ready set의 cardinality를 가리킨다. 이러한 사이드 이펙트 때문에 우리는 select가 호출될 때마다 read set를 업데이트해줘야 한다.
 
select를 이해하는 가장 좋은 방법은 구체적인 예시를 공부하는 것이다. 다음 코드는 사용자의 입력을 받을 수 있는 iterative echo server를 만들기 위해서 select를 어떻게 이용해야 하는지를 보여준다.
 
listening descriptor를 open한 다음에 FD_ZERO를 이용하여 빈 read set를 생성했다.

그다음 descriptor 0번 (표준 입력)과 descriptor 3번 (listening descriptor)로 이루어진 read set를 정의했다.

서버 루프가 시작되면 accept 함수를 통해 연결 요청을 기다리는 대신에 select 함수를 호출한다. select 함수는 listening descriptor 또는 표준 입력이 읽기에 준비가 될 때까지 block된다.
예를 들어, 사용자가 엔터 키를 치면 select 함수는 다음과 같은 ready_set를 리턴하여 표준 입력 디스크립터가 읽기에 준비되도록 만든다.

 
select 함수가 리턴하면 FD_ISSET 매크로를 이용하여 어떤 디스크립터가 읽기 준비가 되었는지 확인한다. 만약 표준 입력이 준비가 되었으면 우리는 command 명령어를 호출한다. 이는 메인 루틴으로 돌아가기 전에 표준 입력에서 데이터를 읽고, 파싱하고 커맨드라인에 응답한다. 만약 listening descriptor가 준비가 되면 accept 함수를 호출하여 connected descriptor를 얻고 echo 함수를 호출한다. 에코 함수는 클라이언트가 커넥션의 한 쪽을 close할 때까지 클라이언트로부터 각 줄을 에코한다.
이 프로그램이 select를 이용하는 좋은 예이기는 하지만 여전히 추구해야 할 점이 있다. 한 번 클라이언트에 연결되고 나면 클라이언트가 연결의 한쪽 끝을 close할 때까지 입력 라인을 계속해서 에코한다는 것이다. 따라서 표준 입력에 명령을 입력하면 서버가 클라이언트와 종료되기 전까지 응답을 받을 수 없을 것이다. 더 나은 접근을 위해 더 세부적으로 멀티플렉싱을 할 필요가 있다. 서버 루프에서 한 번에 최대 하나의 텍스트 라인만 에코하게 하는 것이다.
 
* Linux 시스템에서 Ctrl+D 타이핑은 표준 입력의 EOF를 의미한다.
 

(1) I/O 멀티플렉싱에 기반한 동시 이벤트 드리븐 서버

I/O 멀티플렉싱은 특정 이벤트의 결과로 흐름이 progress를 만드는 동시성 이벤트 드리븐 프로그램의 기반으로 사용될 수 있다. 일반적인 아이디어는 논리 흐름을 state machine으로 모델링하는 것이다. state machine은 state, input event 그리고 transition의 집합이다. 
각각의 transition은 (input state, input event) 쌍을 output state에 매핑한다. self loop는 same input과 output state 사이의 transition이다. state machine은 일반적으로 방향이 있는, 노드는 상태를 가리키고 방향이 있는 호(활 모양의 선)는 transition을 나타내고 호의 라벨은 input event를 나타내는 그래프로 그려진다.


각각의 새로운 클라이언트 k에 대하여, I/O 멀티플렉싱에 기반을 둔 동시성 서버는 새로운 state machine sk를 생성하고 그것을 connected descriptor dk와 연결짓는다. 다음 그림에서 볼 수 있는 것처럼 state machine sk는 하나의 상태 (디스크립터 dk가 읽기에 준비될 때까지 기다리는 상태)와 하나의 input event(디스크립터 dk가 읽기에 준비됨), 하나의 transition(디스크립터 dk로부터 텍스트 라인 읽어들이기)를 가진다. 

 
 
서버는 입력 이벤트의 발생 여부를 감지하기 위해, select 함수의 도움을 받아 I/O 멀티플렉싱을 이용한다. 모든 connected descriptor가 읽기 준비가 완료되면 서버는 해당되는 state에 대한 transition을 실행한다. 이 경우 디스크립터로부터 텍스트 라인을 읽고 에코하는 것이다. 
 
다음은 I/O 멀티플렉싱에 기반한 동시성 이벤트 드리븐 서버의 코드이다.

/* 
 * echoservers.c - A concurrent echo server based on select
 */
/* $begin echoserversmain */
#include "csapp.h"

typedef struct { /* Represents a pool of connected descriptors */ //line:conc:echoservers:beginpool
    int maxfd;        /* Largest descriptor in read_set */   
    fd_set read_set;  /* Set of all active descriptors */
    fd_set ready_set; /* Subset of descriptors ready for reading  */
    int nready;       /* Number of ready descriptors from select */   
    int maxi;         /* Highwater index into client array */
    int clientfd[FD_SETSIZE];    /* Set of active descriptors */
    rio_t clientrio[FD_SETSIZE]; /* Set of active read buffers */
} pool; //line:conc:echoservers:endpool
/* $end echoserversmain */
void init_pool(int listenfd, pool *p);
void add_client(int connfd, pool *p);
void check_clients(pool *p);
/* $begin echoserversmain */

int byte_cnt = 0; /* Counts total bytes received by server */

int main(int argc, char **argv)
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    static pool pool; 

    if (argc != 2) {
	fprintf(stderr, "usage: %s <port>\n", argv[0]);
	exit(0);
    }
    listenfd = Open_listenfd(argv[1]);
    init_pool(listenfd, &pool); //line:conc:echoservers:initpool

    while (1) {
	/* Wait for listening/connected descriptor(s) to become ready */
	pool.ready_set = pool.read_set;
	pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL);

	/* If listening descriptor ready, add new client to pool */
	if (FD_ISSET(listenfd, &pool.ready_set)) { //line:conc:echoservers:listenfdready
            clientlen = sizeof(struct sockaddr_storage);
	    connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); //line:conc:echoservers:accept
	    add_client(connfd, &pool); //line:conc:echoservers:addclient
	}
	
	/* Echo a text line from each ready connected descriptor */ 
	check_clients(&pool); //line:conc:echoservers:checkclients
    }
}
/* $end echoserversmain */


활성화된 클라이언트 집합은 pool이라는 구조체에서 관리된다. init_pool을 통해 pool을 초기화하고 나서 서버는 무한 루프에 진입한다. 이 루프에서 서버는 두 가지 유형의 입력 이벤트를 감지하기 위해 select 함수를 호출한다.

(1) 새로운 클라이언트로부터의 연결 요청

(2) 기존 클라이언트와의 connected descriptor가 읽기 준비가 됐는지


연결 요청이 도착하면 서버는 connection을 open하고 클라이언트를 pool에 추가하기 위해 add_client 함수를 호출한다. 그리고 서버는 준비된 connected descriptor로부터 단일 텍스트 라인을 에코하기 위해 check_clients 함수를 호출한다.
init_pool 함수는 클라이언트 pool을 초기화한다. clientfd 배열은 connected descriptor의 집합이다. 정수 -1은 이용 가능한 슬롯을 의미한다. 초기에 connected descriptor 집합은 비어 있고 listening descriptor만이 select의 read set에 있는 유일한 디스크립터이다. 

/* $begin init_pool */
void init_pool(int listenfd, pool *p) 
{
    /* Initially, there are no connected descriptors */
    int i;
    p->maxi = -1;                   //line:conc:echoservers:beginempty
    for (i=0; i< FD_SETSIZE; i++)  
	p->clientfd[i] = -1;        //line:conc:echoservers:endempty

    /* Initially, listenfd is only member of select read set */
    p->maxfd = listenfd;            //line:conc:echoservers:begininit
    FD_ZERO(&p->read_set);
    FD_SET(listenfd, &p->read_set); //line:conc:echoservers:endinit
}
/* $end init_pool */


add_client 함수는 활성화된 클라이언트 풀에 새로운 클라이언트를 추가한다. clientfd 배열에서 빈 슬롯을 찾은 다음 서버는 배열에 connected descriptor를 추가하고 대응되는 RIO의 읽기 버퍼를 초기화시켜서 이 디스크립터에 rio_readlineb를 호출할 수 있게 만든다. 그다음 우리는 connected descriptor를 select의 read set에 추가하고 관련된 몇몇 풀의 전역 속성을 업데이트한다. maxfd 변수는 select의 가장 큰 파일 디스크립터를 추적한다. maxi 변수는 clientfd 배열에서 가장 큰 인덱스를 추적하여 check_clients 함수가 전체 배열을 탐색하지 않아도 되도록 만든다.

/* $begin add_client */
void add_client(int connfd, pool *p) 
{
    int i;
    p->nready--;
    for (i = 0; i < FD_SETSIZE; i++)  /* Find an available slot */
	if (p->clientfd[i] < 0) { 
	    /* Add connected descriptor to the pool */
	    p->clientfd[i] = connfd;                 //line:conc:echoservers:beginaddclient
	    Rio_readinitb(&p->clientrio[i], connfd); //line:conc:echoservers:endaddclient

	    /* Add the descriptor to descriptor set */
	    FD_SET(connfd, &p->read_set); //line:conc:echoservers:addconnfd

	    /* Update max descriptor and pool highwater mark */
	    if (connfd > p->maxfd) //line:conc:echoservers:beginmaxfd
		p->maxfd = connfd; //line:conc:echoservers:endmaxfd
	    if (i > p->maxi)       //line:conc:echoservers:beginmaxi
		p->maxi = i;       //line:conc:echoservers:endmaxi
	    break;
	}
    if (i == FD_SETSIZE) /* Couldn't find an empty slot */
	app_error("add_client error: Too many clients");
}
/* $end add_client */


check_clients 함수는 준비된 connected descriptor로부터 텍스트 라인을 에코한다. 디스크립터로부터 성공적으로 텍스트 라인을 읽었다면 클라이언트에게 에코한다. 이 코드에서는 모든 클라이언트로부터 받은 바이트를 축적해서 카운팅하고 있다. 클라이언트가 연결의 한쪽 끝을 close해서 EOF가 감지되면 서버 쪽도 연결을 close하고 풀에서 descriptor를 제거한다.

/* $begin check_clients */
void check_clients(pool *p) 
{
    int i, connfd, n;
    char buf[MAXLINE]; 
    rio_t rio;

    for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) {
	connfd = p->clientfd[i];
	rio = p->clientrio[i];

	/* If the descriptor is ready, echo a text line from it */
	if ((connfd > 0) && (FD_ISSET(connfd, &p->ready_set))) { 
	    p->nready--;
	    if ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
		byte_cnt += n; //line:conc:echoservers:beginecho
		printf("Server received %d (%d total) bytes on fd %d\n", 
		       n, byte_cnt, connfd);
		Rio_writen(connfd, buf, n); //line:conc:echoservers:endecho
	    }

	    /* EOF detected, remove descriptor from pool */
	    else { 
		Close(connfd); //line:conc:echoservers:closeconnfd
		FD_CLR(connfd, &p->read_set); //line:conc:echoservers:beginremove
		p->clientfd[i] = -1;          //line:conc:echoservers:endremove
	    }
	}
    }
}
/* $end check_clients */

 

finite state model의 관점에서 select 함수는 input event를 감지한다. add_client 함수는 새로운 논리 흐름 (state machine)을 생성한다. check_clients 함수는 입력 라인을 에코함으로써 state transition을 수행하고 클라이언트가 텍스트 라인 전송을 끝냈을 때 state machine을 제거한다.
 
* 이벤트 드리븐 웹 서버
현대의 고성능 서버, Node.js, nginx, Tornado는 I/O 멀티플렉싱에 기반한 이벤트 드리븐 프로그래밍을 사용한다. 프로세스나 스레드 방식과 비교했을 때 확연한 성능적 이점이 있기 때문이다.
 

(2) I/O 멀티플렉싱의 장점과 단점

장점은 이벤트 드리븐 설계의 경우 프로세스 기반 설계보다 개발자가 프로그램에 대해 더 많은 제어권을 가지게 된다는 것이다. 예를 들어, 이벤트 드리븐 동시성 서버를 이용하면 몇몇 클라이언트에게 특정 서비스를 제공할 수가 있다.
다른 장점은 I/O 멀티플렉싱에 기반한 이벤트 드리븐 서버는 단일 프로세스의 컨텍스트에서 실행되어 모든 논리 흐름이 프로세스의 전체 주소 공간에 접근할 수 있다는 것이다. 이는 sequential program에서처럼 당신의 동시성 서버를 GDB 등의 디버깅 도구를 이용하여 디버깅을 할 수 있다는 장점을 제공한다.
마지막으로 이벤트 드리븐 설계는 프로세스 기반 설계보다 훨씬 효율적인 편이다. 왜냐하면 다음 흐름을 스케줄링하기 위해서 프로세스 컨텍스트 스위칭을 할 필요가 없기 때문이다.


이벤트 드리븐 설계의 분명한 단점은 코딩의 복잡성이다. 이벤트 드리븐 동시성 에코 서버는 프로세스 기반 동시성 에코 서버보다 코드량이 3배 많다. 불행히도 동시성의 granularity(단위?)가 감소할수록 복잡도는 증가한다. granularity란 각각의 논리 흐름이 time slice당 실행하는 명령의 수이다. 예를 들어, 우리의 동시성 서버 예시에서 동시성의 granularity는 전체 텍스트 라인을 읽어들이는 데 요구되는 명령의 수이다. 어떤 논리 흐름이 텍스트 라인을 읽는 데 바쁘다면 다른 논리 흐름은 progress를 만들지 못한다. 우리의 코드에서는 괜찮지만 부분적인 텍스트 라인만 전송한 다음 중단하는 악의적인 클라이언트에 대해 이벤트 드리븐 서버가 취약해지게 만든다. 이벤트 드리븐 서버가 부분적인 텍스트 라인도 잘 핸들링할 수 있도록 변경하는 것은 쉽지 않은 일인데 프로세스 기반 설계에서는 이것이 깔끔하고 자동적으로 처리될 수 있다.
또 다른 단점은 멀티 코어 프로세스를 완전히 활용할 수 없다는 것이다.