Site icon GRIP.News

논-블로킹(non-blocking) I/O, 비동기(Asynchronous) I/O 차이 이해하기

블로킹(Blocking) I/O 및 동기(Synchronous) I/O

블로킹 I/O(혹은 동기 I/O)는 I/O 작업 시 대상 파일의 디스크립터(Descriptor)가 준비되어 있지 않은 프로세스는 시스템 호출 응답 대기상태(차단 상태)가 된다. 즉, 그 동안 프로그램 처리를 진행할 수 없다. 아래는 블로킹 I/O 사용자 모드와 커널 모드의 상호 전환을 나타내는 흐름이다. 커널 모드의 컨텍스트를 전환하는 시점에서 시스템 호출이 완료되며, 원래 사용자 모드 컨텍스트로 돌아감으로서 대기상태가 해제된다.

 

논-블로킹 (non-blocking) I/O

호출 직후 프로그램으로 부터 제어가 돌아옴으로서 시스템 호출 종료를 기다리지 않고 다음 처리로 넘어갈 수 있다. 일반적으로 O_NONBLOCK  플래그를 이용해 논-블로킹 모드를 선언하지만, 이 때 프로세스가 블로킹 상태가 아니기 때문에 CPU 를 다른 프로세스에서 사용함으로서 I/O 대기시간을 줄이거나 활용할 수 있다. 이 때 발생하는 오류는 응용프로그램에서 처리하고 재시도 하는 타이밍을 따로 정의할 필요가 있으며, 논-블로킹 I/O 소켓을 포함한 네트워크에 사용되는 I/O 모델에서는 디스크 I/O가 개입하지 않는다. 참고로, C10K 문제의 대책으로 논-블로킹 I/O에 이벤트 루프 모델을 차용함으로서 단일 스레드에서 여러 요청을 처리할 수 있게 되었다.

C10K

인터넷이 발전하고 서비스가 거대화 되면서, 서버 대당 처리할 수 있는 동시접속자수에 대한 한계가 재기 되었고, 이를 정의한 문제가 C10K (Connection 10,000) 문제이다. 즉 서버에서 10,000 개 이상의 소켓을 생성하고 처리를 할 수 있느냐 에 대한 문제이다. 인터넷 전이나 초기 같으면,동시에 하나의 서버에서 10,000개의 connection을 처리한 것은 아주 초대용량의 서비스 였지만, 요즘 같은 SNS 시대나, 게임만해도 동접 수만을 지원하는 시대에, 동시에 많은 클라이언트를 처리할 수 있는 능력이 요구 되었다. 메모리나 CPU가 아무리 높다하더라도 많은 수의 소켓을 처리할 수 없다면, 동시에 많은 클라이언트를 처리할 수 없다는 문제이다.

Unix의 IO 방식이 이 문제의 도마위에 올랐는데, 기존의 Unix System Call인 select()함수를 이용하더라도, 프로세스당 최대 2048개의 소켓 fd (file descriptor) 밖에 처리를 할 수 없었다. 이를 위한 개선안으로 나온 것이 비동기 IO를 지원하는 API인데, Windows의 iocp와 같은 비동기 시스템 호출이다.

출처: https://bcho.tistory.com/tag/Non blocking [조대협의 블로그]

 

비동기(Asynchronous) I/O

I/O 처리가 완료된 타이밍으로 결과를 회신하는 I/O모델을 비동기 I/O라고 한다. 비동기 I/O의 회신은 시그널 또는 콜백의 형태로 이뤄지며, 회신이 있을 때 까지 어플리케이션은 다른 작업을 진행할 수 있다. 프로세스가 블록 상태가 되지 않는 점에서 논-블로킹 I/O와 같지만 비공기 I/O 는 I/O처리를 완료 했을 때 통지를 하는 반면 비-블로킹 I/O는 I/O가 처리 가능한 상태를 오류로 판단하는 차이가 있다.

 

논-블로킹 I/O 와 비동기 I/O 차이

논-블로킹 I/O는 처리가 완료되지 않으면 에러를 회신하고, 블록 상태로 만들지 않는 반면 비동기 I/O는 처리를 바로할 수 없을 때. 처리가 완료 되는 시점가지 백그라운드에서 대기하고, 종료한 타이밍을 회신 하는 차이가 있다.

 

