현대의 거의 모든 운영체제는 한 프로세스가 다중 스레드를 포함하는 특성을 제공한다.
스레드는 cpu 이용의 기본 단위이다.
스레드는 스레드 id, 프로그램 카운터, 레지스터 집합, 스택으로 구성된다.
스레드는 다른 스레드와 코드, 데이터 섹션, 열린파일 같은 자원들을 공유한다.
프로세스 생성 작업은 매우 많은 시간을 소비하고 많은 자원을 필요로 하는 일이다.
요청이 들어오면 프로세스를 생성하는 것이 아니라,
스레드를 생성해서 서비스한다.
대부분의 운영체제 커널도 다중 스레드다.
Linux에서 시스템을 부트하는 동안 여러 커널 스레드가 생성되어
각 장치 관리, 메모리 관리, 인터럽트 처리와 같은 특정 작업을 수행한다.
다중 스레드 프로그래밍의 이점은 4가지 큰 부류로 나눌 수 있다.
단일 cpu -> 다중 cpu -> 다중 코어로 발전했다.
병행 시스템은 모든 작업이 진행되게 하여 둘 이상의 작업을 지원한다.
이에 반해 병렬 시스템은 둘 이상의 작업을 동시에 수행할 수 있다.
다중 처리기 및 다중 코어 전에는 cpu 스케줄러가 프로세스 간을 빠르게 전환해 병렬성의 환상을 제공하였다.
이러한 프로세스는 병행하게 실행되었지만 병렬로 실행되지는 않았다.
일반적으로 다중 코어 시스템을 프로그래밍하기 위해선 5개의 극복해야 할 도전과제가 있다.
데이터 병렬 실행은 동일한 데이터의 부분집합을
다수의 계산 코어에 분배한 뒤 각 코어에서 동일한 연산을 실행하는 데 초점을 맞춘다.
태스크 병렬 실행은 데이터가 아니라 태스크(스레드)를 다수의 코어에 분배한다. 각 스레드는 고유의 연산을 실행한다.
그러나 이러한 병렬처리는 상호 배타적이지 않으면 두 가지를 혼합하여 사용할 수 있다.
user threads를 위한 지원은 사용자 수준에서,
kernel threads를 위해서는 커널 수준에서 제공된다.
유저 스레드는 커널 위에서 지원되고 커널 스레드는 운영체제에 의해 직접 지원 및 관리된다.
궁극적으로 유저 스레드와 커널 스레드는 연관 관계가 존재해야 한다.
스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 행해진다.
하지만 한번에 하나의 스레드만이 커널에 접근할 수 있기 때문에 다중 코어 시스템에서 병렬 실행될 수 없다.
각 유저 스레드를 각각의 커널 스레드로 매핑한다.
이 모델의 유일한 단점은
유저 스레드를 만드려면 커널 스레드를 만들어야 해서 시스템 성능에 부담을 줄 수 있다는 것이다.
여러 개의 유저 스레드를 그보다 작거나 같은 수의 커널 스레드로 멀티플렉스 한다.
다대일과 일대일 모델의 단점을 어느정도 해결했다.
가장 융통성 있는 것으로 보이지만 구현이 어렵다.
또한 코어 수가 증가하면서 커널 스레드 수를 제한하는 것의 중요성이 줄어들었다.
결과적으로 이제는 대부분 일대일 모델을 사용한다.
스레드 라이브러리는 스레드를 생성하고 관리하기 위한 API를 제공한다.
구현하는 데에 주된 두 가지 방법이 있다.
첫 번째는 커널의 지원 없이 완전히 사용자 공간에서만 라이브러리를 제공하는 것이다.
라이브러리의 함수를 호출하는 것은 사용자 공간의 지역 함수를 호출하게 된다는 것을 의미한다.
두 번째는 운영체제에 의해 지원되는 커널 수준 라이브러리를 구현하는 것이다.
코드와 자료구조가 커널 공간에 존재하여, API를 호출하는 것은 시스템 콜을 부르는 결과를 낳는다.
현재 POSIX Pthreads, Windows, Java 세 종류의 라이브러리가 주로 사용된다.
다수의 스레드를 생성하는 비동기 스레딩과 동기 스레딩의 두 가지 일반적인 전략을 소개한다.
비동기 스레딩은 부모가 자식 스레드를 생성한 후
부모는 자신의 실행을 재개하여 서로 독립적으로 실행된다.
독립적이기 때문에 스레드 사이의 데이터 공유는 거의 없다.
비동기 스레딩은 반응형 ui를 설계하는 데에도 흔히 사용된다.
동기 스레딩은 부모 스레드가 하나 이상의 자식 스레드를 생성하고
모든 자식 스레드가 종료할 때까지 기다렸다가 자신의 실행을 재개하는 방식이다.
통상 동기 스레딩은 스레드 사이의 상당한 양의 데이터 공유를 수반한다.
예를 들어 부모 스레드는 자식들이 계산한 결과를 통합할 수 있다.
이후의 모든 예에서는 동기화 스레딩을 사용한다.
POSIX(IEEE 1003.1c)가 제정한 표준 API이다.
스레드의 동작에 관한 명세일 뿐, 구현한 것은 아니다.
이 명세를 가지고 운영체제 설계자들이 나름대로 구현할 수 있다.
1 #include <pthread.h>
2 #include <stdio.h>
3
4 #include <stdlib.h>
5
6 int sum; // 스레드에 의해 공유되는 데이터
7 void *runner(void *param); // threads call this func
8
9 int main(int argc, char *argv[]) {
10 pthread_t tid; // thread identifier
11 pthread_attr_t attr; // set of thread attributes
12
13 // set the default attribute of the thread
14 pthread_attr_init(&attr);
15
16 // create the thread
17 pthread_create(&tid, &attr, runner, argv[1]);
18
19 // wait for the thread to exit
20 pthread_join(tid, NULL);
21
22 printf("sum = %d\n", sum);
23 }
24
25 // thread will execute in this func
26 void *runner(void *param)
27 {
28 int i, upper = atoi(param);
29 sum = 0;
30
31 for (i = 1; i <= upper; i++)
32 sum += i;
33
34 pthread_exit(0);
35 }
각 스레드는 스택의 크기와 스케줄링 정보를 포함한 속성의 집합을 갖는다.
많은 점에서 Pthreads와 유사하다.
1 #include <windows.h>
2 #include <stdio.h>
3 DWORD Sum; // 공유되는 데이터
4
5 // 스레드가 실행할 함수
6 DWORD WINAPI Summation(LPVOID Param) {
7 DWORD Upper = *(DWORD*)Param;
8 for (DWORD i = 1; i <= Upper; i++)
9 sum += i;
10 return 0;
11 }
12
13 int main(int argc, char *argv[]) {
14 DWORD ThreadId;
15 HANDLE ThreadHandle;
16 int Param;
17
18 Param = atoi(argv[1]);
19
20 // create the thread
21 ThreadHandle = CreateThread(
22 NULL, // default security attributes
23 0, // default stack size
24 Summation, // thread function
25 &Param, // parameter to thread function
26 0, // default creation flags
27 &ThreadId); // returns the thread identifier
28
29 // wait for the thread to finish
30 WaitForSingleObject(ThreadHandle, INFINITE);
31
32 // close the thread handle
33 CloseHandle(ThreadHandle);
34
35 printf("sum = %d\n", Sum);
36 }
개별 스레드가 공유하는 데이터는 전역변수로 선언된다.
여러 스레드의 종료를 기다려야 한다면 WaitForMultipleObjects()
함수가 사용된다.
Java 프로그램은 적어도 하나의 단일 제어 스레드를 포함하고 있다.
Java 스레드는 JVM을 제공하는 시스템이라면 사용할 수 있다.
Java에서 스레드를 명시적으로 생성하는 데에는 두 가지 기법이 있다.
한 가지는 Thread 클래스를 상속하고 run() 메소드를 오버라이드하는 것이다.
보다 일반적으로 사용되는 기법은 Runnable 인터페이스를 구현하는 클래스를 정의하는 것이다.
run() 메소드 코드는 별도의 스레드에서 실행된다.
Java에서 스레드를 생성하려면
Thread worker = new Thread(new Task());
worker.start();
새 스레드 객체에 start()를 호출하면 두 가지 작업이 수행된다.
public interface Executor {
void execute(Runnable command);
}
Executor는 다음과 같이 사용된다.
```java
Executor service = new Executor;
service.execute(new Task());
이 방법의 장점은 스레드의 생성과 실행을 분리하고
병행하게 실행되는 작업 간의 통신 기법을 제공한다는 점이다.
순수한 객체 지향 언어인 Java에는 전역 데이터에 대한 개념이 없다.
이에 Callable 인터페이스를 추가로 정의하여 스레드가 결과를 반환할 수 있게 하여 통신한다.
반환된 결과를 Future 객체라고 하는데 Future 인터페이스에 정의된 get()을 통해 결과를 검색할 수 있다.
이 접근 방식은 스레드 생성과 스레드가 만든 결과를 분리한다.
결과를 확인하기 위해 스레드 종료를 기다리는 대신
결과가 가용해지는 것만 기다리면 된다.
멀티 스레딩 프로그램 설계의 여러움을 극복하는 한 가지 방법은
스레딩의 생성과 관리를 컴파일러와 런타임 라이브러리에게 넘겨주는 것이다.
암묵적 스레딩이라고 불리는 이 전략은 점점 널리 사용되고 있다.
이 방법의 장점은 개발자는 병렬 작업만 식별하면 된다는 것이다.
프로세스를 시작할 때 일정한 수의 스레드를 풀에 만들어둔다.
장점은 다음과 같다.
4절에서 다루는 스레드 전략은 fork-join 모델로 알려져 있다.
부모 스레드가 자식 스레드를 생성(fork)한 다음 자식의 종료를 기다린 후
join하고 자식의 결과를 확인하고 결합할 수 있다.
이는 암시적 스레딩에도 사용될 수 있는데,
fork 단계에서 스레드가 직접 구축되지 않고 대신
병렬 작업이 식별된다.
fork-join 모델은 라이브러리가 생성할 스레드 수를 결정하는
동기식 스레드 풀이다.
OpenMP는 C, C++, FORTRAN등으로 작성된 API와 컴파일러 디렉티브의 집합이다.
OpenMP는 공유 메모리 환경에서 병렬 프로그래밍을 할 수 있도록 도움을 준다.
OpenMP는 병렬로 실행될 수 있는 블록을 찾아 병렬 영역이라고 한다.
응용 개발자는 병렬 영역에 컴파일러 디렉티브를 넣고,
이 디렉티브는 OpenMP에 해당 영역을 병렬로 실행하라고 지시한다.
컴파일러 디렉티브를 만나게 되면
코어 개수만큼 스레드를 생성해 병렬 영역을 병렬 실행한다.
(병렬 영역을 빠져나가면 스레드는 종료된다)
GCD는 개발자가 병렬로 실행될 코드 섹션(태스크)을 식별할 수 있도록 하는 런타임 라이브러리, API 및 언어 확장의 조합이다.
GCD는 실행시간 수행을 위해 태스크를 디스패치 큐에 넣어서 스케줄한다.
GCD는 직렬과 병행의 두 가지 유형의 디스패치 큐를 유지한다.
직렬 큐에 넣어진 태스크는 FIFO 순으로 제거된다.
각 프로세스에는 고유한 직렬 큐(메인 큐라고 함)가 있으며,
로컬인 추가 직렬 큐를 만들 수 있다.
직렬 큐를 개인 디스패치 큐라고도 하는 이유이다.
병행 큐에 넣어진 태스크도 FIFO 순으로 제거되지만
한 번에 여러 태스크가 제거되어 병렬로 실행될 수 있게 한다.
다수의 시스넴 전체의 병행 큐(전역 디스패치 큐라고도 함)가 존재하며,
4가지 주요 서비스 품질 클래스로 나뉜다.
Intel TBB는 C++에서 병렬 응용 프로그램 설계를 지원하는 템플릿 라이브러리이다.
TBB 라이브러리는 루프 반복을 개별적인 chunk로 나누고,
해당 청크에 대해 연산을 수행하는 많은 태스크를 만든다.
이 방법의 장점은 개발자는 parallel_for 루프를 지정하여 병렬로 실행할 수 있는 작업을 식별만 하면 된다는 점이다.
다중 스레드 프로그램을 설계할 때 고려해야 할 몇 가지 문제들을 알아보자.
다중 스레드 프로그램에서는 fork()와 exec()의 의미가 달라질 수 있다.
신호는 프로세스에 어떤 이벤트가 일어났음을 알려주기 위해 사용된다.
모든 신호마다 커널이 실행시키는 디폴트 신호 처리기가 있다.
이 디폴트 처리기는 신호를 처리하기 위하여 호출되는 사용자 정의 처리기에 의해 대체될 수 있다.
단일 스레드에서의 신호처리는 간단하지만 다중 스레드는 다르다.
어느 스레드에 신호를 전달해야 하는가?
전달 방법은 신호의 유형에 따라 다르다.
Windows는 신호를 명시적으로 지원하진 않지만 비동기식 프로시저 호출을 사용해서 이를 대리 실행한다.
스레드 취소란 스레드가 끝나기 전에 그것을 강제 종료시키는 작업을 일컫는다.
이 처럼 취소되어야 할 스레드를 target thread라고 부른다.
스레드의 취소는 두 가지 방식으로 발생할 수 있다.
하지만 스레드들에 할당된 자원들이 스레드 취소를 어렵게 만든다.
또한 다른 스레드와 공유하는 자료구조를 갱신하는 중이라면 더더욱 큰 문제다.
종종 취소된 스레드로부터 자원을 회수하지 못하는 경우도 있다.
따라서 비동기식 취소는 위험하다.
이와 반대로 지연 취소는 취소되어도 안전한 시점에 취소 여부를 검사해 안전하다.
기본 취소 유형은 지연 취소이다.
상황에 따라선 각 스레드가 자기만 액세스할 수 있는 데이터를 가져야 할 필요도 있다.
그러한 데이터를 TLS라고 부른다.
TLS를 지역 변수와 혼동하기 쉽지만 TLS는 전체 함수 호출에 걸쳐 보인다.
또한 TLS는 정적 데이터와 유사하지만 TLS 데이터는 스레드마다 고유하다는 차이점이 있다.
다중 스레드 프로그램에서 마지막으로 고려할 문제는
스레드 라이브러리와 커널의 통신 문제이다.
다대다를 구현하는 많은 시스템은 사용자와 커널 스레드 사이에
중간 자료구조를 둔다.
이 자료구조는 통산 경량 프로세스 또는 LWP라고 불린다.
사용자 스레드 라이브러리와 커널 스레드 간의 통신 방법의 하나는
스케줄러 액티베이션이라고 알려진 방법이다.
커널은 응용에 LWP의 집합을 제공하고
응용은 사용자 스레드를 가용한 LWP로 스케줄한다.
게다가 커널은 응용에게 특정 이벤트에 대해 알려줘야 한다.
이 프로시저를 upcall이라고 한다.
Upcall은 스레드 라이브러리의 upcall 처리기에 의해 처리되고,
upcall처리기는 가상 처리기상에서 실행되어야 한다.
하이퍼스레딩에서는 하나의 코어에 논리적인 스레드를 만들어
단일코어지만 병렬적인 일을 할 수 있다.
DMA 기술을 사용하면 cpu를 거치지 않고 바로 메모리에 접근할 수 있다.
멀티스레드에서의 race condition