스레드의 개념
프로세스 안에서 스레드가 공유하는 자원과 독립적으로 갖는 실행 상태를 구분하고 동시성과 병렬성을 이해합니다.
프로세스가 실행 중인 프로그램이라면, 스레드(Thread)는 프로세스 안에서의 실행 흐름입니다.
하나의 프로세스는 여러 개의 스레드를 가질 수 있으며, 이 스레드들은 같은 메모리 공간을 공유하면서 독립적으로 실행됩니다.
왜 프로세스만으로는 부족할까요?
3장에서 다뤘듯이 프로세스는 독립적인 메모리 공간을 가집니다.
프로세스 간 데이터 공유는 IPC(파이프, 공유 메모리, 소켓 등)를 거쳐야 하고, 프로세스 간 전환은 페이지 테이블 교체와 TLB 플러시를 수반합니다.
만약 하나의 프로그램 안에서 여러 일을 동시에 하고 싶다면 — 웹 서버가 여러 요청을 동시에 처리한다거나, 워드프로세서가 화면 렌더링과 맞춤법 검사를 동시에 한다거나 — 프로세스를 여러 개 만드는 것은 무겁고 비효율적입니다.
이 문제를 해결하기 위해 프로세스 안의 경량 실행 단위인 스레드가 등장했습니다.
프로세스보다 가볍게 동시성을 얻는 대신, 공유 자원 수정에는 동기화 책임이 따라옵니다.
- 1공유 코드, 전역 데이터, 힙, 열린 파일 디스크립터
함께 본다.
- 2고유 각 스레드
스택, 레지스터, 실행 위치를 따로 가진다.
- 3위험 공유 데이터 동시 수정
race condition을 만든다.
| 비교 축 | 프로세스 여러 개 | 스레드 여러 개 |
|---|---|---|
| 메모리 | 주소 공간이 분리되어 안전하지만 IPC가 필요하다. | 주소 공간을 공유해 빠르지만 동기화가 필요하다. |
| 전환 비용 | 페이지 테이블과 TLB 영향으로 상대적으로 무겁다. | 같은 프로세스 안 문맥 전환이라 더 가볍다. |
| 장애 영향 | 한 프로세스 장애가 다른 프로세스로 덜 번진다. | 공유 메모리 오류가 전체 프로세스를 흔들 수 있다. |
스레드가 공유하는 것과 고유한 것
같은 프로세스 내의 스레드들이 무엇을 공유하고 무엇을 독립적으로 가지는지를 정확히 이해하는 것이 멀티스레드 프로그래밍의 기초입니다.
공유하는 자원
- 코드(텍스트) 영역: 모든 스레드가 같은 프로그램 코드를 실행합니다.
- 데이터 영역: 전역 변수를 모든 스레드가 공유합니다. 이것이 장점이자 위험입니다. 전역 변수 하나를 수정하면 모든 스레드가 즉시 변경된 값을 볼 수 있어 IPC 없이 데이터를 공유하지만, 동시에 수정하면 데이터가 손상됩니다.
- 힙 영역:
malloc()이나new로 할당한 동적 메모리를 모든 스레드가 접근할 수 있습니다. - 열린 파일 디스크립터: 한 스레드가 연 파일을 다른 스레드도 사용할 수 있습니다.
- 시그널 핸들러: 프로세스 수준에서 등록되므로 모든 스레드가 공유합니다.
- 가상 주소 공간: 페이지 테이블이 같으므로, 스레드 간 전환 시 TLB가 유효하게 유지됩니다.
스레드 고유 자원
- 스레드 ID: 프로세스 내에서 각 스레드를 식별합니다. PID와는 다릅니다.
- 프로그램 카운터(PC): 각 스레드가 코드의 다른 부분을 실행하고 있으므로, 각자의 PC를 가져야 합니다.
- 레지스터 세트: CPU 레지스터 값은 스레드마다 독립적입니다.
- 스택: 각 스레드가 독립적인 함수 호출 체인을 가지므로, 별도의 스택이 필요합니다. 스레드 A가
f() → g() → h()를 호출하는 동안, 스레드 B는x() → y()를 호출할 수 있습니다. 각자의 지역 변수, 반환 주소, 매개변수가 별도의 스택에 저장됩니다.
이 구분을 시각적으로 정리하면 다음과 같습니다.
특히 어떤 자원이 공유되는지를 먼저 판별하면, 경쟁 조건이 생길 수 있는지와 동기화가 필요한지를 이어서 판단할 수 있습니다.
스레드가 같은 위치를 함께 보거나 쓰는지 먼저 나누면, 경쟁 조건 후보와 동기화 필요 여부가 바로 드러납니다.
- 자원전역 변수 / 힙 객체
자원 전역 변수 / 힙 객체 주소 공간 공유 이유 같은 주소 공간에서 같은 데이터를 접근합니다. 공유 데이터 판정 하나라도 쓰면 잠금, 원자 연산, 메시지 전달을 검토합니다.
- 자원파일 디스크립터 / 소켓
자원 파일 디스크립터 / 소켓 파일 디스크립터 공유 이유 프로세스가 연 핸들을 스레드들이 함께 사용합니다. 공유 데이터 판정 읽기/쓰기 순서가 중요하면 큐나 잠금으로 조율합니다.
- 자원스택 지역 변수
자원 스택 지역 변수 레지스터·스택 고유 이유 각 스레드는 독립적인 호출 체인과 스택을 가집니다. 공유 데이터 판정 주소를 다른 스레드에 넘기면 다시 공유 자원으로 봅니다.
- 자원PC / 레지스터
자원 PC / 레지스터 힙·전역 고유 이유 스케줄러가 스레드별 실행 위치와 문맥을 저장합니다. 공유 데이터 판정 데이터 경쟁의 대상이 아니라 문맥 전환 비용의 대상입니다.
| 자원 | 공유 여부 | 의미 |
|---|---|---|
| 코드, 데이터, 힙 | 공유 | 같은 프로그램, 같은 전역 변수, 같은 동적 메모리 |
| 파일 디스크립터 | 공유 | 한 스레드가 열면 다른 스레드도 접근 가능 |
| 페이지 테이블 | 공유 | 같은 주소 공간 사용 → TLB 유효 |
| PC, 레지스터 | 고유 | 각 스레드가 코드의 다른 부분을 실행 |
| 스택 | 고유 | 독립적인 함수 호출 체인 |
| 스레드 ID | 고유 | 식별자 |
멀티스레드의 장점
응답성(Responsiveness): GUI 애플리케이션에서 핵심입니다.
파일을 다운로드하는 동안 사용자가 버튼을 클릭하면, 다운로드 스레드와 UI 스레드가 분리되어 있으므로 화면이 멈추지 않습니다.
단일 스레드였다면 다운로드가 끝날 때까지 화면이 "응답 없음" 상태가 됩니다.
안드로이드에서는 메인 스레드(UI 스레드)에서 네트워크 요청을 시도하면 NetworkOnMainThreadException이 발생하여 강제로 분리를 요구합니다.
자원 공유(Resource Sharing): 같은 메모리를 공유하므로 IPC 없이 데이터를 주고받습니다.
프로세스 간의 파이프, 소켓, 공유 메모리 설정이 필요 없습니다.
전역 변수 하나를 선언하면 모든 스레드가 접근할 수 있습니다.
물론 동시 접근의 동기화 문제가 따라오지만, 프로세스 간 통신보다 구현이 간단합니다.
경제성(Economy): 스레드 생성은 프로세스 생성보다 10~100배 빠릅니다.
fork()는 PCB 생성, 메모리 공간 설정(COW 포함), 파일 디스크립터 복사 등이 필요하지만, 스레드 생성은 스택 영역 할당과 TCB(Thread Control Block) 생성만으로 충분합니다.
컨텍스트 스위칭도 프로세스 간 전환보다 2~5배 빠릅니다.
페이지 테이블을 바꿀 필요가 없어 TLB가 유지되기 때문입니다.
멀티코어 활용(Scalability): 멀티코어 CPU에서 각 스레드가 서로 다른 코어에서 실제로 동시에 실행될 수 있습니다.
4코어 CPU에서 4개의 스레드가 독립적인 계산을 수행하면, 이론적으로 4배 빠릅니다.
단일 스레드 프로세스는 코어가 아무리 많아도 하나의 코어만 사용합니다.
하지만 멀티스레드가 만능은 아닙니다.
Amdahl의 법칙에 따르면, 프로그램의 순차적(병렬화 불가능한) 부분이 전체의 P%라면, 코어가 무한히 많아도 속도 향상의 상한은 배입니다.
프로그램의 25%가 순차적이라면, 코어를 아무리 늘려도 최대 4배까지만 빨라집니다.
싱글 스레드 vs 멀티 스레드
웹 서버를 예로 들어 비교해 보겠습니다.
싱글 스레드 서버: 요청이 오면 처리하고, 완료된 후 다음 요청을 처리합니다.
앞의 요청이 데이터베이스 쿼리로 500ms를 기다리는 동안, 뒤의 요청들은 전부 대기합니다.
1초에 2개의 요청만 처리할 수 있습니다.
멀티 스레드 서버 (Apache httpd 모델): 각 요청을 별도 스레드에서 처리합니다.
한 스레드가 DB 응답을 기다리는 동안 CPU는 다른 스레드의 요청을 처리합니다.
동시에 수백~수천 개의 요청을 처리할 수 있습니다.
이벤트 기반 싱글 스레드 (Node.js 모델): 그런데 Node.js는 싱글 스레드인데도 수만 개의 동시 연결을 처리합니다.
이것은 네트워크 트랙의 11장에서 다룬 이벤트 루프와 비동기 I/O 덕분입니다.
I/O를 기다리는 동안 스레드가 블로킹되지 않고, 콜백을 등록한 뒤 다른 작업을 처리합니다.
I/O가 완료되면 콜백이 실행됩니다.
멀티스레드와 이벤트 기반은 동시성을 달성하는 서로 다른 전략입니다.
멀티스레드는 여러 스레드가 각각 블로킹 I/O를 수행하고, 이벤트 기반은 하나의 스레드가 논블로킹 I/O로 여러 작업을 인터리빙합니다.
현대의 고성능 서버(예: Nginx)는 두 접근법을 결합합니다.
소수의 워커 스레드(코어 수만큼)가 각각 이벤트 루프를 돌면서 수만 개의 연결을 처리합니다.
아래 다이어그램은 서버 요청 처리 관점에서 싱글 스레드, 멀티스레드, 이벤트 기반 모델의 차이를 정리한 것입니다.
같은 세 개의 요청도 실행 모델에 따라 대기열에 쌓이거나, 별도 스레드에서 막히거나, 이벤트 루프에서 짧게 나뉘어 처리됩니다.
- I/O 대기와 요청 지연
싱글 스레드 Req A DB 응답을 기다리는 동안 실행 흐름이 멈춥니다. Req B 앞 요청이 끝날 때까지 큐에서 대기합니다. Req C 구현은 단순하지만 지연이 연쇄됩니다. one call stack
- 블로킹 I/O를 각 스레드가 따로 감당한다
멀티스레드 Thread A DB 응답을 기다려도 다른 스레드는 실행됩니다. Thread B 코어가 충분하면 Thread A와 Thread B가 같은 시간 구간에 서로 다른 코어에서 실행됩니다. Thread C 공유 메모리는 락과 소유권 규칙이 필요합니다. many stacks
- 논블로킹 이벤트 처리
벤트 기반 Task A I/O를 제출한 뒤 이벤트 루프에 제어를 돌려줍니다. Task B 대기 시간 사이에 다른 콜백을 처리합니다. Task C CPU 작업이 길면 루프 전체가 늦어집니다. event loop
- 응답성
사용자 요청이나 UI 이벤트가 긴 I/O에 막히지 않아야 합니다.
- 경제성
스레드는 프로세스보다 가볍지만 많아지면 스택과 전환 비용이 누적됩니다.
- 병렬성
CPU 바운드 작업은 멀티코어에서 실제로 동시에 실행될 때 빨라집니다.
동시성 vs 병렬성
이 두 용어를 정확히 구분해야 합니다.
동시성(Concurrency): 여러 작업이 논리적으로 동시에 진행되는 것입니다.
단일 코어에서도 시분할로 동시성을 달성할 수 있습니다.
실제로는 번갈아 실행되지만, 인간의 관점에서는 동시에 진행되는 것처럼 보입니다.
병렬성(Parallelism): 여러 작업이 물리적으로 동시에 실행되는 것입니다.
멀티코어 CPU에서 각 코어가 다른 스레드를 실행할 때 달성됩니다.
동시성은 구조(Structure)의 문제이고, 병렬성은 실행(Execution)의 문제입니다.
동시성 있는 프로그램을 멀티코어에서 실행하면 병렬성도 얻을 수 있지만, 싱글 코어에서 실행하면 동시성만 있고 병렬성은 없습니다.
일상 비유로 설명하면: 한 사람이 요리와 빨래를 번갈아 하면 동시성(한 사람, 두 작업), 두 사람이 각각 요리와 빨래를 하면 병렬성(두 사람, 두 작업)입니다.
멀티스레드 프로그래밍의 도전
스레드가 메모리를 공유하는 장점은 곧 위험이기도 합니다.
6장에서 자세히 다루겠지만, 핵심 문제들을 미리 짚어봅니다.
경합 조건(Race Condition): 두 스레드가 같은 변수를 동시에 수정하면 결과가 예측 불가능합니다.
count++라는 단순한 연산도 실제로는 "읽기 → 증가 → 쓰기"의 세 단계이므로, 두 스레드가 동시에 실행하면 하나의 증가가 덮어써질 수 있습니다.
교착 상태(Deadlock): 스레드 A가 자원 1을 잡고 자원 2를 기다리고, 스레드 B가 자원 2를 잡고 자원 1을 기다리면, 둘 다 영원히 멈춥니다.
우선순위 역전(Priority Inversion): 낮은 우선순위 스레드가 높은 우선순위 스레드가 필요한 자원을 잡고 있으면, 높은 우선순위 스레드가 블로킹됩니다.
이 문제들은 공유 메모리라는 장점의 이면입니다.
6장(동기화)과 7장(교착 상태)에서 깊이 다룹니다.
아래 다이어그램은 스레드가 공유하는 자원과 고유하게 보관하는 실행 상태를 동시성, 병렬성, 위험 요소와 함께 묶어 보는 기준표입니다.
이 절을 마무리할 때는 “무엇이 공유되는가”와 “공유되면 어떤 동기화 위험이 생기는가”를 한 번에 확인하면 됩니다.
스레드는 프로세스 안에서 같은 주소 공간을 공유하지만, 실행 흐름을 이어가기 위한 최소 상태는 각자 보관합니다.
- 1프로세스 안에서 공유
Code 모든 스레드가 같은 함수와 명령어 영역을 실행합니다. Data 전역 변수와 static 데이터가 공통 상태가 됩니다. Heap 동적 할당 객체를 함께 읽고 쓸 수 있습니다. FD 파일, 소켓, 파이프 핸들이 같은 테이블을 참조합니다.
- 2스레드마다 고유
PC 다음에 실행할 명령어 위치를 독립적으로 가집니다. Registers 연산 중간값과 스케줄링 복귀 상태가 분리됩니다. Stack 호출 프레임과 지역 변수가 스레드별로 쌓입니다. TLS 스레드 로컬 저장소로 개인별 설정을 보관합니다.
- 3구조의 문제
동시성 단일 코어에서도 여러 작업을 번갈아 진행할 수 있게 프로그램을 나누는 설계입니다.
- 4실행의 문제
병렬성 멀티코어에서 실제로 동시에 실행되어 CPU 바운드 작업의 처리량을 높입니다.
- 5공유의 비용
동기화 race, deadlock, priority inversion을 막기 위해 lock, atomic, queue 같은 규칙이 필요합니다.
우선 다음 절에서는 스레드가 OS 수준에서 어떻게 구현되는지, 사용자 수준 스레드와 커널 수준 스레드의 매핑 모델을 살펴보겠습니다.