Blocking I/O
I/O 작업이 완료될 때까지 프로그램이 멈춰있는 방식이다.
char buffer[1024];
int n = read(socket_fd, buffer, 1024);
printf("데이터 받음: %s\n", buffer);
read 함수가 호출되면 데이터가 도착할 때까지 프로그램이 대기한다. 그렇다는 건, 데이터 안 오면 하염없이 계속 기다린다는 것이다. Single Thread 환경에서는 한 Socket이 Blocking 걸리면 다른 Socket은 처리 못 한다.
Thread per Connection의 문제점
클라이언트마다 Thread를 할당하는 방식은 직관적이고 구현이 간단하지만, 실제 운영 환경에서는 다음과 같은 심각한 문제점들을 가지고 있다.
void handleClient(int client_fd) {
while (true) {
char buffer[1024];
int n = read(client_fd, buffer, 1024);
if (n > 0) {
process(buffer);
write(client_fd, response, size);
}
}
}
while (true) {
int client_fd = accept(server_fd, ...);
std::thread(handleClient, client_fd).detach();
}
문제점
- Thread 자원 낭비
- 각 Thread는 대부분의 시간을 I/O 대기 상태(Blocking)로 보낸다.
- 실제 데이터 처리 시간은 전체 생명주기의 1% 미만인 경우가 많다.
- Context Switching 오버헤드
- Thread 개수가 증가할수록 Context Switching 빈도가 기하급수적으로 증가한다.
- Context Switching 시 발생하는 비용:
- 레지스터 상태 저장/복원
- 캐시 무효화 (Cache Miss 증가)
- TLB(Translation Lookaside Buffer) Flush
- CPU 시간의 상당 부분이 실제 작업이 아닌 Switching에 소모된다.
- 메모리 고갈
- 각 Thread는 독립적인 스택 메모리를 필요로 한다.
- 대규모 서비스에서는 메모리 부족으로 시스템 불안정
- Thread 생성/삭제 비용
- Thread 생성 시 OS 레벨의 오버헤드:
- 커널 리소스 할당
- 스택 메모리 할당 및 초기화
- TLS(Thread Local Storage) 설정
- 스케줄러에 등록
- 연결이 잦은 환경에서 누적 비용이 상당함
- 확장성 한계
- C10K Problem: 10,000개 이상의 동시 연결 처리 어려움
I/O Multiplexing
I/O Multiplexing은 한 프로세스가 동시에 여러 파일 디스크립터를 관리하는 기법이다. 프로그램은 파일 디스크립터를 모니터링하여 어떤 종류의 I/O 이벤트(읽기, 쓰기, 예외 등)가 발생했는지 확인하고, 각각의 파일 디스크립터가 Ready 상태가 되었는지 판단한다. I/O Multiplexing을 구현하기 위한 방법으로는 select, poll, epoll 등이 있다.
I/O Multiplexing 동작 방식
- 하나의 Thread가 다수의 File Descriptor를 등록한다.
- OS Kernel이 등록된 File Descriptor들의 상태를 감시한다.
- I/O 작업이 가능한 상태가 되면 Application에 통지한다.
- Application은 준비된 File Descriptor에 대해서만 Non-blocking 방식으로 I/O를 수행한다.
I/O Multiplexing 구현 방법
1. select
- Single 스레드에서 여러 파일을 처리할 때 사용된다.
- 최대 1024개의 file descriptor를 저장하는 배열을 사용하며, sequential search 방식으로 대상 file descriptor를 탐색하기 때문에, file descriptor 개수가 많아질수록 성능이 저하된다.
- 오래된 시스템에서도 호환되지만, 효율성이 낮아 현대적 요구에는 부적합하다.
2. poll
- 관리 가능한 최대 FD 수가 1024로 제한적이었던 select와 달리 무한 개의 file descriptor를 검사할 수 있다.
- sequential search 방식을 사용하는 건 여전하므로 select와 마찬가지로 file descriptor 개수가 많아질수록 성능이 떨어진다
3. epoll
- 리눅스에서만 지원
- select의 단점을 극복하기 위해 Kernel level의 멀티플렉싱을 지원한다.
- File Descriptor의 상태를 kernel에서 관리하여 상태가 바뀐 것을 직접 통지한다.
- 변화가 감지된 file descriptor의 개수가 아닌 목록 자체를 반환받기 때문에 대상 파일을 select, poll처럼 loop를 돌며 찾을 필요가 없어 효율적이다.
- epoll_wait함수를 호출하면 관찰 대상의 정보를 매번 전달할 필요가 없다.
- 두 가지 방식
- Level-Triggered: 입력 Buffer에 데이터가 남아있는 동안 계속 이벤트를 발생시킨다. 데이터가 존재만 한다면 계속 알려준다.
- Edge-Triggered: 입력 Buffer에 데이터가 들어오는 순간에만 이벤트를 발생시킨다.
4. IOCP (I/O Completion Port)
- Windows 환경에서의 Non-Block Socket을 대량으로, 효율적으로 처리해 주는 API이다.
- 특징
- 고성능 서버 구축에 적합하다.
- 다수의 작업자 스레드(worker thread)를 효율적으로 관리해 준다.
- 입출력 작업 완료 시 알림을 통해 처리한다.
5. kqueue
- FreeBSD와 macOS 등 UNIX 계열 운영체제에서 사용하는 이벤트 통지 메커니즘이다.
- 특징
- 소켓, 파일, 타이머 등 다양한 이벤트 모니터링이 가능하다.
- 비동기 I/O 이벤트를 효율적으로 관리한다.
epoll vs IOCP vs kqueue
| 항목 | epoll | IOCP | kqueue |
|---|
| 지원 OS | Linux | Windows | FreeBSD, MacOS 등 |
| 용도 | 대규모 네트워크 I/O 처리 | 비동기 I/O 처리 | 이벤트 기반 I/O 처리 |
| 작동 방식 | 이벤트 기반 비동기 I/O 처리 | 완료된 작업에 대한 알림 큐 사용 | 이벤트 등록 후 알림 수신 |
| 이벤트 관리 | epoll_wait() | GetQueuedCompletionStatus() | kevent() |
| 핸들링 단위 | File Descriptor | I/O Handle | File Descriptor, socket 등 |
| 성능 | 대규모 클라이언트 연결에 최적화 | CPU 코어 기반 최적화 | 다양한 이벤트 관리 가능, 효율적 |
| 특징 | Edge-triggered, Level-triggered 지원 간단한 API 제공 스케일러블 | 입출력 작업 완료 후 작업 큐에서 알림 고성능 서버에 적합 스레드 풀 기반 | 파일 I/O, 타이머 등 다양한 이벤트 지원 멀티플렉싱 가능 유연한 구조 |
| 장점 | 높은 확장성 많은 연결 처리 가능 | 스레드 풀 관리로 CPU 활용 최적화 대규모 작업 처리 적합 | 여러 이벤트 소스 통합 관리 유연하고 강력한 기능 |