AboutWritten 지나간 발자국 발자국
황현석 | 2003. 06. 23.에 작성되었습니다.

황현석 일지

시스템 프로그래밍 번외 - Kernel Linux Posix VirtualMem 본문

시스템 프로그래밍 번외 - Kernel Linux Posix VirtualMem

Shell Lab 글을 마무리하면서 마지막에 syscall 이야기를 조금 붙였습니다. 처음에는 그냥 fork(), waitpid(), kill() 같은 함수들이 어떻게 커널까지 들어가는지 정리하면 끝이라고 생각했습니다.

 

그런데 syscall을 정리하다 보니, 새로운 공부를 할 수 있었습니다.

POSIX 기준으로 signal handler 안에서 printf를 호출하는 건 안전한 패턴이라고 말하기 어렵다.

 

Shell Lab에서는 sigchld_handler 안에서 printf를 호출하는 식으로 구현했습니다. trace 출력 형식도 맞춰야 했고, 과제 풀이 문맥에서는 그렇게 작성한 코드가 흔합니다. 하지만 그것들은 사실 안티패턴이었습니다. 그리고 문득 궁금해진것은데, printf 가 고작 뭐길래 위험하다는 것인지 궁금해졌습니다.

 

printf도 결국 마지막에는 write syscall을 부를 텐데, 왜 printf는 위험하고 write는 안전하다고 하는 걸까? 버퍼를 안 거치면 정말 안전한 건가? 문장 하나를 쓰는 도중에 signal이 끼어들면 결국 출력이 섞이는 건 똑같은 것 아닌가?

 

이 글은 그 질문을 따라가면서 정리한 글입니다. Shell Lab 본문이 "어떻게 작은 shell을 구현할 것인가"에 가까웠다면, 이번 글은 그 구현의 끝부분에서 만난 async-signal-safe, 재진입성, stdio buffer, write syscall의 경계를 복기하는 글입니다.


1. 처음에는 syscall만 보려고 했습니다

 

Shell Lab에서 쓰는 함수들은 겉으로 보면 전부 평범한 C 함수처럼 보입니다.

pid_t pid = fork();
waitpid(pid, &status, WUNTRACED);
kill(-pid, SIGINT);
sigprocmask(SIG_BLOCK, &mask, &old_mask);

 

그런데 이 함수들은 그냥 라이브러리 내부에서 끝나는 함수가 아닙니다. 일반 프로세스는 유저 모드에서 실행되기 때문에, 자기 마음대로 process table을 고치거나 다른 process group에 signal을 꽂아 넣을 수 없습니다. 그래서 정해진 시스템 콜 경로로 커널에게 요청합니다.

 

 
1

glibc wrapper 호출

fork(), kill(), write() 같은 C 함수 호출이 먼저 보입니다. 이 단계에서는 아직 유저 공간 코드입니다.

 
2

레지스터에 syscall 번호와 인자 배치

x86-64 Linux 기준으로 rax에는 syscall number가 들어가고, rdi, rsi, rdx 같은 레지스터에는 인자가 들어갑니다.

 
3

CPU의 syscall instruction 실행

여기서부터가 진짜 경계입니다. CPU가 유저 모드에서 커널 모드로 전환하고, 커널이 등록해둔 syscall entry로 점프합니다.

4

커널 작업 후 유저 모드 복귀

커널은 process 생성, signal 전달, 파일 쓰기 같은 실제 작업을 처리하고 결과값을 다시 유저 코드에 돌려줍니다.

여기까지는 어느 정도 예상한 흐름이었습니다. 그런데 write()를 같이 보면서, 자연스럽게 출력 함수 쪽으로 시선이 갔습니다. Shell Lab의 handler 안에서는 printf()를 쓰고 있었고, POSIX 문서에서는 handler 안에서 호출해도 되는 함수 목록을 따로 두고 있었습니다. 여기서부터 조금 재미있어졌습니다.


2. POSIX는 signal handler 안에서 아무 함수나 부르지 말라고 합니다

 

signal handler는 평범한 함수처럼 생겼지만, 호출 타이밍이 평범하지 않습니다. 제가 handler()를 직접 호출하는 게 아니라, OS가 "지금 signal이 왔다"면서 기존 실행 흐름을 끊고 handler로 들어옵니다.

 

문제는 이 interrupt 지점이 예쁘게 정해져 있지 않다는 점입니다. 메인 흐름이 printf 내부에서 버퍼 포인터를 바꾸는 중일 수도 있고, malloc 내부에서 heap 구조를 건드리는 중일 수도 있고, 어떤 lock을 잡은 직후일 수도 있습니다.

 

핵심. async-signal-safe라는 말은 "signal handler 안에서 호출해도 안전한 함수"라는 뜻입니다. 반대로 말하면, handler 안에서 아무 함수나 부르면 기존 실행 흐름이 잡고 있던 내부 상태를 다시 건드릴 수 있습니다.

Linux man-pages의 signal-safety(7)는 이 문제를 stdio 예시로 설명합니다. stdio 계열 함수는 buffered I/O를 위해 정적 버퍼와 카운터, 인덱스, 포인터를 관리합니다. 메인 프로그램이 printf를 호출해서 그 내부 상태를 일부만 갱신한 순간 signal handler가 다시 printf를 호출하면, 두 번째 printf는 일관되지 않은 데이터 위에서 동작할 수 있습니다.

 

