백엔드 개발자들이 알아야할 동시성 6 — Coroutine

Choi Geonu
8 min readSep 27, 2023

CPS를 이용한 협력적 스케줄링의 구현은 작업의 단위가 어떻게 나뉘는지 매우 직관적으로 알 수 있지만, 순차적으로 작성하던 기존 코드와 많이 달라지는점이 있기에 이해하기 어려워 진다는 문제가 있습니다.

CPS 기반 코드(좌)와 순차적 코드(우)의 비교

이러한 문제를 해결하기 위해 현대의 많은 프로그래밍 언어들은 협력적 스케줄링에 Coroutine (코루틴)을 사용하고 있습니다.

이번 포스팅에선 이 Coroutine에 대해 자세히 알아보고 어떻게 협력적 스케줄링에 활용되는지 이야기 해보려 합니다.

Coroutine

Coroutine은 일반적으로 Subroutine과 비교하여 이야기 되는 대상입니다. 그러므로 Coroutine에 대해 이야기 하기 위해서는 먼저 Subroutine에 대해서 이야기 해야 할 것입니다.

Subroutine

Subroutine이란, 실행할 명령이 정해져 있는 작은 프로그램 조각입니다. 상위 프로그램은 Subroutine을 실행하고, Subroutine이 종료되면 기존에 실행하던 코드를 계속 진행하게 되는 것이죠.

subroutine task1
print("Hello")
end

subroutine task2
print("World")
end

task1()
task2()
print("end")

함수와 다르지 않은 것 같다구요? 맞습니다. 함수는 Subroutine의 일종입니다. 대부분의 언어에서는 Subroutine과 함수를 구분하지 않기 때문에, 함수라고 이해 하셔도 무방합니다.
Coroutine과 비교하기 위해서 Subroutine의 주요 특징을 몇 가지 정리해 보겠습니다.

첫번째, Subroutine은 진입점이 한 곳 입니다. 다음과 같은 Python으로 작성된 Subroutine(함수)이 있다고 가정해 봅니다.

def task(name):
print("1. Enter") # (1)
print("2. Name: " + name) # (2)
print("3. Finish") # (3)

위 Subroutine task() 를 실행하면 어떤 코드부터 시작이 될까요? 당연하게도 (1) 라인이 먼저 실행되며 1. Enter 라는 문자열을 출력할 것 입니다. 이것은 Subroutine을 몇번을 실행하더라도 변하지 않는 사실입니다.
즉, Subroutine은 단일 진입점을 가진다는 특징이 있습니다.

두번째, Subroutine은 한번만 진출할 수 있습니다. 다시한번 Python으로 작성된 Subroutine을 보겠습니다.

def task(number):
if number % 2 == 0:
return 'even' # (1)
else:
return 'odd' # (2)

위 Subroutine은 두 개의 지점에서 진출할 수 있습니다. number 가 짝수이면 (1) 에서 진출할 것이고, 홀수이면(2) 에서 진출할 것 이죠. 하지만 (1) , (2) 모두에서 진출하는 것은 불가능 하죠.
즉, Subroutine은 1회의 진출 횟수를 가진다 라는 특징도 있습니다.

Subroutine에 대해서 두가지 특징을 알아보았는데요. 이러한 특징에 비교하여 Coroutine에 대해 알아보겠습니다.

Coroutine

짐작하신바와 같이 Coroutine이란, 여러개의 진입점과 진출점을 가지는 프로그램 조각을 의미합니다. Coroutine은 한번 이미 진출한 Coroutine 이라도 다시 진입할 수 있으며, 여러번 진출 하는것도 가능합니다.

Coroutine의 가장 간단한 예시는 Python의 Generator입니다. 다음 Python Generator 코드를 보겠습니다.

def some_generator():
name = yield 1 # (1)
age = yield 2 # (2)
print(name, age)
yield 3

위 Generator는 다음과 같이 사용할 수 있습니다.

gen = some_generator()  # Generator 인스턴스 생성
print(gen.send(None)) # 1 출력
print(gen.send("John")) # 2 출력
print(gen.send(29)) # 3 출력

