본문 바로가기

개발기술/운영체제 핵심개념

Blocking / Non-Blocking I/O

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