리눅스 I/O 모델 5가지 심층 분석

1. 핵심 개념 이해

사용자 공간과 커널 공간

현대 운영체제는 가상 메모리를 사용합니다. 32비트 시스템 기준으로 4GB(2^32)의 가상 주소 공간이 존재합니다. 운영체제의 핵심인 커널은 보호된 메모리 영역에 접근 가능하며, 하드웨어 장치에 대한 모든 권한을 가집니다. 보안을 위해 사용자 프로세스가 커널에 직접 접근하지 못하도록 가상 공간을 두 부분으로 나눕니다. 리눅스에서는 상위 1GB(0xC0000000 ~ 0xFFFFFFFF)를 커널 공간으로, 하위 3GB(0x00000000 ~ 0xBFFFFFFF)를 사용자 공간으로 할당합니다.

프로세스 전환(Context Switching)

CPU에서 실행 중인 프로세스를 일시 중단하고 이전에 중단된 프로세스를 재개하는 기능을 프로세스 전환이라고 합니다. 이 과정은 커널의 지원을 통해 이루어집니다.

프로세스 전환 시 발생하는 단계:

  • 프로그램 카운터와 레지스터 등 프로세서 컨텍스트 저장
  • PCB(Process Control Block) 정보 갱신
  • PCB를 준비, 블록 등 적절한 큐로 이동
  • 다른 프로세스 선택 및 PCB 갱신
  • 메모리 관리 데이터 구조 갱신
  • 프로세서 컨텍스트 복원

프로세스 블로킹

실행 중인 프로세스가 자원 요청 실패, 특정 작업 완료 대기, 새 데이터 미도착 등의 이유로 기다려야 할 때, 시스템이 자동으로 블로킹 원시 코드(Block primitive)를 실행하여 프로세스를 실행 상태에서 블록 상태로 전환합니다. 이는 프로세스의 능동적인 행동이며, 오직 실행 상태(CPU 점유 중)인 프로세스만 블록 상태가 될 수 있습니다. 블록 상태의 프로세스는 CPU 자원을 소비하지 않습니다.

파일 디스크립터(File Descriptor, fd)

파일 디스크립터는 파일을 참조하는 추상화된 개념입니다. 형식적으로는 0 이상의 정수이며, 커널이 각 프로세스에 대해 유지 관리하는 열린 파일 레코드 테이블의 인덱스입니다. 프로그램이 파일을 열거나 생성하면 커널은 파일 디스크립터를 반환합니다. 이 개념은 주로 UNIX, Linux 계열 운영체제에서 사용됩니다.

버퍼 I/O (Buffered I/O)

대부분 파일 시스템의 기본 I/O 방식입니다. 데이터는 파일 시스템의 페이지 캐시(page cache)에 먼저 저장된 후, 커널 버퍼에서 애플리케이션 주소 공간으로 복사됩니다.

단점: 데이터 전송 시 애플리케이션과 커널 간 여러 번의 복사가 발생하여 CPU와 메모리 오버헤드가 큽니다.

2. I/O 모델 개요

네트워크 I/O의 본질은 소켓 읽기입니다. 리눅스에서 소켓은 스트림으로 추상화되며, I/O는 스트림에 대한 작업입니다. read 작업은 두 단계로 진행됩니다:

  1. 데이터 준비 대기: 네트워크 패킷이 도착하여 커널 버퍼에 복사될 때까지 대기
  2. 커널에서 프로세스로 데이터 복사: 커널 버퍼의 데이터를 애플리케이션 프로세스 버퍼로 전송

네트워크 애플리케이션이 처리하는 주요 문제는 네트워크 I/O와 데이터 계산입니다. 일반적으로 I/O 지연이 성능에 더 큰 병목을 초래합니다. 주요 I/O 모델은 다음과 같습니다:

  • BIO (Blocking I/O) - 블로킹 모델
  • NIO (Non-blocking I/O) - 논블로킹 모델
  • I/O 멀티플렉싱 (Multiplexing I/O)
  • AIO (Asynchronous I/O) - 비동기 모델
  • SIO (Signal-driven I/O) - 시그널 기반 모델

참고: SIO(시그널 구동 I/O)는 실제 사용 빈도가 낮아 본 글에서는 다루지 않습니다.

3. I/O 모델 상세

3.1 BIO – 블로킹 I/O

동작 방식: 사용자 프로세스가 요청을 보낸 후 데이터를 받을 때까지 대기 상태로 유지되며, 데이터 복사는 사용자 프로세스가 수행합니다.

비유: 한 사람이 가게에 가서 칼을 사려고 합니다. 가게에 칼이 있으면 바로 받고, 없으면 주문 후 기다려야 합니다. 이 모든 과정 동안 구매자는 계속 기다려야 합니다.

코드 예제 (서버):

import socket

server = socket.socket()
server.bind(('127.0.0.1', 8080))
server.listen(5)

while True:
    conn, addr = server.accept()  # 블로킹
    while True:
        try:
            data = conn.recv(1024)  # 블로킹
            if not data:
                break
            print(data)
            conn.send(data.upper())
        except ConnectionResetError:
            break
    conn.close()

문제점: 멀티스레드/프로세스 풀을 사용해도 I/O 대기 자체를 회피하지 못합니다. 여러 클라이언트가 각각 독립적으로 기다릴 뿐입니다.

