러스트의 코루틴 개념을 공부하다가
스레드의 스택은 메모리 레이아웃에서 stack이 아니라는 사실을 알게 되서,
찾아본 내용을 클로드로 정리한 글, 그래도 참고한 자료들이 많아서 신뢰성이 없진 않다고 봄.
| 구분 | 메인 스레드 | 생성된 스레드 (pthread) |
|---|---|---|
| 스택 할당 | 커널 (execve 시) | pthread (mmap) |
| 스택 관리 | 커널 (VM_GROWSDOWN) | pthread (고정 크기) |
| 크기 | 동적 확장 가능 | 고정 (생성 시 결정) |
| 최대 크기 | RLIMIT_STACK (기본 8MB) | pthread_attr_setstacksize()로 지정 |
| 위치 | 주소 공간 상단 | mmap 영역 (중간) |
| /proc/PID/maps | [stack] 레이블 |
레이블 없음 (익명 매핑) |
메인 스레드만 동적으로 스택이 확장된다:
"A thread's stack size is fixed at the time of thread creation. Only the main thread can dynamically grow its stack." — pthread_attr_setstacksize(3) man page1
동작 방식:
- 스택 포인터가 매핑 안 된 영역에 접근
- Page fault 발생
- 커널이 "스택 확장"으로 인식 (VM_GROWSDOWN 플래그)
- 새 페이지 할당
생성된 스레드는 mmap으로 할당된 고정 크기 영역이라 이 메커니즘이 적용되지 않는다.
시스템콜처럼 커널에서 제공하는 게 아니다. 시스템콜 호출과 연관된 작업(스택 공간 할당 및 관리)을 수행하는 POSIX 표준 함수이다.
pthread는 스레드 실행 중에는 아무것도 안 한다. 생성/종료/동기화 시점에만 작동:
pthread_create():
├── mmap()으로 스택 할당
├── mprotect()로 guard page 설정
├── TLS(Thread Local Storage) 초기화
└── clone() 시스템콜 호출
스레드 실행 중:
└── pthread 코드 실행 안 됨 (커널이 스케줄링)
pthread_exit():
├── cleanup handler 실행
├── TLS 소멸자 호출
├── 스택 munmap()
└── exit() 시스템콜
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 64 * 1024); // 64KB
pthread_create(&thread, &attr, func, NULL);기본값은 glibc가 RLIMIT_STACK을 참조해 결정한다 (일반적으로 8MB). 정확한 값은 pthread_attr_getstacksize()로 확인.
pthread는 편의 라이브러리일 뿐, 직접 시스템콜로 스레드를 만들 수 있다.2
컨텍스트 스위칭은 100% 커널이 한다. pthread는 관여하지 않는다.
커널이 저장/복원하는 것:
- 범용 레지스터 (RAX, RBX, ... R15)
- 스택 포인터 (RSP)
- 명령어 포인터 (RIP)
- 플래그 (RFLAGS)
- 세그먼트 레지스터 (FS, GS) — TLS 포인터 포함
- FPU/SSE/AVX 레지스터
pthread가 하는 일은 생성 시 초기 설정과 동기화 함수 래핑뿐이다.
__thread int my_errno; // 각 스레드가 독립적인 복사본 가짐- FS 레지스터가 TLS 베이스 주소를 가리킴
- 컨텍스트 스위칭 시 커널이 FS 레지스터를 저장/복원
- TLS 메모리 영역 할당과 초기화는 pthread가 담당
동기화는 프로그래머가 명시적으로 요청할 때만 발생한다.
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock); // 경합 시 futex()로 sleep
counter++;
pthread_mutex_unlock(&lock); // 대기자 있으면 futex()로 wake내부 동작:
- 경합 없음: atomic CAS로 유저 공간에서 처리 (커널 개입 없음)
- 경합 있음:
futex()시스템콜로 커널이 sleep/wake 처리
atomic_fetch_add(&counter, 1); // CPU 명령어 하나 (lock xadd)커널 개입 없이 하드웨어가 보장.
| 동기화 방식 | 유저 공간 | 커널 |
|---|---|---|
| Atomic 연산 | CPU 명령어 | 관여 안 함 |
| Mutex (경합 없음) | atomic CAS | 관여 안 함 |
| Mutex (경합 있음) | futex() 호출 | sleep/wake |
High Address (0x7FFFFFFFFFFF)
┌─────────────────────┐
│ Main Thread Stack │ [stack] - kernel managed, dynamic growth
│ grows down │
├─────────────────────┤
│ (guard gap) │ 기본 1MB, 부팅 시 변경 가능
├─────────────────────┤
│ │
│ mmap region │ grows down from below stack
│ - shared libs │ (.so files)
│ - thread stacks │ (allocated by pthread)
│ - large malloc │ (> MMAP_THRESHOLD)
│ - file mappings │
│ - shared memory │
│ │
│ │
│ (free space) │ ← 64-bit: 약 127TB
│ │
│ │
├─────────────────────┤
│ grows up │
│ Heap (brk) │ managed by malloc internally
├─────────────────────┤
│ BSS │ uninitialized globals
├─────────────────────┤
│ Data │ initialized globals
├─────────────────────┤
│ Text │ program code
└─────────────────────┘
Low Address (0x400000)
| Heap (brk) | mmap | |
|---|---|---|
| 시스템콜 | brk() |
mmap() |
| 위치 | Data/BSS 바로 위 | 주소 공간 중간-상단 |
| 성장 방향 | 위로 (↑) | 아래로 (↓) |
| 할당 | brk 포인터를 위로 이동 | 가상 주소 공간 어디든 가능 |
| 반환 | 맨 위가 비어야만 brk를 내릴 수 있음 | munmap()으로 즉시 OS에 반환 가능 |
| 관리 | malloc (유저 공간) | 커널 |
| 용도 | 작은 동적 할당 | 큰 할당, 파일 매핑, 스레드 스택 등 |
brk()는 heap의 끝 주소를 이동하는 시스템콜:
sbrk(4096); // heap 4KB 확장 (바이트 값을 받지만 실제론 페이지 단위로 올림)malloc은 brk()로 받은 영역을 내부적으로 쪼개서 관리:
Heap from brk():
┌────┬────┬────┬────┐
│ 32 │FREE│ 64 │USED│
└────┴────┴────┴────┘
↑
reused via free list
큰 할당 (기본 128KB 이상)은 mmap으로 처리해서 munmap()으로 바로 OS에 반환 가능.
malloc 또한 시스템콜이 아니라 C 표준 라이브러리 함수이며, 내부에 메모리 관리 알고리즘이 들어간다. 때문에 glibc의 기본 구현(ptmalloc2) 외에도 jemalloc, tcmalloc, mimalloc 등 더 나은 성능이나 메모리 효율을 제공하는 대체 구현체들이 존재한다.3
2004년 Ingo Molnar 패치로 도입4:
- 이전: heap(↑)과 mmap(↑)이 같은 방향 → 충돌 위험
- 이후: heap(↑)과 mmap(↓)이 반대 방향 → 가운데서 만남
64비트에서는 사이 공간이 약 127TB라 사실상 충돌 불가능. 만약 공간이 부족해지면 mmap() 호출 시 ENOMEM 에러가 반환된다.
32비트 환경에서는 물리 메모리가 아닌 가상 주소 공간 자체가 고갈되는 문제가 있었다.5
| 32비트 | 64비트 | |
|---|---|---|
| 가상 주소 공간 | 4GB (고정) | 약 128TB |
| 스레드 스택 8MB 기준 | 약 500개 생성 가능 | 사실상 무제한 |
| 주소 공간 고갈 | 현실적 문제 | 사실상 불가능 |
32비트에서는 주소 공간이 4GB로 고정되어 있어서, 실제로 메모리를 안 쓰더라도 "예약"만으로 주소 공간을 다 써버리면 mmap()이 연속된 8MB 영역을 찾지 못해 실패한다. 64비트에서는 128TB 주소 공간에서 8MB씩 예약하는 건 사실상 무한대라 이 문제가 없다.
| 대상 | 방식 | 크기 |
|---|---|---|
| 메인 스택 | Guard gap | 기본 1MB (커널 4.12+) |
| pthread 스택 | Guard page | 기본 4KB (PROT_NONE) |
메인 스택은 동적 확장이 필요해서 guard page가 아닌 guard gap 사용.6
메인 스택의 guard gap 크기는 부팅 시 커널 파라미터로 변경 가능하다:
# 페이지 단위로 지정 (예: 512페이지 = 2MB)
stack_guard_gap=512pthread 스택의 guard 크기는 pthread_attr_setguardsize()로 스레드 생성 전에 설정 가능.
heap, stack, mmap 영역의 시작 주소가 랜덤화됨.
# 확인
$ cat /proc/sys/kernel/randomize_va_space
2 # 0=off, 1=stack/mmap만, 2=전체| 분류 | 예시 | 특징 |
|---|---|---|
| ISO C 표준 | malloc, free, printf | 언어 표준, 모든 C 구현에 존재 |
| POSIX 표준 | pthread_create, pthread_mutex_lock | OS 인터페이스 표준, Unix 계열 |
| 시스템콜 | clone, brk, mmap, futex | OS마다 다름, 커널 직접 호출 |
Linux, macOS, BSD 모두 POSIX 표준 함수를 제공하지만 내부 시스템콜은 다르다:
pthread_create()
├── Linux: clone()
├── macOS: bsdthread_create()
└── FreeBSD: thr_new()
- 메인 스레드 스택: 커널이 관리, 동적 확장, RLIMIT_STACK 제한7
- 생성된 스레드 스택: pthread가 mmap으로 할당, 고정 크기
- pthread: 시스템콜 래퍼 + 편의 기능 (TLS, cancellation 등), 없어도 직접 clone() 가능2
- 컨텍스트 스위칭: 커널이 전담, pthread는 관여 안 함
- 동기화: 경합 없으면 유저 공간, 경합 있으면 커널 (futex)
- Heap: brk()로 확장, malloc이 내부 관리
- mmap: 커널이 빈 자리에 매핑, 큰 할당/파일/스레드 스택 등에 사용
- 메모리 레이아웃: heap(↑)과 mmap(↓)이 반대 방향으로 성장
이 섹션은 CMCDragonkai의 "Understanding the Memory Layout of Linux Executables"를 기반으로 핵심 내용을 요약한 것이다.
00400000-00401000 r-xp 00000000 fc:00 1457150 /home/user/program
7f849c31b000-7f849c4d6000 r-xp 00000000 fc:00 1579071 /lib/x86_64-linux-gnu/libc-2.19.so
7fffb5d61000-7fffb5d82000 rw-p 00000000 00:00 0 [stack]
| 필드 | 의미 |
|---|---|
| 주소 범위 | 시작-끝 (left inclusive, right exclusive) |
| 권한 | r(read), w(write), x(execute), p(private)/s(shared) |
| 오프셋 | 파일 매핑 시 파일 내 오프셋 |
| 장치 | major:minor 장치 번호 |
| inode | 파일 inode 번호 |
| 경로 | 파일 경로 또는 [heap], [stack], [vdso] 등 |
참고: mmap - Wikipedia
| File-backed mmap | Anonymous mmap | |
|---|---|---|
| /proc/PID/maps | 파일 경로 표시 | 경로 없음 (00:00 0) |
| 생성 | mmap(fd, ...) |
mmap(..., MAP_ANONYMOUS, -1, ...) |
| 물리 메모리 공유 | 같은 파일 매핑 시 공유 | 불가 |
| 메모리 부족 시 | 버림 (디스크에서 재로드) | swap에 저장 |
| 용도 | 공유 라이브러리 (.so) | 스레드 스택, 큰 malloc |
File-backed의 장점: libc.so를 100개 프로세스가 써도 물리 메모리엔 한 번만 로드됨.
file-backed mmap은 매핑 시점에 전체를 로드하지 않는다:
mmap()→ 가상 주소만 예약 (물리 메모리 할당 없음)- 첫 접근 → page fault → 해당 페이지만 디스크에서 로드
- 이후 접근 → 물리 메모리에서 직접 읽음
"파일에 연결"은 매번 디스크를 읽는 게 아니라, 파일이 백업 저장소 역할을 한다는 의미다.
0x000000000000 ← 사용 안 함 (링커가 0x400000부터 시작하도록 설정)
0x400000 ← ELF 헤더, 프로그램 헤더
0x400720 (예시) ← 실제 진입점 (_start), readelf --file-header로 확인
← .text (실행 코드)
0x600000 ← .data (초기화된 전역 변수)
← .bss (초기화 안 된 전역 변수)
← [heap] - brk()로 확장, 위로 성장
...
← 127TB 빈 공간 (64비트)
...
0x7f... ← mmap 영역 (공유 라이브러리, 스레드 스택, 큰 malloc)
0x7fff... ← [stack] - 아래로 성장
0x7fff... ← [vdso] - 커널 제공 가상 공유 라이브러리
0xffffffffff600000 ← [vsyscall] - 레거시, 고정 주소
| 조건 | 사용 시스템콜 | 특징 |
|---|---|---|
| 작은 할당 (< MMAP_THRESHOLD) | brk()/sbrk() |
힙 영역 연속 확장, [heap]으로 표시 |
| 큰 할당 (≥ MMAP_THRESHOLD, 기본 128KB) | mmap() |
mmap 영역에 할당, 레이블 없음 |
brk() 호출 시 glibc는 기본적으로 128KB(M_TOP_PAD)를 추가로 확보해 시스템콜 횟수를 줄인다:
// 1000바이트 malloc → 실제 brk()는 1000 + 128KB + 페이지 정렬
addr = malloc(1000);
// /proc/PID/maps에서 [heap]이 132KB로 표시됨brk() 힙의 한계: 맨 위 바이트가 해제되지 않으면 힙 크기를 줄일 수 없다. mmap()은 munmap()으로 즉시 반환 가능.
ASLR (Address Space Layout Randomization): 스택, mmap 영역, 힙의 시작 주소를 실행할 때마다 랜덤화. 보안 기능.
$ cat /proc/sys/kernel/randomize_va_space
2 # 0=off, 1=stack/mmap만, 2=전체(heap 포함)PIE (Position Independent Executable): 실행 파일 자체의 주소도 랜덤화. gcc -fPIE -pie로 컴파일.
- 일반 실행 파일: 항상 0x400000에서 시작
- PIE 실행 파일: 매번 다른 주소에서 시작
둘 다 컨텍스트 스위칭 없이 빠른 시스템콜을 제공:
| vsyscall | vdso | |
|---|---|---|
| 주소 | 고정 (0xffffffffff600000) | ASLR 적용 |
| 제공 syscall | gettimeofday, time, getcpu | 동일 + 확장 가능 |
| 상태 | 레거시 (호환성 유지용) | 현재 사용 |
| 도구 | 용도 |
|---|---|
cat /proc/PID/maps |
메모리 영역 확인 |
cat /proc/PID/smaps |
상세 메모리 정보 (RSS, PSS 등) |
pmap -x PID |
사람이 읽기 쉬운 형태로 출력 |
readelf -h |
ELF 헤더, 진입점 확인 |
objdump -d |
디스어셈블 |
strace |
시스템콜 추적 (brk, mmap 등) |
gcore PID |
프로세스 메모리 덤프 |
# 터미널 1: 프로그램 실행 후 getchar()로 대기
$ ./program
Before malloc...
# 터미널 2: 메모리 맵 확인
$ cat /proc/$(pgrep program)/maps | grep heap
# (없음 - malloc 전)
# 터미널 1: Enter 입력 후 malloc 실행
After malloc...
# 터미널 2: 다시 확인
$ cat /proc/$(pgrep program)/maps | grep heap
019a3000-019c4000 rw-p 00000000 00:00 0 [heap]- pthread_attr_setstacksize(3) man page
- getrlimit(2) man page
- Raw Linux Threads via System Calls
- Reorganizing the address space (LWN, 2004)
- Preventing stack guard-page hopping (LWN, 2017)
- The problem of thread creation
- Linux Memory Layout (GitHub Gist)
Footnotes
-
pthread 없이 직접 clone() 시스템콜로 스레드를 생성하는 예제: Raw Linux Threads via System Calls ↩ ↩2
-
jemalloc (FreeBSD, Firefox, Redis 등에서 사용), tcmalloc (Google), mimalloc (Microsoft) 등이 대표적이다. 멀티스레드 환경에서 lock contention 감소, 메모리 단편화 개선 등의 장점이 있다. ↩
-
Flexible mmap layout에 대한 LWN 기사: Reorganizing the address space (2004) ↩
-
32비트 환경에서의 스레드 생성 한계에 대한 분석: The problem of thread creation ↩
-
Stack Clash 취약점과 guard gap 도입에 대한 LWN 기사: Preventing stack guard-page hopping (2017) ↩
-
RLIMIT_STACK은 프로세스 스택의 최대 크기(바이트)를 지정한다. 이 제한에 도달하면 SIGSEGV 시그널이 생성된다. getrlimit(2) man page 참조. ↩