사전 블로킹
개념

블로킹

gabury1고친 사람 github-actions[bot]

블로킹은 부탁한 일이 끝날 때까지 프로그램이 멈춰 서서 기다리는 일입니다. 기다리는 동안 그 실행 흐름은 다른 일을 하지 못합니다. 네트워크나 디스크에서 데이터를 읽을 때 흔히 일어납니다. 접속을 막는다는 뜻의 보안 용어와는 다른 말입니다.

쉽고 빠른 이해

블로킹은 일을 부탁한 쪽이 결과가 올 때까지 멈춰 서는 것입니다. 네트워크 연결에서 읽기를 걸었다고 합시다. 상대가 아직 아무것도 안 보냈으면 데이터가 올 때까지 그 줄에서 멈춥니다.

이렇게 두는 이유는 코드가 쉬워서입니다. 결과를 받은 다음 줄에서 바로 쓰면 되니 적힌 순서대로 읽힙니다. 기다리는 동안 컴퓨터의 계산 능력을 쓰지 않는다는 장점도 있습니다.

이렇게 돕니다:

  1. 프로그램이 운영체제에 읽기를 부탁합니다
  2. 데이터가 없으면 운영체제가 그 실행 흐름을 재워 둡니다
  3. 데이터가 오면 깨워서 결과를 넘깁니다

대가는 멈춘 흐름이 그동안 아무것도 못 한다는 것입니다. 일을 많이 동시에 맡으려면 흐름을 그만큼 늘려야 합니다. 흐름 하나로 여럿을 맡는 구조에서는 블로킹 한 번이 전부를 멈춥니다.

상세

고객센터에 전화를 걸었더니 상담원이 확인해 보겠다며 대기 음악을 틉니다. 수화기를 귀에 댄 채 음악이 끝나기를 기다립니다. 그동안 다른 곳에 전화를 걸 수는 없습니다.

블로킹은 호출한 일이 끝날 때까지 호출한 쪽의 실행이 멈추는 것입니다. 네트워크 연결에서 데이터를 읽으려 한다고 합시다. 상대가 아직 아무것도 안 보냈다면 읽기 함수는 데이터가 올 때까지 돌아오지 않습니다.

이 절은 먼저 멈춘 실행 흐름에 무슨 일이 일어나는지를 상태 변화로 봅니다. 이어서 블로킹이 흔히 일어나는 곳과, 블로킹이 싸게 끝나는 구조와 비싸지는 구조를 봅니다. 끝으로 자주 섞여 쓰이는 「동기」와 어떻게 다른지를 가릅니다.

멈춰 선 스레드의 상태

멈추는 주체는 스레드입니다. 스레드는 프로그램 안에서 코드를 한 줄씩 따라가며 실행하는 흐름 하나입니다. 프로그램 하나 안에 스레드가 여럿 있을 수 있습니다.

스레드가 데이터를 읽으려면 운영체제에 부탁해야 합니다. 이 부탁을 받는 쪽이 커널입니다. 커널은 운영체제의 핵심 부분입니다. 하드웨어를 직접 다룹니다. 어느 스레드를 언제 돌릴지도 커널이 정합니다.

CPU(Central Processing Unit, 중앙 처리 장치)는 한 번에 몇 개의 스레드만 돌릴 수 있습니다. 그래서 커널은 CPU 를 스레드끼리 번갈아 줍니다. 스레드마다 CPU 를 쓰는 차례가 돌아오는 셈입니다.

커널은 읽을 데이터가 아직 없으면 그 스레드를 대기 상태로 돌립니다. 데이터가 도착하면 스레드를 준비 상태로 옮깁니다.

준비 상태는 CPU 차례만 기다리는 상태입니다. 차례가 오면 스레드는 다시 실행 상태가 됩니다. 그때 읽기 함수가 결과를 들고 돌아옵니다.

stateDiagram-v2
    실행 --> 대기: 읽을 데이터가 없다
    대기 --> 준비: 데이터가 도착했다
    준비 --> 실행: 차례가 왔다

그림에서 블로킹은 가운데 대기 상태에 머무는 시간입니다. 프로그램 쪽에서 보면 읽기 함수 한 줄에 멈춰 있는 시간입니다.

대기 상태의 스레드는 CPU 를 받지 못합니다. 커널은 그 시간에 다른 스레드를 돌립니다. 이렇게 CPU 를 한 스레드에서 다른 스레드로 넘기는 일을 문맥 교환이라고 합니다.

