본문 바로가기

스레드의 개념

프로세스 안에서 스레드가 공유하는 자원과 독립적으로 갖는 실행 상태를 구분하고 동시성과 병렬성을 이해합니다.

프로세스가 실행 중인 프로그램이라면, 스레드(Thread)는 프로세스 안에서의 실행 흐름입니다.

하나의 프로세스는 여러 개의 스레드를 가질 수 있으며, 이 스레드들은 같은 메모리 공간을 공유하면서 독립적으로 실행됩니다.

왜 프로세스만으로는 부족할까요?

3장에서 다뤘듯이 프로세스는 독립적인 메모리 공간을 가집니다.

프로세스 간 데이터 공유는 IPC(파이프, 공유 메모리, 소켓 등)를 거쳐야 하고, 서로 다른 주소 공간의 프로세스 간 전환은 페이지 테이블 기준을 바꾸며 TLB에 미치는 영향은 주소 공간 태그와 무효화 정책에 따라 달라집니다.

만약 하나의 프로그램 안에서 여러 일을 동시에 하고 싶다면 — 웹 서버가 여러 요청을 동시에 처리한다거나, 워드프로세서가 화면 렌더링과 맞춤법 검사를 동시에 한다거나 — 공유 주소 공간을 사용하는 스레드가 통신·생성 비용을 줄이는 선택지가 됩니다. 프로세스 분리는 장애 격리 등 다른 장점이 있습니다.

이 문제를 해결하기 위해 프로세스 안의 경량 실행 단위인 스레드가 등장했습니다.


스레드가 공유하는 것과 고유한 것

같은 프로세스 내의 스레드들이 무엇을 공유하고 무엇을 독립적으로 가지는지를 정확히 이해하는 것이 멀티스레드 프로그래밍의 기초입니다.

공유하는 자원

  • 코드(텍스트) 영역: 모든 스레드가 같은 프로그램 코드를 실행합니다.
  • 데이터 영역: 전역 변수를 모든 스레드가 공유합니다. 이것이 장점이자 위험입니다. 같은 변수에 IPC 없이 접근할 수 있지만 변경의 가시성과 접근 순서를 보장하려면 언어의 동기화 규칙을 지켜야 합니다. 동시 읽기·쓰기가 저절로 안전해지지는 않습니다.
  • 힙 영역: malloc()이나 new로 할당한 동적 메모리에 모든 스레드가 접근할 수 있습니다.
  • 열린 파일 디스크립터: 한 스레드가 연 파일을 다른 스레드도 사용할 수 있습니다.
  • 시그널 핸들러: 프로세스 수준에서 등록되므로 모든 스레드가 공유합니다.
  • 가상 주소 공간: 주소 공간이 같아 스레드 전환만을 이유로 페이지 테이블을 교체할 필요가 없습니다. 다른 원인의 TLB 무효화는 별개입니다.

스레드 고유 자원

  • 스레드 ID: 프로세스 내에서 각 스레드를 식별합니다. PID와는 다릅니다.

  • 프로그램 카운터(PC): 각 스레드가 코드의 다른 부분을 실행하고 있으므로, 각자의 PC를 가져야 합니다.

  • 레지스터 세트: CPU 레지스터 값은 스레드마다 독립적입니다.

  • 스택: 각 스레드가 독립적인 함수 호출 체인을 가지므로, 별도의 스택이 필요합니다. 스레드 A가 f() → g() → h()를 호출하는 동안, 스레드 B는 x() → y()를 호출할 수 있습니다. 각자의 지역 변수, 반환 주소, 매개변수가 별도의 스택에 저장됩니다.

  • TLS와 시그널 마스크: 스레드마다 별도로 갖습니다. 시그널 핸들러의 공유와 구분합니다.

스택은 스레드별 실행 저장소지만 별도의 보호 주소 공간은 아닙니다. 지역 변수의 주소를 다른 스레드에 넘기면 수명이 끝나기 전까지 공유 접근할 수 있으며 동기화가 필요합니다.

