Introduction

하나의 프로세스만으로 늘어나는 작업량을 감당하기는 어렵습니다. 프로세스가 제공하는 강력한 격리는 동시에 무거운 비용을 수반하기 때문입니다. 이 글에서는 프로세스 내부에서 더 가볍고 빠르게 실행 흐름을 나누는 단위인 스레드Thread가 무엇이고, 왜 필요하며, 어떻게 동작하는지를 살펴봅니다.

스레드란 무엇인가?

스레드는 프로세스 내부의 독립적인 실행 흐름이다(Silberschatz, Galvin and Gagne, 2018). 프로세스가 프로그램의 실행 인스턴스라면, 스레드는 그 인스턴스 안에서 실제로 CPU 명령을 수행하는 단위다. 모든 프로세스는 최소 하나의 스레드를 가지며, 필요에 따라 여러 스레드를 생성하여 작업을 병렬로 수행할 수 있다.

스레드의 핵심적인 특성은 같은 프로세스에 속한 스레드끼리 코드, 데이터, 힙 영역을 공유한다는 점이다. 각 스레드는 독립적인 프로그램 카운터Program Counter1, 스택 포인터Stack Pointer2, 레지스터 집합, 그리고 스택 영역만을 개별적으로 소유한다. 프로세스 간 통신에 IPC를 거쳐야 하는 것과 달리, 스레드끼리는 공유 메모리를 통해 직접 데이터를 주고받을 수 있다.

동시성과 병렬성

멀티스레드 프로그램에서 여러 스레드가 "동시에" 실행된다고 할 때, 그 의미는 두 가지로 나뉜다.

동시성Concurrency은 여러 작업이 논리적으로 동시에 진행되고 있는 상태다.3 단일 코어 환경에서도 운영체제가 스레드 사이를 빠르게 전환하면서 각 스레드에 시간을 나누어 주면, 사용자에게는 여러 작업이 동시에 이루어지는 것처럼 보인다. 이처럼 동시성은 하드웨어가 아닌 실행 흐름의 구조에 관한 개념이다.

병렬성Parallelism은 여러 작업이 물리적으로 동시에 실행되는 상태다. 멀티코어 프로세서에서 각 코어가 서로 다른 스레드를 동시에 실행하는 것이 이에 해당한다. 병렬성은 동시성의 하위 개념이다. 병렬적이면 반드시 동시적이지만, 동시적이라고 해서 반드시 병렬적인 것은 아니다.

스레드 모델

사용자가 작성한 스레드(사용자 수준 스레드user-level thread)가 실제로 CPU에서 실행되려면, 운영체제 커널이 관리하는 커널 수준 스레드kernel-level thread에 매핑되어야 한다. 이 매핑 방식에 따라 스레드 모델이 세 가지로 나뉜다(Silberschatz, Galvin and Gagne, 2018).

Many-to-One 모델

다수의 사용자 수준 스레드가 하나의 커널 수준 스레드에 매핑된다. 스레드 관리가 사용자 공간에서 이루어지므로 스레드 전환이 빠르지만, 하나의 스레드가 블로킹 시스템 호출을 수행하면 프로세스 전체가 블로킹된다. 또한 커널이 스레드를 인식하지 못하므로 멀티코어를 활용한 병렬 실행이 불가능하다.4

One-to-One 모델

사용자 수준 스레드 하나가 커널 수준 스레드 하나에 대응한다. 한 스레드가 블로킹되어도 다른 스레드가 영향을 받지 않으며, 멀티코어에서 진정한 병렬 실행이 가능하다. 대신 스레드를 생성할 때마다 커널 자원이 소모되므로 스레드 수에 실질적인 상한이 존재한다. 리눅스와 Windows를 포함한 대부분의 현대 운영체제가 이 모델을 채택하고 있다.

Many-to-Many 모델

다수의 사용자 수준 스레드를 같거나 적은 수의 커널 수준 스레드에 매핑한다. One-to-One의 병렬성과 Many-to-One의 유연성을 결합한 것이 목표이나, 구현 복잡도가 높아 실제로 널리 쓰이지는 않는다. Solaris의 LWP(Lightweight Process) 모델이 대표적인 사례였다.5