I/O 다중화 (Multiplexing)

I/O관련된 내용을 정리하다 보면 반드시 언급되는 것이 I/O 다중화다. I/O 다중화는 poll(), select(), epoll 시스템 호출을 이용해 여러 파일 디스크립터를 하나의 프로세스로 관리한다. 이러한 시스템 호출은 파일 디스크립터 상태 변화를 모니터링 할 수 있다. poll, epoll, select의 차이는 다음과 같다.

select

select는 지정한 소켓의 변화를 확인하고자 사용되는 함수로, 소켓에 변화가 생길 때 까지 기다리다 어떤 소켓이 어떤 동작을 하면 동작한 소켓을 제외한 나머지 소켓을 제거하고 해당 소켓에 대한 확인을 진행하낟. 디스크립터 수에 제한되어 있어 적극적으로 사용되지는 않지만, 사용이 쉽고 지원 OS 가 많아 이식성이 좋은 편이다. 다음은 select를 사용한 socket(), bind(), listen()을 사용한 소켓 감시 프로그램의 예제다.

void accept_loop(int soc)
{
    char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
    int child[MAX_CHILD];
    struct timeval timeout;
    struct sockaddr_storage from;
    int acc, child_no, width, i, count, pos, ret;
    socklen_t len;
    fd_set mask;

    /* child배열의 초기화 */
    for (i = 0; i < MAX_CHILD; i++) {
        child[i] = -1;
    }
    child_no = 0;
    for (;;) {
        /* select()용 마스크 작성 */
        FD_ZERO(&mask);
        FD_SET(soc, &mask);
        width = soc + 1;
        count = 0;
        for (i = 0; i < child_no; i++) {
            if (child[i] != -1) {
                FD_SET(child[i], &mask);
                if (child[i] + 1 > width) {
                    width = child[i] + 1;
                    count++;
                }
            }
        }
        (void) fprintf(stderr, "<<child count:%d>>\n", count);
        /* select()용 시간 초과 값 설정 */
        timeout.tv_sec = 10;
        timeout.tv_usec = 0;
        switch (select(width, (fd_set *) &mask, NULL, NULL, &timeout)) {
        case -1:
            /* 에러 */
            perror("select");
            break;
        case 0:
            /* 타임 아웃 */
            break;
        default:
            /* ready */
            if (FD_ISSET(soc, &mask)) {
                /* 서버 소켓 ready */
                len = (socklen_t) sizeof(from);
                /* 연결 수락 */
                if ((acc = accept(soc, (struct sockaddr *)&from, &len)) == -1) {
                    if(errno!=EINTR){
                        perror("accept");
                    }
                } else {
                    (void) getnameinfo((struct sockaddr *) &from, len,
                               hbuf, sizeof(hbuf),
                               sbuf, sizeof(sbuf),
                               NI_NUMERICHOST | NI_NUMERICSERV);
                    (void) fprintf(stderr, "accept:%s:%s\n", hbuf, sbuf);
                    /* child 여유 확인 */
                    pos = -1;
                    for (i = 0; i < child_no; i++) {
                        if (child[i] == -1) {
                            pos = i;
                            break;
                        }
                    }
                    if (pos == -1) {
                        /* 여유 없음 */
                        if (child_no + 1 >= MAX_CHILD) {
                            /* child에 더이상 저장 불가 */
                            (void) fprintf(stderr,
                       "child is full : cannot accept\n");
                            /* 닫는다 */
                            (void) close(acc);
                        } else {
                            child_no++;
                            pos = child_no - 1;
                        }
                    }
                    if (pos != -1) {
                        /* child에 저장 */
                        child[pos] = acc;
                    }
                }
            }
/* 준비 완료된 파일 디스크립터 확인 (계산량 O(n)) */
            for (i = 0; i < child_no; i++) {
                if (child[i] != -1) {
                    if (FD_ISSET(child[i], &mask)) {
                        /* 전송 */
                        if ((ret = send_recv(child[i], i)) == -1) {
                            /* 오류 */
                            /* close */
                            (void) close(child[i]);
                            /* child 비움 */
                            child[i] = -1;
                        }
                    }
                }
            }
        break;
        }
    }
}

