CS 기초
운영체제·동시성
동기와 비동기: 이벤트 루프, 블로킹, 동시성과 병렬성

동기와 비동기: 이벤트 루프, 블로킹, 동시성과 병렬성

들어가며

API 서버에 async def 를 붙였으니 요청을 동시에 잘 처리할 거라 믿었는데, 큰 파일을 올리는 동안 상태 확인 요청까지 응답하지 않는 일이 생기곤 합니다. 코드는 비동기처럼 보여도 그 안에서 부른 함수 하나가 서버 전체를 세운 경우입니다.

이번 글에서는 동기와 비동기의 차이, 이벤트 루프가 일하는 방식, 블로킹 호출을 격리하는 방법을 정리합니다. 자주 섞여 쓰이는 동시성과 병렬성도 함께 구분합니다. 예시는 파이썬 asyncio 기준입니다.


1. 동기와 비동기: 기다리는 시간을 어떻게 쓰는가

동기는 작업이 끝날 때까지 기다린 뒤 다음 작업으로 넘어갑니다. 비동기는 기다리는 동안 다른 작업을 진행하고, 결과가 오면 이어서 처리합니다.

요청 두 개가 있고, 각각 계산 100ms, 외부 API 대기 400ms, 계산 100ms 가 걸린다고 해 봅니다. 동기로 처리하면 하나씩 끝내므로 1,200ms 가 걸립니다. 비동기로 처리하면 대기 구간이 겹쳐서 700ms 에 끝납니다.

동기는 카운터 앞에 서서 기다리기, 비동기는 진동벨을 받고 다음 손님에게 자리를 내주기예요.

동기1,200ms
요청 A대기계산계산요청 B대기계산계산020040060080010001200ms

앞 요청의 대기가 끝나야 다음 요청을 시작한다.

비동기700ms
요청 A대기계산계산요청 B대기계산계산020040060080010001200ms

A 가 기다리는 동안 B 를 시작해 대기 구간이 겹친다.

계산 시간은 400ms 로 같고 대기만 겹친다. 전체 시간은 줄지만 실행 순서를 다루는 코드가 복잡해진다.

계산에 쓰인 시간은 두 경우 모두 400ms 로 같습니다. 줄어든 것은 기다리는 시간뿐입니다. 그래서 비동기는 네트워크나 디스크 대기가 많은 서버에서 이득이 크고, 계산만 하는 코드에서는 이득이 없습니다.

2. 이벤트 루프와 await

asyncio 는 스레드 하나에서 이벤트 루프가 여러 코루틴을 번갈아 실행합니다. 코루틴은 중간에 멈췄다가 다시 이어서 실행할 수 있는 함수입니다.

코루틴이 await 에 도달하면 제어권을 루프에 돌려줍니다. 루프는 그사이 실행할 수 있는 다른 코루틴을 돌리고, 운영체제가 입출력 완료를 알려 주면 멈췄던 코루틴을 다시 이어 갑니다.

이벤트 루프는 혼자 일하는 카페 직원이에요. 커피가 내려지는 동안 다음 손님의 주문을 받아요.

🔁루프이벤트 루프📄작업 A코루틴📄작업 B코루틴🖥️OS입출력 완료 통지① A 실행② 요청 전송③ await 양보④ B 실행⑤ await 양보⑥ A 응답 도착⑦ A 재개
await 에서 제어권이 루프로 돌아와 스레드 하나로 여러 작업을 진행한다. 대신 코루틴이 양보하지 않으면 다른 작업은 차례를 받지 못한다.
import asyncio
 
async def handle():
    # 두 요청의 대기 시간을 겹친다
    user, orders = await asyncio.gather(
        fetch_user(),
        fetch_orders(),
    )
    return user, orders

여기서 눈여겨볼 점은 양보가 자발적이라는 것입니다. 운영체제가 스레드를 강제로 전환하는 것과 달리, 코루틴이 await 로 넘겨주지 않으면 루프는 다른 작업으로 넘어갈 수 없습니다.

3. 블로킹 호출 격리하기

위 성질 때문에 async 함수 안의 동기 호출 하나가 루프 전체를 멈춥니다. 직원이 한 명뿐인 카페에서 그 직원이 원두를 오래 갈면 뒷줄 전체가 기다리는 것과 같습니다.

# 나쁨: 전송이 끝날 때까지 다른 요청도 모두 멈춘다
async def upload(req):
    req.execute()
 
# 좋음: 동기 호출을 별도 스레드로 격리한다
async def upload(req):
    await asyncio.to_thread(req.execute)

