컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
os · 02

스레드

OS스레드동시성

스레드(Thread)는 프로세스 내부의 실행 단위입니다. 하나의 프로세스는 여러 스레드를 가질 수 있으며, 스레드들은 Code·Data·Heap 영역을 공유하고 Stack과 레지스터만 독립적으로 유지합니다. 이 구조 덕분에 프로세스보다 훨씬 가볍게 생성·전환할 수 있습니다.

핵심 개념

스레드 구성 요소

각 스레드는 독립적인 Stack(지역변수·함수 호출), PC(Program Counter), 레지스터 세트를 가집니다. 반면 Code·Data·Heap·열린 파일 등은 같은 프로세스의 스레드끼리 모두 공유합니다.

핵심 개념

멀티스레딩의 장점

응답성(Responsiveness) — 한 스레드가 I/O 블로킹 중에도 다른 스레드가 실행됩니다. 자원 공유 — 프로세스 간 IPC 없이 메모리를 직접 공유합니다. 경제성 — 프로세스 생성보다 스레드 생성이 훨씬 빠릅니다.

핵심 개념

커널 스레드 vs 유저 스레드

커널이 지원하는 스레드(1:1 모델)는 OS가 직접 스케줄링하며 멀티코어를 활용할 수 있습니다. 유저 스레드(N:1)는 라이브러리가 관리해 컨텍스트 스위치 비용이 낮지만, 하나가 블로킹되면 전체가 멈춥니다. Linux의 NPTL은 1:1 모델입니다.

핵심 개념

스레드 안전(Thread Safety)

여러 스레드가 공유 자원에 동시 접근하면 Race Condition이 발생합니다. Mutex·Lock으로 임계 구역(Critical Section)을 보호하거나, 불변 객체(Immutable Object)·스레드 로컬 저장소(TLS)를 활용해 공유를 피합니다.

핵심 개념

스레드 풀(Thread Pool)

미리 스레드를 생성해 두고 작업을 큐에 넣어 재사용하는 패턴입니다. 매 요청마다 스레드를 생성·삭제하는 비용을 없애고, 최대 스레드 수를 제한해 자원 고갈을 방지합니다. Java의 ExecutorService, .NET의 ThreadPool, Tomcat connector pool이 대표적입니다.

핵심 개념

프로세스 vs 스레드

프로세스는 독립된 주소 공간을 가져 하나가 죽어도 다른 프로세스에 영향이 없습니다. 스레드는 주소 공간을 공유하므로 하나가 잘못된 메모리에 접근하면 같은 프로세스의 모든 스레드가 죽습니다. 컨텍스트 스위치 비용은 보통 스레드가 더 작습니다.

TCB (Thread Control Block) — 프로세스의 PCB에 대응하는 스레드 단위 자료구조. TID, 스택 포인터, PC, 레지스터 스냅샷, 상태(Running/Ready/Blocked), 우선순위 등을 저장합니다.
컨텍스트 스위치 비용 — 같은 프로세스 내 스레드 전환은 주소 공간(페이지 테이블)을 교체할 필요가 없어 프로세스 간 전환보다 보통 빠릅니다(TLB 비용은 CPU 지원에 따라 다릅니다). 다만 캐시 오염(Cache Pollution)은 여전히 발생합니다.
데몬 스레드(Daemon Thread) — Java 등에서 백그라운드 작업(로그 정리 등)을 위해 사용. 모든 비데몬 스레드가 종료되면 JVM과 함께 자동 종료됩니다. 중요한 작업에는 사용하면 안 됩니다.
스택 크기와 OOM — 각 스레드는 독립 스택을 가지므로 스레드 수가 많을수록 메모리 사용이 증가합니다. HotSpot JVM의 기본 스택 크기는 플랫폼마다 다르며(Linux x64는 1MB) -Xss로 조정할 수 있습니다. 스레드를 만들 자원(메모리나 OS 한도)이 모자라면 OutOfMemoryError가 발생할 수 있습니다.
인터럽트와 시그널 — 리눅스에서 프로세스 앞으로 보낸 시그널은 그 시그널을 막지 않은 스레드 중 하나에 전달됩니다. Java의 Thread.interrupt()는 스레드를 강제로 멈추지 않고 인터럽트 상태만 세우며, 스레드가 이를 확인해 스스로 종료하는 방식입니다(sleep·wait·join 중이면 InterruptedException으로 전달됩니다).

