운영체제 학습 노트 목차

I/O — 느린 장치와 빠른 CPU를 잇다

CPU는 나노초 단위로 일하는데, 디스크·네트워크 카드·키보드 같은 장치수천~수백만 배 느리다. 이 속도 차이가 엄청난 둘을 어떻게 효율적으로 잇느냐가 I/O의 과제다. 핵심은 하나다 — 느린 장치를 기다리는 동안 빠른 CPU를 놀리지 않는 것. 이 한 문장이 인터럽트·DMA·논블로킹 I/O까지, 고성능 입출력의 하드웨어적 뿌리임을 본다.

준비됐는지 어떻게 아나 — 폴링 vs 인터럽트

장치가 데이터를 준비했는지를 CPU가 아는 방법은 둘이다. **폴링(polling)**은 — CPU가 *"준비됐어? 준비됐어?"*를 계속 물어본다. 단순하지만, 장치가 느리면 CPU가 묻기만 하느라 시간을 낭비한다. **인터럽트(interrupt)**는 반대다 — CPU는 다른 일을 하다가, 장치가 *준비되면 "나 준비됐어!"라고 CPU를 *방해(인터럽트)**한다. 그러면 CPU가 하던 일을 잠시 멈추고 그 장치를 처리한다. 대부분의 경우 인터럽트가 효율적이다(놀지 않으니). (출처: Polling vs Interrupt vs DMA.)

같은 구조가 소켓에도 — 이 폴링 vs 인터럽트는 소켓 I/O 멀티플렉싱의 select/poll vs epoll과 같은 구조다. select는 *매번 모든 소켓을 확인(폴링)*해 느리고, epoll준비된 것만 알려줘(인터럽트적) 효율적이다. 그리고 epoll의 엣지 트리거는 "상태가 바뀐 순간만 알림"이라 — 정확히 인터럽트의 발상이다.

CPU를 거치지 않고 — DMA

인터럽트언제 처리할지는 풀렸지만, 데이터를 옮기는 일이 남는다. 만약 CPU가 디스크의 1MB를 한 바이트씩 직접 메모리로 복사한다면 — 그동안 CPU는 다른 일을 전혀 못 한다. 그래서 **DMA(Direct Memory Access)**가 있다 — 전용 컨트롤러가 장치↔메모리 데이터 전송을 CPU 대신 해 주고, CPU는 전송이 끝났을 때만 인터럽트로 통지받는다. 덕분에 CPU는 대량 전송 내내 다른 일을 한다.

다이어그램 로딩 중…

zero-copy의 토대 — 파일을 네트워크로 보내는 *zero-copy(sendfile)*가 바로 이 DMA 위에 선다. 보통 파일을 네트워크로 보내면 디스크→커널→유저 공간→커널→네트워크여러 번 복사되는데, sendfile은 DMA로 디스크→페이지 캐시, 다시 DMA로 페이지 캐시→네트워크 카드로 보내 CPU의 복사를 건너뛴다. 고성능 전송의 비결이 이것이다 — DMA가 CPU를 해방해 준 덕이다.

기다리는 동안 무엇을 — blocking·non-blocking·async

응용 입장에서 I/O를 부르는 방식도 셋이다. blocking결과가 올 때까지 스레드가 멈춰 기다린다. **non-blocking**은 *"아직 안 됐으면 일단 돌려줘"*라 — 스레드가 다른 일을 하다 다시 확인한다. **비동기(async)**는 *"되면 알려줘"*라 — 아예 완료 통지를 콜백·이벤트로 받는다. 리눅스의 최신 io_uring은 이를 극단으로 밀어 — *제출 큐(SQ)와 완료 큐(CQ)*라는 커널과 공유하는 링 버퍼로, 시스템 콜 횟수 자체를 줄여 고성능을 낸다(01편에서 본 시스템 콜 비용을 줄이는 것이다).

왜 이게 모든 고성능의 뿌리인가 — *"느린 I/O를 기다리며 CPU/스레드를 놀리지 마라"*는 이 한 원리가 여러 계층에서 같은 모습으로 반복된다. DMA가 하드웨어에서(CPU를 안 거치고 전송), 인터럽트·epoll이 커널에서(준비된 것만 처리), 비동기·이벤트 루프가 런타임·애플리케이션에서 — 모두 기다림을 다른 일로 메운다. 그래서 이 바닥(인터럽트·DMA·논블로킹)을 알면 왜 어떤 시스템이 빠른지, 어디서 막히는지근본부터 보인다.