그래서 블로킹은 CPU 를 아끼는 기다림입니다. 기다리는 방법에는 준비됐는지 쉬지 않고 되묻는 방식도 있습니다. 이것을 바쁜 대기라고 부릅니다. 바쁜 대기는 되묻는 동안 CPU 를 태웁니다. 블로킹은 잠들어 있으니 태우지 않습니다.

블로킹이 일어나는 곳

블로킹은 입출력에만 있는 일이 아닙니다. 스레드가 무언가 준비되기를 기다려야 하는 호출이면 어디서든 일어납니다. 흔히 만나는 경우를 기다리는 대상별로 모으면 이렇습니다.

호출 기다리는 것
네트워크 연결에서 읽기 상대가 보낸 데이터가 도착하기
데이터베이스에 질의 보내기 데이터베이스가 답을 보내 주기
디스크의 파일 읽기 디스크가 내용을 가져오기
락 잡기 락을 쥔 다른 스레드가 풀어 주기
빈 큐에서 꺼내기 누군가 큐에 값을 넣어 주기

위 세 줄은 바깥 장치를 기다립니다. 아래 두 줄은 다른 스레드를 기다립니다. 락은 여러 스레드가 같은 데이터를 한 번에 하나씩만 건드리게 막는 장치입니다. 먼저 쥔 스레드가 풀 때까지 나머지는 블로킹됩니다.

데이터베이스에서도 같은 말을 씁니다. 한 트랜잭션이 잡은 락을 다른 트랜잭션이 기다리고 있으면 그 상황을 블로킹이라고 부릅니다. 트랜잭션은 데이터베이스가 한 묶음으로 처리하는 작업 단위입니다.

기다림이 서로 물리면 영영 안 끝날 수도 있습니다. 두 스레드가 각자 상대가 쥔 락을 기다리면 둘 다 풀려나지 못합니다. 이 상태를 데드락이라고 합니다.

그래서 블로킹 호출은 대개 최대로 기다릴 시간을 정할 수 있게 되어 있습니다. 이 시간이 타임아웃입니다. 시간이 다 되면 호출은 결과 없이 돌아옵니다. 부른 쪽은 이것을 오류로 처리합니다.

연결마다 스레드를 붙이는 구조

블로킹이 얼마나 비싼지는 프로그램을 어떻게 짰느냐에 달려 있습니다. 이 소절은 연결 하나에 스레드 하나를 붙이는 구조를 봅니다.

이 구조에서는 블로킹이 큰 문제가 안 됩니다. 한 스레드가 멈춰도 다른 연결은 자기 스레드가 맡습니다. 코드도 읽고, 처리하고, 쓰는 순서 그대로 적힙니다. 블로킹이 오래 쓰여 온 이유가 이 읽기 쉬움입니다.

대가는 연결 수만큼 스레드가 늘어난다는 것입니다. 스레드마다 스택을 따로 잡습니다. 스택은 스레드가 함수 호출 정보와 지역 변수를 담아 두는 메모리 영역입니다. 연결이 만 개면 스택도 만 개입니다. 그 스레드 대부분은 기다리기만 합니다. 번갈아 돌리는 문맥 교환도 그만큼 늘어납니다.

스레드를 무한정 늘릴 수 없으니 스레드 풀을 쓰기도 합니다. 스레드 풀은 스레드를 정해 둔 수만큼 미리 만들어 돌려쓰는 방식입니다. 풀의 스레드가 전부 블로킹에 걸려 있으면 새 요청은 빈 스레드가 날 때까지 줄을 섭니다.

스레드 하나가 여러 연결을 맡는 구조

반대편에는 스레드 하나로 연결 여럿을 맡는 구조가 있습니다. 이 구조에서는 블로킹 한 번의 무게가 크게 달라집니다.

이 구조는 논블로킹 입출력에 기댑니다. 논블로킹 입출력은 준비가 안 된 읽기가 기다리지 않고 「아직 없다」를 바로 돌려주는 방식입니다. 그러면 스레드는 멈추지 않고 다른 연결로 넘어갈 수 있습니다.

이 구조의 중심은 이벤트 루프입니다. 이벤트 루프는 스레드 하나가 쉬지 않고 도는 반복문입니다. 한 바퀴마다 준비된 일을 하나씩 꺼내 처리합니다. 준비된 일이란 데이터가 도착한 연결처럼 지금 바로 처리할 수 있는 일입니다.

이 루프 안에서 블로킹 호출이 하나 끼면 루프 전체가 멈춥니다. 스레드가 하나뿐이라 대신 맡을 스레드가 없습니다. 멈춘 동안 다른 연결의 요청은 전부 밀립니다.

파이썬에서는 표준 라이브러리 asyncio 가 이벤트 루프를 줍니다. 이 루프 위에서 도는 함수는 async def 로 만듭니다.

