사전 코루틴
개념

코루틴

gabury1고친 사람 github-actions[bot]

코루틴은 실행 도중에 멈췄다가 멈춘 곳부터 다시 이어 가는 함수입니다. 멈춰 있는 동안에도 지역 변수와 진행 위치를 잃지 않습니다. 그래서 스레드 하나로 여러 일을 번갈아 처리할 수 있습니다.

쉽고 빠른 이해

코루틴은 중간에 멈췄다가 나중에 이어서 도는 함수입니다. 서버 코드가 응답을 기다리는 동안 코루틴을 멈춰 둡니다. 그 사이에 다른 요청을 처리하는 것이 흔한 쓰임입니다.

보통 함수는 한 번 들어가면 끝날 때까지 돌아 나오지 않습니다. 기다리는 일이 많은 서버에서 요청마다 스레드를 하나씩 붙이면 메모리가 많이 듭니다. 코루틴은 기다리는 동안 손을 놓아 스레드 하나를 여럿이 나눠 쓰게 합니다.

어떻게 도는가:

  1. 코루틴이 기다릴 일을 만나면 스스로 멈춥니다
  2. 그 틈에 같은 스레드에서 다른 코루틴이 돕니다
  3. 기다리던 일이 끝나면 멈춘 줄부터 다시 이어 갑니다

대가가 있습니다. 코루틴은 스스로 멈춰야만 차례를 넘깁니다. 하나가 오래 붙잡고 안 놓으면 나머지가 전부 기다립니다.

상세

부엌에 도마가 하나뿐이면, 채소를 썰던 사람이 스스로 비켜 줘야 다음 사람이 올라섭니다. 물을 올리러 가는 김에 칼을 내려놓고 자리를 내줍니다. 돌아와서는 썰다 만 자리부터 이어 썹니다.

보통 함수와 다른 점

보통 함수는 서브루틴이라고도 부릅니다. 서브루틴은 부르면 처음부터 끝까지 돕니다. 그리고 한 번만 돌아옵니다. 돌아오는 순간 지역 변수는 사라집니다.

코루틴은 들어가고 나오는 일이 여러 번 일어납니다. 중간에 나올 때 진행 위치와 지역 변수를 남겨 둡니다. 나올 때 값을 하나 내주기도 합니다. 다시 들어가면 나왔던 줄 바로 다음부터 이어 갑니다.

코루틴이 호출한 쪽과 주고받는 순서를 그리면 이렇습니다. 서브루틴이었다면 「시작한다」와 「끝난다」 화살표 둘로 끝났을 것입니다.

sequenceDiagram
    participant 호출한 쪽
    participant 코루틴
    호출한 쪽->>코루틴: 시작한다
    코루틴-->>호출한 쪽: 멈추고 값을 내준다
    Note over 호출한 쪽: 다른 일을 한다
    호출한 쪽->>코루틴: 다시 이어 간다
    코루틴-->>호출한 쪽: 또 멈추고 값을 내준다
    호출한 쪽->>코루틴: 다시 이어 간다
    코루틴-->>호출한 쪽: 끝난다

화살표가 한 번 오가고 끝나지 않습니다. 멈춤과 이어 감이 되풀이됩니다.

멈춤과 이어 감

코루틴이 스스로 멈추는 동작을 양보라고 부릅니다. 영어로는 yield 입니다. 차례를 남에게 내준다는 뜻입니다.

멈춘 코루틴을 다시 돌리는 동작은 재개라고 부릅니다. 영어로는 resume 입니다. 재개는 코루틴 밖의 누군가가 해 줍니다. 호출한 함수가 재개하기도 합니다. 뒤에 나오는 이벤트 루프가 재개하기도 합니다.

멈춘 코루틴이 들고 있는 것은 두 가지입니다. 하나는 다음에 실행할 줄의 위치입니다. 다른 하나는 지역 변수의 값입니다. 이 둘을 묶어 프레임이라고 부릅니다.

보통 함수의 프레임은 돌아올 때 버려집니다. 코루틴의 프레임은 코루틴이 끝날 때까지 남아 있습니다. 멈췄다 이어 가도 값이 그대로인 까닭이 이것입니다.

파이썬 제너레이터로 보는 멈춤

Python 의 제너레이터는 코루틴의 멈춤과 이어 감을 가장 쉽게 볼 수 있는 예입니다. yield 에서 멈추고 next() 로 이어 갑니다. 지역 변수가 살아남는 것을 보면 이렇습니다.

Python
def counter():
    n = 0
    while True:
        n += 1
        yield n   # 멈추고 n 을 내준다

