개요
논블록킹 network I/O 모델이 등장하게 된 배경에 대해 알아봅시다.
C10K 문제와 논블록킹 I/O 모델의 필요성
C10K 문제는 network I/O와 연관이 있는 문제입니다.
socket 프로그래밍을 통해 사용자의 요청을 받는 프로세스를 생각해봅시다.
만 명의 동시 사용자를 처리하기 위해 요청이 들어왔을 때, 프로세스를 fork하는 방식을 사용하거나 스레드를 생성하여 동시 접속을 처리합니다.

하지만 동시 접속을 처리하기 위해 스레드를 활용하면 메모리를 많이 차지할 뿐더러 context switching에 드는 비용 또한 증가하게 됩니다.
운영체제는 모든 스레드에 CPU를 배분하기 때문에 굉장히 많은 스레드가 데이터를 기다리고 있다고 하면 계속 CPU를 배정받아 시간을 낭비하게 됩니다.
이처럼 스레드를 활용할 때,
서버가 10,000개의 동시 접속을 처리하기 어려워지는 문제를 C10K 문제라고 합니다.
이러한 C10K 문제는 논블록킹 I/O를 활용하여 해결할 수 있습니다.
기본적으로 대부분의 입출력 인터페이스는 블록킹 방식입니다.
또한 이러한 블록킹 입출력은 응답을 기다리는 동안 CPU를 사용하지 않기 때문에 효율이 떨어집니다.
그렇다면 스레드의 병렬성을 활용하지 않고도 논블록킹 소켓을 통해 동시성을 달성할 수 있는 코드를 알아봅시다.
아래 코드는 https://github.com/gilbutITbook/080403/blob/main/Chapter%2010/pizza_busy_wait.py 에서 참고했습니다.
080403/Chapter 10/pizza_busy_wait.py at main · gilbutITbook/080403
그로킹 동시성. Contribute to gilbutITbook/080403 development by creating an account on GitHub.
github.com