safe로 지정된 함수

POSIX list

write, read, waitpid, kill, sigprocmask 같은 함수들은 async-signal-safe 목록에 들어갑니다. 이 말은 handler 안에서 호출해도 POSIX가 안전성을 요구한다는 뜻입니다.

safe로 지정되지 않은 함수

stdio / heap

printf, sprintf, malloc, free 같은 함수는 이런 문맥에서 조심해야 합니다. 특히 printf는 보기에는 단순 출력 함수지만, 내부적으로 FILE 객체와 버퍼 상태를 만집니다.

여기서 제가 처음에 했던 질문은 이거였습니다. "그래도 결국 printf도 마지막에는 write syscall을 부르는 것 아닌가? 그럼 뭐가 그렇게 다르지?"


3. printf도 결국 write를 부릅니다. 그런데 그 전에 할 일이 너무 많습니다

 

printf("hello")를 호출한다고 해서 글자가 바로 커널로 들어가는 건 아닙니다. 보통은 유저 공간의 stdio 버퍼에 먼저 들어갑니다. 그리고 버퍼가 차거나, 줄바꿈이 나오거나, fflush가 호출되는 등의 타이밍에 내부적으로 write syscall을 호출합니다.

 

 
1

format 해석

%d, %s 같은 포맷 문자열을 해석하고 실제 출력 문자열을 만듭니다.

 
2

stdio buffer와 FILE 상태 갱신

stdout의 내부 버퍼, 현재 위치 포인터, 남은 공간 카운터 같은 상태를 수정합니다. 이 구간이 signal에 취약한 지점입니다.

 
3

필요하면 write syscall 호출

버퍼를 실제 파일 descriptor로 밀어낼 때 비로소 write(fd, buf, len) 형태의 syscall 경로로 들어갑니다.

4

버퍼 상태 정리 후 반환

write가 끝나면 stdio는 내부 버퍼 상태를 다시 정리하고 printf 호출자에게 돌아갑니다.

그러니까 printfwrite는 같은 층의 함수가 아니었습니다. printf는 유저 공간에서 꽤 많은 상태를 만지는 고수준 함수이고, write는 파일 descriptor에 bytes를 쓰라고 커널에 요청하는 더 낮은 층의 함수입니다.

 

정리. printf도 언젠가는 write syscall을 부릅니다. 하지만 handler 안에서 문제가 되는 건 "마지막에 write를 부르느냐"가 아니라, 그 전에 유저 공간의 공유 버퍼와 lock을 건드리는 과정입니다.


4. 재진입성이 없다는 말이 이제 조금 명확해졌습니다

 

재진입성이 없다는 말은, 아주 투박하게 말하면 함수가 아직 끝나지 않았는데 같은 함수로 다시 들어오면 상태가 꼬일 수 있다는 뜻입니다.

 

이걸 printf로 보면 바로 감이 옵니다. 메인 흐름이 printf를 실행하면서 stdout 내부 버퍼의 포인터를 바꾸고 있습니다. 그런데 바로 그 순간 SIGCHLD가 들어오고, handler 안에서 다시 printf를 호출합니다. 그러면 handler의 printf는 메인 흐름이 건드리다 만 stdout 상태를 다시 만지게 됩니다.

 

/*
 * 실제 glibc 구현이 아니라, 위험한 모양을 보기 위한 개념 코드입니다.
 * 핵심은 stdout 내부 상태를 공유한다는 점입니다.
 */
void rough_printf(const char *s)
{
    lock(stdout);

    while (*s) {
        stdout->buffer[stdout->pos] = *s;
        stdout->pos++;
        s++;

        /*
         * 이 지점에서 SIGCHLD가 들어오고,
         * handler가 다시 printf를 호출하면 같은 stdout 상태를 만집니다.
         */
    }

    write(1, stdout->buffer, stdout->pos);
    stdout->pos = 0;

    unlock(stdout);
}

 

여기서 제가 계속 걸렸던 지점은 "handler 쪽 printf가 먼저 버퍼를 비우고 끝나면, 원래 printf가 다시 이어서 가면 되는 것 아닌가?"였습니다. 그런데 그게 그렇게 깔끔하게 되지 않습니다.

 

현실적인 문제: lock deadlock

stdout lock

CS:APP 보충 문서에서는 printf가 console lock을 잡고 write syscall을 부른 뒤 lock을 푼다고 설명합니다. 메인 흐름의 printf가 lock을 잡은 상태에서 SIGCHLD handler로 들어가고, handler가 다시 printf를 호출하면 같은 lock을 기다리다가 멈출 수 있습니다.

lock이 없다고 해도: 내부 상태 오염

buffer state

lock이 없는 단순한 버퍼라고 해도 문제가 없어지는 건 아닙니다. handler가 같은 버퍼 인덱스와 포인터를 바꾸고 돌아오면, 메인 흐름은 자신이 멈췄던 지점의 가정과 달라진 상태 위에서 계속 실행됩니다. 출력이 깨질 수도 있고, 더 나쁘면 메모리 상태가 망가질 수도 있습니다.

