Skip to content
Published on

[운영체제] 04. 스레드와 동시성 프로그래밍

Authors

스레드 개념

스레드(Thread)는 CPU 이용의 기본 단위다. 하나의 프로세스 안에서 여러 스레드가 실행될 수 있으며, 각 스레드는 고유한 스레드 ID, 프로그램 카운터, 레지스터 집합, 스택을 가진다. 같은 프로세스의 스레드끼리는 코드, 데이터, 파일 등의 자원을 공유한다.

[단일 스레드 vs 멀티스레드 프로세스]

단일 스레드 프로세스:           멀티스레드 프로세스:

+------------------+          +------------------+
| 코드 | 데이터 | 파일 |          | 코드 | 데이터 | 파일 |
+------------------+          +------------------+
| 레지스터 | 스택   |          | 레지스터 | 레지스터 | 레지스터 |
+------------------+          | 스택     | 스택     | 스택     |
                              | 스레드1  | 스레드2  | 스레드3  |
                              +----------------------------------+

스레드 사용 동기

  1. 응답성(Responsiveness): GUI 응용 프로그램에서 하나의 스레드가 사용자 입력을 처리하는 동안, 다른 스레드가 연산 수행
  2. 자원 공유(Resource Sharing): 같은 프로세스의 스레드는 메모리와 자원을 자동으로 공유
  3. 경제성(Economy): 프로세스 생성보다 스레드 생성이 훨씬 가볍고 빠름. 컨텍스트 스위치도 더 빠름
  4. 확장성(Scalability): 멀티코어 아키텍처에서 스레드를 병렬로 실행하여 성능 향상
[웹 서버의 멀티스레드 구조]

                     +-> [워커 스레드 1] -> 요청 처리
클라이언트 요청 ---> [메인 스레드] -+-> [워커 스레드 2] -> 요청 처리
                     +-> [워커 스레드 3] -> 요청 처리

각 워커 스레드가 개별 클라이언트 요청을 처리
코드, 데이터(캐시 등)는 모든 스레드가 공유

멀티코어 프로그래밍

멀티코어 시스템에서의 병렬 처리

멀티코어 시스템에서 스레드는 실제로 병렬로 실행될 수 있다.

[단일 코어 vs 멀티코어에서의 스레드 실행]

단일 코어 (동시성 - Concurrency):
시간 -->
코어1: [T1][T2][T3][T1][T2][T3][T1]...
        번갈아가며 실행 (시분할)

멀티코어 (병렬성 - Parallelism):
시간 -->
코어1: [T1][T1][T1][T1][T1]...
코어2: [T2][T2][T2][T2][T2]...
코어3: [T3][T3][T3][T3][T3]...
        동시에 실행