스레드의 생성

모든 프로세스는 최소 하나의 스레드로 시작하는데, 이 스레드를 메인 스레드main thread라 부른다. 프로세스 내에서 새로운 스레드를 생성하면 메인 스레드와 같은 주소 공간을 공유하는 새로운 실행 흐름이 만들어진다.

POSIX 스레드 (pthreads)

POSIX 호환 시스템에서 스레드를 다루는 표준 인터페이스는 pthreads다. 다음은 스레드를 생성하고, 스레드가 완료되기를 기다리는 기본 패턴이다.

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

void *worker(void *arg) {
    int id = *(int *)arg;
    printf("스레드 %d 실행 중\n", id);
    return NULL;
}

int main(void) {
    pthread_t tid;
    int id = 1;

    pthread_create(&tid, NULL, worker, &id);  // 스레드 생성
    pthread_join(tid, NULL);                   // 스레드 종료 대기

    return 0;
}

pthread_create()는 새 스레드를 생성하여 지정된 함수(worker)를 실행시킨다. 생성된 스레드는 부모 스레드와 독립적으로 스케줄링되며, 생성 직후 언제든 실행될 수 있다.6

모든 스레드는 같은 프로세스 ID(PID)를 공유하며, 스레드를 소유한 프로세스의 속성을 상속받는다. 각 스레드는 프로세스 내부에서 고유한 스레드 ID(TID)를 가지고, 시그널 마스크signal mask7를 통해 수신할 시그널을 개별적으로 제어한다.

결합과 분리

생성된 스레드는 결합 가능joinable 상태와 분리detached 상태 중 하나를 가진다. 기본값은 결합 가능 상태다.

결합 가능 상태의 스레드는 다른 스레드가 pthread_join()을 호출하여 해당 스레드의 종료를 대기하고 반환값을 수거할 수 있다. 부모 스레드가 pthread_join()을 호출하면 자식 스레드가 종료될 때까지 블로킹된다.

pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);

void *result;
pthread_join(tid, &result);  // worker가 종료될 때까지 대기

분리 상태의 스레드는 pthread_detach()를 호출하여 설정하며, 종료 시 시스템이 자동으로 자원을 회수한다. 한 번 분리된 스레드는 다시 결합할 수 없으며, 분리된 스레드의 TID는 스레드 종료 후 재사용될 수 있다.8

pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);
pthread_detach(tid);  // 독립적으로 실행, 자동 자원 회수

여기서 주의할 점이 있다. main() 함수가 return으로 종료되면 exit()이 호출되어 프로세스 전체가 종료되며, 분리된 스레드를 포함한 모든 스레드가 즉시 종료된다. 분리된 스레드가 독립적으로 작업을 완료하도록 하려면, 메인 스레드가 return 대신 pthread_exit()을 호출해야 한다.9

리눅스의 스레드 구현

리눅스 커널은 스레드를 프로세스와 별도의 개념으로 다루지 않는다. 커널 관점에서 스레드와 프로세스는 모두 동일한 task_struct 구조체로 표현되며, 차이는 자원을 어느 범위까지 공유하는가에 있다(Love, 2010).

clone()과 공유 플래그

리눅스에서 스레드를 생성할 때 내부적으로 사용되는 시스템 호출clone()이다.10 clone()은 플래그를 통해 부모와 자식이 공유할 자원의 범위를 세밀하게 제어한다. fork()도 내부적으로 clone()을 호출하지만 공유 플래그 없이 호출하여 독립적인 복사본을 만드는 반면, 스레드 생성 시에는 다음과 같은 플래그를 설정한다.

플래그공유 대상
CLONE_VM가상 메모리 공간(mm_struct)
CLONE_FS파일 시스템 정보(루트 디렉토리, 현재 작업 디렉토리)
CLONE_FILES열린 파일 디스크립터 테이블
CLONE_SIGHAND시그널 핸들러 테이블
CLONE_THREAD스레드 그룹(동일 TGID 공유)

