스레드는 같은 프로세스에 속한 다른 스레드와 코드, 데이터 섹션 그리고 열린 파일이나 시그널과 같은 운영체제 자원들을 공유한다.
Motivation
어플리케이션은 멀티 코어를 사용하여 다수의 CPU집중 작업을 병렬로 처리할 수 있다
프로세스와 스레드가 하나인 시스템에서는 여러개의 동시 요청에 하나씩 처리할 수 밖에 없다.
서버가 요청을 받아들이는 하나의 프로세스로 동작하게 하면 해결되지만, 프로세스를 생성하는 작업은 매우 많은 시간과 자원을 소비한다.
이런 경우 프로세스 안에 스레드를 만들어 나가는것이 효율적이다.
스레드는 RPC에서도 중요한 역할을 한다. RPC서버는 대부분 멀티스레드 서버다.
운영체제의 커널 안에서 다수의 스레드가 동작하고 각 스레드는 장치 또는 인터럽트 등의 특정 작업을 수행한다.
Benefits
응답성 Responsiveness : 대화형 어플리케이션을 멀티스레드화 하면 응용 프로그램의 일부분이 봉쇄 되거나 프로그램이 긴 작업을 수행하더라도 프로그램의 수행이 계속되는것을 허용한다. 그래서 사용자에 대한 응답성을 증가시킨다.
자원 공유 Resource sharing : 스레드는 그들이 속한 프로세스의 자원들과 메모리를 공유한다. 코드와 데이터 공유의 이점은 어플리케이션이 같은 주소 공간 내에 여러개의 다른 작업을 하는 스레드를 가질 수 있다는 점이다.
경제성 Economy : 스레드는 자신이 속한 프로세스의 자원을 공유하기 때문에 스레드를 생성하고 문맥을 교환하는것이 프로세스를 새로 생성하는 것 보다 경제적이다.
확장성 Scalability : 멀티 프로세서 구조에서 멀티 스레드의 이점이 더 증가된다. 멀티 프로세서 구조에서는 각각의 스레드가 다른 프로세서에서 병렬로 수행될 수 있기 때문이다.
멀티코어 프로그래밍
멀티코어, 멀티프로세서 : CPU 코어가 여러 CPU칩 형태를 띄거나 하나의 칩 안에 여러개가 존재하는 등 이러한 형태의 시스템의 명칭
하나의 코어는 한번에 하나의 스레드만 실행할 수 있다. 단일 코어에서 동시성은 스레드 실행이 시간에 따라 교대로 실행될 뿐이다.
여러개의 코어를 갖고 있다면 개별 스레드를 각 코어에 배정할 수 있어 병렬적으로 실행될 수 있다.
병렬(parallelism) 실행 : 하나 이상의 태스크를 동시에 수행할 수 있는 시스템을 병렬적이라 한다.
동시(concurrency) 실행 : 모든 태스크가 진행하게끔 함으로써 하나 이상의 태스크를 수행시킨다. 따라서 병렬 실행 없이 동시 실행하는 것이 가능하다. 또한 동시성이 있다면 병렬적으로 실행도 가능하다는 뜻이다.
프로그래밍 도전 과제
멀티코어 시스템을 위해 프로그래밍 하기 위해서는 5개의 도전과제가 있다.
태스크 식별 Identifying tasks : 어플리케이션을 분석해 동시가능 태스크로 나눌수 있는 영역을 찾는 작업이 필요하다. 이상적으로는 태스크는 독립적이고 개별코어에서 병렬적으로 실행될 수 있어야 한다.
균형 Balance : 모든 태스크는 균등한 기여도를 가지도록 나누는 것이 중요하다.
데이터 분리 Data spliting : 태스크가 접근하고 조작하는 데이터 또한 개별 코어에서 사용할 수 있도록 나누어져야 한다.
데이터 종속성 Data dependency : 태스크가 접근하는 데이터는 둘 이사으이 태스크 사이에 종속성이 없는지 검토 되어야 한다. 한 태스크가 다른 태스크로부터 오는 데이터에 종속적인 경우는 태스크의 수행을 동기화 해야 한다.
테스트 및 디버깅 : 멀티 코어에서 병렬로 실행 될 때 다양한 실행 경로가 존재할 수 있다. 단일 스레드인 경우보다 디버깅이 훨씬 어렵다.
병렬 실행 유형
데이터 병렬 실행과 태스크 병렬 실행이 있다.
데이터 병렬 실행
데이터의 부분집합을 여러 컴퓨팅 코어에 분배한 뒤 각 코어에서 동일한 연산을 실행하는데 초점을 맞춘다.
태스크 병렬 실행
태스크, 즉 스레드를 다수의 코어에 분배한다. 각 스레드는 고유의 연산을 실행한다. 동일 데이터에 대한 연산을 다른 스레드가 할 수도 있다.
멀티스레딩 모델
스레드를 위한 자원은 사용자 와 커널스레드로 나뉜다.
사용자 스레드는 커널 위에서 지원되며 커널의 지원 없이 관리 된다.
커널 스레드는 운영체제에 의해 직접 지원되고 관리된다.
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()이 호출 될 때 부모와 자식 스레드가 자료구조를 얼마나 공유할 지 결정하는 플래그의 집합이 전달된다.