예를 들어 같은 프로세스의 스레드 A가 작업 번호를 지역 변수에 저장하고, B가 전역 설정을 읽는다고 합시다. 두 실행 흐름은 같은 코드·전역 데이터·힙을 사용하지만, A가 어디까지 실행했는지 나타내는 PC와 호출 스택을 B의 것으로 바꾸지는 않습니다. A의 지역 변수 주소를 B에 넘기면 B도 그 주소를 통해 접근할 수 있으므로, 지역이라는 이름만으로 접근 보호나 동기화가 생기지는 않습니다. 주소를 넘긴 저장소는 B가 사용할 때까지 살아 있어야 합니다.

한 논리 CPU에서 A와 B가 번갈아 진행하면 동시성이 있는 실행입니다. 두 실행 가능한 흐름이 서로 다른 사용 가능한 CPU에서 같은 시각에 진행해야 CPU 병렬성도 있습니다. 아래의 공유 영역과 두 실행 문맥을 함께 보고, 공유한다는 말과 동시에 실행한다는 말을 구별해 보세요.

스레드 · 공유 자원과 실행 문맥

주소 공간은 함께 쓰고, 실행을 재개할 문맥은 따로 갖습니다

같은 프로세스의 스레드 A와 B를 비교합니다. 스레드별 스택은 실행 저장소이며 다른 스레드가 접근할 수 없는 보호 영역이라는 뜻은 아닙니다.

같은 주소 공간 안의 공유 저장소와 두 스택주소 공간 안에 공유 코드 데이터 힙과 별도 A B 스택이 있다. 두 실행 문맥은 각자 스택 및 같은 공유 저장소를 사용한다. 보호 경계나 동시에 실행되는 시간선을 뜻하지 않는다. 한 프로세스의 주소 공간 공유 코드 · 전역 데이터 · 힙 수명과 동기화 규칙 필요 A의 호출 스택 B의 호출 스택 스레드 A 실행 문맥 PC · 레지스터 · TLS 상태 스레드 B 실행 문맥 PC · 레지스터 · TLS 상태 화살표: 실행 흐름이 해당 저장소를 사용함 스택 분리는 접근을 금지하는 보호 경계가 아님
공유 영역과 두 스레드의 저장소 사용데스크톱과 같은 공유 코드 데이터 힙 및 A B 스택과 별도 실행 문맥을 세로 배치한다. 네 화살표의 저장소 사용 의미가 같다. 같은 주소 공간 공유 코드 전역 데이터 · 힙 A 호출 스택 B 호출 스택 A 실행 문맥 PC · 레지스터 TLS 상태 B 실행 문맥 PC · 레지스터 TLS 상태 화살표: 저장소 사용 접근 보호·실행 순서 아님

프로세스의 같은 주소 공간 · 공유 자원

코드·전역 데이터·힙과 열린 파일 디스크립터, 등록한 시그널 핸들러를 함께 씁니다.

같은 데이터를 변경할 때는 언어와 API의 동기화 규칙을 지켜야 합니다.

스레드 A · 별도 실행 상태

스레드 ID / PC / 레지스터

호출 스택 / TLS / 시그널 마스크

스레드 B · 별도 실행 상태

스레드 ID / PC / 레지스터

호출 스택 / TLS / 시그널 마스크

다른 스레드에 스택 값의 주소를 넘기면 수명·접근 규칙·동기화가 필요합니다. 같은 시점의 CPU 병렬 실행 폭은 사용 가능한 논리 CPU와 런타임 제약에 따릅니다.


멀티스레드의 장점

응답성(Responsiveness): GUI 애플리케이션에서 핵심입니다.

파일을 다운로드하는 동안 사용자가 버튼을 클릭하면, 다운로드 스레드와 UI 스레드가 분리되어 있으므로 화면이 멈추지 않습니다.

단일 UI 스레드에서 블로킹 다운로드를 수행하면 화면 처리가 멈춥니다. 같은 스레드라도 비동기 I/O로 제어를 반환하면 응답성을 유지할 수 있습니다.