그래서 재진입성이라는 단어가 여기서 실감이 났습니다. "함수가 다시 들어와도 괜찮은가?"는 단순히 같은 코드를 두 번 실행해도 되는지의 문제가 아니었습니다. 그 함수가 내부적으로 공유 상태, lock, heap, static buffer를 만지고 있는지가 핵심이었습니다.


5. write는 안전하지만, 출력이 절대 안 섞인다는 뜻은 아닙니다

 

그럼 write는 왜 안전하다고 할 수 있을까요? write(STDOUT_FILENO, buf, len)는 유저 공간의 stdio 버퍼를 조작하지 않습니다. 호출자는 이미 만들어진 bytes의 주소와 길이를 커널에 넘깁니다.

 

const char msg[] = "child reaped\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);

 

여기서 "안전하다"는 말의 뜻을 좁혀서 봐야 합니다. write가 안전하다는 말은, handler 안에서 호출해도 printf처럼 유저 공간의 stdio 내부 상태를 망가뜨리지 않는다는 뜻에 가깝습니다. 화면에 보이는 문장이 절대 안 섞인다는 뜻은 아닙니다.

 

중요한 구분. async-signal-safe는 "출력이 예쁘게 나온다"가 아니라 "handler 안에서 호출해도 프로그램 내부 상태를 깨뜨리지 않는다"에 더 가깝습니다. write도 signal에 의해 partial write가 될 수 있고, 출력이 시각적으로 섞이는 문제는 별도로 다뤄야 합니다.

예를 들어 메인 흐름이 이미 write로 긴 문장을 출력하고 있었고, 그 사이 handler가 또 write로 짧은 메시지를 출력하면 터미널 출력은 보기 안 좋게 섞일 수 있습니다. 하지만 이것은 printf의 내부 buffer나 lock이 깨지는 문제와는 다른 층의 문제입니다.

 

printf가 위험한 이유

유저 공간의 FILE 구조체, 내부 버퍼, 포인터, lock 같은 공유 상태를 건드립니다. 그 도중 handler에서 다시 들어오면 같은 상태를 다시 건드립니다.

write가 상대적으로 안전한 이유

이미 준비된 bytes를 file descriptor로 쓰라고 커널에 요청합니다. POSIX async-signal-safe 목록에도 들어갑니다. stdio의 내부 버퍼를 다시 타고 들어가지 않습니다.

그래도 남는 문제

출력 순서가 사용자가 보기 좋게 보장되는 건 아닙니다. 또한 write는 partial write나 EINTR도 고려해야 합니다. 안전하다는 말과 출력 UX가 완벽하다는 말은 다릅니다.


6. 그러면 실제로는 어떻게 짜는 게 맞을까

 

Shell Lab trace를 통과하는 코드와, 실제 시스템 프로그램에서 오래 버틸 코드는 기준이 조금 다릅니다. 과제에서는 handler 안에서 바로 상태를 출력하는 구현이 이해하기 쉽고 trace도 맞추기 좋습니다. 하지만 시스템 프로그래밍 관점에서는 handler가 하는 일을 최대한 줄이는 쪽이 좋아보였습니다.

 

/*
 * 가장 단순한 방향:
 * handler 안에서는 flag만 세우고, 실제 출력은 main loop에서 처리합니다.
 */
static volatile sig_atomic_t child_changed = 0;

void sigchld_handler(int sig)
{
    int old_errno = errno;
    child_changed = 1;
    errno = old_errno;
}

int main(void)
{
    while (1) {
        if (child_changed) {
            child_changed = 0;

            /*
             * 여기서는 평범한 실행 흐름이므로 printf를 써도 됩니다.
             * 필요하면 waitpid로 child 상태를 회수하고 출력합니다.
             */
            printf("child state changed\n");
        }
    }
}

 

이 패턴은 handler를 아주 얇게 만듭니다. handler 안에서는 volatile sig_atomic_t 플래그만 바꾸고 바로 나옵니다. 그러면 출력, 메모리 할당, 복잡한 자료구조 수정은 일반 흐름에서 처리할 수 있습니다.

 

그래서 제가 지금 다시 쓴다면, Shell Lab 본문에서는 여전히 과제 trace 기준의 구현을 설명하되, 옆에 "이건 과제 문맥의 구현이고, 실제 프로그램에서는 handler를 더 얇게 만드는 편이 좋다"는 주석을 더 분명하게 달 것 같습니다.


7. syscall을 보다가 오히려 libc의 존재감이 보였습니다

 

처음에는 syscall이 커널로 들어가는 문이라고 생각했습니다. 그건 맞습니다. 그런데 이번에 signal safety를 같이 보면서, 시스템 프로그래밍에서 정말 자주 실수하기 쉬운 지점은 커널과 유저 공간 사이의 층을 뭉뚱그려 보는 것이라고 느꼈습니다.

 

라이브러리 함수

printf처럼 유저 공간에서 format 처리, buffering, lock, 내부 상태 관리를 합니다. 편하지만 내부 동작이 꽤 두껍습니다.

시스템 콜 wrapper

write, waitpid, kill처럼 결국 커널에 요청하는 경로입니다. libc wrapper는 있을 수 있지만, 핵심 작업은 커널 경계 너머에서 일어납니다.

signal handler

