사전 논블로킹 입출력
개념

논블로킹 입출력

gabury1고친 사람 github-actions[bot]

논블로킹 입출력은 읽기와 쓰기를 부탁해 놓고 결과를 기다리지 않게 해 주는 방식입니다. 읽을 것이 아직 없으면 「지금은 없습니다」라는 답이 곧바로 돌아옵니다. 그래서 실행 흐름 하나가 연결 하나에 붙잡히지 않고 여러 연결을 번갈아 돌볼 수 있습니다.

쉽고 빠른 이해

논블로킹 입출력은 「데이터가 올 때까지 멈춰 서 있는 시간」을 없애 주는 방식입니다. 손님 만 명이 붙어 있는 채팅 서버가 대표 예입니다.

이게 없으면 연결 하나마다 기다리는 실행 흐름을 하나씩 붙여야 합니다. 연결이 만 개면 흐름도 만 개입니다. 그중 대부분은 아무 일도 안 하면서 메모리만 차지합니다.

이렇게 돕니다:

  1. 연결에 논블로킹 표시를 달아 둡니다
  2. 읽기를 걸면 준비된 만큼만 받고, 없으면 「없다」는 답을 바로 받습니다
  3. 어느 연결이 준비됐는지 운영체제에 한 번에 물어보고 그것만 처리합니다

대신 코드가 어려워집니다. 한 번에 다 못 읽고 다 못 쓰는 경우를 늘 염두에 둬야 하고, 일이 끝나는 순서가 코드에 적힌 순서와 달라집니다.

상세

음식점에서 번호표를 받아 테이블로 돌아오는 것과 비슷합니다. 앉아서 다른 일을 보다가 가끔 창구를 살핍니다. 아직 안 나왔으면 그냥 돌아옵니다. 창구 앞에 선 채로 음식이 나올 때까지 기다리는 쪽이 블로킹입니다.

논블로킹 입출력은 읽기나 쓰기가 즉시 끝날 수 없을 때 기다리지 않고 바로 돌아오는 입출력 방식입니다. 상대가 아직 아무것도 안 보낸 네트워크 연결에서 읽기를 걸었을 때가 그런 경우입니다.

이 절은 먼저 블로킹 호출이 무엇을 붙잡아 두는지 봅니다. 이어서 논블로킹 호출이 대신 무엇을 돌려주는지, 그 답을 받은 쪽이 어떻게 굴러가는지를 봅니다. 마지막으로 이 방식이 안 통하는 곳과 치르는 대가를 봅니다.

블로킹 호출과 붙잡히는 실행 흐름

블로킹 호출은 결과가 준비될 때까지 돌아오지 않는 호출입니다. 연결에서 읽기를 걸었는데 상대가 아직 아무것도 안 보냈으면, 그 호출은 데이터가 올 때까지 멈춰 있습니다.

멈춰 있는 동안 그 호출을 건 스레드는 다른 일을 못 합니다. 스레드는 한 프로그램 안에서 따로 도는 실행 흐름 하나를 말합니다. 커널이 그 스레드를 재워 두었다가 데이터가 오면 깨웁니다. 커널은 운영체제의 핵심 부분이고, 하드웨어와 프로그램 사이에서 입출력을 실제로 맡는 쪽입니다.

sequenceDiagram
    participant 앱
    participant 커널
    participant S as 상대 서버
    앱->>커널: 읽어 줘
    Note over 앱: 여기서 잠든다
    S->>커널: 데이터 도착
    커널-->>앱: 읽은 바이트

그림에서 볼 것은 가운데 구간입니다. 앱은 그 구간 내내 아무것도 못 합니다.

그래서 연결마다 스레드를 하나씩 붙이는 방식이 오래 쓰였습니다. 연결이 백 개면 스레드 백 개입니다. 연결이 만 개로 늘면 이 방식이 버티지 못합니다. 스레드마다 스택 메모리가 필요하고, 번갈아 실행하려면 문맥 교환도 그만큼 일어납니다.

기다리지 않고 돌아오는 읽기

논블로킹 입출력은 같은 상황에서 다른 답을 돌려줍니다. 읽을 것이 없으면 잠들지 않고 「지금은 없다」를 곧바로 돌려줍니다.

