Go vs Rust/C++: Goroutines와 코루틴의 비교
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Golang, Rust, C++의 코루틴 심층 분석
오늘날 코루틴은 현대 프로그래밍 언어의 중요한 부분이 되었으며 비동기 프로그래밍 및 동시성 제어와 같은 시나리오에서 널리 사용됩니다. Golang의 goroutine 및 JavaScript의 async/await와 같은 많은 주류 프로그래밍 언어들이 코루틴을 지원합니다. 코루틴의 이름과 구현 방법은 언어마다 다르지만, 본질적으로 코루틴은 크게 두 가지 범주로 나뉩니다: 스택풀 코루틴과 스택리스 코루틴. 전자는 goroutine으로 대표되고, 후자는 async/await가 대표적입니다.
1. 스택풀 코루틴과 스택리스 코루틴의 차이점
여기서 언급되는 "스택풀"과 "스택리스"라는 용어는 코루틴이 실행될 때 스택 공간이 필요한지 여부를 의미하지 않습니다. 실제로 대부분의 프로그래밍 언어에서 함수 호출은 필연적으로 호출 스택과 관련됩니다. 핵심적인 차이점은 코루틴이 중첩된 함수(예: 하위 함수, 익명 함수 등)에서 일시 중단될 수 있는지 여부에 있습니다. 스택풀 코루틴은 이러한 능력을 가지고 있지만, 스택리스 코루틴은 그렇지 않습니다. 이러한 차이점을 깊이 이해하려면 함수 호출 스택의 작동 메커니즘부터 시작해야 합니다.
1.1 함수 호출 스택의 작동 메커니즘
이 문서의 논의는 x86 플랫폼을 기반으로 하며 32비트 시스템을 대상으로 합니다. x86 플랫폼에서 호출 스택의 주소는 높은 주소에서 낮은 주소로 증가합니다. 호출 스택은 연속적인 주소 공간이며, 호출자와 피호출자 모두 그 안에 위치합니다. 호출 스택에서 각 함수가 차지하는 주소 공간을 "스택 프레임"이라고 하며, 전체 호출 스택은 여러 개의 스택 프레임으로 구성됩니다. 다음은 Wikipedia에서 가져온 일반적인 호출 스택 모델입니다.
Compiler Explorer를 통해 C 코드를 어셈블리 코드로 변환하여 기본 실행 프로세스를 이해하는 것이 편리합니다. 다음은 컴파일 매개변수 -m32
를 사용하여 x86_64 gcc 9.3에서 생성된 AT&T 구문 어셈블리 코드입니다.
int callee() { int x = 0; return x; } int caller() { callee(); return 0; }
해당 어셈블리 코드는 다음과 같습니다.
callee: pushl %ebp movl %esp, %ebp subl $16, %esp movl $0, -4(%ebp) movl -4(%ebp), %eax leave ret caller: pushl %ebp movl %esp, %ebp call callee movl $0, %eax popl %ebp ret
caller
가 callee
를 호출할 때 실행 단계는 다음과 같습니다.
eip
에 저장된 명령어 주소(즉,caller
의 반환 주소,caller
의movl $0, %eax
명령어의 주소)를 스택에 푸시하여 보존합니다.callee
로 이동합니다.caller
의 스택 프레임의 하단 주소를 스택에 푸시하여 보존합니다.- 이 시점에서 호출 스택의 상단 주소를
callee
의 스택 프레임의 하단 주소로 사용합니다. callee
의 스택 프레임 공간으로 호출 스택의 상단을 16바이트 확장합니다. x86 플랫폼에서 호출 스택 주소는 높은 주소에서 낮은 주소로 증가하므로subl
명령어가 사용됩니다.
callee
가 caller
로 반환될 때 실행 단계는 다음과 같습니다.
- 호출 스택의 상단을
callee
스택 프레임의 하단과 정렬하고callee
스택 프레임 공간을 해제합니다. - 이전에 저장한
caller
의 스택 프레임의 하단 주소를 스택에서 팝하여ebp
에 할당합니다. - 이전에 저장한
caller
의 반환 주소를 스택에서 팝하여eip
(즉,caller
의movl $0, %eax
명령어의 주소)에 할당합니다. caller
는callee
에서 반환되어 후속 명령어를 계속 실행합니다.
호출 스택의 실제 작동 프로세스는 더 복잡합니다. 이 문서의 논의를 단순화하기 위해 함수 매개변수 전달과 같은 세부 사항은 무시됩니다.
2. 스택풀 코루틴(Goroutine)의 구현 및 원리
코루틴 구현의 핵심은 컨텍스트 저장, 복원 및 전환에 있습니다. 함수는 호출 스택에서 실행되므로 컨텍스트를 저장한다는 것은 자연스럽게 함수 및 중첩된 함수의 연속적인 스택 프레임의 값과 현재 레지스터의 값을 저장하는 것을 의미합니다. 컨텍스트를 복원한다는 것은 이러한 값을 해당 스택 프레임과 레지스터에 다시 작성하는 것을 의미합니다. 컨텍스트를 전환한다는 것은 현재 실행 중인 함수의 컨텍스트를 저장하고 실행될 다음 함수의 컨텍스트를 복원하는 것을 의미합니다. 스택풀 코루틴은 정확히 이러한 아이디어를 기반으로 구현됩니다.
2.1 스택풀 코루틴의 구현
스택풀 코루틴을 구현하려면 먼저 컨텍스트를 저장할 메모리 공간을 할당해야 합니다. 컨텍스트를 이 메모리 조각에 복사하거나 코루틴이 실행될 때 이 메모리 조각을 스택 프레임 공간으로 직접 사용하여 복사로 인한 성능 손실을 피할 수 있습니다. 그러나 메모리 공간의 크기를 합리적으로 할당해야 합니다. 너무 작으면 코루틴이 실행될 때 스택 오버플로가 발생할 수 있고, 너무 크면 메모리가 낭비됩니다.
동시에 레지스터의 값도 저장해야 합니다. 함수 호출 스택에서 규칙에 따라 eax
, ecx
및 edx
와 같은 레지스터는 caller
에 의해 저장되고 ebx
, edi
및 esi
와 같은 레지스터는 callee
에 의해 저장됩니다. 호출된 코루틴의 경우 callee
와 관련된 레지스터 값, 호출 스택과 관련된 ebp
및 esp
값, 그리고 eip
에 저장된 반환 주소를 저장해야 합니다.
// *(ctx + CTX_SIZE - 1)은 반환 주소를 저장합니다. // *(ctx + CTX_SIZE - 2)는 ebx를 저장합니다. // *(ctx + CTX_SIZE - 3)는 edi를 저장합니다. // *(ctx + CTX_SIZE - 4)는 esi를 저장합니다. // *(ctx + CTX_SIZE - 5)는 ebp를 저장합니다. // *(ctx + CTX_SIZE - 6)는 esp를 저장합니다. // x86의 스택 증가 방향은 높은 주소에서 낮은 주소로 향하므로 주소 지정은 아래쪽으로 오프셋됩니다. char **init_ctx(char *func) { size_t size = sizeof(char *) * CTX_SIZE; char **ctx = malloc(size); memset(ctx, 0, size); *(ctx + CTX_SIZE - 1) = (char *) func; *(ctx + CTX_SIZE - 6) = (char *) (ctx + CTX_SIZE - 7); return ctx + CTX_SIZE; }
레지스터 값을 저장하고 복원하려면 어셈블리 코드를 작성해야 합니다. 컨텍스트를 저장하는 메모리 주소가 eax
에 할당되었다고 가정하면 저장 로직은 다음과 같습니다.
movl %ebx, -8(%eax) movl %edi, -12(%eax) movl %esi, -16(%eax) movl %ebp, -20(%eax) movl %esp, -24(%eax) movl (%esp), %ecx movl %ecx, -4(%eax)
복원 로직은 다음과 같습니다.
movl -8(%eax), %ebx movl -12(%eax), %edi movl -16(%eax), %esi movl -20(%eax), %ebp movl -24(%eax), %esp movl -4(%eax), %ecx movl %ecx, (%esp)
위의 어셈블리 코드를 기반으로 void swap_ctx(char **current, char **next)
함수를 구성할 수 있습니다. char **init_ctx(char *func)
로 구성된 컨텍스트를 전달하여 컨텍스트 전환을 실현할 수 있습니다. 사용 편의성을 위해 swap_ctx()
함수를 yield()
함수로 캡슐화하여 함수 스케줄링 로직을 구현할 수도 있습니다. 다음은 전체 예제입니다.
#include <stdio.h> #include <stdlib.h> #include <string.h> // 컴파일 // gcc -m32 stackful.c stackful.s const int CTX_SIZE = 1024; // *(ctx + CTX_SIZE - 1)은 반환 주소를 저장합니다. // *(ctx + CTX_SIZE - 2)는 ebx를 저장합니다. // *(ctx + CTX_SIZE - 3)는 edi를 저장합니다. // *(ctx + CTX_SIZE - 4)는 esi를 저장합니다. // *(ctx + CTX_SIZE - 5)는 ebp를 저장합니다. // *(ctx + CTX_SIZE - 6)는 esp를 저장합니다. char **MAIN_CTX; char **NEST_CTX; char **FUNC_CTX_1; char **FUNC_CTX_2; // 코루틴 컨텍스트 전환을 시뮬레이션하는 데 사용됩니다. int YIELD_COUNT; // 컨텍스트를 전환합니다. 자세한 내용은 stackful.s의 주석을 참조하십시오. extern void swap_ctx(char **current, char **next); // x86의 스택은 높은 메모리 주소에서 낮은 메모리 주소로 증가하므로 // 주소 지정은 아래로 이동합니다. char **init_ctx(char *func) { // 코루틴 컨텍스트를 저장하기 위해 CTX_SIZE 메모리를 동적으로 할당합니다. size_t size = sizeof(char *) * CTX_SIZE; char **ctx = malloc(size); memset(ctx, 0, size); // 함수의 주소를 해당 스택 프레임의 초기 반환 주소로 설정합니다. // таким чином, щоб під час першого планування функція починала виконуватися з точки входу. *(ctx + CTX_SIZE - 1) = (char *) func; // https://github.com/mthli/blog/pull/12 // 6개의 레지스터 값을 저장하기 위한 공간을 예약해야 합니다. // 남은 메모리 공간은 함수의 스택 프레임으로 사용할 수 있습니다. *(ctx + CTX_SIZE - 6) = (char *) (ctx + CTX_SIZE - 7); return ctx + CTX_SIZE; } // 코루틴이 4개(그 중 하나는 메인 코루틴)뿐이므로 // switch 문을 사용하여 컨텍스트 전환을 위한 스케줄러를 간단히 시뮬레이션합니다. void yield() { switch ((YIELD_COUNT++) % 4) { case 0: swap_ctx(MAIN_CTX, NEST_CTX); break; case 1: swap_ctx(NEST_CTX, FUNC_CTX_1); break; case 2: swap_ctx(FUNC_CTX_1, FUNC_CTX_2); break; case 3: swap_ctx(FUNC_CTX_2, MAIN_CTX); break; default: // 아무 것도 하지 않음 break; } } void nest_yield() { yield(); } void nest() { // 태그로 사용할 난수 생성 int tag = rand() % 100; for (int i = 0; i < 3; i++) { printf("nest func, tag: %d, index: %d\n", tag, i); nest_yield(); } } void func() { // 태그로 사용할 난수 생성 int tag = rand() % 100; for (int i = 0; i < 3; i++) { printf("func, tag: %d, index: %d\n", tag, i); yield(); } } int main() { MAIN_CTX = init_ctx((char *) main); // nest()가 중첩된 함수 내에서 일시 중단될 수 있음을 보여줍니다. NEST_CTX = init_ctx((char *) nest); // 동일한 함수가 다른 스택 프레임에서 실행될 수 있음을 보여줍니다. FUNC_CTX_1 = init_ctx((char *) func); FUNC_CTX_2 = init_ctx((char *) func); int tag = rand() % 100; for (int i = 0; i < 3; i++) { printf("main, tag: %d, index: %d\n", tag, i); yield(); } free(MAIN_CTX - CTX_SIZE); free(NEST_CTX - CTX_SIZE); free(FUNC_CTX_1 - CTX_SIZE); free(FUNC_CTX_2 - CTX_SIZE); return 0; }
gcc -m32 stackful.c stackful.s
를 사용하여 컴파일하고 ./a.out
를 실행합니다. 실행 결과 nest()
함수가 실제로 중첩된 함수 내에서 일시 중단될 수 있으며 동일한 함수가 여러 번 호출될 때 다른 스택 프레임 공간에서 실행됨을 보여줍니다.
3. 스택리스 코루틴의 구현 및 원리
스택 프레임을 직접 전환하는 스택풀 코루틴과 달리 스택리스 코루틴은 함수 호출 스택을 변경하지 않고 제너레이터와 유사한 방식으로 컨텍스트 전환을 구현합니다.
스택리스 코루틴은 함수 호출 스택을 변경하지 않기 때문에 중첩된 함수에서 코루틴을 거의 일시 중단할 수 없습니다. 그러나 스택 프레임을 전환할 필요가 없기 때문에 스택리스 코루틴은 일반적으로 스택풀 코루틴보다 성능이 더 높습니다. 또한 위의 문서에서 coroutine.h
에서 볼 수 있듯이 작성자는 C 언어 매크로를 통해 코루틴의 모든 변수를 구조체로 캡슐화하고 이 구조체에 대한 메모리 공간을 할당하여 메모리 낭비를 방지합니다. 이는 스택풀 코루틴으로는 달성하기 어렵습니다.
4. Rust 및 C++의 코루틴(스택리스 코루틴)
4.1 Rust의 코루틴
Rust는 async
및 await
키워드를 통해 비동기 프로그래밍을 지원하며 이는 본질적으로 스택리스 코루틴입니다. Rust의 비동기 런타임(예: Tokio)은 이러한 코루틴을 스케줄링하고 관리합니다. 예를 들어:
async fn fetch_data() -> Result<String, reqwest::Error> { let client = reqwest::Client::new(); let response = client.get("https://example.com").send().await?; response.text().await }
Rust에서 async
함수는 Future
트레이트를 구현하는 객체를 반환하고 await
키워드는 현재 코루틴을 일시 중단하고 Future
가 완료될 때까지 기다리는 데 사용됩니다.
4.2 C++의 코루틴
C++20은 코루틴에 대한 지원을 도입하고 co_await
, co_yield
및 co_return
과 같은 키워드를 통해 코루틴 함수를 구현했습니다. C++의 코루틴 모델은 더 유연하며 필요에 따라 스택풀 또는 스택리스 코루틴을 구현할 수 있습니다. 예를 들어:
#include <iostream> #include <experimental/coroutine> struct task { struct promise_type { task get_return_object() { return task{this}; } auto initial_suspend() { return std::experimental::suspend_always{}; } auto final_suspend() noexcept { return std::experimental::suspend_always{}; } void return_void() {} void unhandled_exception() {} }; task(promise_type* p) : coro(std::experimental::coroutine_handle<promise_type>::from_promise(*p)) {} ~task() { coro.destroy(); } void resume() { coro.resume(); } bool done() { return coro.done(); } private: std::experimental::coroutine_handle<promise_type> coro; }; task async_function() { std::cout << "Start" << std::endl; co_await std::experimental::suspend_always{}; std::cout << "Resume" << std::endl; }
5. 결론
스택풀 및 스택리스 코루틴에 대한 심층 분석을 통해 기본 구현에 대한 명확한 이해를 얻었습니다. 스택풀 및 스택리스 코루틴은 컨텍스트 저장 메커니즘에 따라 이름이 지정되었지만, 본질적인 차이점은 중첩된 함수에서 일시 중단될 수 있는지 여부에 있습니다. 이러한 차이점으로 인해 스택풀 코루틴은 일시 중단될 때 더 높은 자유도를 가지며 기존 동기 코드와 호환되는 데 더 편리합니다. 반면 스택리스 코루틴은 일시 중단 자유도가 제한적이지만 더 높은 성능과 더 나은 메모리 관리 기능을 제공합니다. 실제 응용 프로그램에서는 특정 요구 사항에 따라 적절한 유형의 코루틴을 선택해야 합니다.
Leapcell: 최고의 서버리스 웹 호스팅
마지막으로 Go/Rust 서비스를 배포하는 데 가장 적합한 플랫폼인 **Leapcell**을 추천하고 싶습니다.
🚀 좋아하는 언어로 빌드
JavaScript, Python, Go 또는 Rust로 쉽게 개발하십시오.
🌍 무료로 무제한 프로젝트 배포
사용한 만큼만 지불하십시오. 요청도 없고 요금도 없습니다.
⚡ 사용량에 따라 지불, 숨겨진 비용 없음
유휴 요금이 없고 원활한 확장성만 있습니다.
🔹 Twitter에서 팔로우하십시오: @LeapcellHQ