CLONE_THREAD 플래그가 핵심이다. 이 플래그가 설정되면 새로 생성된 태스크는 부모와 같은 스레드 그룹thread group에 속하게 되며, 동일한 TGID(Thread Group ID)를 공유한다.11

NPTL (Native POSIX Threads Library)

현재 리눅스에서 사용되는 스레드 구현은 NPTL이다. NPTL은 1:1 모델을 채택하여 사용자 수준 스레드 하나가 커널 수준 스레드(task_struct) 하나에 직접 대응한다. 이전의 LinuxThreads 구현에 비해 POSIX 표준 호환성, 시그널 처리, 동기화 성능이 크게 개선되었다.12

프로세스 전환 vs. 스레드 전환

같은 프로세스에 속한 스레드 간의 컨텍스트 스위치는 프로세스 간 컨텍스트 스위치보다 가볍다. 프로세스를 전환할 때는 가상 주소 공간 전체를 교체해야 하므로 TLB 플러시가 수반되지만, 같은 프로세스 내 스레드 전환에서는 주소 공간이 동일하므로 TLB를 유지할 수 있다.

이처럼 스레드 전환은 주소 공간 교체와 TLB 플러시를 생략할 수 있으므로, 동일한 작업을 여러 프로세스로 나누는 것보다 하나의 프로세스 안에서 여러 스레드로 나누는 것이 생성 비용과 전환 비용 모두에서 유리하다.

스레드 안전성

여러 스레드가 공유 메모리에 동시에 접근하면 경합 조건race condition이 발생할 수 있다.13 스레드 안전한thread-safe 코드란, 여러 스레드가 어떤 순서와 타이밍으로 실행되더라도 올바른 결과를 보장하는 코드다.

스레드 안전성을 달성하는 대표적인 방법은 동기화 프리미티브를 사용하여 임계 영역critical section14에 대한 접근을 제어하는 것이다. 세마포어는 정수 값을 이용해 자원 접근을 제어하는 저수준 프리미티브이고, 모니터는 락과 조건 변수를 하나의 추상 자료형으로 캡슐화한 고수준 구조체다. 이 동기화 메커니즘들에 대해서는 각각의 포스트에서 상세히 다룬다.

공유 데이터 자체를 줄이는 것도 중요한 전략이다. 스레드 로컬 저장소Thread-Local Storage, TLS15를 사용하면 각 스레드가 전역 변수의 독립적인 복사본을 가질 수 있어 동기화 없이도 안전하게 데이터를 다룰 수 있다.

스레드의 종료

스레드는 다음 세 가지 방식으로 종료된다.

  1. 스레드 함수가 정상적으로 반환되면 스레드가 종료된다. pthread_join()으로 반환값을 수거할 수 있다.
  2. 스레드가 pthread_exit()을 명시적으로 호출하면 해당 스레드만 종료된다. 프로세스의 다른 스레드에는 영향을 주지 않는다.
  3. 다른 스레드가 pthread_cancel()을 호출하여 대상 스레드를 취소할 수 있다. 취소된 스레드는 취소 지점cancellation point에 도달했을 때 종료된다.16

프로세스 자체가 exit()으로 종료되거나 시그널에 의해 강제 종료되면, 해당 프로세스에 속한 모든 스레드가 함께 종료된다. 이는 결합 상태이든 분리 상태이든 마찬가지다.


출처

  • Silberschatz, A., Galvin, P.B. and Gagne, G. (2018) Operating System Concepts. 10th edn. Hoboken: Wiley.
  • Love, R. (2010) Linux Kernel Development. 3rd edn. Upper Saddle River: Addison-Wesley.
  • Bryant, R.E. and O'Hallaron, D.R. (2016) Computer Systems: A Programmer's Perspective. 3rd edn. Boston: Pearson.
  • Kerrisk, M. (2010) The Linux Programming Interface. San Francisco: No Starch Press.
  • Tanenbaum, A.S. and Bos, H. (2023) Modern Operating Systems. 5th edn. Hoboken: Pearson.
  • Arpaci-Dusseau, R.H. and Arpaci-Dusseau, A.C. (2018) Operating Systems: Three Easy Pieces. Version 1.01. Madison: Arpaci-Dusseau Books. Available at: https://www.ostep.org (Accessed: 10 April 2026).

