논-블로킹(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에 이벤트 루프 모델을 차용함으로서 단일 스레드에서 여러 요청을 처리할 수 있게 되었다.
비동기(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) {