안동민 개발노트

본문 시작

스레드의 개념

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

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

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

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

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

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

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

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


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

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

공유하는 자원

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

스레드 고유 자원

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

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

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

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

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

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


멀티스레드의 장점

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

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

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

안드로이드에서는 메인 스레드(UI 스레드)에서 네트워크 요청을 시도하면 NetworkOnMainThreadException이 발생하여 강제로 분리를 요구합니다.

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

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

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

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

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

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

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

4코어 CPU에서 4개의 스레드가 독립적인 계산을 수행하면, 이론적으로 4배 빠릅니다.

단일 스레드 프로세스는 코어가 아무리 많아도 하나의 코어만 사용합니다.

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

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

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


싱글 스레드 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 수준에서 어떻게 구현되는지, 사용자 수준 스레드와 커널 수준 스레드의 매핑 모델을 살펴보겠습니다.