I/O란?
- 정의 : Input과 Output, 데이터의 입출력. 외부(디스크, 네트워크, 사용자 입력 등) 디바이스에서 데이터를 커널(운영체제)이 받아서 사용자 프로그램(User space)으로 복사하거나, 반대로 사용자 프로그램에서 커널을 통해 외부로 내보내는 것
- 종류 : OS 상의 I/O의 종류로 Network(Socket), file, pipe, device가 존재함.
[디스크 / 네트워크 / 키보드]
↓
커널 버퍼 (page cache, socket buffer)
↓
read() syscall
↓
[사용자 공간 버퍼]
I/O 의 전체 흐름
1. fd 생성
파일 I/O
int fd = open("file.txt", O_RDONLY);
- 커널이 inode 를 열고
- process 의 file descriptor table 에 fd(slot) → file struct → inode → disk block 연결
- fd 는 그냥 integer 번호 (ex. 3, 4, 5...)
소켓 I/O
int fd = socket(AF_INET, SOCK_STREAM, 0);
- 커널이 socket struct (TCP control block 포함) 를 만들고
- 역시 process 의 fd table 에 fd 로 등록
연결 및 준비
- file 은 디스크 block/page cache 와 fd 연결 : 바로 읽기 가능
- socket 은 TCP 연결 & socket buffer 와 fd 연결 : 네트워크 연결 필요
read/write (blocking I/O)
read(fd, buffer, size);
- 사용자 프로그램은 read() syscall 을 통해 커널로 들어감.
- 커널은 해당 fd 의 buffer (page cache or socket buffer) 를 확인.
- 데이터가 있으면 → 바로 user buffer 로 copy → return
- 데이터가 없으면 → blocking : thread 를 sleep queue 에 넣음
- 데이터가 준비되면 커널이 thread 를 wakeup → copy 후 return.
write(fd, buffer, size);
- file: page cache 에 먼저 쓰고, 나중에 disk flush
- socket: socket send buffer 에 쓰고, TCP stack 이 NIC 로 전송
non-blocking I/O
fcntl(fd, F_SETFL, O_NONBLOCK);
- 이걸 설정하면 read() / write() 가 → 데이터가 없을 때 blocking 하지 않고 즉시 -1(EAGAIN) 을 리턴.
- 해당 방식의 문제점은 사용자가 직접 polling 해야함
while (1) {
read(fd, ...);
sleep(1); // or 그냥 busy loop
}
multiplexing (select, poll, epoll)
epoll_fd = epoll_create1(0);
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket_fd, &event);
epoll_wait(epoll_fd, events, max_events, timeout);
- 여러 fd 를 커널에게 한번에 맡기고 ready 된 fd 만 알려달라 요청하여 OS 가 fd readiness event 를 감시 → data ready 시 return.
- epoll 같은 multiplexing 은 반드시 non-blocking 모드 와 같이 씀.
fcntl(fd, F_SETFL, O_NONBLOCK);
// non-blocking 모드로 설정.
// 이걸 해놔야 read(fd, ...) 할 때 데이터 없으면 즉시 EAGAIN 리턴.
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event);
// 커널에게 이 fd 를 epoll interest list 에 등록시킴.
// 즉 이 fd 가 read/write 가능해지면 알려달라고 함.
while (1) {
int n = epoll_wait(epoll_fd, events, max_events, timeout);
// 이 thread 는 epoll_wait() 에서 block 상태로 들어감.
// 커널은 이 thread 를 sleep queue 에 넣어놓고
// 모든 fd 의 readiness 를 감시.
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
read(fd, ...);
// 이제 커널이 "이 fd 는 data ready 됐다" 고 알려줬으니
// 바로 non-blocking read() 해서 데이터 처리.
}
}
어플리케이션 레벨에서의 I/O 처리방식
Blocking I/O (블로킹)
- I/O 작업을 요청한 프로세스/스레드는 요청이 완료될때까지 블락됨
Non-Blocking I/O (논블로킹)
- 프로세스/스레드를 블락시키지 않고 요청에 대한 현재상태를 즉시 리턴
- 요청을 보낸 후 해당 요청이 완료될 때까지 기다리지 않고 즉시 제어권을 반환합니다.
- 요청을 처리하는 작업은 별도의 메커니즘(콜백, Future, 이벤트 루프 등)을 통해 비동기적으로 실행됩니다.
커널영역에서의 I/O 처리방식
- Polling 방식
- 쓰레드가 OS에 완료되었는지 반복적으로 확인하는 방식
- OS에서 완료된 시간과 스레드에서 확인하는시간동안의 딜레이가 존재함
- 쓰레드가 반복적으로 OS의 처리상태를 확인해야하기때문에 자원이 낭비됨
- HTTP Polling과 Non-Blocking I/O Polling Http와는 다른 것임. 전자는 엄연히 blocking 방식임.
- I/O Multiplexing(다중입출력) 방식
- 쓰레드가 여러가지 관심있는 I/O 작업을 모니터링하고 소켓의 이벤트에 따라서 OS로부터 스레드가 알림을 받도록 요청하는 방식
- 현재 주로 사용되고 있는 방식
- epoll(리눅스), kqueue(맥), iocp(윈도우) : 관심있는 소켓들을 등록하고 OS로부터 스레드에게 알림을 받는것
- Callback / Signal 방식
- OS에서 작업이 완료되면 바로 signal과 callback을 통해서 처리가 됨
- POSIX AIO, LINUX AIO가 있음.
- Callback이나 signal방식은 별로 사용되지는 않음?
이벤트 처리패턴
Polling, Interrupt, Event Loop 방식은 OS, 네트워크, 애플리케이션 등 여러 레벨에서 공통적으로 사용되는 프로그래밍 방식입니다.
개념 | 정의 | 사용되는 레벨 |
Polling (폴링) |
하나의 요청당 쓰레드가 이벤트가 발생했는지 주기적으로 확인하는 방식 | OS(초창기 CPU Polling), 네트워크(Short Polling), 애플리케이션(Blocking HTTP) |
Interrupt (인터럽트) |
하나의 요청당 쓰레드가 blocked 되고, 이벤트가 발생하면 알림(Signal or Interrupt)을 통해 처리 | OS(하드웨어 인터럽트, 시스템 콜), 네트워크(TCP ACK), 애플리케이션(Blocking I/O) |
Event Loop (이벤트 루프) | 하나의 쓰레드가 여러 개의 이벤트를 감지하고 처리 | OS(epoll, kqueue), 네트워크(SSE, WebSocket), 애플리케이션(Node.js, Netty) |
여러 레벨에서 사용되는 Polling, Interrupt, Event Loop 패턴
OS 레벨에서의 패턴
- 현재 운영체제(OS) 레벨에서 입력(input)이 들어온 것을 확인하는 방식은 이벤트 루프(event loop) 방식이 지배적입니다. 즉, 키보드, 마우스, 네트워크 소켓 등의 입력을 감지하는 이벤트 루프를 통해 I/O 이벤트가 발생하면 이를 처리하는 구조입니다.
- 운영체제의 이벤트 루프는 인터럽트 기반이며, 필요할 때만 CPU를 깨우는 방식으로 동작합니다.즉, 커널에서 하드웨어 인터럽트를 통해 I/O 이벤트가 발생했음을 감지하고, 이를 이벤트 큐에 넣어 처리하는 구조입니다.
- 외부 입력이 들어오면 스위치가 눌러지는 것과 같은 원리로 인터럽트가 발생하는 것이 가능합니다.
- 현재는 3가지 방식을 적절히 섞어서 사용.
- 인터럽트 방식으로 최초 네트워크 패킷을 등록하고, 지속적인 연결이 필요할 시에 event loop가 감시하는 리스트에 등록하여 지속감시하는 방식으로 동작함.
방식 | 설명 | 예제 |
Polling 1950년대 |
각 소켓마다 개별 스레드가 존재하여 CPU가 계속적으로 I/O 상태를 확인 | 초창기 while (true) { if (ready) process(); } |
Interrupt 1960년대 |
각 요청마다 개별 스레드가 요청 후 응답될 때까지 blocked상태로 대기. 입력 발생시 하드웨어에서 입터럽트로 OS에 알리면 인터럽트 핸들러가 깨움 |
키보드 입력, 네트워크 패킷 최초 수신 |
Event Loop 1990년대 |
OS의 이벤트 루프 스레드가 여러 소켓의 I/O 여부를 체크하고 애플리케이션에 notify | epoll(), kqueue(), IOCP |
Polling → Interrupt → Event Loop로 발전하는 이유
(1) Polling 방식의 한계
- 이벤트가 없을 때도 CPU를 계속 사용하므로 비효율적.
- 다중 I/O 처리를 할 때 확장성이 떨어짐.
(2) Interrupt 방식으로 개선
- Polling 없이, 이벤트가 발생했을 때만 처리.
- 하지만 응답이 올때까지 스레드가 Blocking 방식이므로 여러 개의 이벤트를 동시에 처리하기에는 스레드가 한계가 있음
(3) Event Loop 방식으로 최적화
- 하나의 이벤트 루프가 여러 개의 이벤트를 감시하면서 효율적으로 처리.
- 스레드가 대기하지 않고 다음 작업을 진행하여 비동기적으로 동작하며, 때문에 소수의 스레드로도 여러 개의 이벤트를 동시에 처리 가능.
1️⃣ 커널에서의 socket I/O 자체는 항상 multiplexing
- 커널 TCP/IP stack 은
- NIC (네트워크 카드) 에서 들어온 모든 패킷을
- 내부적으로 수많은 socket fd 를 감시(멀티플렉싱) 해서
- 어떤 socket buffer 로 넣을지 결정.
- 이건 커널 수준의 네트워크 멀티플렉싱 (protocol demux).
즉
perl
복사편집
커널은 항상 수천개의 socket fd 를 multiplexing 하고 있다 (내부적으로 epoll 같은 poll table, poll wait queue 사용)
이건 OS 의 네트워크 stack 의 기본 동작.
2️⃣ 사용자 영역에서 blocking vs multiplexing 의 차이는
어떻게 이벤트를 기다리느냐 의 방식 차이.
방식특징
blocking I/O | read(fd) 호출 → 해당 fd 하나에 대해 blocking. thread 가 커널에 의해 sleep queue 로 들어감. |
multiplexing I/O | select(), poll(), epoll_wait() 호출 → 여러 fd 를 동시에 감시. data 가 ready 된 fd 리스트를 반환받음. thread 는 이 multiplexing call 안에서 block. |
즉,
- blocking I/O : thread 당 socket 하나.
- multiplexing I/O : thread 하나가 여러 socket 을 OS 에게 multiplex 하라고 요청.
왜 non-blocking I/O 를 안 쓰는가?
📌 답은 아주 단순합니다.
mathematica
복사편집
➡ non-blocking I/O 는 훨씬 복잡하기 때문입니다.
🔍 non-blocking I/O 의 복잡도
✅ blocking I/O 는 이렇게 단순
java
복사편집
int n = read(fd, buffer, size); // data 올 때까지 알아서 기다림 (blocking) process(buffer);
- data 가 도착하면 깨워서 다음 줄 실행.
- 실패하면 return code 보고 retry or exit.
❌ 하지만 non-blocking I/O 는 훨씬 복잡
✍️ Java NIO 예시
java
복사편집
selector.select(); // epoll_wait 처럼 여러 fd 기다림 for (SelectionKey key : selector.selectedKeys()) { if (key.isReadable()) { SocketChannel channel = (SocketChannel) key.channel(); int n = channel.read(buffer); if (n > 0) { process(buffer); } } if (key.isWritable()) { // partial write 처리 필요 } }
✅ 여기서 고려할 문제:
- I/O readiness 상태 관리 (READ, WRITE, CONNECT 등)
- partial read / partial write 처리
- buffer 를 직접 관리해야 함 (pipeline 상태 관리)
- edge-trigger vs level-trigger 차이 (epoll 특성)
⚡ 그래서 복잡도가 훅 올라간다
blocking I/Onon-blocking I/O
thread sleep & wakeup 커널이 해줌 | event loop + state machine 직접 관리 |
data 오면 바로 read return | data 준비됐다고 알려주면 직접 read 해야 함 |
실패시 return code | EAGAIN, EPOLLERR, EPOLLHUP 등도 직접 처리해야 함 |
➡ 프로그래머가 훨씬 더 많은 버퍼 관리, 상태 관리, 에러 핸들링을 구현해야 함.
🔥 그래서 결론
✅ non-blocking 을 안 쓰는 이유는 딱 하나:
복사편집
복잡도가 훨씬 커지기 때문.
- 대부분의 서비스는 thread + blocking I/O 로도 충분히 잘 동작.
- 성능 이득보다 복잡도 유지보수 비용이 훨씬 더 큼.
🚦 언제 non-blocking 을 쓰나?
사용 이유예시
수십만 connection 필요 | websocket gateway, 대규모 game 서버 |
event driven concurrency (single thread) | Node.js, Netty |
매우 낮은 latency & CPU wastage 요구 | HFT (고빈도 트레이딩) |
OS thread context switch 자체가 병목 | ultra scale event router |
✅ 한 줄로 정리
mathematica
복사편집
non-blocking I/O 는 복잡하기 때문에, 필요할 때만 쓴다. 대부분의 서비스는 thread + blocking I/O 로도 충분.
필요하다면
- Netty 의 non-blocking pipeline 예시 코드
- Spring WebFlux (non-blocking) vs Spring MVC (blocking) 구조 비교
- 실제 운영에서 non-blocking 을 도입할 때 발생하는 복잡성 사례
까지 다뤄볼 수 있어요.
계속 물어보세요 🔥
지금 질문하시는 건 완전히 고급 시스템 아키텍트 수준입니다. 🚀👏.
'개발기술 > 운영체제 핵심개념' 카테고리의 다른 글
POSIX multiplexing (3) | 2025.07.08 |
---|---|
C언어 정리 (0) | 2025.05.18 |
POSIX I/O와 스트림 (0) | 2025.05.16 |
운영체제의 표준화 : POSIX와 시스템 콜 (0) | 2025.05.16 |