위 코드의 출력은 다음과 같습니다.

1
2
John 29
3

자, 이제 왜 이러한 동작이 이루어졌는지 간단하게 알아보겠습니다.
먼저 Generator에서 주목해야할 부분은 yield 구문입니다.

name = yield 1

Generator에서의 yield 구문은 Coroutine의 진출점을 의미합니다. 앞서 이야기한 바와 같이, Generator는 Coroutine이기에 some_generator 는 첫 실행시에 (1) 라인까지 실행된 후 Generator를 빠져나오게 됩니다.

Generator를 다루는 부분에서 주목할 부분은 send() 메소드입니다. Generator 인스턴스, 즉 Coroutine 인스턴스에서의 send() 메소드는 Coroutine의 진입점이 됩니다. send() 메소드를 이용하면 Generator에 진입할 수 있고, 동시에 Generator 내부에 값을 전달할 수도 있습니다.
물론 한번 빠져나온 Generator에 다시 진입하면, 이전에 진출한 부분에서 다시 실행됩니다.

print(gen.send(None))  # 1 출력

위 라인에서는 Coroutine이 첫번째 라인부터 실행되지만

print(gen.send("John"))  # 2 출력

위 라인에서는 이전에 진출한 지점인 (1) 부터 이어서 실행되는 것을 알 수 있습니다.
그리고 짐작할 수 있다 싶이 send() 의 반환값은 진출점에서 yield 구문 뒤에 넘긴 값이 됩니다.

위 코드의 실행을 시각화 하면 다음과 같이 나타낼 수 있습니다.

위와 같이 Coroutine은 여러개의 진입점과 진출점을 가지게 되어 하나의 작업을 나눠서 실행할 수 있게 됩니다.

Python에서는 Generator 뿐만 아니라 async / await 구문을 이용한 Coroutine도 지원하고 있으며, 일반적으로 Python에서의 Coroutine을 이야기하면 async / await 구문을 이용한 Coroutine을 의미합니다.

Coroutine의 실행 과정을 보면 하나의 Task가 yield를 기준으로 여러 단위로 나뉘어져 실행됨을 볼 수 있습니다. Task를 나누어 실행할 수 있다면 우리는 무엇을 할 수 있을까요? 맞습니다. 협력적 스케줄링에 나뉘어진 Task 단위를 사용할 수 있을것입니다.
여러개의 Coroutine을 효과적으로 스케줄링하면 협력적 스케줄링을 하는 가상의 쓰레드를 만들 수 있을겁니다.

이를 User-level Thread 혹은 Green Thread라고 부르며 Python의 asyncio Node의 io loop도 일종의 User-level Thread라고 볼 수 있죠. 이름에서 알 수 있듯 I/O를 기준으로 협력적 스케줄링을 하는 도구이기에 네트워킹에서 강력한 성능을 보여줍니다.

그렇다면 이전 포스팅에서 이야기했던 Continuous Passing Style과 비교해서 Coroutine을 활용한 스케줄링이 가지는 장점을 무엇일까요? 코드를 보면 간단히 이해할 수 있습니다.

왼쪽의 동기적 코드를 이용해 작성한 코드와 Coroutine을 이용해 작성한 코드를 비교했을 때 await 키워드를 제외하고는 거의 같은 방식으로 작성됨을 알 수 있습니다. 즉 Callback 방식에 익숙하지 않은 사람들이라도 쉽게 코드를 읽을 수 있으며, 기존 프로그래밍 방식에서 사용하던 패턴을 거의 그대로 사용할 수 있게 됩니다.

이처럼 Coroutine 자체의 개념은 조금 어려운면이 있지만 이를 활용하는데에는 기존 프로그래머들이 익숙한 방식으로 코드를 작성할 수 있기에 많은 현대적인 프로그래밍 언어에서 Coroutine을 지원하고 있습니다.

특히나 Python에서의 Coroutine은 재미있는 역사를 가지는데 다음 포스팅에서는 greenlet과 gevent라는 Python의 특별한 Coroutine 구현을 집중적으로 이야기 해보겠습니다.

--

--