Skip to content

✍️ 필사 모드: 비동기 I/O 모델 완전 가이드 2025: epoll, io_uring, Reactor/Proactor, async/await 내부

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

TL;DR

  • 비동기 I/O = 현대 서버의 기반: Nginx, Node.js, Redis, 모든 고성능 서버
  • 진화: select → poll → epoll/kqueue → io_uring
  • Reactor vs Proactor: 이벤트 알림 vs 작업 완료 알림
  • io_uring: Linux 5.1+의 혁명. epoll보다 효율적, 진정한 async
  • async/await: 컴파일러가 state machine으로 변환

1. I/O 모델의 진화

1.1 5가지 I/O 모델

POSIX의 분류:

  1. Blocking I/O: 작업 완료까지 대기
  2. Non-blocking I/O: 즉시 반환, 폴링
  3. I/O Multiplexing: select/poll/epoll
  4. Signal-driven I/O: 시그널로 알림
  5. Asynchronous I/O: 진정한 async (POSIX AIO, io_uring)

1.2 Blocking I/O — 가장 단순

data = sock.recv(1024)  # 데이터 올 때까지 블록
process(data)

문제:

  • 한 스레드 = 한 연결
  • 1만 연결 = 1만 스레드 (메모리 폭증)
  • C10K 문제

1.3 멀티 스레드 해결?

def handle_client(sock):
    data = sock.recv(1024)
    process(data)

while True:
    client = server.accept()
    threading.Thread(target=handle_client, args=(client,)).start()

한계:

  • 스레드당 메모리 (1-8 MB)
  • 컨텍스트 스위칭 비용
  • 1만 스레드 = 죽음

1.4 Non-blocking I/O

sock.setblocking(False)
try:
    data = sock.recv(1024)
except BlockingIOError:
    pass  # 데이터 없음, 다른 일 하기

문제: 무한 루프 폴링 → CPU 100%.

1.5 I/O Multiplexing의 등장

아이디어: 하나의 시스템 콜로 여러 fd를 동시에 감시.

ready, _, _ = select([sock1, sock2, sock3], [], [])
for sock in ready:
    data = sock.recv(1024)

단일 스레드로 수많은 연결 처리.


2. select와 poll

2.1 select (1983)

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock, &readfds);

int n = select(max_fd + 1, &readfds, NULL, NULL, &timeout);

if (FD_ISSET(sock, &readfds)) {
    // 읽기 가능
}

문제:

  • FD_SETSIZE 제한 (보통 1024)
  • O(n) 스캔: 매번 모든 fd 검사
  • fd_set 복사: 매 호출마다 사용자 ↔ 커널

2.2 poll (1986)

struct pollfd fds[1024];
fds[0].fd = sock;
fds[0].events = POLLIN;

int n = poll(fds, 1024, timeout);

if (fds[0].revents & POLLIN) {
    // 읽기 가능
}

개선:

  • fd 수 제한 없음
  • 동적 배열

여전한 문제:

  • O(n) 스캔
  • 매 호출마다 fd 목록 전달

2.3 select/poll의 한계

10,000 연결 + 100개만 활성:

  • select/poll: 매번 10,000 스캔
  • CPU 낭비

새로운 메커니즘 필요.


3. epoll — Linux의 혁명

3.1 등장 (2002, Linux 2.5.44)

int epfd = epoll_create1(0);

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);

struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);

for (int i = 0; i < n; i++) {
    int fd = events[i].data.fd;
    // 처리
}

3.2 epoll의 장점

1. O(1) 이벤트 감지:

  • 커널이 ready 상태인 fd만 반환
  • 10,000 연결 + 100개 활성 → 100만 반환

2. 등록 한 번:

  • epoll_ctl로 fd 등록
  • 매번 전달 X

3. 이벤트 트리거 모드:

  • Level-Triggered (LT): 데이터 있는 한 계속 알림 (기본)
  • Edge-Triggered (ET): 상태 변할 때만 알림 (Nginx)

3.3 ET vs LT

LT (Level-Triggered):

// 데이터 16KB 있고 8KB만 읽음 → 다음 epoll_wait도 알림

쉽지만 약간 느림.

ET (Edge-Triggered):

// 새 데이터 도착 시에만 알림 → 모두 읽어야 함
while (true) {
    n = recv(fd, buf, sizeof(buf), 0);
    if (n < 0 && errno == EAGAIN) break;  // 데이터 다 읽음
    process(buf, n);
}

