Thread

🗓️

스레드

  • 스레드는 CPU이용의 기본 단위다.
  • 스레드는 다음으로 구성된다
    • 스레드ID
    • 프로그램 카운터
    • 레지스터 집합
    • 스택
  • 스레드는 같은 프로세스에 속한 다른 스레드와 코드, 데이터 섹션 그리고 열린 파일이나 시그널과 같은 운영체제 자원들을 공유한다.

Motivation

  • 어플리케이션은 멀티 코어를 사용하여 다수의 CPU집중 작업을 병렬로 처리할 수 있다
  • 프로세스와 스레드가 하나인 시스템에서는 여러개의 동시 요청에 하나씩 처리할 수 밖에 없다.
  • 서버가 요청을 받아들이는 하나의 프로세스로 동작하게 하면 해결되지만, 프로세스를 생성하는 작업은 매우 많은 시간과 자원을 소비한다.
  • 이런 경우 프로세스 안에 스레드를 만들어 나가는것이 효율적이다.
  • 스레드는 RPC에서도 중요한 역할을 한다. RPC서버는 대부분 멀티스레드 서버다.
  • 운영체제의 커널 안에서 다수의 스레드가 동작하고 각 스레드는 장치 또는 인터럽트 등의 특정 작업을 수행한다.

Benefits

  1. 응답성 Responsiveness : 대화형 어플리케이션을 멀티스레드화 하면 응용 프로그램의 일부분이 봉쇄 되거나 프로그램이 긴 작업을 수행하더라도 프로그램의 수행이 계속되는것을 허용한다. 그래서 사용자에 대한 응답성을 증가시킨다.
  2. 자원 공유 Resource sharing : 스레드는 그들이 속한 프로세스의 자원들과 메모리를 공유한다. 코드와 데이터 공유의 이점은 어플리케이션이 같은 주소 공간 내에 여러개의 다른 작업을 하는 스레드를 가질 수 있다는 점이다.
  3. 경제성 Economy : 스레드는 자신이 속한 프로세스의 자원을 공유하기 때문에 스레드를 생성하고 문맥을 교환하는것이 프로세스를 새로 생성하는 것 보다 경제적이다.
  4. 확장성 Scalability : 멀티 프로세서 구조에서 멀티 스레드의 이점이 더 증가된다. 멀티 프로세서 구조에서는 각각의 스레드가 다른 프로세서에서 병렬로 수행될 수 있기 때문이다.

멀티코어 프로그래밍

  • 멀티코어, 멀티프로세서 : CPU 코어가 여러 CPU칩 형태를 띄거나 하나의 칩 안에 여러개가 존재하는 등 이러한 형태의 시스템의 명칭
  • 하나의 코어는 한번에 하나의 스레드만 실행할 수 있다. 단일 코어에서 동시성은 스레드 실행이 시간에 따라 교대로 실행될 뿐이다.
  • 여러개의 코어를 갖고 있다면 개별 스레드를 각 코어에 배정할 수 있어 병렬적으로 실행될 수 있다.
  • 병렬(parallelism) 실행 : 하나 이상의 태스크를 동시에 수행할 수 있는 시스템을 병렬적이라 한다.

  • 동시(concurrency) 실행 : 모든 태스크가 진행하게끔 함으로써 하나 이상의 태스크를 수행시킨다. 따라서 병렬 실행 없이 동시 실행하는 것이 가능하다. 또한 동시성이 있다면 병렬적으로 실행도 가능하다는 뜻이다.

프로그래밍 도전 과제

  • 멀티코어 시스템을 위해 프로그래밍 하기 위해서는 5개의 도전과제가 있다.
  1. 태스크 식별 Identifying tasks : 어플리케이션을 분석해 동시가능 태스크로 나눌수 있는 영역을 찾는 작업이 필요하다. 이상적으로는 태스크는 독립적이고 개별코어에서 병렬적으로 실행될 수 있어야 한다.
  2. 균형 Balance : 모든 태스크는 균등한 기여도를 가지도록 나누는 것이 중요하다.
  3. 데이터 분리 Data spliting : 태스크가 접근하고 조작하는 데이터 또한 개별 코어에서 사용할 수 있도록 나누어져야 한다.
  4. 데이터 종속성 Data dependency : 태스크가 접근하는 데이터는 둘 이사으이 태스크 사이에 종속성이 없는지 검토 되어야 한다. 한 태스크가 다른 태스크로부터 오는 데이터에 종속적인 경우는 태스크의 수행을 동기화 해야 한다.
  5. 테스트 및 디버깅 : 멀티 코어에서 병렬로 실행 될 때 다양한 실행 경로가 존재할 수 있다. 단일 스레드인 경우보다 디버깅이 훨씬 어렵다.