실제로 눈으로 보기

blocking 과 non-blocking 은 한 플래그 차이다

int fd = open("/dev/ttyUSB0", O_RDONLY);
read(fd, buf, n);                     // 데이터가 올 때까지 여기서 멈춘다

int fl = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);  // 플래그 하나를 켜면
ssize_t r = read(fd, buf, n);
if (r < 0 && errno == EAGAIN) {       // 없으면 기다리지 않고 즉시 돌아온다
    /* 다른 일을 한다 */
}

O_NONBLOCK 하나로 성격이 바뀐다. 다만 없으면 바쁜 대기(busy wait)로 CPU를 태우기 쉽다 — 그래서 혼자 쓰지 않고 다중화와 함께 쓴다.

연결 만 개를 한 스레드로 — epoll

int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sock };
epoll_ctl(ep, EPOLL_CTL_ADD, sock, &ev);

struct epoll_event events[64];
int n = epoll_wait(ep, events, 64, -1);   // 준비된 것만 골라서 돌려준다
for (int i = 0; i < n; i++) handle(events[i].data.fd);

select는 매번 전체 목록을 커널에 넘기고 전부 훑었다 — 연결 수에 비례해 느려진다. epoll은 관심 목록을 커널에 등록해 두고 준비된 것만 받으므로, 연결이 만 개여도 비용이 준비된 개수에만 비례한다.

ls -l /proc/1234/fd | wc -l         # 이 프로세스가 연 디스크립터 수
cat /proc/1234/limits | grep files  # 상한

"Too many open files" 는 이 상한에 닿은 것이다. 연결을 많이 받는 서버는 이 값부터 올린다.

시스템 콜을 얼마나 부르나

strace -c -f ./myapp
# % time     seconds  usecs/call     calls    syscall
#  62.11    2.104882           2    980221    read
#  21.42    0.725901           3    241002    epoll_wait

read를 98만 번 부르고 있다면 한 번에 너무 조금씩 읽는다는 뜻이다. 버퍼를 키우면 호출 수가 줄고, 그만큼 모드 전환 비용이 준다.

장치가 실제로 바쁜가

iostat -x 1
# Device  r/s   w/s   rkB/s   wkB/s  await  %util
# sda    12.0  480.2   192.0 61440.0  38.42   99.8
#                                     │       └ 장치 포화도
#                                     └ 요청 하나가 완료되기까지 평균 대기 (ms)

%util이 100%에 가깝고 await이 수십 ms면 디스크가 병목이다. 이때 CPU를 늘려도 소용없다 — 앞서 본 D 상태 프로세스가 여기서 나온다.

DMA 가 하는 일

DMA 가 없다면 CPU 가 장치의 데이터를 한 워드씩 읽어 메모리에 옮겨야 한다. 1GB 파일이면 그 복사에만 CPU 가 통째로 묶인다. DMA 컨트롤러가 대신 옮기고 **끝났을 때만 인터럽트**를 주므로, CPU 는 그동안 다른 일을 한다.

iostat에서 초당 수십 MB가 오가는데 sy(커널 CPU)가 낮게 유지되는 것이 DMA 가 일하고 있다는 증거다.

정리하면, I/O는 나노초의 CPU와 수만 배 느린 장치를 잇는 일이다 — 언제 처리할지는 *폴링(낭비)이 아니라 인터럽트(준비되면 알림)*로, 데이터 전송CPU를 거치지 않는 DMA로(zero-copy의 토대) 효율화한다. 응용은 *blocking·non-blocking·async(io_uring)*로 기다리는 방식을 고르고 — *"기다리며 놀지 마라"*는 이 원리가 하드웨어부터 응용까지 모든 고성능을 관통한다. 마지막으로, 이 모든 OS를 통째로 가상화하는 — VM컨테이너가 다음이다.

파일 시스템 — inode·저널링(WAL)·페이지 캐시가상화·컨테이너 — VM·namespace·cgroup