#!/usr/bin/env python3.9
"""Busy-waiting non-blocking server implementation"""
import typing as T
from socket import socket, create_server
# the maximum amount of data to be received at once
BUFFER_SIZE = 1024
ADDRESS = ("127.0.0.1", 12345) # address and port of the host machine
class Server:
clients: T.Set[socket] = set()
def __init__(self) -> None:
try:
print(f"Starting up at: {ADDRESS}")
self.server_socket = create_server(ADDRESS)
# set socket to non-blocking mode
self.server_socket.setblocking(False)
except OSError:
self.server_socket.close()
print("\nServer stopped.")
def accept(self) -> None:
try:
conn, address = self.server_socket.accept()
print(f"Connected to {address}")
# making this connection non-blocking
conn.setblocking(False)
self.clients.add(conn)
except BlockingIOError:
# [Errno 35] Resource temporarily unavailable
# indicates that "accept" returned without results
pass
def serve(self, conn: socket) -> None:
"""Serve the incoming connection by sending and receiving data."""
try:
while True:
data = conn.recv(BUFFER_SIZE)
if not data:
break
try:
order = int(data.decode())
response = f"Thank you for ordering {order} pizzas!\n"
except ValueError:
response = "Wrong number of pizzas, please try again\n"
print(f"Sending message to {conn.getpeername()}")
# send a response
conn.send(response.encode())
except BlockingIOError:
# recv/send returns without data
pass
def start(self) -> None:
"""Start the server by continuously accepting and serving incoming
connections."""
print("Server listening for incoming connections")
try:
while True:
self.accept()
for conn in self.clients.copy():
self.serve(conn)
finally:
self.server_socket.close()
print("\nServer stopped.")
if __name__ == "__main__":
server = Server()
server.start()
위 코드의 흐름은 아래와 같습니다.
- 새로운 연결이 있는지 확인하며 새로운 연결이 있다면 전용 소켓을 생성하여 추가합니다.
- 1번을 수행한 뒤 현재 연결된 모든 소켓에 대해 받을 데이터가 있는지 확인하며 데이터가 있다면 응답을 생성합니다.
- 무한 반복합니다.
즉, 무한 반복으로 계속 데이터를 확인하면서 병렬성을 활용하지 않고 동시성을 수행하고 있습니다.
스레드를 생성하지 않기 때문에 메모리나 context switching에 드는 비용을 절약할 수 있습니다.
I/O multiplexing
위에서 논블록킹 소켓을 통해 병렬성을 활용하지 않고 동시성을 수행하는 방법에 대해 알아보았습니다.
그리고 C10K 문제를 해결하기 위해서는 I/O multiplexing에 대해 알아야 합니다.
I/O multiplexing이란 하나의 채널로 여러 개의 I/O 작업 흐름을 관리하는 방식입니다.
I/O multiplexing에 대해 이해하려면 File Descriptor에 대해 알아야 합니다.
File Descriptor란 컴퓨터 프로그래밍 분야에서 특정한 파일에 접근하기 위한 추상적인 키로
보통 음수가 아닌 정수 값을 갖습니다.
유닉스 계열 운영체제에서는 흔히 ‘모든 것을 파일로 관리한다’고 합니다.
일반 파일뿐 아니라 디렉토리, 소켓, 파이프와 같은 다양한 객체들도 모두 파일처럼 관리됩니다.
이러한 구조 덕분에 다양한 입출력 자원들을 일관된 방식으로 다룰 수 있고 이는 곧 I/O multiplexing 기법의 기반이 됩니다.
이러한 I/O multiplexing을 구현한 방식은 여러가지가 있는데 크게 나누자면 user 레벨의 라이브러리와 kernel 레벨의 라이브러리로 나눌 수 있습니다.
- user 레벨 라이브러리
- select
- pselect
- poll
- ppoll
- kernel 레벨 라이브러리
- epoll (linux)
- kqueue (BSD)
- iocp (windows)
- devpoll (solaris)
user 레벨 라이브러리는 무한 루프를 돌면서 계속 이벤트 발생 여부를 체크하는 방식입니다.
또한, 이벤트가 발생한 FD의 목록이 아닌 개수를 반환하기 때문에 감시 대상 FD를 모두 탐색해야 합니다.
kernel 레벨 라이브러리는 커널에서 이벤트 발생 여부를 직접 알려주는 방식입니다.
이벤트가 발생한 FD의 목록을 반환하기 때문에 다른 FD를 탐색할 필요가 없습니다.
구체적인 내용은 지금부터 알아봅시다.
select
I/O multiplexing은 여러 개의 파일을 다루기 위해 아래 그림과 같이 File Descriptor를 fd_set이라는 배열로 관리합니다.
또한 fd_set은 기본적으로 최대 1024개의 fd를 관리할 수 있습니다.