await 는 그런 함수 안에서 루프에 차례를 넘기는 표시입니다. 차례를 넘긴 함수가 쉬는 동안 루프는 다른 함수를 돌립니다. 아래 두 함수는 이 차이가 한 줄입니다.

Python
async def blocking_handler():
    time.sleep(3)          # 루프가 멈춤

async def waiting_handler():
    await asyncio.sleep(3) # 이 함수만 쉼

위 함수의 time.sleep 은 스레드를 재우는 블로킹 호출입니다. 그 3초 동안 이 루프가 맡은 다른 요청은 하나도 처리되지 않습니다. 아래 함수의 asyncio.sleep 은 루프에 차례를 넘기고 기다리므로 다른 요청이 계속 돕니다.

nginx 와 Node.js 가 이런 구조로 도는 대표적인 서버입니다. 루프 안에서 블로킹 호출을 피할 수 없을 때는 그 일을 따로 둔 스레드 풀에 넘깁니다. 풀의 스레드가 대신 멈추므로 루프는 계속 돕니다.

블로킹과 동기

블로킹은 동기와 자주 섞여 쓰입니다. 동기 호출 대부분이 블로킹이라서 그렇습니다. 두 말은 서로 다른 것을 묻습니다.

블로킹은 호출이 바로 돌아오느냐를 묻습니다. 동기는 결과를 부른 쪽이 직접 챙기느냐를 묻습니다. 읽기 호출 세 가지를 두 물음에 대 보면 이렇습니다.

호출 바로 돌아오나 결과를 누가 챙기나
블로킹 읽기 아니다 · 데이터가 와야 돌아온다 부른 쪽이 반환값으로 받는다
논블로킹 읽기 그렇다 · 없으면 없다고 답한다 부른 쪽이 다시 물어서 받는다
비동기 읽기 그렇다 · 맡기고 바로 돌아온다 끝나면 운영체제가 알려 준다

가운데 줄이 두 말이 갈리는 경우입니다. 논블로킹 읽기는 멈추지 않습니다. 그래도 결과는 부른 쪽이 직접 챙기므로 동기입니다.

블로킹이 맞는 경우

동시에 맡는 일이 수십 개 수준이고 각자 바쁘게 일한다면 블로킹 코드로 충분합니다. 스레드 비용이 크지 않습니다. 코드도 읽기 쉽습니다. 한 번에 한 가지 일을 하는 배치 작업이나 명령줄 도구도 마찬가지입니다.

연결이 수천 개를 넘으면 사정이 달라집니다. 그 대부분이 조용히 기다리기만 하는 경우입니다. 기다리는 스레드마다 메모리와 문맥 교환 비용이 붙기 때문입니다. 이때는 논블로킹 입출력 위에 이벤트 루프를 얹은 구조로 옮겨 갑니다.

이 구조로 옮긴 뒤에도 블로킹 호출은 조심해야 합니다. 루프 안에서 부른 블로킹 호출 하나가 루프 전체를 멈춥니다. 이벤트 루프 위에서 도는 코드가 느려졌을 때 제일 먼저 의심하는 곳이 이것입니다.

관련 항목

블로킹과 맞세워지는 호출 방식

논블로킹 입출력 · 비동기 · 비동기 입출력 · 바쁜 대기 · 폴링

블로킹과 헷갈리는 이웃 개념

동기 · 동기 호출 · 동기 입출력 · 블로킹 호출 · 블로킹 입출력 · 헤드 오브 라인 블로킹

블로킹이 벌어지는 운영체제 장치

스레드 · 프로세스 · 커널 · 시스템 콜 · 스케줄러 · 문맥 교환 · CPU · 스택 · 파일 디스크립터 · 소켓

스레드를 기다리게 만드는 동기화 도구

락 · 뮤텍스 · 세마포어 · 조건 변수 · 블로킹 큐 · 큐

블로킹이 길어질 때 터지는 장애

데드락 · 타임아웃 · 기아 · 스레드 풀 고갈 · 입출력 대기

블로킹을 피하려고 얹는 실행 구조

이벤트 루프 · 입출력 다중화 · 스레드 풀 · 코루틴 · async-await · 가상 스레드 · 그린렛

블로킹을 다루는 방식이 드러나는 서버와 런타임

nginx · Node.js · asyncio · Netty

블로킹이 흔히 일어나는 작업 대상

디스크 입출력 · 디스크 · 네트워크 · 데이터베이스 · 트랜잭션

기다림이 영향을 주는 성능 지표

처리량 · 지연 · 동시성 · C10K 문제

다른 이름: blocking · 블로킹 대기