언제 끼어들지 모르는 비동기 실행 지점입니다. 그래서 평범한 함수 호출 규칙보다 훨씬 보수적으로 생각해야 합니다.

이 구분이 잡히니까 Shell Lab에서 signal mask를 왜 신경 쓰는지, handler에서 왜 일을 많이 하면 위험한지, printfwrite가 왜 다르게 취급되는지가 한 번에 이어졌습니다.

 

결론은 단순합니다. syscall은 커널로 들어가는 문이고, signal handler는 그 문맥을 비동기로 끊고 들어올 수 있는 특수한 흐름입니다. 그 안에서 유저 공간의 두꺼운 라이브러리 함수를 아무렇지 않게 부르면, 평소에는 안 보이던 내부 lock과 buffer 상태가 갑자기 문제가 됩니다.

 

Shell Lab은 작은 과제처럼 보이지만, 다시 보면 이런 경계들을 계속 건드립니다. process를 만들고, child를 회수하고, signal을 전달하고, signal handler에서 공유 상태를 건드리고, 마지막에는 "이 출력 함수 하나를 handler 안에서 불러도 되는가"까지 내려갑니다. 저는 이런 지점 때문에 이 과제가 재미있었다고 생각합니다.


8. 여기서 진짜 재미있는거

 

쉘랩을 추가로 이해하고 자료들을 정리하는 과정에서 희열을 얻는 미친 학습을 할 수 있었습니다. 처음에는 printf가 왜 signal handler 안에서 위험한지만 보려고 했는데, 그 다음에는 volatile sig_atomic_tsigaltstack까지 이어졌습니다.

 

이 두 개는 Shell Lab 기본 풀이에 반드시 필요한 주제는 아닐 수 있습니다. 그런데 signal을 진짜로 이해하려면 한 번은 밟고 가야 하는 주제처럼 보였습니다. 하나는 "handler와 main 흐름이 변수를 공유할 때 컴파일러가 뭘 할 수 있는가"이고, 다른 하나는 "handler 자체가 어느 stack 위에서 실행되는가"입니다.

8-1. volatile sig_atomic_t: signal이 바꾼 값을 컴파일러가 보게 만들기

 

예를 들어 foreground job을 기다리기 위해 전역 flag 하나를 둔다고 해봅시다. 대충 이런 형태입니다.

#include <signal.h>

static int should_wait = 1;

void sigchld_handler(int sig)
{
    should_wait = 0;
}

void wait_foreground_job(void)
{
    while (should_wait) {
        /*
         * foreground job이 끝날 때까지 기다린다고 생각한 코드입니다.
         */
    }
}

 

사람이 보기에는 SIGCHLD가 오면 handler가 should_wait = 0으로 바꿔주고, 그러면 while문이 끝날 것 같습니다. 그런데 컴파일러 입장에서는 다르게 볼 수 있습니다. wait_foreground_job 함수 안에서는 should_wait를 바꾸는 코드가 없습니다. 그러면 최적화 단계에서 "이 값은 루프 안에서 변하지 않겠네"라고 보고, 값을 메모리에서 매번 다시 읽지 않고 레지스터에 올려둔 채 계속 돌 수 있습니다.

 

핵심. signal handler는 평범한 함수 호출처럼 코드 흐름에 보이지 않습니다. 컴파일러는 현재 함수 안의 코드만 보고 최적화할 수 있으므로, signal handler가 전역 값을 바꿀 수 있다는 사실을 타입으로 알려줘야 합니다.

그래서 signal handler와 main 흐름이 공유하는 단순 flag는 보통 이렇게 씁니다.

#include <signal.h>

static volatile sig_atomic_t should_wait = 1;

void sigchld_handler(int sig)
{
    should_wait = 0;
}

void wait_foreground_job(void)
{
    while (should_wait) {
        /*
         * volatile 때문에 컴파일러는 should_wait를
         * 매번 다시 읽어야 한다고 봅니다.
         */
    }
}

 

volatile

컴파일러에게 "이 값은 네가 보는 코드 밖에서 바뀔 수 있으니, 마음대로 캐싱하지 말고 매번 실제로 읽어라"라고 말하는 쪽에 가깝습니다. signal handler가 바꾸는 flag에서 이 의미가 중요했습니다.

sig_atomic_t

signal handler와 main 흐름 사이에서 원자적으로 읽고 쓸 수 있는 정수 타입입니다. 복잡한 구조체를 handler에서 막 고치는 게 아니라, 단순한 flag 하나만 바꾸는 패턴이 여기서 나옵니다.

다만 이걸 너무 크게 해석하면 안 됩니다. volatile sig_atomic_t는 signal handler와 main 흐름 사이의 작은 flag를 위한 도구에 가깝습니다. 멀티스레드 동기화 전체를 해결해주는 물건도 아니고, 복잡한 job table을 안전하게 공유하게 해주는 마법도 아닙니다. 그래서 handler에서는 flag만 바꾸고, 실제 복잡한 처리는 일반 흐름에서 하는 패턴이 계속 좋아보였습니다.

8-2. sigaltstack: 힙 메모리를 signal handler용 stack처럼 쓰기

 

그 다음으로 재미있었던 건 sigaltstack이었습니다. 일반적으로 signal handler는 현재 스레드의 기본 user stack 위에서 실행됩니다. Linux signal 문서도 기본적으로 handler가 normal process stack에서 호출된다고 설명합니다.

 

