
return은 제어의 양도다
- Control Flow
- Assembly
- Call Stack
값이 아니라 제어다
return을 수만 번은 썼다. 습관처럼 쓰면서도 이게 실제로 무엇을 하는지 생각해 본 적은 거의 없었다. 대부분의 설명은 "값을 반환한다"로 끝난다. 그런데 void 함수도 return한다. 값이 없는데 뭘 반환하는 걸까.
간단한 C# 코드를 하나 보자.
int Add(int a, int b)
{
return a + b;
}
void Main()
{
int result = Add(1, 2);
}이 코드가 실제로 실행될 때 기계 수준에서 일어나는 일은 이렇다. x86 어셈블리를 단순화했다.
; Main
push 2
push 1
call Add ; 제어를 Add에 양도, 복귀 주소를 스택에 저장
; <- return 후 여기로 돌아온다
Add:
mov eax, [esp+4]
add eax, [esp+8]
ret ; 저장된 복귀 주소로 점프call 명령어가 하는 일은 두 가지다. 다음에 실행할 명령어의 주소를 스택에 저장하고, 대상 함수의 주소로 점프한다. ret 명령어는 스택에서 저장된 주소를 꺼내 그 위치로 점프한다. return이라는 키워드의 본체는 결국 ret, 저장된 주소로의 점프다. 값을 돌려주는 건 eax 레지스터에 결과를 넣는 별도의 동작이고, 제어를 돌려주는 건 ret이 한다. 두 동작은 분리되어 있다.
void도 ret한다
void 함수를 보면 이 분리가 더 명확해진다.
void Log(string msg)
{
Console.WriteLine(msg);
}이 함수의 어셈블리에서도 마지막에는 ret이 있다.
Log:
; Console.WriteLine 호출
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이 "끝남"을 의미하지 않는 경우가 있다.
void LoadImage(string path)
{
Task.Run(() => {
var data = File.ReadAllBytes(path);
ProcessImage(data);
});
// Task.Run은 즉시 return한다
// 파일 읽기는 다른 스레드에서 진행 중이다
}LoadImage가 return한다. 호출 스택 관점에서 이 함수는 끝났다. 프레임은 제거됐고, 호출자는 다음 줄을 실행한다. 그런데 Task.Run이 스레드풀에 넘긴 작업은 여전히 진행 중이다. 파일 읽기가 끝나고 ProcessImage가 호출되는 시점에, 원래의 호출 스택은 이미 존재하지 않는다.
호출자 입장에서 return을 받았으니 작업이 완료된 것처럼 보인다. 하지만 실제로는 완료되지 않았다. "return하면 끝난다"는 계약이 깨진 것이다. 호출 스택은 "호출하고 돌아온다"는 동기적 흐름만 추적할 수 있고, 호출 관계 밖에서 나중에 실행될 콜백은 스택의 관할 밖이다.
콜백은 이 간극에서 태어났다. return으로 끝낼 수 없는 작업의 완료를 통보하기 위해, 호출 스택이 아닌 별도의 경로가 필요해졌다. call/ret이라는 단순한 쌍으로 구축한 제어 흐름의 질서가 흔들리기 시작하는 지점이다.
return을 쓸 때 무의식적으로 전제하는 것이 있었다. 함수를 호출하면 끝날 때까지 기다리고, return하면 끝난다는 전제. 수백 번을 쓰면서 한 번도 의심하지 않았던 이 전제는, 비동기라는 맥락을 만나는 순간 더 이상 유효하지 않게 된다. 다음 편에서는 그 "기다린다"는 것의 비용을 본다.