Android의 NetworkOnMainThreadException 공식 설명은 메인 스레드의 네트워크 작업과 Honeycomb SDK 이상을 대상으로 하는 앱이라는 적용 조건을 함께 명시합니다. UI 응답성을 위해 블로킹 네트워크 작업을 메인 스레드에 두지 않으며, 실제 작업을 비동기 API나 다른 실행 흐름으로 옮깁니다.

자원 공유(Resource Sharing): 같은 메모리를 공유하므로 IPC 없이 데이터를 주고받습니다.

프로세스 간의 파이프, 소켓, 공유 메모리 설정이 필요 없습니다.

전역 변수 하나를 선언하면 모든 스레드가 접근할 수 있습니다.

물론 동시 접근의 동기화 문제가 따라오지만, 프로세스 간 통신보다 구현이 간단합니다.

경제성(Economy): 스레드는 기존 주소 공간과 파일 정보를 공유하여 생성·전환 비용 일부를 줄일 수 있습니다.

fork()는 새 주소 공간의 관리 정보 등을 준비하고, 스레드 생성도 스택·TLS·커널 태스크·스케줄링 자원을 준비합니다. 어느 쪽이 몇 배 빠른지는 구현과 작업에 따라 측정해야 합니다.

멀티코어 활용(Scalability): 멀티코어 CPU에서 각 스레드가 서로 다른 코어에서 실제로 동시에 실행될 수 있습니다.

사용 가능한 CPU 4개에서 같은 양의 독립 계산을 네 스레드에 균등 분할하고 순차 부분·전환·경합 비용을 무시하면 이상적인 가속비는 4입니다. 스레드 수만으로 실제 4배 향상을 보장하지는 않습니다.

하나의 CPU 실행 스레드는 한 시각에 하나의 논리 CPU에서 명령을 진행합니다. 다른 시각에는 다른 CPU로 이동할 수 있고, 라이브러리가 만드는 추가 스레드나 장치의 작업은 별도로 셉니다.

하지만 멀티스레드가 만능은 아닙니다.

Amdahl의 법칙에 따르면, 프로그램의 순차적(병렬화 불가능한) 부분이 전체의 P%라면, 코어가 무한히 많아도 속도 향상의 상한은 1P/100\frac{1}{P/100}배입니다.

프로그램의 25%가 순차적이라면, 코어를 아무리 늘려도 최대 4배까지만 빨라집니다.


자습 계산에서 한 작업의 순차 실행 시간을 100초, 병렬화할 수 없는 부분을 25초라고 고정해 봅시다. 나머지 75초를 이상적으로 4개 CPU에 균등 분할하면 총 시간은 25 + 75/4 = 43.75초, 가속비는 100/43.75 ≈ 2.29입니다. CPU 수를 무한히 늘려도 25초는 남아 상한은 100/25 = 4입니다. 전환·통신·경합 비용과 불균등 작업은 이 이상 모델에 추가됩니다.

독립 확인: 병렬 부분이 60초라면

원래 전체 100초 중 60초만 병렬화할 수 있고 CPU 4개를 사용한다고 가정합니다. 순차 부분은 40초이므로 이상 시간은 40 + 60/4 = 55초, 가속비는 약 1.82, CPU 수를 늘릴 때의 상한은 2.5입니다. 스레드를 8개 만든 사실만으로 4개의 CPU에서 병렬 부분을 8등분해 동시에 끝낸다고 계산할 수는 없습니다.

싱글 스레드 vs 멀티 스레드

웹 서버를 예로 들어 비교해 보겠습니다.

블로킹 순차 서버: 요청이 오면 처리하고, 완료된 후 다음 요청을 처리합니다.

앞의 요청이 데이터베이스 쿼리로 500ms를 기다리는 동안, 뒤의 요청들은 전부 대기합니다.

다른 비용을 무시한 이 가정에서는 초당 약 2개입니다. 모든 싱글 스레드 서버의 한계를 뜻하지는 않습니다.

멀티 스레드 서버(Apache httpd의 일부 MPM 등): 각 요청을 별도 스레드에서 처리합니다.

한 스레드가 DB 응답을 기다리는 동안 CPU는 다른 스레드의 요청을 처리합니다.

동시에 수백~수천 개의 요청을 처리할 수 있습니다.