c = counter()
next(c)   # 1
next(c)   # 2
next(c)   # 3

next() 를 부를 때마다 n 이 하나씩 늘었습니다. 멈춘 사이에 n 이 사라지지 않았다는 뜻입니다. 보통 함수였다면 부를 때마다 n = 0 부터 시작해 매번 1 이 나왔을 것입니다.

누가 차례를 넘기나

여러 코루틴이 한 스레드를 나눠 쓸 때, 차례는 코루틴이 양보해야만 넘어갑니다. 밖에서 억지로 끊지 않습니다. 이 방식을 협력형 멀티태스킹이라고 부릅니다.

스레드는 반대입니다. 운영체제가 정해 둔 시간이 지나면 스레드를 강제로 멈추고 다른 스레드를 돌립니다. 이것이 선점형 멀티태스킹입니다.

협력형에서는 끊기는 시점을 코드가 압니다. 양보하는 줄에서만 끊기므로, 그 줄 사이의 코드는 다른 코루틴에 방해받지 않습니다. 그래서 공유 변수를 만질 때 락이 덜 필요합니다. 락은 여러 실행 흐름이 한 값을 동시에 고치지 못하게 막는 장치입니다.

대신 양보를 안 하는 코루틴 하나가 스레드 전체를 붙잡습니다. 반복문을 돌며 오래 계산하는 코루틴이 그렇습니다. 양보하지 않는 방식으로 파일이나 네트워크를 기다리면 같은 스레드의 코루틴이 모두 멈춥니다.

스레드와 견주기

코루틴과 스레드는 둘 다 여러 일을 동시에 진행하게 해 줍니다. 무엇이 다른지는 축별로 보는 것이 빠릅니다.

스레드 코루틴
누가 멈추나 운영체제가 아무 때나 멈춥니다 코루틴이 양보하는 줄에서만 멈춥니다
전환하는 곳 운영체제 커널을 거칩니다 프로그램 안에서 함수 호출처럼 넘어갑니다
하나가 쓰는 메모리 스택을 따로 크게 잡습니다 프레임 하나만 들고 있습니다(스택을 따로 갖는 방식은 아래)
여러 코어에서 동시에 도나 돕니다 한 스레드 안의 코루틴은 한 번에 하나만 돕니다

전환이 커널을 안 거치는 것이 코루틴을 쓰는 큰 이유입니다. 스레드를 바꿀 때 운영체제는 멈출 스레드의 상태를 저장하고 다음 스레드의 상태를 복원합니다. 이 일을 문맥 교환이라고 합니다. 코루틴은 이 일을 피하므로 스레드보다 훨씬 많은 수를 한꺼번에 띄울 수 있습니다.

반대로 한 스레드 안의 코루틴은 한 번에 하나만 돕니다. 그래서 코루틴만으로는 CPU(Central Processing Unit, 중앙 처리 장치) 코어 여럿을 동시에 쓰지 못합니다.

이벤트 루프 위에서 도는 코루틴

백엔드에서 코루틴을 만나는 곳은 대개 비동기 프로그래밍입니다. 네트워크 응답이나 데이터베이스 결과를 기다리는 시간이 계산 시간보다 훨씬 길기 때문입니다.

이때 코루틴을 재개해 주는 쪽이 이벤트 루프입니다. 이벤트 루프는 준비된 코루틴을 하나씩 돌리는 반복문입니다. 응답이 도착한 코루틴이 있으면 골라서 다시 이어 갑니다. 코루틴은 기다릴 일을 만나면 루프에게 「응답이 오면 깨워 달라」고 맡기고 양보합니다.

코루틴 둘이 응답을 기다리며 한 루프를 나눠 쓰는 순서를 그리면 이렇습니다.

sequenceDiagram
    participant 루프 as 이벤트 루프
    participant A as 코루틴 A
    participant B as 코루틴 B
    루프->>A: 돌린다
    A-->>루프: 응답을 기다리며 양보한다
    루프->>B: 돌린다
    B-->>루프: 응답을 기다리며 양보한다
    Note over 루프: A 의 응답이 도착한다
    루프->>A: 멈춘 줄부터 이어 간다
    A-->>루프: 끝난다

A 가 기다리는 동안 B 가 돌았습니다. 스레드는 하나뿐이었습니다.

여러 언어가 이 방식을 async 와 await 라는 낱말로 씁니다. async 를 붙인 함수가 코루틴이 됩니다. await 를 적은 줄이 양보하는 줄이 됩니다. 코루틴을 이벤트 루프에 맡겨 따로 돌게 할 때 쓰는 객체는 태스크 항목에서 다룹니다.