빠르지만 처리 누락 위험.

3.4 epoll을 사용하는 시스템

  • Nginx — 핵심
  • Node.js (libuv)
  • Redis
  • HAProxy
  • Memcached
  • 거의 모든 Linux 고성능 서버

3.5 다른 OS의 동등물

OSAPI
Linuxepoll
macOS/BSDkqueue
WindowsIOCP
Solaris/dev/poll (deprecated), event ports

4. kqueue (BSD/macOS)

4.1 epoll보다 강력

int kq = kqueue();

struct kevent change;
EV_SET(&change, sock, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);

struct kevent events[64];
int n = kevent(kq, NULL, 0, events, 64, NULL);

epoll보다 풍부:

  • 파일 변경 (EVFILT_VNODE)
  • 시그널 (EVFILT_SIGNAL)
  • 타이머 (EVFILT_TIMER)
  • 프로세스 종료 (EVFILT_PROC)
  • 디스크 I/O 등

문제: epoll보다 사용자 적음.


5. io_uring — 새로운 혁명

5.1 epoll의 한계

epoll도 한계가 있습니다:

  • 동기 I/O: epoll_wait 후 read/write 호출 필요
  • 시스템 콜 비용: 매번 사용자 ↔ 커널
  • 진정한 async X: read/write 자체는 블록 가능

5.2 io_uring (2019, Linux 5.1)

Jens Axboe (Linux 커널 개발자)가 만듦. 진정한 async I/O.

핵심:

  • 두 개의 ring buffer (Submission, Completion)
  • 공유 메모리 (사용자 ↔ 커널)
  • 시스템 콜 거의 0
[User Space]                    [Kernel Space]
[Submission Queue (SQ)] ────→ [Worker]
[Completion Queue (CQ)] ←──── [I/O 완료]

5.3 사용 예시

struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

// 작업 제출
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_submit(&ring);

// 완료 대기
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// cqe->res = 읽은 바이트 수
io_uring_cqe_seen(&ring, cqe);

5.4 io_uring의 장점

1. 진정한 async:

  • 디스크 I/O도 블록 안 함
  • 네트워크 I/O 통합

2. 적은 시스템 콜:

  • 배치 제출
  • 일부 모드에서는 0 시스템 콜 가능

3. 다양한 작업:

  • read, write, send, recv
  • accept, connect
  • fsync, fallocate
  • splice, tee
  • statx, openat, close

4. 더 빠름:

  • epoll 대비 30-50% 향상 (특정 워크로드)

5.5 io_uring 사용 사례

  • Nginx 1.21+: 옵션 지원
  • PostgreSQL 17: 일부 사용
  • Tokio (Rust): io-uring backend
  • ScyllaDB: 핵심 기술
  • Cloud Hypervisor

5.6 io_uring의 미래

  • eBPF 통합
  • NVMe 직접 액세스
  • GPU/FPGA I/O
  • 모든 시스템 콜 async

io_uring은 Linux I/O의 미래입니다.


6. Reactor vs Proactor 패턴

6.1 Reactor Pattern

[Event Loop]
   ↓ epoll_wait
[Event: fd=5 readable]
[Handler]
[read(5, ...)]  ← 사용자가 직접 read

특징:

  • 이벤트 발생 알림
  • 사용자가 read/write 호출
  • epoll, kqueue 기반

: Nginx, Node.js, Redis

6.2 Proactor Pattern

[User]"이 fd에서 1024 byte 읽어줘"
[Kernel] → 비동기 작업
[Completion: 데이터 준비됨]
[Handler] → 사용자가 받음

특징:

  • 작업 자체를 커널에 위임
  • 완료되면 알림
  • IOCP, io_uring 기반

: ASIO (Boost), Windows IOCP, io_uring

6.3 비교

ReactorProactor
이벤트"준비됨""완료됨"
읽기사용자가 호출커널이 알아서
OS 지원광범위 (epoll, kqueue)제한적 (io_uring, IOCP)
추상화단순복잡
성능좋음더 좋음 (이론)

6.4 ASIO의 통합 모델

Boost.ASIO (C++): Reactor와 Proactor를 같은 인터페이스로:

async_read(socket, buffer, [](error_code ec, size_t bytes) {
    // 완료 시 호출
});

내부적으로 Linux는 epoll, Windows는 IOCP, 최신은 io_uring.


7. async/await 내부