이 성질은 연결마다 켜고 끕니다. 소켓이나 파일을 가리키는 번호에 논블로킹 표시를 달면, 그때부터 그 번호에 건 읽기와 쓰기가 기다리지 않습니다. 이 번호가 파일 디스크립터입니다. 운영체제가 열린 파일과 연결에 매기는 정수입니다.

논블로킹 읽기의 답은 세 갈래입니다.

돌아온 값 뜻
1 이상 그만큼 읽었다
0 상대가 연결을 닫았다
오류 표시 읽을 것이 아직 없다

셋째 줄이 논블로킹에만 있는 답입니다. 유닉스 계열에서는 이것을 EAGAIN(Resource temporarily unavailable, 자원을 지금은 쓸 수 없음)이라는 오류 번호로 돌려줍니다. 오류라는 이름이 붙었지만 잘못된 상황이 아닙니다. 「아직 준비가 안 됐으니 나중에 다시 오라」는 뜻입니다.

파이썬으로 쓰면 두 줄입니다.

Python
sock.setblocking(False)
data = sock.recv(4096)   # BlockingIOError

첫 줄이 그 연결에 논블로킹 표시를 답니다. 둘째 줄은 받을 것이 없으면 기다리지 않고 바로 예외를 냅니다. 파이썬은 EAGAIN 을 BlockingIOError 라는 예외로 감싸서 줍니다.

되묻기가 만드는 헛수고

답을 바로 받았다고 일이 끝난 것은 아닙니다. 데이터는 여전히 안 왔고, 언젠가는 읽어야 합니다.

제일 단순한 방법은 계속 되묻는 것입니다. 읽어 보고, 없으면 조금 뒤에 또 읽어 보는 식입니다. 이렇게 될 때까지 물으며 도는 것을 바쁜 대기라고 합니다.

연결이 많아지면 이 방법이 무너집니다. 연결 만 개를 한 바퀴 돌며 물어보는데 준비된 것이 하나뿐이면, 나머지는 전부 헛물입니다. CPU(Central Processing Unit, 중앙 처리 장치)를 태우면서 아무 일도 안 하는 셈입니다.

준비된 연결만 골라 주는 창구

그래서 논블로킹 입출력은 혼자 쓰이지 않습니다. 「어느 연결이 준비됐나」를 운영체제에 한 번에 물어보는 시스템 콜과 짝을 이룹니다. 시스템 콜은 프로그램이 커널에 일을 부탁하는 창구입니다.

이 짝을 입출력 다중화라고 합니다. 지켜볼 연결 목록을 넘기면 커널이 준비된 것만 골라 돌려줍니다. 하나도 준비가 안 됐으면 이 호출은 그 안에서 기다립니다. 헛물을 켜며 도는 대신 한곳에서 한 번만 기다리는 셈입니다.

준비됐다고 알려 준 연결만 읽으면 되묻기가 사라집니다. 그런데 준비됐다고 해서 원하는 만큼 읽힌다는 보장은 없습니다. 그래서 읽기 자체는 여전히 논블로킹이어야 합니다. 다중화가 「어디를 건드릴지」를 정하고, 논블로킹이 「멈추지 않음」을 맡습니다.

이 둘을 한 바퀴로 묶어 계속 돌리는 구조가 이벤트 루프입니다.

flowchart TD
    A["준비된 연결을 커널에 물어본다"] --> B["돌아온 목록을 하나씩 본다"]
    B --> C["읽거나 쓴다 · 멈추지 않는다"]
    C --> D["처리 결과를 애플리케이션에 넘긴다"]
    D --> A

그림의 고리는 서버가 살아 있는 동안 끝나지 않습니다. 스레드 하나가 이 고리를 돌면서 연결 수천 개를 돌봅니다.

한 번에 다 못 쓰는 쓰기

반쪽이 되는 것은 읽기만이 아닙니다. 쓰기도 그렇습니다.

커널은 보낼 데이터를 자기 버퍼에 받아 둔 뒤 실제 전송을 맡습니다. 버퍼가 꽉 차 있으면 블로킹 쓰기는 빈 공간이 생길 때까지 기다립니다. 논블로킹 쓰기는 들어가는 만큼만 받고 「이만큼 썼다」를 돌려줍니다.