그런데 만약 스택이 이미 거의 터지기 직전이면 어떻게 될까요? 무한 재귀를 돌고 있거나, 너무 큰 local buffer를 잡아서 기본 stack이 거의 꽉 찬 상태에서 SIGSEGV나 다른 signal이 들어오면, 커널은 handler 실행에 필요한 signal frame을 또 stack에 올려야 합니다. 이미 stack이 벼랑 끝이면 handler가 시작되기도 전에 더 망가질 수 있습니다.

 

생각이 바뀐 지점. stack은 "반드시 원래 stack segment만 stack이다"라는 느낌보다, CPU의 stack pointer가 가리키는 메모리 영역이라고 보는 쪽이 더 정확해보였습니다. 어떤 메모리든 현재 stack pointer가 그쪽을 가리키고, 함수 호출과 push/pop이 그 영역에 쌓이면 그 순간 그 메모리는 stack처럼 쓰입니다.

sigaltstack은 이 직관을 그대로 씁니다. malloc으로 힙에 메모리 덩어리를 잡고, 그 주소와 크기를 커널에 알려줍니다. 그리고 sigaction에서 SA_ONSTACK을 켜면, 해당 handler는 기본 stack이 아니라 등록해둔 alternate signal stack 위에서 실행됩니다.

#include <signal.h>
#include <stdlib.h>
#include <string.h>

static void segv_handler(int sig)
{
    /*
     * 이 handler는 SA_ONSTACK으로 등록되면
     * alternate signal stack 위에서 실행됩니다.
     */
}

int main(void)
{
    stack_t ss;
    struct sigaction sa;

    ss.ss_sp = malloc(SIGSTKSZ);
    ss.ss_size = SIGSTKSZ;
    ss.ss_flags = 0;
    sigaltstack(&ss, NULL);

    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = segv_handler;
    sa.sa_flags = SA_ONSTACK;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSEGV, &sa, NULL);
}

 

 
1

힙에 비상용 메모리를 잡습니다

malloc(SIGSTKSZ)로 받은 메모리는 원래 힙 영역입니다. 하지만 커널에게 alternate signal stack으로 등록하면 handler 실행 때 stack처럼 쓰일 수 있습니다.

 
2

sigaltstack으로 커널에 위치를 알려줍니다

커널은 이 스레드의 alternate stack 주소와 크기를 기억합니다. man-page 기준으로 ss_sp는 시작 주소, ss_size는 크기입니다.

 
3

SA_ONSTACK handler가 들어오면 그쪽으로 실행합니다

sigactionSA_ONSTACK을 지정해야 실제로 handler가 alternate stack을 씁니다. 등록만 해두고 이 flag를 안 켜면 기본 stack을 씁니다.

4

handler가 끝나면 원래 문맥으로 돌아갑니다

signal return 과정에서 원래 레지스터와 signal mask, stack 관련 문맥이 복원됩니다. 유저 코드 입장에서는 끊겼던 지점으로 돌아온 것처럼 보입니다.

여기서 중요한 건, malloc으로 잡은 메모리를 진짜로 "계속 stack처럼 써도 된다"는 식으로 아무렇게나 만지는 게 아니라는 점입니다. 커널에게 alternate signal stack으로 빌려준 메모리는 그 용도로 살아 있어야 합니다. 등록해놓고 바로 free하면, 나중에 signal이 왔을 때 커널은 여전히 그 주소를 stack으로 쓰려고 할 수 있습니다.

 

주의. alternate signal stack은 자동으로 늘어나지 않습니다. man-page도 할당된 크기를 넘기면 결과를 예측하기 어렵다고 말합니다. 그러니까 이건 무한정 튼튼한 비상구가 아니라, "기본 stack이 망가졌을 때 handler를 시작할 최소한의 발판"에 가깝습니다.

이 부분이 좋았습니다. 처음에는 stack, heap, data segment를 교과서 그림처럼 분리해서 봤는데, sigaltstack을 보면 운영체제가 훨씬 실용적으로 움직입니다. 커널은 "이 주소 범위를 signal handler용 stack으로 써라"라는 약속을 들고 있다가, signal delivery 시점에 그 stack 위로 handler 문맥을 얹습니다. 힙 메모리가 진짜 힙이라는 이름표를 달고 있어도, stack pointer가 그쪽을 가리키고 함수 프레임이 거기에 쌓이면 그 순간에는 stack처럼 일합니다.

 

결국 Shell Lab에서 출발한 질문이 여기까지 왔습니다. printf는 왜 handler 안에서 위험한가, handler가 바꾼 flag는 왜 volatile sig_atomic_t여야 하는가, handler는 기본적으로 어느 stack에서 실행되는가, 기본 stack이 무너지면 어디서 handler를 실행할 수 있는가. 이 정도까지 이어지니까 과제 하나를 다시 복기하는데도 꽤 깊게 내려온 느낌이었습니다.


9. OS는 메모리를 계속 속입니다

 

sigaltstack에서 힙 메모리를 signal handler용 stack처럼 쓰는 구조가 보이니까, 비슷한 생각이 계속 이어졌습니다. 운영체제는 생각보다 자주 이런 식으로 움직입니다. 이름표는 heap, stack, file, child process처럼 붙어 있지만, 실제로는 page table, permission bit, page fault, mapping metadata로 그 이름표를 구현합니다.

 

