스레드 — 한 프로세스 안의 여러 손
앞 편에서 프로세스 전환은 주소 공간·TLB를 갈아 비싸다고 했다. 그런데 한 프로그램 안에서도 여러 일을 동시에 하고 싶을 때가 많다 — 웹 서버가 여러 요청을 한꺼번에, 브라우저가 렌더링하면서 다운로드. 매번 무거운 프로세스를 만들 순 없다. 그래서 한 프로세스 안에 여러 실행 흐름을 두는 게 **스레드**다. 스레드는 같은 집(프로세스)에 사는 여러 일꾼 같아서 — 빠르게 전환되고 자원을 나눠 쓰지만, 바로 그 공유 때문에 동시성의 모든 함정이 시작된다.
무엇을 나누고 무엇을 따로 갖나
스레드는 같은 프로세스의 주소 공간 안에서 산다. 그래서 무엇을 공유하고 무엇을 따로 갖는지가 핵심이다.
코드·전역 데이터·힙(동적 할당 객체가 사는 곳)은 모든 스레드가 공유하고, *스택·레지스터·프로그램 카운터(PC)*만 스레드마다 따로 갖는다. 그래서 한 스레드가 만든 데이터를 다른 스레드가 곧바로 본다 — 통신이 공짜다. 하지만 바로 이 공유가 — "공유 가변 상태" 버그의 뿌리다. 두 스레드가 같은 힙 변수를 동시에 수정하면 데이터가 깨진다(다음 동시성 편). 즉 스레드의 *축복(쉬운 공유)*과 *저주(경쟁 조건)*는 같은 동전의 양면이다.
전환이 싸다 — 스레드의 가장 큰 장점
스레드의 결정적 이점은 전환 비용이다. 같은 프로세스 안의 스레드끼리 전환할 때는 — 주소 공간이 그대로라 TLB를 비울 필요가 없다. 프로세스 전환이 집을 통째로 옮기는 거라면, 스레드 전환은 같은 집에서 일꾼만 바꾸는 것이라 훨씬 가볍다(레지스터·스택 포인터·PC만 갈아 끼운다). (출처: Process vs Thread context switch.) 그래서 동시 작업이 많을수록 프로세스보다 스레드가 효율적이다.
누가 스레드를 스케줄하나 — 유저 vs 커널 스레드
여기서 미묘한 갈림길이 있다 — 스레드를 누가 관리하느냐다. 커널 레벨 스레드는 OS가 직접 안다 — 그래서 여러 코어에 분산되고, 한 스레드가 I/O로 블록돼도 다른 스레드는 계속 돈다(진짜 병렬). 대신 생성·전환에 커널 모드 전환 비용이 든다. 유저 레벨 스레드는 OS는 모른 채 라이브러리가 관리한다 — 전환이 시스템 콜 없이 빨라 가볍지만, 치명적 약점이 있다: OS가 한 덩어리(프로세스)로만 봐서, 한 스레드가 블로킹 시스템 콜을 부르면 프로세스 전체가 멈추고, 여러 코어도 못 쓴다. (출처: User vs kernel threads.)
두 마리 토끼 — M:N 스케줄링
이 둘의 장점을 합치려는 게 M:N 스케줄링 — 많은(M) 유저 스레드를 적은(N) 커널 스레드에 얹는다. 가장 단순한 옛 방식인 *그린 스레드(M:1)*는 모든 유저 스레드가 한 OS 스레드 위라, 하나가 블록되면 전부 막혔다. 진짜 M:N은 다르다 — 한 유저 스레드가 블로킹 작업을 만나면, OS 스레드에서 떼어내고(unmount) 다른 유저 스레드를 그 OS 스레드에 올린다. 그래서 블로킹 코드를 그대로 써도 적은 OS 스레드로 수만 개의 동시성을 낸다 — 읽기 쉬운 블로킹 코드와 높은 동시성을 동시에 얻는 셈이다.
왜 이 페이지가 중요한가 — 스레드 모델의 선택이 동시성의 거의 모든 것을 좌우한다. 커널 스레드 풀을 몇 개 둘지는 "비싼 OS 스레드를 몇 개나 둘까"의 문제이고, 공유 가변 상태 버그는 "스레드가 힙을 공유"하는 이 구조에서 나오며, 전역으로 공유되는 객체에 요청별 상태를 두면 안 되는 이유도 — 여러 스레드가 그 객체를 동시에 건드리기 때문이다. 스레드를 이해하면 동시성 버그가 왜 나는지가 바닥부터 보인다.
실제로 눈으로 보기
한 프로세스 안의 스레드를 센다
ps -o pid,nlwp,comm -p 1234 # nlwp = 스레드 개수
# PID NLWP COMMAND
# 1234 247 myapp
ls /proc/1234/task | wc -l # 같은 값 — 커널은 스레드를 task 로 관리한다
top -H -p 1234 # 스레드별 CPU 사용률
/proc/<pid>/task/에 스레드마다 디렉터리가 하나씩 있다. 리눅스 커널이 프로세스와 스레드를 같은 구조체(task)로 다루고 무엇을 공유하느냐만 다르게 준다는 사실이 파일 구조에 그대로 드러난다.
무엇을 공유하는지 코드로 확인한다
#include <pthread.h>
#include <stdio.h>
int global = 0; // 데이터 영역 — 공유된다
void *worker(void *arg) {
int local = 0; // 스택 — 스레드마다 따로다
global++; local++;
printf("tid=%lu global=%d local=%d &local=%p\n",
pthread_self(), global, local, (void*)&local);
return NULL;
}
int main(void) {
pthread_t t[3];
for (int i = 0; i < 3; i++) pthread_create(&t[i], NULL, worker, NULL);
for (int i = 0; i < 3; i++) pthread_join(t[i], NULL);
printf("최종 global=%d\n", global);
}
global은 계속 오르고 local은 매번 1이다. 그리고 &local 주소가 스레드마다 다르다 — 스택이 따로라는 것이 주소로 확인된다. 프로세스였다면 global도 각자 것이라 전부 1이 나왔을 것이다.
스레드는 공짜가 아니다 — 스택이 붙는다
ulimit -s # 8192 — 스레드 하나당 스택 상한 (KB)
기본이 8MB다. 스레드 1,000개면 주소 공간에서 8GB를 예약한다. 다만 실제 물리 메모리는 건드린 페이지만 잡히므로(demand paging) RSS는 훨씬 작다 — VSZ만 크게 뜬다.
그래도 커널 자료구조와 스케줄링 대상이 늘어나는 비용은 실재한다. "스레드를 늘리면 늘린 만큼 빨라진다"가 성립하지 않는 이유가 이것이고, 그래서 풀을 만들어 재사용한다.
전환이 정말 싼가
vmstat 1
# r b swpd free ... in cs us sy id wa
# 4 0 0 20144 ... 8221 182004 42 51 7 0
# └ cs — 초당 컨텍스트 스위치
cs가 초당 18만이면서 sy(커널 시간)가 51%면 전환 자체에 CPU를 쓰고 있다는 뜻이다. 스레드가 너무 많거나 락 경합이 심할 때 나타난다. 스레드 전환이 프로세스 전환보다 싼 것은 맞지만 공짜는 아니다.
정리하면, 스레드는 한 프로세스 안의 여러 실행 흐름으로 — 코드·데이터·힙은 공유하고 스택·레지스터만 따로 가져, 전환이 싸고(TLB flush 불필요) 통신이 공짜다. 대신 *공유 때문에 경쟁 조건*이 생긴다(다음 편). 커널 스레드는 진짜 병렬이나 무겁고, *유저 스레드*는 가볍지만 블로킹·멀티코어에 약하며, *M:N(경량 스레드)*이 둘의 장점을 합쳐 싼 블로킹으로 높은 동시성을 낸다. 이 스레드들을 CPU에 어떻게 번갈아 올릴지 정하는 게 — 다음 편 스케줄링이다.