스레드는 왜 필요한가

프로세스마다 주소 공간이 따로라서 프로세스 사이에서 데이터를 나누려면 IPC가 필요합니다. 스레드는 같은 프로세스 안에서 주소 공간을 공유하는 실행 흐름이라, 같은 메모리를 직접 읽고 쓰며 협력할 수 있습니다. 대신 공유하는 만큼 동기화 문제가 따라옵니다. 리눅스의 POSIX 스레드 사용법은 pthreads(7) 매뉴얼에 정리되어 있습니다.

숫자로 확인하기

스택 메모리 예약 — 스레드 스택 크기를 1MB로 잡고 스레드를 1,000개 만들면 약 1,000MB, 곧 약 1GB의 가상 메모리를 스택용으로 예약합니다. 물리 메모리 사용량은 실제로 쓴 스택 크기에 따라 달라집니다.
경쟁 상태의 계산 — count++는 읽기, 더하기, 쓰기의 세 단계입니다. count가 5일 때 두 스레드가 모두 5를 읽고 각자 6을 쓰면 두 번 증가시켰는데 결과가 7이 아니라 6입니다. 락 없이 각각 100,000번 증가시키면 결과가 200,000보다 작을 수 있습니다.
병렬화의 한계(암달의 법칙) — 작업의 90%를 병렬화할 수 있으면 코어 4개의 속도 향상은 1 / (0.1 + 0.9 / 4) ≈ 3.08배이고, 코어를 무한히 늘려도 최대 10배입니다. 스레드를 코어 수보다 훨씬 많이 만든다고 비례해서 빨라지지 않습니다.
Tomcat의 기본 스레드 수 — Tomcat 문서는 커넥터의 maxThreads 기본값이 200이라고 설명하고, Spring Boot에서는 server.tomcat.threads.max로 조정합니다. 동시에 처리하는 요청 수가 이 값으로 제한됩니다(가상 스레드를 켜면 이 값은 적용되지 않습니다).

자주 하는 오해

스레드가 많을수록 빨라진다 — 코어 수를 넘으면 컨텍스트 스위칭과 메모리 비용이 늘고, 병렬화하지 못하는 부분이 한계를 정합니다. I/O 대기가 많은 작업만 스레드를 늘리면 이득이 있습니다.
지역 변수는 항상 스레드 안전하다 — 지역 변수 자체는 스레드마다 스택에 따로 있어 안전하지만, 그 변수가 가리키는 힙 객체를 여러 스레드가 공유하면 안전하지 않습니다.
Java 스레드는 모두 운영체제 스레드와 1:1이다 — JDK 21의 플랫폼 스레드는 운영체제 스레드를 얇게 감싼 것이지만, JDK 21에 정식으로 들어온 가상 스레드(JEP 444)는 JVM이 관리하는 가벼운 스레드라서 1:1이 아닙니다.
한 스레드가 죽으면 프로세스 전체가 죽는다 — C에서 잘못된 메모리 접근처럼 프로세스 수준의 오류는 전체를 종료시키지만, Java에서 처리하지 않은 예외는 해당 스레드만 종료시킵니다.

면접에서 이렇게 물어요

면접 질문

프로세스와 스레드의 차이는 무엇인가요?

프로세스는 독립된 주소 공간과 자원을 가지고, 스레드는 같은 프로세스의 코드, 데이터, 힙, 열린 파일을 공유하며 스택과 레지스터만 따로 가집니다. 스레드가 생성과 전환이 가볍지만 서로의 오류와 경쟁 상태에 영향을 받습니다.

면접 질문

스레드 풀을 쓰는 이유는 무엇인가요?

요청마다 스레드를 만들고 없애는 비용을 줄이고, 스레드 수의 상한을 두어 자원 고갈과 과도한 컨텍스트 스위칭을 막기 위해서입니다. 작업은 큐에 쌓였다가 가용한 스레드가 가져갑니다.

면접 질문

경쟁 상태는 어떻게 막나요?

공유 데이터에 접근하는 임계 구역을 락이나 뮤텍스로 보호하거나, 공유 자체를 없애도록 불변 객체와 스레드 로컬 저장소를 씁니다. 원자적 연산을 제공하는 자료구조를 쓰는 방법도 있습니다.

