
I/O
컴퓨터가 외부와 데이터를 주고받는 모든 과정을 I/O(Input/Output)라고 한다. 키보드 입력, 디스크 읽기, 네트워크 통신, 화면 출력 이 모든 게 I/O다. OS는 이 다양한 I/O 장치들을 통일된 방식으로 관리해야 하는데, 장치마다 속도도 다르고 동작 방식도 다르기 때문에 이걸 어떻게 처리하느냐가 시스템 성능에 큰 영향을 준다.
I/O 하드웨어의 구성
I/O 장치는 매우 다양하지만, OS 관점에서 공통적으로 적용되는 구성 요소가 있다.
장치가 시스템에 연결되는 접점을 포트(Port) 라고 하고, 여러 장치를 연결하는 공유 통로가 버스(Bus) 다. 현대 시스템은 PCIe 같은 고속 버스를 사용하며, 데이터 버스/주소 버스/제어 버스로 구분된다.
장치와 버스 사이에는 장치 컨트롤러(Device Controller) 가 있다. 컨트롤러는 장치를 대신해서 버스와 통신하는 전자 회로인데, 보통 4가지 레지스터를 가지고 있다:
- data-in 레지스터 : 호스트가 장치에서 데이터를 읽어오는 곳
- data-out 레지스터 : 호스트가 장치로 데이터를 보내는 곳
- status 레지스터 : 장치의 현재 상태를 나타냄 (완료, 에러, 바쁨 등)
- control 레지스터 : 호스트가 장치에 명령을 보내는 곳 (시작, 모드 변경 등)
CPU가 이 레지스터에 접근하는 방법은 두 가지다. 포트 매핑 I/O는 별도의 I/O 주소 공간을 사용하며 x86의 IN/OUT 명령어가 이 방식이다. 메모리 매핑 I/O는 장치 레지스터를 메모리 주소 공간에 매핑해서 일반 메모리 접근 명령어로 장치를 제어할 수 있게 하는데, 프로그래밍이 편하고 보호 메커니즘도 메모리와 동일하게 적용할 수 있어서 많이 쓰인다.
CPU가 I/O를 처리하는 세 가지 방식
CPU가 I/O 장치와 데이터를 주고받는 방법은 세 가지가 있다. 각각 장단점이 다르다.
폴링 (Polling / Busy Waiting)
가장 단순한 방식이다. CPU가 장치의 status 레지스터를 반복적으로 확인하면서 작업 완료를 기다린다. 끝났는지 안끝났는지 계속 묻는 셈이다.
구현은 매우 단순하지만, 기다리는 동안 CPU가 다른 작업을 못 하기 때문에 CPU 시간을 낭비한다. 장치가 매우 빠르게 응답하는 경우에만 적합하다.
PIO(Programing I/O) 에서 사용된다
인터럽트 (Interrupt)
장치가 작업을 끝내면 CPU에 인터럽트 신호를 보내서 알린다. CPU는 기다리지 않고 다른 일을 하다가 인터럽트가 오면 처리한다.
동작 흐름은 이렇다: CPU가 장치에 명령을 보낸 뒤 다른 작업을 계속 수행한다. 장치가 작업을 완료하면 인터럽트를 발생시키고, CPU가 현재 작업을 중단한 뒤 인터럽트 핸들러(ISR)를 실행한다. ISR 실행이 끝나면 원래 작업으로 복귀한다.
CPU가 대기하지 않아서 폴링보다 훨씬 효율적이지만, 인터럽트 처리 자체에도 오버헤드가 있어서 인터럽트가 너무 자주 발생하면 오히려 성능이 떨어질 수 있다.
인터럽트를 효율적으로 처리하기 위한 구조도 있다. 인터럽트 벡터는 인터럽트 번호에 대응하는 ISR 주소를 저장한 테이블이고, 여러 인터럽트가 동시에 발생하면 우선순위에 따라 처리 순서를 결정한다. 같은 인터럽트 번호에 여러 핸들러가 연결되어 있으면 순서대로 확인하는 인터럽트 체이닝도 사용된다.
또한 특정 인터럽트를 일시적으로 무시하는 인터럽트 마스킹(masking)이 있다. 크리티컬 섹션에서 중요한 작업을 처리하는 동안 다른 인터럽트가 끼어들지 못하게 막을 때 사용한다. 단, NMI(Non-Maskable Interrupt)는 마스킹할 수 없는 인터럽트로 메모리 오류 같은 치명적인 이벤트에 사용된다.
DMA (Direct Memory Access)
대용량 데이터를 전송할 때, CPU가 바이트 하나하나를 옮기면 너무 느리기 때문에, DMA 컨트롤러가 CPU 대신 데이터를 장치 ↔ 메모리 사이에서 직접 전송한다.
CPU는 DMA 컨트롤러에 전송 정보(출발지, 목적지, 크기)만 설정해주면 된다. DMA 컨트롤러가 버스를 통해 데이터를 직접 전송하고, 전송이 완료되면 CPU에 인터럽트로 알린다. CPU가 데이터 전송에서 해방되어 다른 작업을 할 수 있으므로 대용량 전송에 매우 효율적이다. 다만 DMA 컨트롤러 하드웨어가 필요하고, 버스 사용권을 CPU와 경쟁(cycle stealing)하게 된다.
비교
| 구분 | 폴링 | 인터럽트 | DMA |
|---|---|---|---|
| CPU 개입 | 계속 확인 (바쁜 대기) | 이벤트 발생 시에만 | 설정 시 + 완료 알림 시에만 |
| CPU 효율 | 낮음 | 높음 | 가장 높음 |
| 구현 복잡도 | 단순 | 중간 | 복잡 |
| 적합한 상황 | 빠른 응답 장치 | 일반적인 I/O | 대용량 데이터 전송 |
응용 프로그램에게 보이는 I/O 인터페이스
I/O 장치는 종류가 너무 다양하다. 키보드, 마우스, 디스크, 네트워크, GPU... 이 각각에 대해 따로 코드를 짜면 끝이 없다. OS는 이 다양성을 추상화하여 응용 프로그램에 일관된 인터페이스를 제공한다.
OS는 장치를 몇 가지 범주로 나눠서 각 범주에 공통 인터페이스를 제공한다:
- 블록 장치(Block Device) : 데이터를 고정 크기 블록 단위로 읽고 씀. 임의 접근(random access) 가능. ex: HDD, SSD
- 문자 장치(Character Device) : 데이터를 바이트 스트림으로 순차 전송. 임의 접근 불가. ex: 키보드, 마우스, 시리얼 포트
- 네트워크 장치 : 블록이나 문자 장치와 다른 인터페이스 사용. 소켓 인터페이스를 통해 접근 (select/poll/epoll 등으로 다중 소켓 감시 가능)
- 시계/타이머 장치 : 현재 시간 제공, 경과 시간 측정, 타이머 인터럽트 발생
응용 프로그램이 I/O를 요청할 때 기다리는 방식도 다르다. 블로킹 I/O는 작업이 완료될 때까지 프로세스가 멈춘다. 프로그래밍이 직관적이다. 논블로킹 I/O는 요청을 보내고 즉시 반환한다. 완료 여부를 나중에 확인해야 해서 UI가 멈추면 안 되는 상황에 적합하다. 비동기 I/O는 논블로킹과 유사하지만, 완료 시 OS가 프로세스에 콜백/시그널로 알려줘서 프로세스가 직접 확인할 필요가 없다.
장치마다 운영체제의 표준 인터페이스로 처리할 수 없는 고유한 기능이 있다. 이런 경우 ioctl() 시스템 콜이 장치별 고유 명령을 전달하는 통로(백도어/escape) 역할을 한다.
커널의 I/O 스케줄링
여러 프로세스가 동시에 같은 I/O 장치를 사용하려고 하면, 요청 순서를 어떻게 정할지가 성능에 큰 영향을 준다.
OS는 각 장치별로 I/O 요청 큐를 유지한다. 들어온 순서대로 처리할 수도 있지만, 요청을 재정렬하면 장치 성능을 크게 높일 수 있다. 특히 HDD의 경우 헤드 이동을 최소화하는 순서로 재정렬하면 처리 속도가 빨라지는데, 이것이 SCAN, C-SCAN, LOOK 같은 디스크 스케줄링 알고리즘이다.
다만 한 프로세스의 요청만 계속 처리하면 다른 프로세스는 기아(starvation)가 발생할 수 있으므로, 성능 최적화와 공정성 사이의 균형도 고려해야 한다.
기아 상태 : 프로세스가 끊임없이 필요한 컴퓨터 자원을 가져오지 못하는 상황
SSD는 기계적 이동이 없으므로 디스크 스케줄링의 효과가 크지 않다.
버퍼링 (Buffering)
데이터를 한 곳에서 다른 곳으로 전송할 때, 속도 차이를 극복하고 전송 크기 차이를 맞추기 위해 데이터를 임시 저장하는 메모리 영역이 버퍼다.
버퍼링이 필요한 이유:
- 속도 차이 극복 : 생산자와 소비자의 속도가 다를 때 중간에 버퍼를 두어 조율. 예: 키보드(느림) → 버퍼 → CPU(빠름)
- 전송 크기 적응 : 장치마다 다루는 데이터 단위가 다를 때 맞춰줌. 예: 네트워크 패킷(1500B) → 버퍼 → 디스크 블록(4KB)
- 복사 의미론(copy semantics) 유지 : write() 호출 시점의 데이터 내용을 보장. 호출 후 애플리케이션이 버퍼를 수정해도 이미 커널 버퍼에 복사되어 영향 없음
더블 버퍼링: 버퍼 두 개를 번갈아 사용하여 하나를 채우는 동안 다른 하나를 비우는 방식으로 연속적 데이터 전송 가능
캐싱 (Caching)
자주 사용되는 데이터의 복사본을 빠른 저장소에 보관하여 접근 속도를 높이는 기법.
버퍼링과의 차이:
- 버퍼링 : 데이터의 유일한 복사본을 임시 저장. 전송 완료하면 사라짐
- 캐싱 : 다른 곳에 원본이 있고, 빠른 접근을 위한 추가 복사본. 계속 유지됨
같은 메모리 영역이 버퍼와 캐시 역할을 동시에 할 수도 있다. 예: 파일 데이터를 처음 읽을 때는 버퍼에 저장하고, 이후 같은 데이터를 다시 요청하면 버퍼에서 바로 제공(캐시 역할).
스풀링과 장치 예약 (Spooling & Device Reservation)
스풀링은 한 번에 하나의 작업만 처리할 수 있는 장치(프린터 등)를 여러 프로세스가 동시에 사용할 수 있게 해주는 기법이다. 각 프로세스의 출력을 디스크의 임시 파일(스풀 파일)에 보관하고, 장치가 준비되면 순서대로 처리한다.
장치 예약(Device Reservation)은 장치를 한 프로세스가 독점으로 사용해야 할 때 OS가 장치를 할당하고 다른 프로세스는 대기시키는 메커니즘이다. 데드락 발생 가능성이 있으므로 OS가 주의해서 관리해야 한다.
데드락-교착상태 : 두 개 이상의 작업이 서로 상대방의 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태
cupsd(Linux)가 인쇄 작업을 /var/spool/cups/에 저장하고 순서대로 프린터에 전송하는 것이 스풀링의 대표적인 예시다. 메일 시스템에서는 postfix, exim 같은 MTA가 /var/spool/mail/에 메일을 저장하고 순서대로 발송한다.
오류 처리 (Error Handling)
I/O 작업 중 오류는 피할 수 없다. 네트워크 연결이 끊긴다거나, 디스크에 불량 블록이 있거나, 장치가 응답하지 않는 상황 등이 발생할 수 있다.
OS는 I/O 오류를 처리하기 위해:
- 시스템 콜이 오류 정보를 반환 (read() 실패 시 -1 반환, errno에 오류 코드 설정)
- 일시적 오류는 **재시도(retry)**로 해결 시도 (네트워크 타임아웃, 디스크 읽기 오류 등)
- 영구적 오류는 사용자에게 알리거나 로그 기록
- UNIX에서는
errno변수에 오류 코드가 저장되고,perror()로 오류 메시지를 출력할 수 있다
I/O 보호 (I/O Protection)
사용자 프로세스가 I/O 장치를 직접 제어하면 다른 사용자의 데이터를 읽거나 시스템을 망가뜨릴 수 있다. 그래서:
- 모든 I/O 명령어는 특권 명령어로 지정되어 커널 모드에서만 실행 가능
- 사용자 프로그램은 반드시 시스템 콜을 통해 OS에 I/O를 요청해야 함
- OS가 요청의 유효성을 검사한 후 대신 수행
- 메모리 매핑 I/O 영역도 커널만 접근할 수 있도록 보호
커널 I/O 데이터 구조
OS 커널은 I/O를 관리하기 위해 여러 데이터 구조를 유지한다:
- 열린 파일 테이블(Open File Table) : 시스템 전체의 열린 파일 목록. 각 항목에 파일 위치, 접근 모드, 오프셋 등 저장
- 프로세스별 fd 테이블 : 각 프로세스마다 열린 fd 목록을 유지하고, 시스템 전체 열린 파일 테이블을 가리킴
- 네트워크 연결 정보 : 소켓별 상태, 버퍼, 주소 등 관리
- 장치 상태 테이블 : 각 장치의 현재 상태, 대기 큐, 드라이버 정보
UNIX/Linux에서는 모든 것이 파일(everything is a file) 철학을 따른다. 장치, 소켓, 파이프 등을 모두 fd로 다루기 때문에 read()/write()/close() 같은 동일한 인터페이스로 접근할 수 있다.
I/O 요청의 하드웨어 변환 (Transforming I/O Requests to Hardware Operations)
사용자가 파일 이름으로 I/O를 요청하면, OS는 이걸 실제 하드웨어 동작으로 변환해야 한다. 핵심은 "파일 이름을 어떻게 특정 장치의 물리적 주소로 바꾸는가"이다.
변환 과정:
- 사용자 프로그램이 파일 이름으로 시스템 콜을 호출 (ex: open("/dev/sda1"))
- 파일 시스템이 파일 이름을 장치 이름으로 변환. UNIX에서는 /dev 디렉토리의 장치 파일을 통해 매핑
- 장치 이름에서 major/minor 장치 번호를 추출. major 번호는 장치 종류(드라이버)를, minor 번호는 장치 인스턴스를 식별
- 커널이 장치 테이블에서 major 번호로 해당 드라이버를 찾음
- 커널 I/O 서브시스템이 버퍼링/캐싱/스케줄링 수행
- 캐시에 데이터가 있으면 즉시 반환 (캐시 히트)
- 캐시에 없으면 드라이버가 장치 컨트롤러의 레지스터에 명령을 쓰고, 프로세스는 대기 큐에 들어가 블로킹됨
- 장치가 데이터를 전송하고 완료 시 인터럽트 발생 (DMA라면 DMA 컨트롤러가 전송 후 인터럽트)
- ISR이 완료 상태를 기록하고 대기 큐에서 프로세스를 ready 상태로 전환
- 커널이 데이터를 커널 버퍼에서 사용자 버퍼로 복사하고 시스템 콜 반환
즉, 파일 이름 → 장치 이름 → major/minor 번호 → 드라이버 → 컨트롤러 레지스터 → 하드웨어 동작으로 이어지는 변환 체인이다.
# 장치 번호 확인 예시
ls -l /dev/sda*
# brw-rw---- 1 root disk 8, 0 /dev/sda
# brw-rw---- 1 root disk 8, 1 /dev/sda1
# major=8(드라이버), minor=0(전체 디스크), minor=1(파티션 1)
STREAMS
UNIX System V에서 도입된 I/O 통신 프레임워크. 사용자 프로그램과 장치 드라이버 사이에 모듈화된 처리 계층을 동적으로 끳고 빼서 데이터 처리 파이프라인을 구성할 수 있다.
구성 요소:
- Stream Head : 사용자 프로세스와 연결되는 상단 인터페이스
- Driver End : 장치 드라이버와 연결되는 하단 인터페이스
- Stream Module : 중간에 동적으로 삽입되는 처리 모듈. 각 모듈은 read queue와 write queue를 가짐
예: 사용자 프로그램 ↔ TCP 모듈 ↔ IP 모듈 ↔ 이더넷 드라이버 순서로 모듈이 쌓이는 방식
특징:
- 모듈을 동적으로 push/pop할 수 있어 유연함
- 각 모듈이 독립적으로 데이터를 처리하므로 재사용성이 높음
- 모듈 간 통신은 메시지 전달 방식
- Linux는 STREAMS를 기본 지원하지 않고, 대신 소켓 계층 등 별도 구조를 사용
I/O 성능 (Performance)
I/O는 시스템 전체 성능에서 가장 큰 병목이 되는 부분이다. CPU나 메모리에 비해 I/O 장치는 압도적으로 느리기 때문에, I/O를 얼마나 효율적으로 처리하느냐가 시스템 성능을 좌우한다.
I/O 성능에 영향을 주는 요소
- 인터럽트 처리 오버헤드 : 인터럽트가 발생할 때마다 CPU는 현재 상태를 저장하고 ISR로 전환해야 한다. 고속 장치에서 인터럽트가 초당 수만 번 발생하면 이 오버헤드만으로도 CPU를 잡아먹을 수 있다
- 컨텍스트 스위칭 : I/O 요청 시 프로세스가 블로킹되면 다른 프로세스로 전환해야 하고, 이때 컨텍스트 스위칭 비용이 발생
- 데이터 복사 : 데이터가 장치 → DMA 버퍼 → 커널 버퍼 → 사용자 버퍼로 여러 번 복사되면서 CPU 사이클과 메모리 대역폭을 소모
- 네트워크 트래픽 : 네트워크 I/O는 특히 패킷 하나하나마다 인터럽트와 프로토콜 스택 처리가 필요해서 부하가 크다
I/O 성능을 개선하는 방법
- 컨텍스트 스위칭 횟수 줄이기 : 불필요한 프로세스 전환을 최소화
- 데이터 복사 횟수 줄이기 : 커널 버퍼에서 사용자 버퍼로의 복사를 없애는 제로 카피(zero-copy) 기법 사용. 예: sendfile() 시스템 콜은 커널 내에서 직접 소켓으로 데이터를 전송
- 인터럽트 병합(interrupt coalescing) : 인터럽트를 즉시 처리하지 않고 여러 개를 모아서 한 번에 처리. 네트워크 카드에서 많이 사용
- 폴링과 인터럽트의 혼합 : 트래픽이 많을 때는 폴링으로 전환하여 인터럽트 오버헤드를 줄이고, 트래픽이 적을 때는 인터럽트로 전환 (Linux NAPI가 이 방식)
- DMA 활용 확대 : 가능한 한 CPU 개입 없이 DMA로 데이터를 전송
- 기능을 하드웨어로 이동 : 프로토콜 처리 등을 전용 하드웨어(NIC 오프로딩, TCP Offload Engine 등)로 넘겨 CPU 부담 감소
- 커널 내에서 처리 : 사용자 공간과 커널 공간 사이의 전환을 줄이기 위해 I/O 처리를 커널 스레드나 커널 내부에서 수행
성능 관점에서의 I/O 방식 선택
상황에 따라 적절한 I/O 방식을 선택해야 한다:
- 장치가 매우 빠르고 데이터가 적을 때 → 폴링이 인터럽트보다 효율적일 수 있음 (인터럽트 오버헤드 회피)
- 일반적인 I/O → 인터럽트 기반이 CPU 효율적
- 대용량 데이터 전송 → DMA 필수
- 초고속 네트워크 → 폴링+인터럽트 혼합 (NAPI 방식)