유심히 봐야할 부분은 이 부분이다. 파일 디스크립터 수 만큼 루프를 돌리는 부분.

            for (i = 0; i < child_no; i++) {
                if (child[i] != -1) {
                    if (FD_ISSET(child[i], &mask)) {
                        /* 전송 */
                        if ((ret = send_recv(child[i], i)) == -1) {
                            /* 오류 */
                            /* close */
                            (void) close(child[i]);
                            /* child 비움 */
                            child[i] = -1;
                        }
                    }
                }
            }

 

poll

poll은 select의 문제였던 디스크립터 수 제한이 없다. 처리 방식은 select와 비슷하며, 어려개의 파일 디스크립터를 동시에 모니터링 하다 한개라도 읽을 수 있는 상태면 블로킹을 해제한다. 단, 디스크립터 수가 늘어날 수록 퍼포먼스가 떨어지는 단점이 있지만, 디스크립터 수 제한이 없어 select 보다 많이 사용된다. 단, 이식성이 나쁜편.  다음은 poll을 이용한 socket(), bind(), listen() 후 소켓 감시하는 프로그램의 예제다.

void accept_loop(int soc)
{
    char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
    int child[MAX_CHILD];
    struct sockaddr_storage from;
    int acc, child_no, i, j, count, pos, ret;
    socklen_t len;
    struct pollfd targets[MAX_CHILD + 1];
    /* child 배열 초기화 */
    for (i = 0; i < MAX_CHILD; i++) {
        child[i] = -1;
    }
    child_no = 0;
    for (;;) {
        /* poll()용 데이터 작성 */
        count = 0;
        targets[count].fd = soc;
        targets[count].events = POLLIN;
        count++;
        for (i = 0; i < child_no; i++) {
            if (child[i] != -1) {
                targets[count].fd = child[i];
                targets[count].events = POLLIN;
                count++;
            }
        }
        (void) fprintf(stderr,"<<child count:%d>>\n", count - 1);
        switch (poll(targets, count, 10 * 1000)) {
        case -1:
            /* 에러 */
            perror("poll");
            break;
        case 0:
            /* 타임아웃 */
            break;
        default:
            if (targets[0].revents & POLLIN) {
                /* 서버 소켓 ready */
                len = (socklen_t) sizeof(from);
                /* 연결 수락 */
                if ((acc = accept(soc, (struct sockaddr *)&from, &len)) == -1) {
                    if(errno!=EINTR){
                        perror("accept");
                    }
                } else {
                    (void) getnameinfo((struct sockaddr *) &from, len,
                               hbuf, sizeof(hbuf),
                               sbuf, sizeof(sbuf),
                               NI_NUMERICHOST | NI_NUMERICSERV);
                    (void) fprintf(stderr, "accept:%s:%s\n", hbuf, sbuf);
                    /* child 여유 검색 */
                    pos = -1;
                    for (i = 0; i < child_no; i++) {
                        if (child[i] == -1) {
                            pos = i;
                            break;
                        }
                    }
                    if (pos == -1) {
                        /* 여유 없음 */
                        if (child_no + 1 >= MAX_CHILD) {
                            /* child에 더이상 저장 불가 */
                            (void) fprintf(stderr,
                       "child is full : cannot accept\n");
                            /* close */
                            (void) close(acc);
                        } else {
                            child_no++;
                            pos = child_no - 1;
                        }
                    }
                    if (pos != -1) {
                        /* child에 저장 */
                        child[pos] = acc;
                    }
                }
            }
/* 준비 완료된 파일 디스크립터 확인 (계산량 O(n)) */         
            for (i = 1; i < count; i++) {
                if (targets[i].revents & (POLLIN | POLLERR)) {
                    /* 전송 */
                    if ((ret = send_recv(targets[i].fd, i - 1)) == -1) {
                        /* 오류 */
                        /* close */
                        (void) close(targets[i].fd);
                        /* child 비우기 */
                        for (j = 0; j < child_no; j++) {
                            if (child[j] == targets[i].fd) {
                                child[j] = -1;
                                break;
                            }
                        }
                    }
                }
            }
        break;
        }
    }
}