이걸 조금 과격하게 말하면, OS는 프로그램을 계속 속입니다. 모든 프로세스에게 자기만의 주소 공간이 있는 것처럼 보여주고, fork()에서는 메모리를 복사한 것처럼 보여주고, mmap()에서는 파일이 메모리에 올라온 것처럼 보여줍니다. 그런데 실제로는 거의 다 lazily 처리합니다. 필요해지는 순간까지 진짜 작업을 미룹니다.

 

이번에 잡힌 감각. OS가 메모리를 관리한다는 말은 "RAM을 바로 나눠준다"가 아니라, 먼저 가상 주소 공간에 장부를 만들고, 접근이 발생하면 MMU와 page fault를 통해 그때그때 물리 페이지를 붙인다는 뜻에 가까웠습니다.

9-1. 가상 메모리: 같은 주소를 쓰는데 왜 안 부딪힐까

 

프로세스마다 0x400000, 0x7fff... 같은 주소가 비슷하게 보일 수 있습니다. 같은 프로그램을 여러 개 켜도 내부에서 보는 주소 모양은 비슷합니다. 처음 보면 이상합니다. 같은 주소면 같은 RAM 위치를 가리켜야 할 것 같은데, 실제로는 서로 덮어쓰지 않습니다.

 

이유는 프로세스가 보는 주소가 물리 RAM 주소가 아니라 가상 주소이기 때문입니다. CPU의 MMU는 가상 주소를 물리 주소로 바꿀 때, 현재 프로세스의 page table을 기준으로 번역합니다. 그래서 두 프로세스가 같은 0x400000을 보고 있어도, page table이 다르면 실제 물리 페이지는 전혀 다를 수 있습니다.

 

 
1

프로세스는 가상 주소를 냅니다

코드는 ptr[0] 같은 접근을 하고, CPU는 그 주소를 현재 프로세스의 가상 주소로 봅니다.

 
2

MMU가 page table을 봅니다

같은 가상 주소라도, 현재 프로세스의 page table이 어느 물리 페이지를 가리키는지에 따라 실제 RAM 위치가 달라집니다.

3

각 프로세스는 자기 방 안에 있다고 착각합니다

프로세스 입장에서는 주소 공간 전체가 자기 것처럼 보입니다. 실제 격리와 매핑은 커널과 MMU가 뒤에서 맞춥니다.

Linux kernel 문서를 보면, 유저 공간 메모리 범위는 VMA, 즉 Virtual Memory Area로 추적됩니다. 하나의 VMA는 연속된 가상 주소 범위와 권한, 파일 backing 여부 같은 속성을 담습니다. 그러니까 커널은 "이 프로세스의 어느 주소부터 어느 주소까지는 이런 성질의 메모리다"라는 장부를 들고 있습니다.

9-2. fork와 Copy-on-Write: 복사한 척하고 버티기

 

Shell Lab에서 fork()는 계속 나왔습니다. 설명만 보면 parent의 주소 공간을 복사해서 child를 만든다고 합니다. 그런데 이걸 진짜 byte 단위로 매번 다 복사하면 너무 비쌉니다. parent가 큰 메모리를 들고 있는데 shell이 명령 하나 실행할 때마다 그걸 전부 복사하면 말이 안 됩니다.

 

그래서 Copy-on-Write가 들어갑니다. 처음에는 parent와 child가 같은 물리 페이지를 가리키게 두고, 쓰기 권한을 막아둡니다. 둘 중 하나가 값을 바꾸려고 쓰는 순간 page fault가 나고, 그때 커널이 해당 page를 진짜로 복사합니다.

 

fork 직후

parent와 child는 독립된 프로세스처럼 보이지만, 많은 page는 아직 같은 물리 메모리를 공유할 수 있습니다. page table과 권한 설정으로 "아직 복사하지 않음" 상태를 버팁니다.

누군가 write 시도

읽기 전용으로 막아둔 page에 쓰려고 하면 page fault가 납니다. 이건 커널 입장에서는 "이제 진짜 분리해야 할 때"라는 신호입니다.

그 page만 복사

전체 주소 공간을 복사하는 게 아니라, 실제로 수정하려는 page 단위로만 복사합니다. 자식이 바로 execve()로 다른 프로그램이 되면 대부분의 page는 끝까지 복사되지 않을 수 있습니다.

이게 Shell Lab이랑도 이어집니다. shell은 fork()로 child를 만들고, child는 거의 바로 execve()로 다른 프로그램이 됩니다. 이 흐름에서는 "parent 메모리 전체 복사"를 실제로 다 해버릴 이유가 거의 없습니다. OS가 복사한 척만 하고 버티는 쪽이 훨씬 맞아보였습니다.

9-3. mmap: 파일을 메모리에 올리는 게 아니라, 메모리처럼 보이게 하기

 

mmap()도 같은 계열의 착시였습니다. 처음에는 "파일을 메모리에 올린다"라고 말하기 쉽습니다. 그런데 정확히는 파일 전체를 RAM에 먼저 올리는 게 아니라, 파일의 특정 범위를 프로세스의 가상 주소 공간에 mapping합니다.