그래서 보내려던 양보다 적게 쓰이는 일이 흔합니다. 남은 몫은 프로그램이 들고 있다가, 그 연결에 빈 공간이 생겼다는 알림을 받고 마저 써야 합니다. 이 남은 몫을 챙기는 코드가 논블로킹 서버에서 제일 자주 틀리는 대목입니다.

이 방식이 안 통하는 곳

논블로킹 표시가 모든 입출력에 먹히지는 않습니다.

대표가 디스크 위의 일반 파일입니다. 파일을 논블로킹으로 열어도 읽기는 디스크에서 내용을 가져오는 동안 멈춥니다. 파일은 「언젠가 준비되는 것」이 아니라 「읽으면 읽히는 것」으로 다뤄지기 때문입니다. 디스크 입출력까지 안 멈추게 하려면 다른 수단이 필요합니다. 그 일을 스레드 여럿에게 맡기거나, 커널이 따로 내주는 비동기 입출력 창구를 쓰는 식입니다.

계산 자체가 오래 걸리는 일도 이 방식으로는 못 풉니다. 논블로킹은 기다림을 없애는 방식이지 계산을 빠르게 하는 방식이 아닙니다. 이벤트 루프 안에서 오래 도는 계산을 하면 그동안 다른 연결이 전부 밀립니다.

대가와 고르는 기준

논블로킹으로 바꾸면 코드의 모양이 달라집니다. 무엇이 어떻게 달라지는지를 축별로 보면 이렇습니다.

축 블로킹 논블로킹
연결 하나당 드는 것 실행 흐름 하나 상태 한 벌
코드와 실행 순서 적힌 순서와 같다 준비된 순서대로 흩어진다
한 연결이 느릴 때 그 흐름만 멈춘다 고리 전체가 밀릴 수 있다

표의 마지막 줄이 이 방식의 제일 아픈 대목입니다. 여럿을 한 흐름으로 돌보는 대신, 그 흐름이 한 군데서 오래 붙잡히면 나머지가 다 같이 늦어집니다.

요즘 언어는 가운데 줄의 어려움을 코루틴이나 async-await 같은 장치로 덮습니다. 기다리는 것처럼 읽히는 코드를 쓰되 실제로는 논블로킹으로 돌게 해 줍니다.

고르는 기준은 대체로 연결 수와 그 연결들이 얼마나 한가한가입니다. 연결이 수십 개고 각각 쉬지 않고 일한다면 블로킹 쪽이 읽기 쉽고 충분합니다. 연결이 수천에서 수만 개인데 대부분은 조용히 기다리기만 한다면 논블로킹이 답입니다. 채팅 서버, 알림 푸시, 리버스 프록시처럼 연결을 오래 열어 두는 물건이 그런 경우입니다.

관련 항목

논블로킹 입출력과 맞세워지는 입출력 방식

블로킹 입출력 · 블로킹 호출 · 동기 입출력 · 비동기 입출력 · 바쁜 대기

준비된 연결을 골라 주는 수단

입출력 다중화 · 다중화 · select · poll · epoll · kqueue · io_uring · 에지 트리거 · 레벨 트리거

논블로킹 입출력 위에 얹히는 실행 구조

이벤트 루프 · 리액터 패턴 · 콜백 · Promise · async-await · 코루틴 · 그린렛 · 비동기 프로그래밍

이 방식이 다루는 운영체제 장치

파일 디스크립터 · 소켓 · 시스템 콜 · 커널 · O_NONBLOCK · 스레드 · 문맥 교환 · 디스크

논블로킹 호출이 내는 오류와 신호

EAGAIN · EWOULDBLOCK · EINPROGRESS · 부분 쓰기

이 방식을 채택한 서버와 런타임

nginx · Node.js · Netty · Redis · memcached · Gatling · asyncio · 리버스 프록시

이 방식이 겨냥하는 성능 지표

처리량 · 지연 · 동시성 · C10K 문제 · 커넥션 풀 · 초당 요청 수

다른 이름: non-blocking IO · nonblocking IO · 논블로킹 IO · 논블로킹 I-O · 넌블로킹 입출력