3.2 NIO – 논블로킹 I/O

동작 방식: 사용자 프로세스가 요청 시 데이터가 준비되지 않았으면 즉시 "준비되지 않음"을 반환받습니다. 프로세스는 계속 요청을 보내거나 다른 작업을 하다가 나중에 다시 확인합니다. 데이터가 준비되면 프로세스가 직접 복사합니다.

비유: 칼을 사러 가게에 갔는데 재고가 없습니다. 주기적으로 "도착했나요?" 물어보며 기다리다가 도착하면 받아옵니다. 대기 중에는 다른 일을 할 수 있지만, 복사는 직접 해야 합니다.

코드 예제 (서버, 단일 스레드 논블로킹):

import socket
server = socket.socket()
server.bind(('127.0.0.1', 8081))
server.listen(5)
server.setblocking(False)  # 논블로킹 모드

connections = []
to_remove = []

while True:
    try:
        conn, addr = server.accept()
        connections.append(conn)
    except BlockingIOError:
        for conn in connections[:]:  # 복사본 사용
            try:
                data = conn.recv(1024)
                if not data:
                    conn.close()
                    connections.remove(conn)
                    continue
                conn.send(data.upper())
            except (BlockingIOError, ConnectionResetError):
                pass

단점: 하나의 스레드가 여러 소켓을 처리하지만, 지속적인 폴링(polling)으로 CPU 낭비가 심합니다.

3.3 I/O 멀티플렉싱 (select/poll/epoll)

동작 방식: 블로킹 I/O와 유사하지만, 여러 파일 디스크립터를 한 번에 모니터링하는 대리자(selector)를 사용합니다. 데이터 복사는 여전히 사용자 프로세스가 수행합니다.

비유: 여러 사람이 한 가게에 칼을 주문합니다. 가게 주인(select/epoll)이 모든 주문을 기록하고, 공급이 완료되면 각 구매자에게 알립니다. 구매자는 통보를 받고 가게에 와서 칼을 직접 받습니다.

코드 예제 (select 기반 서버):

import socket
import select

server = socket.socket()
server.bind(('127.0.0.1', 8080))
server.listen(5)
server.setblocking(False)

read_list = [server]

while True:
    # select를 통해 읽기 가능한 소켓 목록을 얻음 (블로킹)
    readable, _, _ = select.select(read_list, [], [])
    
    for sock in readable:
        if sock is server:  # 새로운 연결 요청
            conn, addr = sock.accept()
            read_list.append(conn)
        else:  # 기존 클라이언트 데이터
            data = sock.recv(1024)
            if not data:
                sock.close()
                read_list.remove(sock)
            else:
                print(data)
                sock.send(b'response')

select, poll, epoll 비교

특징selectpollepoll
파일 디스크립터 수 제한1024 (FD_SETSIZE)없음 (pollfd 배열)없음 (최대 열린 파일 수)
데이터 구조비트 배열 (fd_set)pollfd 구조체 배열이벤트 기반 (red-black tree + linked list)
성능O(n) (모든 fd 스캔)O(n)O(1) (준비된 fd만 처리)
호출 방식매번 fd_set 초기화 및 복사매번 pollfd 배열 복사epoll_ctl에서 한 번만 복사, epoll_wait은 준비 목록만 확인
트리거 모드레벨 트리거 (Level Triggered)레벨 트리거레벨 트리거 + 엣지 트리거 (Edge Triggered)

select 동작 과정:

  1. 사용자 공간의 fd_set을 커널 공간으로 복사
  2. 모니터링할 모든 fd에 대해 poll 메서드 호출 (내부적으로 __pollwait 호출)
  3. 현재 프로세스(current)를 각 fd의 대기 큐에 등록
  4. 데이터가 준비되면 대기 큐의 프로세스 깨움
  5. 모든 fd를 순회하며 준비된 fd를 fd_set에 표시
  6. 결과를 사용자 공간으로 복사

epoll의 개선점:

  • fd 등록 시 한 번만 커널에 복사 (epoll_ctl)
  • 콜백 기반: 각 fd에 콜백 함수 등록, 이벤트 발생 시 준비된 fd를 리스트에 추가
  • mmap을 활용해 사용자-커널 간 데이터 복사 최소화
  • 준비된 fd만 반환하므로 O(1) 성능

3.4 AIO – 비동기 I/O

동작 방식: 요청을 보내면 즉시 응답을 받고, 데이터 복사는 커널이 완전히 처리한 후 결과를 알려줍니다. 프로세스는 전혀 기다리지 않습니다.

비유: 온라인으로 칼을 주문하면 가게에서 확인 메시지를 보내고, 가게가 직접 재고를 확보하여 배송까지 완료합니다. 구매자는 배송 알림만 받으면 됩니다.

참고:

  • Windows에서는 IOCP, Linux에서는 epoll로 AIO를 부분적으로 구현합니다.
  • 실제 고성능 프레임워크 대부분은 AIO보다 I/O 멀티플렉싱(epoll)을 선호합니다. 그 이유는 epoll의 성능이 AIO에 비해 크게 뒤지지 않고, AIO의 복잡성과 디버깅 어려움 때문입니다.

태그: linux IO모델 BIO NIO IO멀티플렉싱

5월 30일 03:42에 게시됨