char *file_in_memory = mmap(
    NULL,
    file_size,
    PROT_READ,
    MAP_PRIVATE,
    fd,
    0
);

char c = file_in_memory[10000000000ULL];

 

여기서 중요한 조건이 있습니다. file_in_memory[10000000000]이 되려면 그 인덱스가 mmap으로 잡은 길이 안에 있어야 합니다. mapping 밖을 찌르면 그냥 잘못된 주소 접근입니다. 그리고 파일 mapping에서 실제 파일 범위를 넘어선 page를 건드리면 SIGBUS 같은 문제도 날 수 있습니다. "큰 숫자를 넣으면 OS가 알아서 다 해준다"가 아니라, mapping된 가상 주소 범위 안에서만 배열처럼 보이는 것입니다.

 

 
1

mmap은 VMA를 만듭니다

커널은 "이 가상 주소 범위는 이 파일의 이 offset부터 연결된다"는 mapping 정보를 기록합니다. 이 시점에 파일 전체가 RAM으로 올라온다고 보면 안 됩니다.

 
2

처음 접근할 때 page fault가 납니다

MMU가 page table을 봤는데 아직 RAM에 물리 page가 없으면 fault가 납니다. 이것은 실패라기보다, kernel에게 "이제 이 page가 필요하다"라고 알려주는 신호에 가깝습니다.

3

필요한 page 단위로 채웁니다

1byte를 읽더라도 커널은 보통 page 단위로 다룹니다. 8바이트만 딱 가져온다기보다, 그 주소가 포함된 page를 RAM에 붙이고 실패했던 명령을 다시 진행하게 만듭니다.

그러니까 mmap은 파일 읽기를 없애는 마법이라기보다, 파일 I/O를 page fault와 page cache 흐름에 얹는 방식이라고 보는 게 더 맞아보였습니다. 개발자는 배열처럼 읽지만, OS는 뒤에서 page 단위로 필요한 부분만 메모리에 붙입니다.

9-4. malloc도 비슷합니다. VIRT와 RSS가 갈라지는 이유

 

여기서 다시 힙으로 돌아오면, malloc도 비슷한 질문을 던집니다. 힙 공간에도 한계가 있을 텐데, 큰 메모리를 요청했을 때 왜 바로 물리 RAM을 다 먹지 않는 것처럼 보일까요?

 

Linux에서는 큰 할당이 brkmmap 경로로 가상 주소 공간을 확보하는 식으로 처리될 수 있습니다. 이때 중요한 건, 가상 주소 공간에 잡혔다고 해서 그 크기만큼 물리 RAM page가 즉시 붙는 건 아니라는 점입니다. 실제로 읽거나 쓰는 순간 page fault가 나고, 그때 물리 page가 붙습니다.

char *p = malloc(10ULL * 1024 * 1024 * 1024);

/*
 * 이 시점에는 큰 가상 주소 범위를 받은 것에 가깝습니다.
 * 실제 물리 page는 아직 대부분 붙지 않았을 수 있습니다.
 */

p[0] = 'A';          // 이 주소가 포함된 page가 실제로 필요해집니다.
p[4096] = 'B';       // 다음 page가 필요해집니다.

 

VmSize / VIRT

프로세스가 가진 가상 메모리 크기입니다. 큰 mmap이나 아직 다 만지지 않은 큰 heap 예약도 여기에 크게 잡힐 수 있습니다.

VmRSS / RES

실제로 RAM에 올라와 있는 resident memory 쪽입니다. 아직 접근하지 않은 가상 주소 범위는 VIRT에는 크게 보이지만, RSS에는 바로 그만큼 잡히지 않을 수 있습니다.

다만 이것도 무한정 공짜는 아닙니다. 커널 metadata, VMA, page table 같은 비용은 있고, overcommit 정책이나 RLIMIT에 따라 malloc이나 mmap 자체가 실패할 수도 있습니다. 그리고 실제로 그 거대한 영역을 전부 쓰기 시작하면 결국 물리 RAM과 swap이 필요해집니다. 거기서도 감당이 안 되면 OOM 상황으로 갑니다.

 

정리. 아직 안 쓴 메모리는 실제로 안 쓴 것에 가깝습니다. 가상 주소 공간에는 예약되어 있어도, 물리 page는 첫 접근 시점에 붙을 수 있습니다. 이게 mmap, 큰 heap 할당, COW를 한 줄로 이어주는 감각이었습니다.

9-5. malloc은 syscall인가? page fault는 누가 판정하는가?

 

여기서 malloc을 다시 보면 질문이 조금 더 정확해집니다. malloc은 시스템 콜 그 자체가 아닙니다. 유저 공간의 allocator입니다. 다만 allocator가 들고 있던 공간이 부족하면 커널에게 더 큰 가상 메모리 범위를 달라고 요청할 수 있습니다. glibc malloc 문서도 일반적으로 heap을 쓰고 필요하면 sbrk 계열로 heap 크기를 조정하며, 큰 할당은 private anonymous mmap을 쓴다고 설명합니다.

 

 
1

malloc은 먼저 자기 장부를 봅니다

이미 확보해둔 arena나 free list에서 잘라줄 수 있으면 유저 공간 계산만으로 끝납니다. 이 경우 매번 syscall을 부르는 구조가 아닙니다.

 
2

