
return은 제어의 양도다
- Control FlowAssemblyCall Stack
값을 돌려주는 것과 제어를 돌려주는 것
return을 수만 번은 썼지만, 이게 실제로 무엇을 하는 건지 진지하게 생각해 본 적은 거의 없었다. 대부분의 설명은 "값을 반환한다"로 끝나는데, void 함수도 return한다. 값이 없는데 뭘 반환하는 걸까.
간단한 C 코드를 하나 보자.
int add(int a, int b) {
return a + b;
}
int main(void) {
int result = add(1, 2);
return 0;
}이 코드가 컴파일되면 기계 수준에서는 다른 일이 벌어진다. gcc -O0으로 뽑은 x86-64 어셈블리를 단순화하면 이런 형태가 된다.
rip-- Instruction Pointer. CPU가 다음에 실행할 명령어의 주소rsp-- Stack Pointer. 스택의 꼭대기 주소rbp-- Base Pointer. 현재 스택 프레임의 기준 주소eax-- 함수 반환값을 담는 범용 레지스터 (32비트)edi,esi-- 함수 호출 시 첫 번째, 두 번째 인자를 전달하는 레지스터
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여기서 주목할 부분은 call과 ret이다.
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:#d79921call이 실행되면 복귀 주소가 스택 꼭대기에 쌓이고, ret이 실행되면 그 주소를 꺼내 돌아간다. 값을 돌려주는 건 eax 레지스터에 결과를 넣는 별도의 동작이고, 제어를 돌려주는 건 ret이 담당한다. 이 둘은 처음부터 분리된 동작이었다.
void도 ret한다
void 함수를 보면 이 분리가 더 뚜렷해진다.
#include <stdio.h>
void log_msg(const char* msg) {
printf("%s\n", msg);
}이 함수의 어셈블리에서도 마지막에는 ret이 있다.
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이 "끝남"을 의미하지 않는 경우가 있다.
#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하면 끝난다"는 암묵적 계약이 깨진 것이다. 호출 스택은 동기적 흐름, 그러니까 "호출하고 돌아온다"는 관계만 추적할 수 있고, 호출 관계 밖에서 나중에 실행되는 작업은 스택의 관할 밖에 놓인다.
콜백은 이 간극에서 태어났다. 일반적인 호출은 호출자(front)가 피호출자(back)의 주소를 알고 호출하면 끝이다. 그런데 return으로는 끝낼 수 없는 작업이 생기면 상황이 달라진다. 피호출자가 나중에 호출자의 코드를 실행해야 하는 경우가 발생하고, 이를 위해 호출자가 자신의 함수 포인터를 피호출자에게 넘겨둔다. 호출 방향이 역전되는 것이다. call back, 즉 back에서 다시 호출한다는 이름은 이 방향 역전에서 왔다. call과 ret이라는 단순한 한 쌍으로 만들어낸 제어 흐름의 질서가, 호출 스택 밖의 경로를 필요로 하면서 흔들리기 시작하는 지점이기도 하다.
return을 쓸 때 무의식적으로 전제하는 것이 있었다. 함수를 호출하면 끝날 때까지 기다리고, return하면 끝난다는 전제. 수만 번을 쓰면서 한 번도 의심하지 않았던 이 전제는, 비동기라는 맥락 앞에서 더 이상 유효하지 않게 된다. 다음 편에서는 그 "기다린다"는 것의 비용을 본다.