수업/Network Programming

[Network Programming] IO Multiplexing 기반 서버

hw-ani 2023. 5. 29. 04:01

MultiProcess 서버는 아래와 같은 단점을 가진다.

    1. process의 빈번한 생성은 성능 저하로 이어진다.

    2. MultiProcess의 흐름을 고려해야하므로 구현이 쉽지 않다.

    3. 만약 process간 통신까지 해야한다면 구현이 더 복잡해진다.

 

따라서 하나의 process가 다수의 client에게 서비스를 제공할 수 있는, 하나의 process가 여러 개의 소켓을 핸들링 할 수 있는 IO MultiPlexing이 그 대안이다. (물론 무거운 작업이면 process 하나가 따로 해주는게 맞다고 교수님께서 말씀하심)

데이터 송수신이 무조건 실시간으로 0의 지연시간을 가져아하는 것은 아니므로 멀티플렉싱이 가능하다. 이를 위한 기능을 하나씩 배워보자.

 

 


select() 함수를 사용하면 멀티플렉싱 서버를 구현할 수 있다.

select() 함수는 인자로 받은 배열에 저장된 다수의 file descriptor에게 다음과 같은 질문을 던질 수 있다.

    1. 수신한 데이터를 지닌 소켓이 존재하는가?

    2. 블로킹되지 않고 데이터의 전송이 가능한 소켓은 무엇인가?

    3. 예외상횡이 발생한 소켓은 무엇인가?

우리는 여기서 1번 질문을 활용해 특정 관찰 대상들에 대해 주기적으로 select 함수를 호출하며 데이터를 수신한 socket이 있는지 확인하고 그걸 처리한다.

 

 

select() 함수에 관찰 대상을 전달하기 위해, 그 관찰대상 목록을 만드는 방법을 먼저 알아보자.
관찰 대상은 fd_set type의 변수에 담아서 전달하는데, 이를 위해 사용할 수 있는 함수는 아래와 같다.

- FD_ZERO(fd_set * fdset) : 인자로 전달된 fd_set type형 변수의 모든 bit를 0으로 초기화한다.

- FD_SET(int fd, fd_set * fdset) : 두번째 매개변수에 첫번째 매개변수로 전달된 file descriptor 정보를 등록한다.

- FD_CLR(int fd, fd_set * fdset) : 두번째 매개변수에 첫번째 매개변수로 전달된 file descriptor 정보를 삭제한다.

- FD_ISSET(int fd, fd_set * fdset) : 두번째 매개변수에 첫번째 매개변수로 전달된 file descriptor 정보가 있다면 양수를 반환한다.

 

fd_set은 뭐 충분히 큰 변수이고, 비트 단위로 특정 descriptor가 관찰 대상에 등록됐는지 안됐는지를 표시하는 것 같다. FD_SET을 하면 해당 file descriptor에 해당하는 bit가 1로되고, FD_CLR이면 0으로 되고... 관찰대상이면 그냥 FD_SET으로 넣어버리면 됨.

 

 

 

 

select

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfd, fd_set *readset, fd_set *writeset,
           fd_set *exceptset, const struct time val * timeout);

위에서 말한 세가지 질문의 답을 줄 수 있는 함수이다. select()가 호출되면 그 세가지 질문에 대해 변동 사항이 있는 file descriptor가 생길때까지 block된다.(마지막 인자로 조절 가능)

첫번째 인자로 2/3/4 번째 인자로 들어가는 검사 대상 중 file descriptor 값이 가장 큰 놈에게 더하기 1을 한 값을 전달한다.(select 내부에서 거기까지 반복문 돌리면서 확인하는 듯)

두번째 인자로 첫번째 질문(수신된 데이터가 있는 file descriptor가 있는가)에 대한 검사를 진행할 대상이 담긴 fd_set type 변수의 주소를 전달한다. 그럼 이 함수가 종료될 때 readset에는 '수신된 데이터가 존재하는' file descriptor만 setting된다.(FD_ISSET으로 확인 가능)

세번째 인자로 두번째 질문(블로킹 없이 데이터 전송이 가능한 file descriptor가 있는가)에 대한 검사를 진행할 대상이 담긴 fd_set type 변수의 주소를 전달한다. 없다면 0을 전달한다.

네번째 인자로 세번째 질문(예외 상황이 발생한 file descriptor가 있는가)에 대한 검사를 진행할 대상이 담긴 fd_set type 변수의 주소를 전달한다. 없다면 0을 전달한다.

다섯번째 인자로 select 함수 호출 이후에 무한 블로킹에 빠지지 않도록 time-out 시간을 설정한다. 즉, 아무 일이 없어도 이 시간마다 무조건 확인하도록 한다. timeout을 설정하고 싶지 않다면 NULL을 전달한다.

 

성공 시 0 이상(time-out에 의한 반환이라면 0이 반환되고, 일반적인 경우 0보다 큰 값 반환), 실패 시 -1 을 반환한다.

 