어디로 보낼지는 작업의 성격으로 정합니다. 비동기 라이브러리가 있는 입출력은 그대로 await 하고, 동기 라이브러리밖에 없는 입출력은 스레드로 보냅니다. 오래 걸리는 계산은 프로세스 풀로 보냅니다.

오래 붙드는 일은 혼자 일하는 직원이 쥐지 않고 다른 사람에게 넘겨요.

📨요청코루틴 시작🔁이벤트 루프스레드 1개🌐await비동기 입출력🧵스레드to_thread🧮프로세스 풀run_in_executor비동기 입출력동기 입출력긴 계산
이벤트 루프는 막지 않되, 스레드는 공유 자원 경합을, 프로세스는 메모리와 직렬화 비용을 떠안는다.
import asyncio
import concurrent.futures as cf
 
pool = cf.ProcessPoolExecutor(max_workers=4)
 
async def transcode(path):
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(pool, decode_file, path)

4. 동시성과 병렬성

동시성(concurrency) 은 여러 작업을 번갈아 진행해 함께 처리 중인 상태를 만드는 것입니다. 코어 하나로도 가능합니다. 병렬성(parallelism) 은 여러 작업을 실제로 같은 순간에 여러 코어에서 실행하는 것입니다.

asyncio 는 스레드 하나에서 도는 동시성입니다. 파이썬 스레드도 기본 빌드에서는 GIL(전역 인터프리터 락) 때문에 파이썬 코드를 한 번에 한 스레드만 실행합니다. 3.13 부터 GIL 없는 free-threaded 빌드가 별도로 제공되지만 기본 빌드는 아직 GIL 을 유지합니다.

한눈에 비교

asyncio스레드프로세스
잘 맞는 작업대기가 많은 입출력동기 라이브러리 입출력오래 걸리는 계산
전환 방식await 에서 자발적 양보운영체제가 전환운영체제가 전환
파이썬 코드 병렬 실행안 됨기본 빌드에서 안 됨됨
주요 비용블로킹 호출 주의공유 자원 경합메모리, 직렬화

헷갈리기 쉬운 점

  • 비동기로 바꾸면 계산도 빨라진다는 오해. 겹쳐지는 것은 대기 시간뿐입니다. 계산이 긴 함수는 오히려 루프를 막아 다른 요청까지 세웁니다.
  • async def 만 붙이면 비동기가 된다는 오해. 함수 안의 호출이 동기라면 양보할 지점이 없어 그대로 순서대로 실행됩니다.
  • 비동기와 병렬을 같은 말로 쓰는 경우. asyncio 코루틴은 많이 띄워도 어느 한 순간에는 하나만 실행됩니다.

면접에서 자주 받는 질문

async 함수 안에서 time.sleep 을 쓰면 어떻게 되나요?

이벤트 루프 전체가 멈춥니다. time.sleep 은 스레드 자체를 재우기 때문에 다른 코루틴도 실행되지 못합니다. await asyncio.sleep() 을 써야 차례를 넘깁니다.

asyncio 와 스레드는 언제 각각 쓰나요?

비동기 라이브러리가 있는 입출력은 asyncio 가 가볍습니다. 동기 라이브러리밖에 없다면 to_thread 로 스레드에 맡깁니다. 둘 다 입출력에 쓰는 도구이고, 계산에는 맞지 않습니다.

파이썬 스레드로 CPU 작업을 나누면 왜 잘 빨라지지 않나요?

GIL 때문에 파이썬 코드를 한 번에 한 스레드만 실행하기 때문입니다. numpy 처럼 C 코드가 GIL 을 놓는 구간은 예외입니다.

멀티프로세스의 대가는 무엇인가요?

프로세스마다 메모리를 따로 쓰고, 인자와 결과를 pickle 로 직렬화해 주고받습니다. 그래서 작은 작업을 잘게 보내면 오히려 느려집니다. 넘기는 함수도 pickle 이 가능하도록 모듈 최상위에 두어야 합니다.

정리

  • 동기는 끝날 때까지 기다리고, 비동기는 기다리는 동안 다른 작업을 진행합니다.
  • asyncio 는 스레드 하나의 동시성이라, 블로킹 호출 하나가 루프 전체를 멈춥니다.
  • 동기 입출력은 스레드로, 긴 계산은 프로세스로 보냅니다.

비동기는 일을 빠르게 하는 기술이 아니라, 기다리는 시간을 양보하는 기술입니다.