장치가 CPU를 거치지 않고 메모리에 직접 데이터를 옮기는 방식.
그전에 — 준비됐는지 어떻게 아나
폴링 CPU 가 계속 물어본다 "됐어? 됐어?"
→ 즉시 반응한다
→ 기다리는 동안 CPU 를 전부 태운다
→ 아주 짧고 빈번한 장치에만 (고성능 네트워크 카드가 부하가 높을 때 쓴다)
인터럽트 장치가 끝나면 CPU 를 툭 친다
→ 기다리는 동안 CPU 는 다른 일을 한다
→ 대신 인터럽트 처리에 문맥 전환 비용이 든다
→ 대부분의 장치가 이 방식
DMA가 없다면
디스크에서 1MB 를 읽는다
- CPU 가 4바이트씩 읽어 메모리에 옮긴다 → 26만 번 반복
- 그동안 CPU 는 아무것도 못 한다 (Programmed I/O)
DMA가 있으면
-
① CPU 가 DMA 컨트롤러에 지시한다 ("디스크 X 에서 메모리 Y 로 1MB")
-
② CPU 는 다른 일을 하러 간다
-
③ DMA 컨트롤러가 직접 전송한다
-
④ 끝나면 인터럽트로 CPU 에 알린다
-
CPU 는 시작과 끝만 관여한다
대량 전송에서 CPU를 해방하는 것이 DMA의 본질이고, 인터럽트와 짝을 이뤄야 완성된다.
캐시 일관성 문제
DMA 가 메모리를 직접 고치면 CPU 캐시의 사본이 낡는다
- 하드웨어가 스누핑으로 해결하거나
- 드라이버가 명시적으로 캐시를 무효화/플러시해야 한다
기다리는 동안 무엇을 하나 — 세 모델
-
blocking — 호출이 끝날 때까지 스레드가 멈춘다
- 코드가 단순하다. 대신 스레드가 놀고 있다
-
non-blocking — "아직 준비 안 됨"(EAGAIN)을 즉시 반환한다
- 직접 다시 물어봐야 한다 → 대개 멀티플렉싱과 함께 쓴다
-
async — "다 되면 알려 줘" 를 등록하고 즉시 돌아온다
- 완료되면 콜백·시그널로 통지받는다 (io_uring · IOCP)
- 커널이 데이터 복사까지 끝내 준다는 점이 non-blocking 과 다르다
non-blocking 은 "준비됐는지" 를 알려 준다 (readiness) async 는 "다 끝났다" 를 알려 준다 (completion)
이 차이가 리눅스 epoll 과 io_uring 을 가른다
관측
cat /proc/interrupts | head # 인터럽트 종류별 횟수
iostat -x 1 # 장치별 I/O · await(대기 시간) · %util
cat /proc/<pid>/io # 프로세스별 읽고 쓴 바이트
면접 함정
- ❌ "인터럽트가 폴링보다 항상 낫다" → 이벤트가 폭주하면 인터럽트 처리 비용이 지배한다. 그래서 고부하 네트워크는 폴링으로 전환한다(NAPI).
- ❌ "non-blocking = 비동기" → 다르다. non-blocking은 준비 여부만 알려 주고 읽기는 내가 한다.
zero-copy — 복사를 없앤다
파일을 네트워크로 보낼 때 (전통적 방식)
① 디스크 → 커널 페이지 캐시 (DMA)
② 커널 → 유저 버퍼 (CPU 복사)
③ 유저 버퍼 → 커널 소켓 버퍼 (CPU 복사)
④ 소켓 버퍼 → NIC (DMA)
- 복사 4번, 모드 전환 4번
sendfile / splice
① 디스크 → 페이지 캐시 (DMA)
② 페이지 캐시 → NIC (DMA, 디스크립터만 전달)
- 복사 2번, 모드 전환 2번
정적 파일 서버·프록시가 빠른 이유가 여기 있다.
io_uring
유저와 커널이 공유하는 링 버퍼 두 개(제출·완료)를 둔다
- 요청을 링에 쌓고 한 번의 시스템 콜로 여러 개를 제출한다
- 폴링 모드에서는 시스템 콜 없이도 진행된다
- 파일·네트워크 I/O 를 같은 인터페이스로 다룬다
리눅스에서 "진짜 비동기 I/O" 를 처음으로 제대로 제공한 API 다 (기존 aio 는 제약이 많아 거의 쓰이지 않았다)
iostat -x 1
# %util 장치가 바쁜 비율 (SSD 에서는 100% 여도 여유가 있을 수 있다)
# await 요청당 평균 대기 시간 ← 이게 튀면 진짜 병목
# aqu-sz 평균 큐 길이