마지막 인자로 쓰이는 구조체는 아래와 같이 생겼다.

struct timeval {
    long tv_sec;
    long tv_usec;
};
//tv_sec + tv_usec이 time-out 값으로 설정됨.

 

 

Q. maxfd를 교재나 강의자료에선 검사 대상이 되는 파일 디스크립터의 수라고 한다.
A. 아마 잘못된 설명인 것 같다. 아래 예제 코드에서도 볼 수 있듯이 검사 대상이 되는 file descriptor 중 가장 큰 file descriptor 값을 fd_max 값으로 설정해두고, select() 호출할 땐 더하기 1을 해서 보낸다. select() 내부에선 그냥 간단하게 for(int i; i<fd_max; i++) ~ 식으로 확인하니까 그렇게 넘겨주는게 아닐까 짐작해본다.

Q. time-out? 뭐 어떤 경우에 쓰이는건지...
A. select() 함수를 호출하면 관찰 대상인 file descriptor들 중에서 변동사항이 나올때까지 잠시 block된다. 이러면 무한 블로킹에 빠질 수 있으니 time-out 시간을 설정해줘서 그 시간이 지난 뒤에는 반환하도록 하는 것이다.
(그래서 select() 함수는 정교한 sleep() 함수로도 활용할 수 있다. 마이크로초 단위까지 표현 가능하므로, 원하는 sleep 시간을 잘 설정해서 select(0, 0, 0, 0, &time-out); 식으로 사용하면 된다.)

 

이렇게 select 함수로 데이터를 수신받은 file descriptor의 정보를 받아왔다면, 반복문을 돌려서 FD_ISSET으로 처리할 socket을 골라낼 수 있다.

※주의※ 이때 보통 select 함수에는 이전에 설정한 검사 대상을 담은 fd_set의 복사본을 넘겨준다. 왜냐하면 해당 변수의 값이 "수신한 데이터가 있는 file descriptor만 0으로 바껴서 나오기때문에" 초기에 설정한 검사 대상 목록을 계속 사용하려면 어쩔 수 없다.

 

 

 

int main(int argc, char *argv[]) {
    //필요한 변수들 선언
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    struct timeval timeout;     //select() timeout 설정 위한 변수
    fd_set reads, cpy_reads;    //select() 호출때 사용할 복사본 변수도 같이 선언
    socklen_t adr_size;
    int fd_max, str_len, fd_num;
    char buf[BUF_SIZE];
    
    //TCP server socket 만들기
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);  //소켓 생성
    memset(&serv_adr, 0, sizeof(serv_adr));       //sin_zero 초기화 위한 memset
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));
    
    //bind() & listen()
    if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error!");
    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error!");
    
    //초기 관찰 대상 선정
    FD_ZERO(&reads);  //초기화
    FD_SET(serv_sock, &reads);  //서버소켓도 데이터 수신이므로 관찰대상 포함
    fd_max = serv_sock;
    
    //계속 반복하며 select()로 수신 데이터 확인하여 처리
    while (1) {
        cpy_reads = reads;  //select() 호출 전 관찰대상 복사
        //select()를 호출하면 넘겨준 fd_set 변수의 값이 변경되므로
        //reads에서 계속 관찰 대상을 keep하고, select() 호출전에 cpy_reads에 복사해서
        //이를 사용하도록 한다.
        
        timeout.tv_sec = 5;
        timeout.tv_usec = 5000;  //select 함수 호출 전 "매번" time-out 명시해야 함.
        
        if ((fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout)) == -1)
            break;
        if (fd_num == 0)  //time-out인 경우
            continue;
        
        //데이터를 수신한 socket이 있다면 반복문 돌려서 찾은 후 처리한다.
        for (int i = 0; i<fd_max+1; i++) {  //fd_max+1전까지 확인해야 다 확인한 것
            if (FD_ISSET(i, &cpy_reads)) {
                if (i==serv_sock) {  //수신한 데이터가 연결 요청인 경우!
                    adr_size = sizeof(clnt_adr);
                    clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_size);
                    FD_SET(clnt_sock, &reads);  //관찰 대상에 추가!!
                    if (fd_max < clnt_sock)
                        fd_max = clnt_sock;
                    printf("connected client: %d\n", clnt_sock);
                }
                else {   //수신한 데이터가 일반 data인 경우!
                    str_len = read(i, buf, BUF_SIZE);
                    if (str_len == 0) {  //close 요청인 경우
                        FD_CLR(i, &reads);
                        close(i);
                        printf("closed client: %d\n", i);
                    }
                    else  write(i, buf, str_len);  //echo 해주기!!
                }
            }
        }
    }
    close(serv_sock);
    return 0;
}

select() 함수 특성상 반복문이 들어갈 수 밖에 없다. 이게 select() 함수를 활용했을 때의 단점이다.

하나 짚고 갈만한건, 수신한 데이터의 크기가 0이라면 이는 close 요청으로 (1)관찰 대상에서 제외하고 (2)close()로 연결을 종료해주면 된다.