File에 대응되는 배열의 값이 1이라면 해당 File에 변경 사항이 발생했다는 것을 의미합니다.
select 함수는 다음과 같습니다.
int select (int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
- nfds : 관리하는 파일의 개수(최대 파일 지정 번호 + 1)
- fd_set : 관리하는 파일의 지정번호가 등록되어 있는 비트 배열 구조체
- readfds : 읽을 데이터가 있는지 검사하기 위한 파일 목록
- writefds : 쓰여진 데이터가 있는지 검사하기 위한 파일 목록
- exceptfds : 파일에 예외 사항들이 있는지 검사하기 위한 파일 목록
- timeout : select함수는 fd_set에 등록된 파일들을 timeout동안 감시한다
- null : 데이터가 올 때까지 기다린다.
- 0 : 즉시 반환한다.
- > 0 : 해당 시간만큼 기다리고 데이터가 들어오면 즉시 리턴하고 초과되면 0을 반환한다.
select 함수의 반환 값은 변경된 파일의 목록이 아닌 변경된 파일의 개수라는 것을 이해해야 합니다.
이 말은 곧 select 함수를 통해 변경된 파일이 존재한다는 것을 알게 되면
배열 전체를 순회하면서 어떤 파일이 변경되었는지 직접 확인해야한다는 뜻입니다.
그리고 fd_set은 단일 비트 테이블이기 때문에 이전 상태를 기억하지 못 합니다.
아래 그림을 통해 이해해봅시다.

- fd_set의 모든 값들을 0으로 초기화 합니다.
- 2, 4, 8번 파일에 읽을 데이터가 있는지 감시하기 위해 대응되는 값을 1로 변경합니다.
- 만약 4번 파일에 읽을 데이터가 있다면 4번 파일만 1로 남기고 모든 값을 0으로 변경합니다.
2, 4, 8번 파일에 읽을 데이터가 있는지 감시하기 위해 대응되는 비트를 1로 변경했는데 select를 호출했더니 4번에 대응되는 비트 값만 1이고 나머지는 모두 0으로 변경되었습니다.
그래서 select 함수를 호출하기 전에 기존 fd_set 정보를 복사해두어야 합니다.
select 함수의 특징을 정리해봅시다.
- 변경된 파일의 개수를 반환하기 때문에 fd_set을 순차 탐색하면서 변경된 파일을 직접 찾아야 합니다.
- 기존 fd_set이 변경되기 때문에 따로 저장을 해야 합니다.
pselect
pselect 함수는 다음과 같습니다.
반환 값은 revent가 발생한 pollfd의 개수입니다.
int pselect(
int nfds,
fd_set * readfds,
fd_set * writefds,
fd_set * exceptfds,
const struct timespec * timeout,
const sigset_t * sigmask
);
pselect는 select와 유사하지만 다음과 같은 차이점이 있습니다.
- select는 timeout 인자로 마이크로초(1/1,000,000)까지 표현 가능한 timeval을 사용하지만 pselect는 timeout 인자로 나노초까지 표현 가능한 timespec(1/1,000,000,000)을 사용합니다.
- select는 timeout 인자의 값이 변하지만 pselect는 timeout 인자가 const이기 때문에 변하지 않습니다.
- sigmask를 통해 특정 signal을 무시할 수 있기 때문에 signal에 의한 비정상 동작을 막을 수 있습니다.
참고로 pselect의 sigmask 인자를 null로 준다면 select와 동일하게 동작합니다.
poll
poll 함수는 다음과 같습니다.
int poll(struct poolfd *ufds, unsigned int nfds, int timeout);
- ufds : poolfd 구조체 포인터
- nfds : 관리하는 파일의 개수
- timeout : 대기 시간
- -1 : 이벤트가 발생할 때까지 기다린다.
- 0 : 기다리지 않고 다음 루틴을 실행한다.
- > 0 : 해당 시간만큼 기다리고 이벤트가 발생하면 즉시 리턴하고 시간이 초과되면 0을 반환한다.
select를 이해한 상태로 poolfd 구조체의 구조만 알고 있으면 쉽게 이해할 수 있습니다.
struct pollfd
{
int fd; // 감시하려는 FD
short events; // 감시하려는 FD의 이벤트
short revents; // 실제로 발생한 이벤트
};
감시하려는 FD를 세팅하고 감지하고 싶은 이벤트를 events에 세팅한 뒤, 해당 파일에 해당 이벤트가 발생하면 revents에 발생한 이벤트가 세팅됩니다.
즉, revents를 통해 파일에 발생한 이벤트를 감지할 수 있습니다.
events의 종류는 다음과 같은 것들이 있습니다.
|
POLLIN
|
읽을 데이터 있음
|
|
POLLPRI
|
긴급 데이터(Out-Of-Band Data) 있음 (events / revents)
|
|
POLLOUT
|
쓸 data 있음 (events / revents)
|
|
POLLERR
|
오류 발생
|
|
POLLHUP
|
Hang up 상태
|
|
POLLNVAL
|
유효하지 않은 요청
|
select의 경우 입출력 이벤트가 발생했을 때, 얻을 수 있는 정보가 너무 적지만 poll은 pollfd라는 구조체를 통해 구체적인 이벤트를 알려줄 수 있습니다.
ppoll
ppoll 함수는 다음과 같습니다.
int ppoll(
struct pollfd * fds,
nfds_t nfds,
const struct timespec * tmo_p,
const sigset_t * sigmask
);
poll과 ppoll의 관계는 select와 pselect의 관계와 유사합니다.
ppoll은 poll과 비교했을 때 다음과 같은 차이점이 존재합니다.
- select는 timeout 인자로 마이크로초(1/1,000,000)까지 표현 가능한 timeval을 사용하지만 pselect는 timeout 인자로 나노초까지 표현 가능한 timespec(1/1,000,000,000)을 사용합니다.
- select는 timeout 인자의 값이 변하지만 pselect는 timeout 인자가 const이기 때문에 변하지 않습니다.
- sigmask를 통해 특정 signal을 무시할 수 있기 때문에 signal에 의한 비정상 동작을 막을 수 있습니다.
참고로 ppoll의 sigmask 인자를 null로 준다면 poll와 동일하게 동작합니다.
중간 정리
select와 poll 방식은 위의 논블록킹 소켓의 동시성을 다룬 챕터처럼 감시하고 싶은 FD를 등록해놓고 무한 루프를 도는 방식입니다.
select와 poll 방식은 함수 레벨의 I/O multiplexing이라고 볼 수 있기 때문에 호환성이 굉장히 좋지만 I/O 성능 문제를 해결하기 위해 운영체제 레벨의 I/O multiplexing이 필요합니다.
지금부터는 운영체제 레벨의 I/O multiplexing 기법에 대해 알아보겠습니다.
epoll (linux)
epoll은 poll 방식처럼 FD의 수는 무제한이고 kernel에서 FD를 관리하여 상태 변화를 알려주기 때문에 FD의 상태가 바뀐 것을 알기 위해 무한 루프를 돌 필요가 없습니다.
또한 변화가 생긴 FD의 개수가 아닌 목록을 반환하기 때문에 한 번 더 탐색할 필요가 없습니다.
epoll에서는 두 가지 trigger 방식을 지원합니다.
- Level-Trigging : 특정 상태가 유지되는 동안 감지합니다. (입력 buffer에 데이터가 있으면 계속 이벤트 등록)
- Edge-Trigging : 특정 상태가 변화는 시점만 감지합니다. (입력 buffer에 데이터가 등록된 순간만 이벤트 등록)

참고로 select와 poll은 level trigging 방식입니다.
edge trigging 방식을 사용하면 효율적으로 이벤트를 처리할 수 있지만 주의 사항이 있습니다.
이벤트가 발생하여 데이터를 처리할 때 일부만 꺼내게 되면 남은 데이터는 꺼내지 못 할 수 있기 때문에 읽을 데이터가 없을 때까지 반복하는 것이 중요합니다.
마무리
지금까지 I/O multiplexing 기법에 대해 알아보았습니다.
비동기/논블록킹 개념을 배우다보면 I/O multiplxing 기법에 대한 이해가 필요한데 이번 포스팅이 도움이 되었으면 좋겠습니다.
읽어주셔서 감사합니다!
출처
https://github.com/gilbutITbook/080403/tree/main/Chapter%2010
https://applefarm.tistory.com/144
https://blog.naver.com/n_cloudplatform/222189669084
https://medium.com/@sumit-s/io-multiplexing-the-secret-sauce-behind-rediss-efficiency-1e48a9397f8c
https://www.joinc.co.kr/w/Site/system_programing/File/select