공간이 부족하면 brk/sbrk 또는 mmap으로 커널에 요청합니다

CS:APP의 mem_sbrk 같은 이름은 이 흐름을 학습용으로 추상화한 느낌이고, Linux에서는 brk/sbrkmmap 계열이 실제 경계에 더 가깝습니다.

 
3

그 범위 안은 합법적인 가상 주소가 됩니다

커널 장부에는 이 주소 범위가 heap이나 anonymous mapping으로 잡힙니다. 아직 물리 RAM이 붙지 않았더라도, 주소 범위 자체는 합법입니다.

4

처음 실제 접근에서 page fault가 날 수 있습니다

MMU가 아직 present하지 않은 page를 만나면 fault를 냅니다. 커널이 VMA를 보고 합법적인 범위면 물리 page를 붙이고, 실패했던 명령을 다시 진행하게 만듭니다.

반대로 그 주소가 heap도 아니고, stack도 아니고, 어떤 VMA에도 속하지 않으면 다른 결론이 납니다. 이때도 시작은 page fault입니다. CPU는 "이 주소를 물리 주소로 번역할 수 없다"는 사실만 보고 커널로 넘깁니다. 그 다음 커널이 장부를 보고 "합법적인 지연 할당인가, 아니면 그냥 잘못된 주소 접근인가"를 판정합니다.

 

합법적인 page fault

주소가 VMA 안에 있고 권한도 맞습니다. 아직 물리 page만 없었던 상태입니다. 커널은 새 page를 붙이거나 파일에서 읽어오고, 프로그램은 계속 실행됩니다.

불법적인 page fault

주소가 어떤 VMA에도 없거나, 쓰기 금지 page에 쓰는 것처럼 권한이 맞지 않습니다. 이 경우 커널은 보통 프로세스에 SIGSEGV를 보냅니다. SIGINT가 아니라 segmentation fault 쪽입니다.

그럼 이 검사는 누가 하는가? 어셈블리 코드에 if (address_is_valid) 같은 검사문이 박혀 있는 건 아닙니다. 예를 들어 mov [rax], 1 같은 명령은 그냥 메모리에 쓰려고 합니다. 그런데 그 메모리 접근이 실제 RAM으로 나가기 전에, CPU 안의 MMU가 현재 활성화된 page table을 통해 가상 주소를 물리 주소로 번역합니다.

 

핵심. OS는 page table을 만들고, CPU는 그 page table을 하드웨어적으로 참조합니다. x86 계열에서는 현재 page table의 최상위 구조를 가리키는 값이 CR3 같은 제어 레지스터에 들어갑니다. context switch 때 이 기준이 바뀌므로, 같은 가상 주소라도 프로세스마다 다른 물리 주소로 번역될 수 있습니다.

그래서 "OS는 장부만 주고 끝인데, 체크를 안 하면 엉뚱한 곳에 쓰는 것 아닌가?"라는 질문의 답은 이렇습니다. OS가 장부를 만들고 CPU에게 현재 장부의 위치를 알려줍니다. 그 다음부터 모든 메모리 접근은 MMU의 번역 경로를 지나갑니다. 매번 소프트웨어 if문을 넣는 게 아니라, 하드웨어 경로 자체가 page table 규칙을 통과해야 물리 RAM으로 나갈 수 있게 되어 있습니다.

 

결론

OS는 RAM을 단순히 나눠주는 관리자가 아니라, 가상 주소와 page fault를 이용해서 프로그램에게 꽤 그럴듯한 세계를 보여주는 시스템입니다. 프로그램은 "내 메모리", "내 파일", "내 child 복사본"이라고 생각하지만, 커널은 그 뒤에서 page table과 mapping 장부를 들고 최대한 늦게, 최대한 필요한 만큼만 진짜 일을 합니다.

 

저는 그동안 구간 Lazy 쿼리 업데이트 문제를 굉장히 많이 풀었습니다. segment tree에서 값을 바로 끝까지 밀어 넣지 않고, 나중에 정말 필요해지는 순간까지 미뤄두는 그 방식이 처음에는 그냥 알고리즘 테크닉처럼 보였습니다. 그런데 OS의 메모리 관리도 비슷한 냄새가 났습니다. fork도, mmap도, 큰 heap 할당도, 전부 일단 약속만 해두고 실제 비용은 필요한 순간까지 미뤄둡니다.

 

맞습니다. 세상에는 Lazy하게 풀어나가야 할 문제들이 굉장히 많은 것 같습니다. 지금 당장 모든 걸 계산하고, 복사하고, 확정하려고 하면 버티지 못하는 문제들이 있습니다. 그래서 똑똑한 시스템은 먼저 장부를 만들고, 약속을 걸고, 경계를 세운 다음, 진짜 사건이 발생한 순간에만 움직입니다. 저는 이 지점이 꽤 좋았습니다. 알고리즘 문제에서 보던 lazy propagation이 운영체제 안에서 훨씬 거대한 형태로 다시 나타난 느낌이었습니다. 그냥 뭔가 아귀가 맞는 느낌이었습니다. 찔러왔던 다양한 길이 무언가를 이해하는데 종합적으로 도움을 주는 느낌입니다.


참고한 문서