면접 질문

가상 스레드는 기존 스레드와 어떻게 다른가요?

플랫폼 스레드는 운영체제 스레드와 1:1이라 수를 많이 늘리기 어렵습니다. 가상 스레드는 JVM이 소수의 플랫폼 스레드 위에 얹어 스케줄링해서, 블로킹 I/O가 많은 서버에서 요청마다 스레드 하나를 쓰는 단순한 모델을 유지하며 동시성을 크게 늘릴 수 있습니다.

실제 시스템에서는

스레드 확인 — 리눅스에서 ps -eLf는 스레드를 한 줄씩 보여 주고, top -H는 스레드 단위로 CPU 사용을 보여 줍니다. NPTL 구현은 pthreads(7) 매뉴얼이 설명합니다.
Spring Boot와 가상 스레드 — Spring Boot는 spring.threads.virtual.enabled=true 속성으로 가상 스레드를 켤 수 있다고 문서가 설명합니다(JDK 21 이상).
시그널과 스레드 — signal(7) 매뉴얼은 프로세스 앞으로 보낸 시그널이 그 시그널을 막지 않은 스레드 중 하나에 전달된다고 설명합니다.

함께 보면 좋은 토픽

프로세스 — 스레드의 바탕이 되는 프로세스와 PCB를 봅니다.
프로세스 동기화 — 공유 자원을 보호하는 락과 세마포어를 봅니다.
교착 상태 — 락을 여러 개 잡을 때 생기는 문제를 봅니다.
CPU 스케줄링 — 운영체제가 스레드에 CPU를 나누는 방식을 봅니다.
암달의 법칙 — 병렬화의 이득이 한계를 갖는 이유를 봅니다.
스레드 풀 — 요청마다 스레드를 만들지 않고 미리 만든 스레드를 재사용하는 방법을 봅니다.

참고 자료

C — pthread 생성 / 조인 / 뮤텍스

#include <stdio.h>
#include <pthread.h>

// 공유 자원 — 여러 스레드가 동시에 접근하면 Race Condition 발생
static int            counter = 0;
static pthread_mutex_t mtx     = PTHREAD_MUTEX_INITIALIZER;

void *worker(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 100000; i++) {
        // 임계 구역(Critical Section) 진입 전 Lock 획득
        pthread_mutex_lock(&mtx);
        counter++;  // 원자적이지 않은 read-modify-write → Lock 필수
        pthread_mutex_unlock(&mtx);
    }
    printf("Thread %d done\n", id);
    return NULL;
}

int main() {
    const int N = 4;
    pthread_t  threads[N];
    int        ids[N];

    // 스레드 생성: 각 스레드는 독립 Stack + PC 를 가지고
    // counter / mtx 를 공유함 (Code·Data·Heap 공유)
    for (int i = 0; i < N; i++) {
        ids[i] = i;
        pthread_create(&threads[i], NULL, worker, &ids[i]);
    }

    // join(): 스레드 종료 대기 + 자원 회수 (프로세스의 wait()에 해당)
    for (int i = 0; i < N; i++) {
        pthread_join(threads[i], NULL);
    }

    printf("Final counter: %d (expected %d)\n", counter, N * 100000);
    pthread_mutex_destroy(&mtx);
    return 0;
}

pthread_create()는 새 스레드를 생성합니다. 각 스레드는 독립된 Stack과 PC를 가지지만 counter·mtx 등 전역 변수는 공유합니다.
counter++ 연산은 읽기(load) → 증가 → 쓰기(store) 3단계로 이루어져 원자적이지 않습니다. Mutex 없이 실행하면 최종값이 N×100000보다 작게 나오는 Race Condition을 확인할 수 있습니다.
기본 attribute(PTHREAD_CREATE_JOINABLE)로 생성된 스레드는 pthread_join()을 호출하지 않으면 좀비 스레드로 남아 시스템 자원이 누수됩니다(누적되면 새 스레드 생성 불가).
join이 필요 없는 경우에는 pthread_detach() 또는 PTHREAD_CREATE_DETACHED 속성으로 생성해 종료 시 자동으로 회수되도록 합니다.