select를 사용한 프로그램과 마찬가지로 루프에서 감시하는 파일 디스크립터의 수만큼 루프를 돌 필요가 있기 때문에 성능이 저하된다. 이 부분을 해결할려면 epoll를 사용해야 한다.

            for (i = 1; i < count; i++) {
                if (targets[i].revents & (POLLIN | POLLERR)) {
                    /* 전송 */
                    if ((ret = send_recv(targets[i].fd, i - 1)) == -1) {
                        /* 오류 */
                        /* close */
                        (void) close(targets[i].fd);
                        /* child 비우기 */
                        for (j = 0; j < child_no; j++) {
                            if (child[j] == targets[i].fd) {
                                child[j] = -1;
                                break;
                            }
                        }
                    }
                }
            }

 

epoll

epoll API 는 Linux 커널 2.5.44에서 도입되었다. 파일 디스크립터 수에 제한이 없으며 상태 변화 모니터링도 크게 개선되었다. 구체적으로 파일 디스크립터 상태를 커널에서 감시하고 변화된 내용을 직접 확인할 수 있기 떄문에 select, poll 처럼 루프를 사용한 모니터링이 불필요하다. 때문에 효율적인 I/O 를 구현할 수 있다.

void accept_loop(int soc)
{
    char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
    struct sockaddr_storage from;
    int acc, count, i, epollfd, nfds, ret;
    socklen_t len;
    struct epoll_event ev, events[MAX_CHILD];

    if ((epollfd = epoll_create(MAX_CHILD + 1)) == -1) {
        perror("epoll_create");
        return;
    }
    /* EPOLL용 데이터 작성 */
    ev.data.fd = soc;
    ev.events = EPOLLIN;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, soc, &ev) == -1) {
        perror("epoll_ctl");
        (void) close(epollfd);
        return;
    }
    count = 0;
    for (;;) {
        (void) fprintf(stderr,"<<child count:%d>>\n", count);
        switch ((nfds = epoll_wait(epollfd, events, MAX_CHILD+1, 10 * 1000))) {
        case -1:
            /* 에러 */
            perror("epoll_wait");
            break;
        case 0:
            /* 타임아웃 */
            break;
        default:
            /* 소켓 ready */
            for (i = 0; i < nfds; i++) {
                /* events[i].data.fd는 모두 읽을 수 있다 */
                if (events[i].data.fd == soc) {
                    /* 서버 대기 */
                    len = (socklen_t) sizeof(from);
                    /* 접속 수락 */
                    if ((acc = accept(soc, (struct sockaddr *)&from, &len))==-1) {
                        if (errno != EINTR){
                            perror("accept");
                        }
                    } else {
                        (void) getnameinfo((struct sockaddr *) &from, len,
                                           hbuf, sizeof(hbuf),
                                           sbuf, sizeof(sbuf),
                                           NI_NUMERICHOST | NI_NUMERICSERV);
                        (void) fprintf(stderr, "accept:%s:%s\n", hbuf, sbuf);
                        /* 여유 없음 */
                        if (count + 1 >= MAX_CHILD) {
                            /* 더이상 저장 불가 */
                            (void) fprintf(stderr,
                      "connection is full : cannot accept\n");
                            /* close */
                            (void) close(acc);
                        } else {
                            ev.data.fd = acc;
                            ev.events = EPOLLIN;
                            if (epoll_ctl(epollfd, EPOLL_CTL_ADD, acc, &ev) == -1) {
                                perror("epoll_ctl");
                                (void) close(acc);
                                (void) close(epollfd);
                                return;
                            }
                            count++;
                        }
                    }
                } else {
                    /* 보내는중 */
                    if ((ret = send_recv(events[i].data.fd, events[i].data.fd)) == -1) {
                        /* 오류 */
                        if (epoll_ctl(epollfd, EPOLL_CTL_DEL, events[i].data.fd, &ev) == -1) {
                            perror("epoll_ctl");
                            (void) close(acc);
                            (void) close(epollfd);
                            return;
                        }
                        /* close */
                        (void) close(events[i].data.fd);
                        count--;
                    }
                }
            }
        break;
        }
    }
    (void) close(epollfd);
}

epoll은 select 나 poll과 달리 events에 들어있는 데이터는 모두 읽을 수있는 파일 디스크립터 덕분에 O (1)의 계산량으로 끝난다.

if (events[i].data.fd == soc) {

 

Exit mobile version