Non-blocking I/O와 I/O 다중화: EAGAIN부터 epoll·io_uring까지
Non-blocking I/O의 EAGAIN 처리, O_NONBLOCK 설정, select·poll·epoll·kqueue와 비동기 I/O 인터페이스를 정리한다.
2026-08-14 · 최초 발행 2026-01-06
기다리지 않고 반환하는 I/O 호출
read()나 write()가 끝날 때까지 호출한 프로세스를 멈추는 방식이 Blocking I/O다. 반대로 Non-blocking I/O는 I/O가 바로 끝나지 않더라도 제어를 즉시 반환한다. 읽을 데이터가 없다면 EAGAIN 또는 EWOULDBLOCK을 돌려주며, 애플리케이션은 다른 작업을 수행한 뒤 적절한 시점에 다시 시도한다.
이 특성은 여러 연결을 한 스레드에서 다뤄야 하는 서버, UI 응답성을 유지해야 하는 GUI, 이벤트 기반 시스템에 유용하다. 스레드 수와 메모리 사용량, Context Switching 오버헤드를 줄일 수 있고, select, poll, epoll 같은 I/O 다중화와 함께 사용하면 단일 스레드에서도 수천 개의 동시 연결을 처리할 수 있다.
Blocking I/O에서는 호출 지점 이후의 코드가 I/O 완료 뒤에야 실행된다.
// 데이터가 준비될 때까지 대기
ssize_t n = read(fd, buffer, size);
// 여기까지 도달하려면 read가 완료되어야 함
Non-blocking I/O에서는 데이터가 준비되지 않아도 호출 결과를 확인하는 위치까지 곧바로 도달한다.
// 즉시 반환
ssize_t n = read(fd, buffer, size);
if (n < 0 && errno == EAGAIN) {
// 데이터 없음, 나중에 재시도
}
// 항상 즉시 여기에 도달
O_NONBLOCK과 반환값을 다루는 방식
파일 디스크립터에는 fcntl()로 O_NONBLOCK을 설정할 수 있다.
// 파일 디스크립터를 Non-blocking으로 설정
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
소켓을 만들 때 플래그를 지정하거나, 생성 후 같은 방식으로 설정할 수도 있다.
// 소켓 생성 시
int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
// 또는 fcntl 사용
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
읽기 결과는 양수, 0, 음수를 구분해야 한다. 음수라고 해서 모두 장애는 아니다. EAGAIN과 EWOULDBLOCK은 아직 읽을 데이터가 없다는 상태이고, 나머지 오류는 별도로 처리한다.
ssize_t n = read(fd, buffer, sizeof(buffer));
if (n > 0) {
// n 바이트 읽음
process_data(buffer, n);
} else if (n == 0) {
// EOF (연결 종료)
close(fd);
} else {
// n < 0: 에러 발생
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 데이터 없음, 나중에 재시도
} else {
// 실제 에러
perror("read");
}
}
쓰기에서는 부분 쓰기가 발생할 수 있다. 한 번의 write()가 요청한 크기보다 적은 바이트를 썼다면 남은 버퍼를 보관했다가 이후에 전송해야 한다.
ssize_t n = write(fd, buffer, size);
if (n > 0) {
// n 바이트 씀
if (n < size) {
// 부분 쓰기, 나머지는 나중에
buffer += n;
size -= n;
}
} else if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 버퍼 가득 찼음, 나중에 재시도
} else {
perror("write");
}
}
재시도는 폴링이 아니라 준비 알림과 결합한다
데이터가 없을 때 반복해서 read()를 호출하면 CPU를 계속 점유하는 Busy-Wait가 된다.
while (1) {
n = read(fd, buffer, size);
if (n >= 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) {
break;
}
// CPU 100% 사용
}
usleep()을 넣어 반복 간격을 두는 방식도 가능하지만, 대기 시간 선택과 응답성 사이의 문제가 남는다.
while (1) {
n = read(fd, buffer, size);
if (n >= 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) {
break;
}
usleep(1000); // 1ms 대기
}
여러 파일 디스크립터를 다루는 서버라면 I/O 다중화를 통해 준비된 대상만 처리하는 흐름이 적합하다.
// select, poll, epoll 등 사용
// 데이터 준비될 때까지 대기
// 여러 fd 동시 모니터링
파일·소켓·파이프에서의 적용
일반 디스크 파일은 커널이 read와 write를 즉시 처리하는 경우가 많아 Non-blocking I/O의 이점이 작다. 반면 네트워크, 파이프, 소켓에서는 데이터 도착 시점과 버퍼 상태가 달라 이 방식이 유용하다.
int fd = open("file.txt", O_RDONLY | O_NONBLOCK);
char buffer[1024];
ssize_t n;
while ((n = read(fd, buffer, sizeof(buffer))) > 0) {
write(STDOUT_FILENO, buffer, n);
}
if (n < 0 && errno == EAGAIN) {
// 일반 파일은 보통 항상 ready
// 특수 파일 (파이프, 소켓)에서 발생
}
close(fd);
리스닝 소켓과 연결 소켓 모두를 Non-blocking으로 두면, accept()가 새 연결을 기다리며 이벤트 루프 전체를 멈추지 않는다.
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(listen_fd, F_SETFL, O_NONBLOCK);
// 바인딩 및 리스닝
bind(listen_fd, ...);
listen(listen_fd, SOMAXCONN);
while (1) {
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 연결 없음, 계속
continue;
} else {
perror("accept");
break;
}
}
// 연결된 소켓도 Non-blocking으로
fcntl(conn_fd, F_SETFL, O_NONBLOCK);
// 처리
handle_connection(conn_fd);
}
connect()는 즉시 연결되지 않을 수 있다. EINPROGRESS를 받은 뒤 쓰기 가능 이벤트를 확인하고, SO_ERROR로 연결 결과를 판별한다.
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK);
int ret = connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));
if (ret < 0) {
if (errno == EINPROGRESS) {
// 연결 진행 중
// select/poll로 완료 대기
fd_set wfds;
FD_ZERO(&wfds);
FD_SET(sockfd, &wfds);
struct timeval tv = {5, 0}; // 5초 타임아웃
if (select(sockfd + 1, NULL, &wfds, NULL, &tv) > 0) {
// 연결 완료 확인
int error;
socklen_t len = sizeof(error);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len);
if (error == 0) {
// 연결 성공
}
}
}
}
파이프와 FIFO에서도 읽을 데이터가 없을 때 동일한 반환값 처리가 필요하다.
int pipefd[2];
pipe(pipefd);
fcntl(pipefd[0], F_SETFL, O_NONBLOCK);
char buffer[1024];
ssize_t n = read(pipefd[0], buffer, sizeof(buffer));
if (n < 0 && errno == EAGAIN) {
// 파이프에 데이터 없음
}
mkfifo("/tmp/myfifo", 0666);
int fd = open("/tmp/myfifo", O_RDONLY | O_NONBLOCK);
// Non-blocking 읽기
ssize_t n = read(fd, buffer, sizeof(buffer));
준비된 파일 디스크립터를 찾는 인터페이스
select는 여러 파일 디스크립터를 감시하는 POSIX 표준 인터페이스다. 준비된 fd만 골라 처리할 수 있지만, 최대 FD_SETSIZE(보통 1024)개라는 제한과 O(n) 시간 복잡도가 있다.
fd_set readfds;
struct timeval tv;
while (1) {
FD_ZERO(&readfds);
FD_SET(sockfd1, &readfds);
FD_SET(sockfd2, &readfds);
tv.tv_sec = 5;
tv.tv_usec = 0;
int maxfd = (sockfd1 > sockfd2) ? sockfd1 : sockfd2;
int ret = select(maxfd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
if (FD_ISSET(sockfd1, &readfds)) {
handle_socket(sockfd1);
}
if (FD_ISSET(sockfd2, &readfds)) {
handle_socket(sockfd2);
}
}
}
poll은 파일 디스크립터 개수 제한이 없고 이벤트 마스크를 더 유연하게 다룰 수 있지만, O(n) 시간 복잡도는 유지한다.
struct pollfd fds[2];
fds[0].fd = sockfd1;
fds[0].events = POLLIN;
fds[1].fd = sockfd2;
fds[1].events = POLLIN;
while (1) {
int ret = poll(fds, 2, 5000); // 5초 타임아웃
if (ret > 0) {
if (fds[0].revents & POLLIN) {
handle_socket(sockfd1);
}
if (fds[1].revents & POLLIN) {
handle_socket(sockfd2);
}
}
}
Linux의 epoll은 O(1) 시간 복잡도로 동작하는 고성능 I/O 다중화 방식이며, 수만 개의 연결을 처리할 수 있다.
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
handle_socket(events[i].data.fd);
}
}
}
epoll은 Level-Triggered와 Edge-Triggered 모드를 제공한다.
// Level-Triggered (기본)
ev.events = EPOLLIN;
// Edge-Triggered
ev.events = EPOLLIN | EPOLLET;
FreeBSD와 macOS 등에서는 kqueue를 사용할 수 있다. epoll과 유사한 성능을 제공하며 파일 시스템 이벤트도 모니터링할 수 있다.
int kq = kqueue();
struct kevent change;
EV_SET(&change, sockfd, EVFILT_READ, EV_ADD, 0, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);
struct kevent event;
while (1) {
int nev = kevent(kq, NULL, 0, &event, 1, NULL);
if (nev > 0) {
handle_socket(event.ident);
}
}
완료 통지 중심의 비동기 I/O
Non-blocking I/O는 호출이 즉시 반환된 뒤 애플리케이션이 폴링하는 방식이다. 비동기 I/O에서는 커널이 작업 완료 시점을 알린다. 애플리케이션은 I/O 수행 자체를 커널에 맡기고 다른 작업에 집중할 수 있다.
POSIX AIO는 aio_read()로 비동기 읽기를 시작하고, 진행 상태와 결과를 별도 호출로 확인한다.
#include <aio.h>
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buffer;
cb.aio_nbytes = sizeof(buffer);
cb.aio_offset = 0;
// 비동기 읽기 시작
aio_read(&cb);
// 다른 작업 수행
do_other_work();
// 완료 대기
while (aio_error(&cb) == EINPROGRESS) {
// 대기 또는 다른 작업
}
// 결과 확인
ssize_t n = aio_return(&cb);
Windows의 IOCP는 완료 통지 큐를 사용하는 고성능 비동기 I/O 방식이다. 수만 개의 동시 I/O를 처리하며 스레드 풀과 결합해 서버 애플리케이션에 사용할 수 있다.
Linux의 io_uring은 5.1 이후 제공되는 비동기 I/O 인터페이스다. 링 버퍼를 기반으로 시스템 콜 오버헤드를 최소화한다.
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buffer, size, offset);
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// cqe->res에 결과
int bytes_read = cqe->res;
io_uring_cqe_seen(&ring, cqe);
이벤트 루프에서 확인할 성능 특성
Non-blocking I/O만으로 지속적인 폴링을 하면 CPU를 100% 사용할 수 있다. 이벤트가 준비될 때만 대기에서 깨어나는 select나 epoll 같은 다중화 방식이 CPU 사용을 줄이는 수단이다.
확장성 요구도 인터페이스 선택에 영향을 준다. C10K는 10,000개 동시 연결을 처리하는 문제를 말하며, O(n)인 select와 poll보다 O(1)인 epoll과 kqueue가 유리하다. C10M은 10,000,000개 동시 연결을 전제로 하며, io_uring이나 IOCP 같은 최신 기술이 필요하다.
I/O 대기 때문에 생기는 지연을 줄이고 여러 작업을 병렬로 진행할 수 있는 반면, 폴링 오버헤드와 재시도 로직의 복잡도는 함께 고려해야 한다.
서버와 이벤트 기반 애플리케이션에서의 사용
Nginx는 Linux에서 epoll, BSD에서 kqueue를 사용하며 이벤트 기반 아키텍처로 단일 워커 프로세스에서 수천 연결을 처리한다. Node.js는 libuv와 Non-blocking I/O, 이벤트 루프를 사용해 단일 스레드 비동기 처리를 수행한다.
Redis는 단일 스레드와 Non-blocking I/O를 결합하고 epoll 또는 kqueue를 사용해 높은 처리량을 낸다. Memcached는 libevent 및 멀티 스레드와 Non-blocking I/O를 함께 사용한다.
GUI 프레임워크인 Qt와 GTK+는 이벤트 루프에서 Non-blocking I/O를 사용해 UI 응답성을 유지할 수 있다. 게임 엔진에서는 실시간 네트워크 통신, 다중 플레이어 동기화, 낮은 지연 시간이 필요한 상황에 적용된다.
재시도 상태와 쓰기 버퍼를 분리한다
실제 구현에서는 일시적 미준비 상태, 시그널 인터럽트, 실제 오류를 구분해야 한다.
ssize_t n = read(fd, buffer, size);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 일시적, 재시도 필요
return RETRY_LATER;
} else if (errno == EINTR) {
// 시그널 인터럽트, 재시도
return RETRY_NOW;
} else {
// 실제 에러
perror("read");
return ERROR;
}
}
부분 쓰기는 전송한 위치를 버퍼 상태로 유지해 처리한다.
// 쓰기 버퍼 관리
struct write_buffer {
char *data;
size_t size;
size_t written;
};
void handle_write(int fd, struct write_buffer *wb) {
ssize_t n = write(fd, wb->data + wb->written,
wb->size - wb->written);
if (n > 0) {
wb->written += n;
if (wb->written == wb->size) {
// 완료
free_buffer(wb);
}
} else if (n < 0 && errno == EAGAIN) {
// 나중에 재시도
}
}
단일 스레드 이벤트 루프는 동기화가 단순하지만 CPU 바운드 작업에는 맞지 않는다. 여러 이벤트 루프를 둔 멀티 스레드 모델은 코어 활용을 높일 수 있으며 Nginx 워커 프로세스 모델이 이에 해당한다. I/O는 Non-blocking으로 처리하고 CPU 작업은 별도 스레드 풀로 보내는 하이브리드 구조는 Memcached 모델로 볼 수 있다.