7.1 async/await가 하는 일

async def fetch_data():
    data = await http_request("https://api.example.com")
    return process(data)

이는 마법이 아닙니다. 컴파일러가 state machine으로 변환.

7.2 변환 결과 (의사 코드)

class FetchDataStateMachine:
    state = 0
    
    def resume(self):
        if self.state == 0:
            self.future = http_request("https://api.example.com")
            self.future.set_callback(self.resume)
            self.state = 1
            return  # 일시 중지
        
        if self.state == 1:
            data = self.future.result()
            return process(data)

핵심:

  • await에서 함수 일시 중지
  • 결과 준비되면 재개
  • 스택을 이벤트 루프에 양보

7.3 이벤트 루프

loop = asyncio.get_event_loop()

while True:
    # 1. ready 큐의 작업 실행
    while ready_queue:
        task = ready_queue.pop()
        task.run()
    
    # 2. epoll_wait로 I/O 이벤트 대기
    events = epoll.wait(timeout)
    
    # 3. 대기 중이던 작업을 ready 큐로
    for event in events:
        task = pending[event.fd]
        ready_queue.append(task)

7.4 Python asyncio

import asyncio

async def main():
    # 동시 실행
    results = await asyncio.gather(
        fetch_user(1),
        fetch_user(2),
        fetch_user(3),
    )
    return results

asyncio.run(main())

내부:

  • selectors 모듈 (epoll/kqueue/select 추상화)
  • 이벤트 루프
  • Task와 Future
  • Coroutine

7.5 JavaScript (Node.js)

async function main() {
    const data = await fetch("https://api.example.com")
    const json = await data.json()
    console.log(json)
}

내부:

  • libuv (C 라이브러리)
  • 이벤트 루프
  • epoll/kqueue/IOCP

7.6 Rust Tokio

#[tokio::main]
async fn main() {
    let data = fetch("https://api.example.com").await;
    println!("{:?}", data);
}

내부:

  • mio (epoll/kqueue 추상화)
  • io-uring 옵션
  • M:N 스케줄링 (멀티 OS 스레드)
  • Work-stealing

8. 단일 스레드 vs 멀티 스레드

8.1 단일 스레드 (Node.js, Redis, Nginx)

[1 스레드]
[Event Loop] (epoll)
[Callback 1] [Callback 2] [Callback 3]

장점:

  • 락 없음 (race condition 없음)
  • 단순
  • 메모리 효율

단점:

  • CPU 작업이 블록
  • 단일 코어만 사용

8.2 멀티 스레드 + 이벤트 루프

[N 스레드 (CPU 코어 수)]
[Event Loop per thread]
[Work-stealing]

: Tokio (Rust), Akka (JVM), Kotlin Coroutines

장점:

  • 멀티 코어 활용
  • 단일 프로세스에서 수십만 동시 작업

단점:

  • 동기화 필요
  • 디버깅 어려움

8.3 SO_REUSEPORT

리눅스 3.9+의 기능:

setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));

여러 프로세스가 같은 포트 listen:

  • 커널이 자동 로드 밸런싱
  • 각 프로세스가 독립 epoll
  • Nginx, HAProxy가 사용
[Port 80]
   ├─ [Worker 1] (epoll)
   ├─ [Worker 2] (epoll)
   └─ [Worker 3] (epoll)

9. 성능 비교

9.1 1만 연결 처리

모델메모리처리량
Thread per connection~10 GB낮음
select적음매우 낮음
poll적음낮음
epoll적음높음
io_uring적음매우 높음

9.2 실제 벤치마크

HTTP 서버 (10K connections):

Apache (prefork):    5,000 req/s
Apache (worker):     20,000 req/s
Nginx (epoll):       100,000 req/s
Nginx (io_uring):    150,000 req/s

ScyllaDB (io_uring):

  • Cassandra 대비 10배+ 처리량
  • 단일 노드에서 100만+ ops/s

9.3 NewSQL/스토리지

ScyllaDB의 비밀:

  • io_uring + DPDK
  • 코어당 shard
  • 락 없음
  • 사용자 공간 TCP 스택

10. 실전 — 고성능 서버 만들기

10.1 Python (asyncio)

import asyncio

async def handle_client(reader, writer):
    while True:
        data = await reader.read(1024)
        if not data:
            break
        writer.write(data)
        await writer.drain()
    writer.close()