병렬 실행 유형

  • 데이터 병렬 실행과 태스크 병렬 실행이 있다.

데이터 병렬 실행

  • 데이터의 부분집합을 여러 컴퓨팅 코어에 분배한 뒤 각 코어에서 동일한 연산을 실행하는데 초점을 맞춘다.

태스크 병렬 실행

  • 태스크, 즉 스레드를 다수의 코어에 분배한다. 각 스레드는 고유의 연산을 실행한다. 동일 데이터에 대한 연산을 다른 스레드가 할 수도 있다.

멀티스레딩 모델

  • 스레드를 위한 자원은 사용자 와 커널스레드로 나뉜다.
  • 사용자 스레드는 커널 위에서 지원되며 커널의 지원 없이 관리 된다.
  • 커널 스레드는 운영체제에 의해 직접 지원되고 관리된다.

N to 1 모델

  • 다대일 모델은 많은 사용자 수준 스레드를 하나의 커널 스레드로 사상한다.
  • 스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 행해진다. 따라서 효율적이라 할 수 있다.
  • 그러나 한 스레드가 블로킹 시스템을 호출할 경우 전체 프로세스가 블로킹이 일어난다.
  • 한번에 하나의 스레드만이 커널에 접근할 수 있어 멀티 스레드가 멀티코어 시스템에서 병렬로 실행될 수 없다.
  • 아무럿 이점이 없어 거의 사용되지 않는다.

1 to 1 모델

  • 하나의 사용자 스래드를 하나의 커널 스레드로 사상한다.
  • 이 모델은 하나의 스레드가 블로킹 시스템을 호출하더라도 다른 스레드가 실행될 수 있기 때문에 다대일 모델보다 더 많은 병렬성을 제공한다.
  • 사용자 수준 스레드를 생성할때 커널 스레드를 생성해야하는 점이다. 그래서 오버헤드를 가진다.
  • 이 모델의 구현은 시스템에 의해 지원되는 스레드 수를 제한한다.
  • 윈도우와 Linux가 이 모델을 구현한다.

M to N 모델

  • 여러개의 사용자 수준 스레드를 그보다 작은 수 혹은 같은 수의 커널 스레드로 멀티플렉싱(multuplex) 한다.
  • 다대일 모델은 개발자가 원하는 만큼의 사용자 수준 스레드를 생성하도록 허용한다. 그러나 한번에 하나의 스레드만이 커널에 의해서 스케줄 되기 때문에 진정한 병렬 실행을 획득할 수 없다.
  • 일대일 모델은 더 많은 동시 실행을 제공하지만 개발자가 한 어플리케이션 내에 너무 많은 스레드를 생성하지 않도록 주의해야 한다.
  • 다대다 모델은 이러한 두가지의 단점들을 어느정도 해결했다. 개발자는 필요한 만큼 스레드를 생성할수 있다. 또한 커널 스레드가 다중 프로세서에서 병렬로 수행될 수 있다.

Threads Library

  • 개발자에게 스레드를 생성하고 관리하기 위한 API를 제공한다.
  • 스레드 라이브러리 구현에는 두가지 방법이 있다.
    • 커널의 지원 없이 완전히 사용자 공간에서만 라이브러리를 제공하는 방법 – 코드와 자료구조는 사용자 공간에 존재한다.
    • 운영체제에 의해 지원되는 커널 수준 라이브러리를 구현하는 방법 – 코드와 자료구조는 커널 공간에 존재한다.
스레딩 전략 두가지
  • 비동기 스레딩 : 부모가 자식 스레드를 생성한 후 부모는 자신의 실행을 재개해 부모와 자식 스레드가 병행하는 구조다. 각 스레드가 모두 독립적으로 실행되고 부모는 자식의 종료를 알 필요가 없다. 스레드가 독립적이라 스레드 사이 데이터 공유는 거의 없다.
  • 동기 스레딩 (fork-join) : 부모 스레드가 하나 이상의 자식 스레드를 생성하고 자식이 모두 종료할때까지 기다렸다가 실행을 재개하는 방식이다. 동기 스레딩은 스레드 사이에 데이터 공유를 수반한다. 부모는 자식들의 계산 결과를 통합할 수 있다.

pthread

  • pthread는 POSIX가 스레드 생성과 동기화를 위헤 제정한 표준 API다
  • 아래 코드는 별도의 스레드에서 음이 아닌 정수의 합을 구하는 다중 스레드프로그램을 제작하기 위한 기본적인 pthread API다.