Footnotes

  1. 프로그램 카운터(Program Counter)는 CPU가 다음에 실행할 명령어의 주소를 저장하는 레지스터다. 각 스레드가 독립적인 프로그램 카운터를 가지므로, 같은 프로세스 안에서도 스레드마다 서로 다른 코드 지점을 실행할 수 있다.

  2. 스택 포인터(Stack Pointer)는 현재 스택의 최상단 위치를 가리키는 레지스터다. 각 스레드가 독립적인 스택을 가지므로 함수 호출 체인이 스레드마다 독립적으로 관리된다.

  3. 동시성과 같은 의미로 멀티태스킹Multitasking이라는 용어가 사용되기도 한다.

  4. 초기 Java의 그린 스레드Green Thread가 Many-to-One 모델의 대표적인 사례다. JDK 1.3부터 네이티브 스레드(One-to-One)로 전환되었다.

  5. Many-to-Many 모델은 Solaris에서 가장 적극적으로 사용되었으나, Solaris 9 이후 One-to-One 모델로 전환되었다. 리눅스 역시 초기 LinuxThreads 라이브러리에서 NPTL(Native POSIX Threads Library)로 전환하면서 완전한 1:1 모델을 채택했다.

  6. 스레드가 생성된 직후 즉시 실행될지, 아니면 부모 스레드가 먼저 계속 실행될지는 스케줄러에 달려 있으며 예측할 수 없다.

  7. 시그널 마스크(Signal Mask)는 스레드가 수신을 차단하는 시그널의 집합이다. pthread_sigmask()로 스레드별로 독립적으로 설정할 수 있다. 자세한 내용은 GNU C Library 문서에서 확인할 수 있다.

  8. 결합 가능 상태의 스레드는 종료 후에도 pthread_join()이 호출될 때까지 스레드의 종료 코드와 최소한의 상태 정보가 유지된다. 이는 프로세스의 좀비 상태와 유사한 개념이다.

  9. pthread_exit()은 호출한 스레드만 종료시키며, 프로세스의 다른 스레드는 계속 실행된다. 반면 exit()이나 main()return은 프로세스 전체를 종료시킨다(Kerrisk, 2010).

  10. 사용자 공간에서 pthread_create()를 호출하면 glibc의 NPTL(Native POSIX Threads Library)이 적절한 플래그를 설정하여 clone() 시스템 호출을 수행한다.

  11. 리눅스에서 getpid()가 반환하는 값은 실제로 TGID(Thread Group ID)다. 같은 프로세스에 속한 모든 스레드는 동일한 TGID를 공유하므로 외부에서는 하나의 프로세스로 보인다. 각 스레드의 고유 식별자는 TID(Thread ID)이며, gettid() 시스템 호출로 얻을 수 있다.

  12. NPTL의 동기화 프리미티브는 Futex(Fast Userspace Mutex)를 기반으로 구현된다. 경합이 없는 경우 커널 진입 없이 사용자 공간에서 CAS(Compare-And-Swap) 연산으로 락을 획득하므로 오버헤드가 매우 낮다.

  13. 경합 조건은 두 개 이상의 스레드가 공유 자원에 동시에 접근하고, 그중 최소 하나가 쓰기 연산을 수행할 때, 실행 순서에 따라 결과가 달라지는 상황을 말한다.

  14. 임계 영역은 공유 자원에 접근하는 코드 구간으로, 한 번에 하나의 스레드만 실행해야 하는 영역이다.

  15. C11에서는 _Thread_local 키워드, pthreads에서는 pthread_key_create()pthread_setspecific()으로 스레드 로컬 저장소를 사용할 수 있다. GCC와 Clang에서는 __thread 확장도 지원된다.

  16. POSIX는 read(), write(), sleep(), pthread_cond_wait() 등 특정 함수들을 취소 지점으로 지정하고 있다. 스레드 취소는 이 지점에서만 실제로 수행되므로, 취소 지점이 없는 루프에서는 pthread_testcancel()을 명시적으로 호출해야 한다.