.LAB
RETURN 에서 AWAIT 까지·RETURN 에서 AWAIT 까지 #1

return은 제어의 양도다

·5분 분량
    Control FlowAssemblyCall Stack

값을 돌려주는 것과 제어를 돌려주는 것

return을 수만 번은 썼지만, 이게 실제로 무엇을 하는 건지 진지하게 생각해 본 적은 거의 없었다. 대부분의 설명은 "값을 반환한다"로 끝나는데, void 함수도 return한다. 값이 없는데 뭘 반환하는 걸까.

간단한 C 코드를 하나 보자.

c
int add(int a, int b) {
    return a + b;
}

int main(void) {
    int result = add(1, 2);
    return 0;
}

이 코드가 컴파일되면 기계 수준에서는 다른 일이 벌어진다. gcc -O0으로 뽑은 x86-64 어셈블리를 단순화하면 이런 형태가 된다.

x86-64 레지스터 용어
  • rip -- Instruction Pointer. CPU가 다음에 실행할 명령어의 주소
  • rsp -- Stack Pointer. 스택의 꼭대기 주소
  • rbp -- Base Pointer. 현재 스택 프레임의 기준 주소
  • eax -- 함수 반환값을 담는 범용 레지스터 (32비트)
  • edi, esi -- 함수 호출 시 첫 번째, 두 번째 인자를 전달하는 레지스터
asm
main:
    push  rbp
    mov   rbp, rsp

    mov   edi, 1            ; 첫 번째 인자
    mov   esi, 2            ; 두 번째 인자
    call  add               ; push rip + jmp add
    ; ← ret 이후 여기로 돌아온다
    mov   [rbp-4], eax      ; result = 반환값

    pop   rbp
    ret

add:
    push  rbp
    mov   rbp, rsp

    add   edi, esi          ; a + b
    mov   eax, edi          ; 결과를 eax에 저장

    pop   rbp
    ret                     ; pop rip + jmp rip

여기서 주목할 부분은 callret이다.

  • call add는 두 가지 일을 한다. 다음 명령어의 주소(복귀 주소)를 스택에 push하고, add의 주소로 jmp한다.
  • ret도 두 가지 일을 한다. 스택에서 복귀 주소를 pop하고, 그 주소로 jmp한다.

결국 둘 다 본질은 jump다. call은 "돌아올 곳을 기억해두는 jump"이고, ret은 "기억해둔 곳으로 돌아가는 jump"에 가깝다.

스택의 상태 변화를 그려보면 이 동작이 좀 더 선명해진다.

flowchart LR
  subgraph S1["call add 실행 전"]
    direction TB
    s1a(" "):::empty
    s1b["main의 스택 프레임"]
  end

  subgraph S2["call add 실행 후"]
    direction TB
    s2a["return addr ← rsp"]:::highlight
    s2b["main의 스택 프레임"]
  end

  subgraph S3["ret 실행 후"]
    direction TB
    s3a(" "):::empty
    s3b["main의 스택 프레임"]
  end

  S1 --"call add"--> S2 --"ret"--> S3

  classDef empty fill:none,stroke:none
  classDef highlight fill:#d79921,color:#1d2021,stroke:#d79921

call이 실행되면 복귀 주소가 스택 꼭대기에 쌓이고, ret이 실행되면 그 주소를 꺼내 돌아간다. 값을 돌려주는 건 eax 레지스터에 결과를 넣는 별도의 동작이고, 제어를 돌려주는 건 ret이 담당한다. 이 둘은 처음부터 분리된 동작이었다.

void도 ret한다

void 함수를 보면 이 분리가 더 뚜렷해진다.

c
#include <stdio.h>

void log_msg(const char* msg) {
    printf("%s\n", msg);
}

이 함수의 어셈블리에서도 마지막에는 ret이 있다.

asm
log_msg:
    push  rbp
    mov   rbp, rsp
    ; printf 호출
    pop   rbp
    ret               ; 값은 없지만 ret은 실행된다

eax에 의미 있는 값을 넣지 않지만, ret은 반드시 실행된다. 호출자에게 제어를 돌려줘야 하기 때문이다.