#include <pthread.h>
#include <stdio.h>

int sum;
void *runner(void *param);

int main(int argc, char *argv[])
{
    // 스레드 식별자
    pthread_t tid;
    // 스레드 속성 (기본값)
    pthread_attr_t attr;

    if (argc != 2)
    {
        fprintf(stderr ,"usage: a.out <integer value>\n");
        return -1;
    }
    if(atoi(argv[1]) < 0) {
        fprintf(stderr, "%d must be >= 0\n", atoi(argv[1]));
    }

    // 스레드 속성 지정 (기본값)
    pthread_attr_init(&attr);
    // 스레드의 생성 및 runner 포인터 함수 작업 시작
    pthread_create(&tid, &attr, runner, argv[1]);
    // fork-join 전략으로 스레드가 종료될때까지 부모 스레드가 대기
    pthread_join(tid, NULL);

    printf("sum = %d\n", sum);
}

void *runner(void *param)
{
    int i, upper = atoi(param);
    sum = 0;

    for (i = 1; i <= upper; i++)
        sum+=i;

    pthread_exit(0);
}
  • 실행 결과
[platanus@TEST]$ gcc pthread_test.c -lpthread
[platanus@TEST]$ ./a.out 1
sum = 1
[platanus@TEST]$ ./a.out 2
sum = 3
[platanus@TEST]$ ./a.out 3
sum = 6
[platanus@TEST]$ ./a.out 100
sum = 5050
[platanus@TEST]$ ./a.out 200
sum = 20100
  • 만약 여러개의 스레드를 생성하고 기다려야 한다면 아래의 코드를 작성해서 대기하면 된다. (해보진 않음)
#define NUM_THREADS 10

pthread_t workers[NUM_THREADS];

for (int i = 0; i < NUM_THREADS; i++)
    pthread_join(workers[i], NULL);

Windows thread

  • 생략

Java thread

  • 스레드는 Java 프로그램에서 프로글매 실행의 근본적인 모델이고 Java언어와 API는 스레드의 생성과 관리를 지원하는 풍부한 기능을 제공한다.
  • 모든 Java 프로그램은 적어도 하나의 단일 제어 스레드를 포함하고 있다.
  • 단지 main()함수로만 이루어진 단순한 Java 프로그램 조차 JVM내의 하나의 단일 스레드로 수행된다.
  • Java에서 스레드를 생성하는 방법에는 두가지가 있다.
    • Thread클래스로부터 파생된 새로운 클래스를 생성하고, Thread클래스의 run()메소드를 오버라이딩 하는 것이다.
    • Runnable 인터페이스를 구현하는 클래스를 정의하는 것이다.
  • (Runnable에 관한 내용 생략)
  • Java를 이용한 코드
public class Sum {

    private int sum;

    public int getSum() {
        return sum;
    }

    public void setSum(int sum) {
        this.sum = sum;
    }
}

public class Summation implements Runnable {

    private int upper;
    private Sum sumValue;

    public Summation(int upper, Sum sumValue) {
        this.upper = upper;
        this.sumValue = sumValue;
    }

    @Override
    public void run() {
        int sum = 0;
        for (int i = 0; i <= upper; i++) {
            sum += i;
        }
        sumValue.setSum(sum);
    }
}

public class Driver {