async def main():
    server = await asyncio.start_server(handle_client, '0.0.0.0', 8080)
    async with server:
        await server.serve_forever()

asyncio.run(main())

내부적으로 selectors → epoll 사용.

10.2 Rust (Tokio)

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() {
    let listener = TcpListener::bind("0.0.0.0:8080").await.unwrap();
    
    loop {
        let (mut socket, _) = listener.accept().await.unwrap();
        
        tokio::spawn(async move {
            let mut buf = [0; 1024];
            loop {
                let n = socket.read(&mut buf).await.unwrap();
                if n == 0 { return }
                socket.write_all(&buf[..n]).await.unwrap();
            }
        });
    }
}

Tokio가 알아서:

  • mio (epoll)
  • M:N 스케줄링
  • Work-stealing

10.3 Go

listener, _ := net.Listen("tcp", ":8080")

for {
    conn, _ := listener.Accept()
    go handle(conn)
}

func handle(conn net.Conn) {
    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)
        if err != nil { return }
        conn.Write(buf[:n])
    }
}

Go runtime이 알아서:

  • netpoll (epoll/kqueue)
  • Goroutine 스케줄링
  • 채널과 통합

10.4 비교

Python asyncioRust TokioGo
구문async/awaitasync/awaitgo func()
성능보통최고매우 좋음
러닝커브낮음가파름낮음
메모리보통매우 적음적음
권장빠른 프로토타입최고 성능균형

퀴즈

1. select와 epoll의 핵심 차이는?

: select: 매 호출마다 모든 fd를 스캔 (O(n)), FD_SETSIZE 제한 (보통 1024), fd_set을 사용자/커널 간 복사. epoll: 이벤트 발생한 fd만 반환 (O(1)), fd 수 제한 없음, epoll_ctl로 한 번만 등록. 10,000 연결 + 100개 활성: select는 매번 10,000 스캔, epoll은 100만 반환. Nginx, Node.js, Redis가 epoll 기반인 이유.

2. io_uring이 epoll보다 좋은 이유는?

: epoll도 한계가 있습니다 — 동기 read/write (epoll_wait 후 직접 호출), 시스템 콜 비용, 디스크 I/O는 여전히 블록 가능. io_uring: (1) 두 개의 ring buffer (SQ/CQ) + 공유 메모리, (2) 시스템 콜 거의 0 (배치 제출), (3) 진정한 async (디스크 I/O도 블록 안 함), (4) read/write 외에도 accept, connect, fsync 등 다양한 작업. 30-50% 빠름. ScyllaDB, PostgreSQL 17이 사용. Linux I/O의 미래.

3. Reactor와 Proactor 패턴의 차이는?

: Reactor (epoll 기반): 이벤트 루프가 "fd가 ready됨"을 알림 → 사용자가 read 호출. Proactor (io_uring, IOCP 기반): 사용자가 "이 데이터 읽어줘"라고 요청 → 커널이 알아서 처리 → 완료 시 알림. Reactor는 사용자가 일을 함, Proactor는 커널이 일을 함. Boost.ASIO는 두 패턴을 같은 인터페이스로 추상화.

4. async/await가 어떻게 작동하나요?

: 마법이 아닙니다. 컴파일러가 state machine으로 변환합니다. await 지점마다 함수가 일시 중지되고, future/promise에 콜백을 등록. 결과가 준비되면 재개. 이벤트 루프는 (1) ready 큐의 작업 실행, (2) epoll_wait로 I/O 이벤트 대기, (3) 완료된 작업을 ready 큐로 이동. Python asyncio, JS Node.js, Rust Tokio, Go의 goroutine 모두 이 패턴을 따릅니다.

5. SO_REUSEPORT가 무엇이고 어떻게 사용되나요?

: Linux 3.9+의 기능. 여러 프로세스가 같은 포트를 동시에 listen 가능. 커널이 새 연결을 자동으로 분산. 각 프로세스가 자체 epoll 인스턴스 → 락 없는 진짜 병렬 처리. Nginx, HAProxy가 사용 — 워커 프로세스마다 같은 포트 listen, 커널이 로드 밸런싱. 단일 프로세스의 한계를 우회. CPU 코어 수만큼 워커가 일반적.


참고 자료

현재 단락 (1/408)

- **비동기 I/O = 현대 서버의 기반**: Nginx, Node.js, Redis, 모든 고성능 서버

작성 글자: 0원문 글자: 10,429작성 단락: 0/408