값의 반환은 선택이지만, 제어의 반환은 필수다.

return이라는 키워드가 실제로 하는 일을 들여다보면, 값 전달보다는 제어의 양도에 가까운 동작이었다.

호출 스택이 보장하는 것

함수 호출과 반환의 흐름을 시각화하면 이런 모습이다.

sequenceDiagram
    participant main
    participant add
    main->>add: call add(1, 2) — 프레임 생성
    Note over add: eax = a + b
    add-->>main: ret (return 3) — 프레임 제거
    Note over main: 중단 지점에서 재개

call이 실행되면 새 스택 프레임이 만들어지고, 함수의 매개변수와 지역 변수, 복귀 주소가 이 프레임에 들어간다. ret이 실행되면 프레임이 제거되면서 복귀 주소로 점프한다. 후입선출(LIFO) 구조이므로 가장 최근에 호출된 함수가 가장 먼저 돌아온다.

이 구조가 보장하는 것은 단순하면서도 강력하다. 함수를 호출하면 반드시 돌아온다는 것. 호출자는 피호출자가 끝날 때까지 대기하고, 피호출자가 return하면 호출자는 중단했던 바로 그 지점에서 재개한다. 너무 당연해서 의식하는 사람이 드물지만, A가 B를 호출하면 B가 끝난 뒤 반드시 A로 돌아온다는 이 약속 위에 모든 동기적 프로그래밍이 서 있는 셈이다.

이 약속이 깨지는 순간

그런데 return이 "끝남"을 의미하지 않는 경우가 있다.

c
#include <pthread.h>
#include <stdlib.h>

void* load_worker(void* arg) {
    const char* path = (const char*)arg;
    unsigned char* data = read_file(path);
    process_image(data);
    free(data);
    return NULL;
}

void load_image(const char* path) {
    pthread_t thread;
    pthread_create(&thread, NULL, load_worker, (void*)path);
    // pthread_create는 즉시 return한다
    // 파일 읽기는 다른 스레드에서 진행 중이다
}

load_image가 return한다. 호출 스택 관점에서 이 함수는 끝났고, 프레임은 제거됐으며, 호출자는 다음 줄로 넘어간다. 그런데 pthread_create가 만든 스레드에서는 파일 읽기가 여전히 진행 중이다. read_file이 끝나고 process_image가 호출되는 시점에, 원래의 호출 스택은 이미 사라진 뒤다.

return이 "끝남"을 보장하지 않는 순간

호출자 입장에서는 return을 받았으니 작업이 완료된 것처럼 보이지만, 실제로는 아직 끝나지 않았다. "return하면 끝난다"는 암묵적 계약이 깨진 것이다. 호출 스택은 동기적 흐름, 그러니까 "호출하고 돌아온다"는 관계만 추적할 수 있고, 호출 관계 밖에서 나중에 실행되는 작업은 스택의 관할 밖에 놓인다.

콜백은 이 간극에서 태어났다. 일반적인 호출은 호출자(front)가 피호출자(back)의 주소를 알고 호출하면 끝이다. 그런데 return으로는 끝낼 수 없는 작업이 생기면 상황이 달라진다. 피호출자가 나중에 호출자의 코드를 실행해야 하는 경우가 발생하고, 이를 위해 호출자가 자신의 함수 포인터를 피호출자에게 넘겨둔다. 호출 방향이 역전되는 것이다. call back, 즉 back에서 다시 호출한다는 이름은 이 방향 역전에서 왔다. call과 ret이라는 단순한 한 쌍으로 만들어낸 제어 흐름의 질서가, 호출 스택 밖의 경로를 필요로 하면서 흔들리기 시작하는 지점이기도 하다.


이 글의 핵심

return을 쓸 때 무의식적으로 전제하는 것이 있었다. 함수를 호출하면 끝날 때까지 기다리고, return하면 끝난다는 전제. 수만 번을 쓰면서 한 번도 의심하지 않았던 이 전제는, 비동기라는 맥락 앞에서 더 이상 유효하지 않게 된다. 다음 편에서는 그 "기다린다"는 것의 비용을 본다.