아래 파이썬 코드에서 asyncio.run 은 이벤트 루프를 만들어 main 을 돌립니다. asyncio.gather 는 두 코루틴을 한 루프에 같이 올리고 둘 다 끝날 때까지 기다립니다. 두 코루틴은 await 에서 번갈아 양보합니다.

Python
import asyncio

async def work(name):
    print(name, "start")    # a start, b start
    await asyncio.sleep(0)  # 양보한다
    print(name, "end")      # a end, b end

async def main():
    await asyncio.gather(work("a"), work("b"))

asyncio.run(main())

a 가 끝나기 전에 b 가 시작했습니다. sleep(0) 은 기다릴 것 없이 양보만 하는 호출이라, 그 줄에서 차례가 b 로 넘어갔습니다.

스택 있는 코루틴과 스택 없는 코루틴

코루틴은 멈출 때 무엇을 들고 가느냐로 둘로 갈립니다.

스택 없는 코루틴은 자기 프레임 하나만 들고 멈춥니다. 그래서 양보는 코루틴 함수 본문에서만 할 수 있습니다. 그 안에서 부른 보통 함수 깊은 곳에서는 멈출 수 없습니다. 파이썬의 async def 와 코틀린의 suspend 함수가 이쪽입니다.

이 제약 때문에 양보하는 함수를 부르는 함수도 코루틴이어야 합니다. 그 함수를 부르는 함수도 마찬가지입니다. async 가 호출 사슬을 따라 위로 번지는 현상을 흔히 함수 색깔 문제라고 부릅니다.

스택 있는 코루틴은 호출 스택을 하나씩 따로 가집니다. 그래서 몇 겹 아래 함수에서도 양보할 수 있습니다. 파이썬의 그린렛과 Lua 의 코루틴이 이쪽입니다. 대신 코루틴마다 스택을 들고 있어 스택 없는 쪽보다 메모리를 더 씁니다.

스택 없는 코루틴 스택 있는 코루틴
들고 멈추는 것 자기 프레임 하나 호출 스택 전체
양보할 수 있는 곳 코루틴 함수 본문 호출 사슬의 어느 깊이든
호출하는 함수에 번지나 번집니다 안 번집니다
예 파이썬 async def · 코틀린 suspend 그린렛 · Lua 코루틴

코루틴을 쓸 때 치르는 것

코루틴은 기다림이 많은 일에 맞춰진 도구입니다. 계산이 대부분인 일에는 이득이 없습니다. 양보할 틈이 없어서 한 스레드에서 차례대로 도는 것과 다르지 않습니다.

양보하지 않고 기다리는 호출이 하나라도 섞이면 루프 전체가 섭니다. 이런 호출을 블로킹 호출이라고 부릅니다. async def 안에서 asyncio.sleep() 대신 time.sleep() 을 부르는 것이 그런 호출입니다. 코루틴 안에서는 양보를 지원하는 라이브러리를 골라 써야 합니다.

오류 추적도 달라집니다. 멈춘 코루틴을 이어 갈 때 부르는 쪽은 처음 시작한 함수가 아니라 이벤트 루프입니다. 그래서 오류가 났을 때 보이는 호출 스택 맨 아래에는 루프가 있습니다. 「누가 이 일을 시작했나」까지 이어지지 않을 때가 있습니다.

관련 항목

코루틴과 맞세워지는 실행 단위

스레드 · 프로세스 · 서브루틴 · 그린 스레드 · 가상 스레드 · 파이버 · 고루틴

코루틴을 감싸거나 돌리는 장치

이벤트 루프 · 태스크 · 퓨처 · 프로미스 · 스케줄러 · 런타임 · 취소

코루틴을 문법으로 쓰는 언어 기능

async-await · 제너레이터 · yield · 이터레이터 · suspend 함수 · 컨티뉴에이션

코루틴을 구현하거나 채택한 도구

그린렛 · gevent · asyncio · Kotlin 코루틴 · Lua · Python

코루틴이 기대는 실행 방식

협력형 멀티태스킹 · 선점형 멀티태스킹 · 비동기 프로그래밍 · 논블로킹 입출력 · 동시성 · 병렬성

코루틴이 멈출 때 남기는 실행 상태

프레임 · 호출 스택 · 스택 · 문맥 교환 · 상태 기계

코루틴에서 자주 터지는 오류

블로킹 호출 · 함수 색깔 문제 · 기아 상태 · 경쟁 상태

다른 이름: coroutine · 코루틴 함수