Java — Thread / ExecutorService / volatile

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo {

    // AtomicInteger: CAS(Compare-And-Swap) 기반 락-프리 원자 연산
    private static final AtomicInteger counter = new AtomicInteger(0);

    // volatile: happens-before 관계 보장 (가시성 + 재정렬 금지)
    // 단, 복합 연산(i++)의 원자성은 보장하지 않음
    private static volatile boolean running = true;

    public static void main(String[] args) throws Exception {

        // 스레드 풀: 최대 4개 스레드를 재사용
        ExecutorService pool = Executors.newFixedThreadPool(4);

        for (int i = 0; i < 4; i++) {
            final int id = i;
            pool.submit(() -> {               // Callable / Runnable 제출
                for (int j = 0; j < 100_000; j++) {
                    counter.incrementAndGet(); // 원자적 증가
                }
                System.out.println("Worker " + id + " done");
            });
        }

        // shutdown(): 새 작업 제출 거부 + 기존 작업 완료 후 종료
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);

        System.out.println("Final: " + counter.get()); // 400000

        // 가상 스레드 (Java 21, Project Loom, JEP 444)
        // JVM이 carrier thread 위에 M:N으로 스케줄링 — 수백만 개 생성 가능
        try (var vtPool = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> future = vtPool.submit(() -> {
                Thread.sleep(100);      // I/O·sleep 블로킹 시 carrier에서 unmount
                return "virtual thread result";
            });
            System.out.println(future.get());
        }
    }
}

Java의 스레드 풀(ExecutorService)은 미리 생성된 스레드를 재사용해 생성·삭제 비용을 없앱니다. 제출된 작업은 내부 큐에 쌓이고 유휴 스레드가 꺼내 실행합니다.
AtomicInteger는 CAS 명령어를 활용해 Mutex 없이도 원자적 증가를 보장합니다. 경쟁이 낮을 때는 synchronized 블록보다 오버헤드가 적지만, 경쟁이 매우 높으면 CAS 재시도 비용으로 성능이 떨어질 수 있습니다.
volatile은 메모리 배리어를 삽입해 레지스터 캐싱·명령어 재정렬을 금지하고 happens-before 관계를 보장합니다. 한 스레드의 쓰기가 이후 다른 스레드의 읽기에 즉시 보이므로 단순 플래그 변수에 적합합니다. 단, read-modify-write 복합 연산의 원자성은 보장하지 않습니다.
Java 21의 가상 스레드(Virtual Thread, JEP 444)는 OS 스레드 1:1 매핑이 아닌 JVM 스케줄러(ForkJoinPool 기반 carrier thread)에 M:N으로 마운트됩니다.
I/O·Thread.sleep 등 블로킹 시 carrier에서 unmount되어 다른 가상 스레드가 실행되므로, 수백만 개의 동시 작업을 적은 OS 스레드로 처리할 수 있습니다.
다만 Java 21~23에서는 synchronized 블록 내부에서 블로킹하면 carrier가 pinning되어 unmount되지 않으므로 이런 경우 ReentrantLock 사용이 권장됩니다(JEP 491에서 Java 24부터 해소).
가상 스레드의 스택도 힙에 저장되므로 메모리는 소모하지만 OS 스레드보다 훨씬 가볍습니다.

Q1

같은 프로세스 내의 스레드들이 공유하는 것은?

스레드는 '경량 프로세스'로, 프로세스의 자원을 나눠 씁니다.
같은 프로세스에 속한 스레드들은 코드, 데이터, 힙 등 프로세스 자원을 공유하되, 스택과 레지스터는 각자 독립적으로 가집니다.

Q2

여러 스레드가 동시에 같은 변수에 접근할 때 락(Lock)이 필요한 이유는?

여러 스레드가 동시에 값을 바꾸면 예상과 다른 결과가 나올 수 있습니다.
락은 한 번에 하나의 스레드만 공유 자원에 접근하도록 강제해, 경쟁 상태(race condition)로 인한 데이터 불일치를 방지합니다.

Q3

멀티스레딩의 주요 장점은?

스레드는 프로세스보다 생성/전환 비용이 적습니다.
스레드는 프로세스 자원을 공유해 생성과 컨텍스트 전환 비용이 프로세스보다 적으면서도 병렬 작업 처리가 가능합니다.