암달의 법칙 (Amdahl's Law)

프로그램에서 순차적으로만 실행해야 하는 부분이 있으면, 코어를 아무리 많이 추가해도 속도 향상에 한계가 있다.

[암달의 법칙]

속도 향상 = 1 / (S + (1 - S) / N)

S = 순차 실행 비율
N = 프로세서(코어)
예시: 프로그램의 25%순차적(S = 0.25)

코어 (N)  |  속도 향상
-----------+-----------
    1      |   1.00    2      |   1.60    4      |   2.29    8      |   2.91   16      |   3.20   무한대   |   4.00 (이론적 최대)

순차 부분이 25%이면 아무리 코어를 추가해도 4배가 한계!

멀티코어 프로그래밍의 과제

  1. 작업 분할: 독립적으로 실행 가능한 작업을 식별
  2. 균형: 각 스레드에 동등한 양의 작업을 배분
  3. 데이터 분할: 데이터를 개별 코어에서 사용할 수 있도록 분할
  4. 데이터 종속성: 공유 데이터에 대한 동기화 필요
  5. 테스트와 디버깅: 비결정적 실행 경로로 인한 어려움

멀티스레딩 모델

사용자 스레드와 커널 스레드

  • 사용자 스레드: 사용자 수준 라이브러리에서 관리. 커널은 존재를 모름
  • 커널 스레드: OS 커널이 직접 관리. 커널이 스케줄링
[멀티스레딩 모델]

1. 다대일(Many-to-One):
   사용자 스레드: T1 T2 T3 T4
                  \  |  |  /
   커널 스레드:     K1
   - 하나가 블로킹되면 전체 블로킹
   - 멀티코어 활용 불가

2. 일대일(One-to-One):
   사용자 스레드: T1  T2  T3  T4
                  |   |   |   |
   커널 스레드:  K1  K2  K3  K4
   - 더 높은 병렬성
   - 스레드 수에 제한 가능
   - Linux, Windows 사용

3. 다대다(Many-to-Many):
   사용자 스레드: T1 T2 T3 T4 T5
                  \  | X  |  /
   커널 스레드:   K1  K2  K3
   - 유연한 매핑
   - 구현이 복잡

현대 대부분의 OS는 일대일 모델을 사용한다. Linux는 clone() 시스템 콜로 커널 스레드를 생성한다.


스레드 라이브러리

Pthreads

POSIX 표준의 스레드 API다. Linux, macOS에서 사용한다.

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

#define NUM_THREADS 5

// 스레드 함수
void *thread_func(void *arg) {
    int thread_id = *(int *)arg;
    long sum = 0;

    // 각 스레드가 다른 범위의 합을 계산
    long start = thread_id * 1000000L + 1;
    long end = (thread_id + 1) * 1000000L;

    for (long i = start; i <= end; i++) {
        sum += i;
    }

    printf("스레드 %d: %ld부터 %ld까지의 합 = %ld\n",
           thread_id, start, end, sum);

    long *result = malloc(sizeof(long));
    *result = sum;
    return (void *)result;
}

int main() {
    pthread_t threads[NUM_THREADS];
    int thread_ids[NUM_THREADS];
    long total = 0;

    // 스레드 생성
    for (int i = 0; i < NUM_THREADS; i++) {
        thread_ids[i] = i;
        int rc = pthread_create(&threads[i], NULL,
                                thread_func, &thread_ids[i]);
        if (rc) {
            fprintf(stderr, "스레드 생성 실패: %d\n", rc);
            exit(EXIT_FAILURE);
        }
    }

    // 모든 스레드 종료 대기 및 결과 수집
    for (int i = 0; i < NUM_THREADS; i++) {
        void *result;
        pthread_join(threads[i], &result);
        total += *(long *)result;
        free(result);
    }

    printf("전체 합: %ld\n", total);
    return 0;
}
# 컴파일 시 -pthread 플래그 필요
gcc -pthread thread_sum.c -o thread_sum

Java 스레드

Java에서는 Thread 클래스 상속 또는 Runnable 인터페이스 구현으로 스레드를 생성한다.

// Runnable 인터페이스를 사용한 멀티스레드 합산
public class ThreadSum {

    static long[] partialSums;

    static class SumTask implements Runnable {
        private final int threadId;
        private final long start;
        private final long end;

        SumTask(int threadId, long start, long end) {
            this.threadId = threadId;
            this.start = start;
            this.end = end;
        }

        @Override
        public void run() {
            long sum = 0;
            for (long i = start; i <= end; i++) {
                sum += i;
            }
            partialSums[threadId] = sum;
            System.out.printf("스레드 %d: %d~%d 합 = %d%n",
                              threadId, start, end, sum);
        }
    }

    public static void main(String[] args) throws Exception {
        int numThreads = 5;
        partialSums = new long[numThreads];
        Thread[] threads = new Thread[numThreads];

        for (int i = 0; i < numThreads; i++) {
            long start = i * 1_000_000L + 1;
            long end = (i + 1) * 1_000_000L;
            threads[i] = new Thread(new SumTask(i, start, end));
            threads[i].start();
        }

        // 모든 스레드 종료 대기
        for (Thread t : threads) {
            t.join();
        }

        long total = 0;
        for (long s : partialSums) {
            total += s;
        }
        System.out.println("전체 합: " + total);
    }
}

암묵적 스레딩 (Implicit Threading)

멀티스레딩 프로그래밍은 어렵고 오류가 발생하기 쉽다. 암묵적 스레딩은 스레드 생성과 관리를 컴파일러나 런타임 라이브러리에게 맡기는 접근법이다.

스레드 풀 (Thread Pool)

스레드를 미리 여러 개 생성해 두고, 작업이 들어오면 유휴 스레드에 할당한다.

// Java의 스레드 풀 사용 예시
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Callable;
import java.util.ArrayList;
import java.util.List;

public class ThreadPoolExample {

    static class SumTask implements Callable<Long> {
        private final long start, end;

        SumTask(long start, long end) {
            this.start = start;
            this.end = end;
        }

        @Override
        public Long call() {
            long sum = 0;
            for (long i = start; i <= end; i++) {
                sum += i;
            }
            return sum;
        }
    }

    public static void main(String[] args) throws Exception {
        // CPU 코어 수만큼 스레드 풀 생성
        int cores = Runtime.getRuntime().availableProcessors();
        ExecutorService pool = Executors.newFixedThreadPool(cores);

        List<Future<Long>> futures = new ArrayList<>();

        // 작업 제출
        for (int i = 0; i < 10; i++) {
            long start = i * 1_000_000L + 1;
            long end = (i + 1) * 1_000_000L;
            futures.add(pool.submit(new SumTask(start, end)));
        }

        // 결과 수집
        long total = 0;
        for (Future<Long> f : futures) {
            total += f.get();
        }

        System.out.println("전체 합: " + total);
        pool.shutdown();
    }
}

스레드 풀의 장점은 다음과 같다.

  • 기존 스레드를 재사용하므로 생성 오버헤드 감소
  • 동시 스레드 수를 제한하여 시스템 자원 보호
  • 작업 생성과 실행을 분리하여 유연한 스케줄링 가능

Fork-Join 프레임워크

큰 작업을 작은 하위 작업으로 분할(fork)하고, 결과를 합치는(join) 패턴이다.

[Fork-Join 동작]

       [전체 작업: 1~1000]
              |
        fork /  \ fork
            /    \
  [1~500]      [500+1~1000]
     |                |
  fork/ \fork      fork/ \fork
   /    \           /    \
[1~250][250+1  [500+1 [750+1
         ~500]  ~750]  ~1000]
   \    /           \    /
  join\ /join      join\ /join
     |                |
  [부분합1]        [부분합2]
        \    /
      join\ /join
           |
       [전체 합]
import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;

public class ForkJoinSum extends RecursiveTask<Long> {
    private static final long THRESHOLD = 100_000;
    private final long start, end;

    ForkJoinSum(long start, long end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            // 기본 사례: 직접 계산
            long sum = 0;
            for (long i = start; i <= end; i++) {
                sum += i;
            }
            return sum;
        }

        // 재귀적 분할
        long mid = (start + end) / 2;
        ForkJoinSum left = new ForkJoinSum(start, mid);
        ForkJoinSum right = new ForkJoinSum(mid + 1, end);

        left.fork();              // 왼쪽 비동기 실행
        long rightResult = right.compute();  // 오른쪽 현재 스레드에서 실행
        long leftResult = left.join();       // 왼쪽 결과 대기

        return leftResult + rightResult;
    }

    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool();
        long result = pool.invoke(new ForkJoinSum(1, 10_000_000));
        System.out.println("결과: " + result);
    }
}