이벤트 기반 싱글 스레드 (Node.js 모델): 그런데 Node.js는 싱글 스레드인데도 수만 개의 동시 연결을 처리합니다.

이것은 네트워크 트랙의 11장에서 다룬 이벤트 루프와 비동기 I/O 덕분입니다.

I/O를 기다리는 동안 스레드가 블로킹되지 않고, 콜백을 등록한 뒤 다른 작업을 처리합니다.

I/O가 완료되면 콜백이 실행됩니다.

멀티스레드와 이벤트 기반은 동시성을 달성하는 서로 다른 전략입니다.

멀티스레드는 여러 스레드가 각각 블로킹 I/O를 수행하고, 이벤트 기반은 하나의 스레드가 논블로킹 I/O로 여러 작업을 인터리빙합니다.

Nginx는 master와 여러 worker 프로세스로 구성되고 각 worker가 이벤트 기반으로 요청을 처리합니다. 별도의 파일 I/O 스레드 풀을 사용할 수 있지만 기본 worker를 스레드라고 부르지는 않습니다.

Node.js의 주 JavaScript 이벤트 루프와 일부 파일·DNS 작업을 맡는 libuv 스레드 풀도 구분합니다. worker_threads로 JavaScript를 병렬 실행하는 기능은 별도입니다.


동시성 vs 병렬성

이 두 용어를 정확히 구분해야 합니다.

동시성(Concurrency): 여러 작업이 논리적으로 동시에 진행되는 것입니다.

단일 코어에서도 시분할로 동시성을 달성할 수 있습니다.

실제로는 번갈아 실행되지만, 인간의 관점에서는 동시에 진행되는 것처럼 보입니다.

병렬성(Parallelism): 여러 작업이 물리적으로 동시에 실행되는 것입니다.

멀티코어 CPU에서 각 코어가 다른 스레드를 실행할 때 달성됩니다.

동시성은 구조(Structure)의 문제이고, 병렬성은 실행(Execution)의 문제입니다.

동시성이 있는 프로그램을 멀티코어에서 실행하면 병렬성도 얻을 수 있지만, 하나의 논리 CPU만 사용한다면 그 위의 CPU 명령 실행은 번갈아 진행됩니다. I/O 장치와 CPU의 동시 작업은 또 다른 축입니다.

일상 비유로 설명하면: 한 사람이 요리와 빨래를 번갈아 하면 동시성(한 사람, 두 작업), 두 사람이 각각 요리와 빨래를 하면 병렬성(두 사람, 두 작업)입니다.


멀티스레드 프로그래밍의 도전

스레드가 메모리를 공유하는 장점은 곧 위험이기도 합니다.

6장에서 자세히 다루겠지만, 핵심 문제들을 미리 짚어봅니다.

경합 조건(Race Condition): 두 스레드가 같은 변수를 동시에 수정하면 결과가 예측 불가능합니다.

count++라는 단순한 연산도 실제로는 "읽기 → 증가 → 쓰기"의 세 단계이므로, 이를 원자적이지 않은 읽기·수정·쓰기로 생각하면 증가가 유실되는 위험을 이해할 수 있습니다. 특히 C/C++의 동기화 없는 데이터 경쟁은 정의되지 않은 동작이므로 특정 유실 결과만 예상해서는 안 됩니다.

교착 상태(Deadlock): 스레드 A가 자원 1을 잡고 자원 2를 기다리고, 스레드 B가 자원 2를 잡고 자원 1을 기다리면, 둘 다 영원히 멈춥니다.

우선순위 역전(Priority Inversion): 낮은 우선순위 스레드가 높은 우선순위 스레드가 필요한 자원을 잡고 있으면, 높은 우선순위 스레드가 블로킹됩니다.

이 문제들은 공유 메모리라는 장점의 이면입니다.

6장(동기화)과 7장(교착 상태)에서 깊이 다룹니다.

우선 다음 절에서는 스레드가 OS 수준에서 어떻게 구현되는지, 사용자 수준 스레드와 커널 수준 스레드의 매핑 모델을 살펴보겠습니다.