    public static void main(String[] args) {
        int i = 10;

        if (i < 0) {
            System.err.println(i + " must be >= 0.");
        } else {
            // 합을 저장할 인스턴스 생성
            Sum sum = new Sum();
            // 스레드 설정
            Thread thread = new Thread(new Summation(i, sum));
            // 스레드 작업 시작
            thread.start();
            try {
                // fork-join 전략으로 스레드 작업이 끝나길 대기
                thread.join();
                System.out.println("The sum of " + i + " is " + sum.getSum());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  • 아래는 실행 결과다
The sum of 10 is 55

묵시적 스레딩 Implict threading

  • 멀티코어 시스템이 발전함에 따라 수백수천개의 스레드를 가진 어플리케이션들이 등장했다.
  • 멀티 스레드 어플리케이션의 설계를 도와주는 한가지 방법은 스레딩의 생성과 관리 책임을 어플리케이션 개발자로부터 컴파일러와 실행시간 라이브러리에게 넘겨주는 것이다.
  • 이것을 묵시적 스레딩 또는 암묵적 스레딩이라 부른다.
  • 묵시적 스레팅을 이용해 멀티코어 프로세서를 활용할 수 있는 멀티스레드 프로그램을 설계하는 3가지 접근법
    • Thread pool < 여기에 대해서만 알아보겠음.
    • OpenMP
    • Grand Central Dispatch
  • 멀티 스레드로 구성된 예로 좋은 웹브라우저가 있다. 웹 서버는 요청을 받을때 마다 요청으 루이해 새로운 스레드를 만들어 준다.
  • 새로운 스레드를 매 요청마다 만들어 주는 것은 그때마다 새로운 프로세스를 만들어주는 것 보다 확실히 낫지만, 멀티스레드 서버는 여전히 아래의 문제가 있다.
    • 요청당 스레드를 생성할 때 마다 발생하는 비용 (시간)
    • 동시에 실행할 수 있는 스레드의 수를 지정하지 않으면 자원이 고갈된다 (최대 한계 지정)

Thread pool

  • 스레드 풀의 기본 아이디어는 프로세스를 시작할 때 일정 스레드를 미리 풀로 만들어 두는 것이다.
  • 이 스레드들은 평소에는 기다린다. 요청이 들어오면 이 풀에서 한개의 스레드에게 작업을 할당한다. 작업이 끝나면 스레드는 다시 풀로 들어가 다음 작업을 기다린다.
  • 모든 스레드가 바닥 나면 서버는 스레드풀에 스레드가 생길 때 까지 기다려야 한다.
  • 스레드 풀 전략의 장점
    • 새 스레드를 만들어 주기 보다 기존 스레드를 서비스해 주는것이 빠르다.
    • 스레드 풀은 임의 시각에 존재할 스레그 개수에 제한을 둔다. 이러한 제한은 많은 수의 스레드를 병행 처리할 수 없는 시스템에 도움이 된다.
    • 작업과 무관하게 작업을 생성할 수 있으면 작업을 실행할 때 다양한 정략을 사용할 수 있다. 예를 들어 작업을 일정 시간 후에 실행되도록 하거나 주기적으로 실행시키는 스케줄링이 가능하다.
  • 스레드 풀의 스레드 개수는 CPU수, 메모리용량, 동시요청 클라이언트 최대 개수를 고려해 정해질 수 있다.
  • 동적으로 풀의 크기를 바꿀 수도 있다.

OpenMP

  • 공유 메모리 환경에서 병렬 프로그래밍을 할 수 있도록 도움을 주는 API와 컴파일러 디렉티브의 집합.

Grand Central Dispatch

  • Apple의 macOS와 iOS를 위한 기술, OpenMP와 마찬가지로 스레딩의 상세구현의 대부분을 관리한다.

Threading issues

fork() 와 exec()

  • 멀티 스레드 프로그램에서 fork()와 exec()의 의미가 달라질 수 있다.
  • UNIX에서 지원하는 fork()
    • 한 프로로그램의 스레드가 fork()를 호출하면 새로운 프로세스는 모든 스레드를 복제
    • fork()를 호출한 스레드만 복제
  • 어떤 스레드가 exec()호출을 부르면 exec() 매개변수로 지정된 프로그램이 모든 스레드를 포함한 전체 프로세스를 대체시킨다.
  • 두 버전의 fork()중 어느것을 택할지는 어플리케이션에 달려있다. fork()를 부르자 마자 다시 exec()를 호출한다면 모든 스레드를 다 복제해서 만들어주는 것이기 때문에 불필요하다. 왜냐하면 exec()에서 지정한 프로그램이 곧 모든 것을 다시 대체할 것이기 때문이다.
  • 이 경우에는 fork()를 호출한 스레드만 복사해 주는 것이 적절하다.
  • 그러나 새 프로세스가 fork()후에 exec()를 하지 않는다면 새 프로세스는 모든 스레드를 복제해야 한다.

시그널 처리

  • 시그널은 UNIX에서 프로세스에게 어떤 이벤트가 일어났음을 알려주기 위해 사용된다.
  • 시그널은 알려줄 이벤트의 소스나 이유에 따라 동기식 또는 비동기식으로 전달될 수 있다.
  • 모든 시그널은 다음과 같은 형태로 전달된다.
    • 시그널은 특정 이벤트가 일어나야 생성된다
    • 생성된 시그널이 프로세스에 전달된다
    • 시그널이 전달되면 반드시 처리되어야 한다.
  • 동기식 시그널의 예로는 불법적인 메모리접근, 0으로 나누기 등이 있다. 실행중인 프로그램이 이러한 행동을 하면 시그널이 발생한다.
  • 시그널이 실행중인 프로세스 외부로 부터 발생되면 그 프로세스는 시그널을 비동기식으로 전달 받는다. 이러한 시그널의 예는 Ctrl+C와 같은 특수키를 눌러서 프로세스를 강제 종료시키거나 타이머가 만료되는 경우를 포함한다.
  • 모든 시그널은 둘 중 하나의 프로세서에 의해 처리 된다.
    • 디폴트 시그널 프로세서
    • 사용자 정의 시그널 프로세서
  • 모든 신호마다 커널이 실행 시키는 디폴트 시그널 프로세서가 있다. 이 디폴트 시그널 프로세서는 사용자 정의 시그널 프로세서로 정의될 수 있다.
  • 단일 스레드에서 시그널 처리는 간단하다. 시그널은 항상 프로세스에 전달된다.
  • 다중 스레드에서 시그널 처리는 다음과 같은 선택이 존재한다.
    • 시그널이 적용될 스레드에 전달한다.
    • 모든 스레드에 전달한다
    • 몇몇 스레드들에게만 선택적으로 전달한다
    • 측정 스레드가 모든 시그널을 받도록 지정한다.
  • 시그널의 유형에 따라 전달방법이 다르다.
  • 동기식 시그널은 그 시그널을 야기한 스레드에게 정확하게 전달되야한다.
  • 비동기식 시그널은 Ctrl+C같은 키를 입력하면 그 프로세스 내의 모든 스레드에게 전달해야 한다.

스레드 취소

  • 스레드 취소는 스레드가 끝나기 전에 강제종료 시키는 작업을 일컫는다.
  • 취소되어야 할 스레드를 목표 스레드라고 부른다. 목표 스레드의 취소는 다음과 같은 두가지 방식으로 발생할 수 있다.
    • 비동기식 취소 : 한 스레드가 즉시 목표 스레드를 강제 종료 시킨다.
    • 지연 취소 : 목표 스레드가 주기적으로 자신이 강제 종료 되어야할지를 점검한다. 이 경우 목표 스레드가 질서정연하게 강제 종료 될 수 있는 기회가 만들어 진다.
  • 스레드 취소를 어렵게 만드는 것은 취소 스레드들에게 할당된 자원 문제다. 스레드가 다른스레드와 공유하는 데이터를 갱신하는 도중에 취소 요청이 와도 문제가 된다.
    • 비동기식 취소의 경우 모든 자원을 반환하지 못할 수도 있다. (유효 자원을 회수하지 못하는 경우임)
    • 지연 취소의 경우는 한 스레드가 목표 스레드를 취소해야 한다고 표시하지만 실제 취소는 목표 스레드가 취소 여부를 결정하기 위한 플래그를 검사한 이후에 일어난다. 자신이 취소되어도 된다고 판단되는 시점에 취소여부가 일어난다.
  • 기본 취소 유형은 지연취소다.
  • 지연 취소는 스레드가 취소 지점에 도달했을때만 취소 작업이 일어난다.

스레드 로컬 저장소

  • 한 프로세스에 속한 스레드들은 그 프로세스의 데이터를 모두 공유한다. 데이터 공유는 멀티 스레드 프로그래밍의 큰 장점 중 하나다.
  • 그러나 상황에 따라 자기만의 데이터를 가져야 할 필요가 있다. 그러한 데이터를 스레드 로컬 저장소 라고 무른다. (thread-local storage)
  • TLS에는 각 스레드의 고유한 정보가 저장된다. 지역변수와는 달리 TLS는 메소드 호출 전후에도 접근이 가능하다.

스케줄러 활성

  • 멀티 스레드 프로그램은 스레드 라이브러리와 커널의 통신 문제를 고려해야 한다.
  • 많은 시스템에서 사용자와 커널 스레드 사이에 중간 자료구조를 둔다. (경량 프로세스 LWP)
  • 각 LWP는 하나의 커널 스레드에 부속된다.
  • 어플리케이션은 효율적으로 실행되기 위해 임의의 개수의 LWP를 필요로 할 수 있다.
  • 사용자 스레드 라이브러이와 커널 스레듯같의 통신 방법 중 하나가 스케줄러 활성이다.
  • 커널은 어플리케이션에 LWP의 집합을 제공하고 어플리케이션은 사용자 스레드를 가용한 LWP로 스케줄 한다.

운영체제 사례 (Linux Threads)

  • 리눅스는 clone()을 이용해 스레드를 생성할 수 있는 기능을 제공한다.
  • clone()이 호출 될 때 부모와 자식 스레드가 자료구조를 얼마나 공유할 지 결정하는 플래그의 집합이 전달된다.