OpenMP

컴파일러 지시어(pragma)를 사용하여 C/C++ 코드를 병렬화한다.

#include <stdio.h>
#include <omp.h>

int main() {
    long sum = 0;
    int n = 10000000;

    // OpenMP 병렬 for 루프
    // 컴파일러가 자동으로 반복을 스레드에 분배
    #pragma omp parallel for reduction(+:sum)
    for (int i = 1; i <= n; i++) {
        sum += i;
    }

    printf("합: %ld\n", sum);
    printf("사용된 스레드 수: %d\n", omp_get_max_threads());

    return 0;
}
# OpenMP 컴파일
gcc -fopenmp parallel_sum.c -o parallel_sum

# 스레드 수 지정하여 실행
OMP_NUM_THREADS=4 ./parallel_sum

OpenMP의 주요 지시어는 다음과 같다.

  • parallel: 병렬 영역 시작
  • for: 반복문 병렬화
  • critical: 임계 영역 지정
  • atomic: 원자적 연산
  • reduction: 축소 연산 (합, 곱 등)

스레드 관련 이슈

fork()와 exec()의 의미

멀티스레드 프로세스에서 fork()를 호출하면 두 가지 선택이 있다.

  • 모든 스레드를 복사하는 fork
  • 호출한 스레드만 복사하는 fork (대부분의 Unix 구현)

exec()을 호출하면 모든 스레드가 새 프로그램으로 대체된다.

시그널 처리

시그널은 어떤 스레드에게 전달해야 하는가?

  1. 시그널이 해당하는 스레드에게 전달
  2. 모든 스레드에게 전달
  3. 특정 스레드에게 전달
  4. 특정 스레드를 시그널 수신 전용으로 지정

스레드 취소

실행 중인 스레드를 조기 종료시키는 것으로, 두 가지 방식이 있다.

  • 비동기 취소: 즉시 대상 스레드를 종료 (자원 누수 위험)
  • 지연 취소: 대상 스레드가 주기적으로 취소 요청을 확인 (안전)

정리

스레드는 프로세스 내에서 실행 흐름을 나누는 경량 단위다. 멀티코어 시스템에서 진정한 병렬 실행이 가능하지만, 암달의 법칙에 의해 순차 부분이 병목이 된다. 스레드 풀, Fork-Join, OpenMP 같은 암묵적 스레딩 기법은 프로그래머의 부담을 줄이면서 효율적인 병렬 처리를 가능하게 한다.