프로세스가 운영체제의 스케쥴링 단위라면, 스레드는 처리 단위이다.(= CPU 이용의 기본 단위)
스레드는 스레드 ID, PC, 레지스터 모음, 그리고 스택으로 구성된다.
스레드는 프로세스와 달리 같은 프로세스 내에서 자원을 공유한다.
(프로세스는 프로텍션 도메인(프로세스에 할당되는 메모리 영역)이 지원되어 메모리 독립성을 유지한다.)
전통적인 프로세스는 단일 스레드를 가지고 있다. 하지만 프로세스가 다수의 제어 스레드(다중 스레드)를 가진다면, 프로세스는 동시에 하나 이상의 작업을 수행할 수 있다.
현대에 작동되는 대부분의 어플리케이션은 독립적인 프로세스로 실행되며 여러 개의 실행 흐름을 가진다.
이러한 어플리케이션을 다중 스레드를 이용한다고 하며, 다중 코어 시스템에서 여러 개의 코어를 사용하여 작업을 병렬적으로 수행하여 처리능력을 향상 시킬 수 있다.
예를 들어, 비슷한 작업 여러 개를 수행해야 할 때 단일 스레드 프로세스로 동작을 한다면 모든 작업을 순차적으로 진행되며 이는 작업 효율을 저하시킨다.
과거에는 이를 해결하기 위해 프로세스를 별도로 생성하여 해당 프로세스에 필요한 작업을 할당하는 방식이 보편적으로 사용되었지만, 유사한 작업을 수행하는데 큰 오버헤드가 발생하였다.
이와 달리 응용 프로그램이 다중 스레드화 되면 프로세스를 생성하는 것보다 효율적이며 처리능력이 향상된다.
이러한 이유로 대부분의 운영체제 커널과 많은 응용 프로그램에서 다중 스레드를 활용한다.
다중 스레드 프로그래밍의 이점은 4가지의 큰 특징으로 나눌 수 있다.
컴퓨팅 칩에 여러 개의 코어를 배치하는 것을 멀티 코어라고 하며, 멀티 스레드 프로그래밍은 여러 개의 코어를 효율적으로 사용하고 병행성을 향상시키는 기법을 제공한다.
싱글 코어 시스템에서는 코어가 하나의 스레드만 처리할 수 있기 때문에, 병행성은 시간이 지남에 따라 교차로 배치(인터리브)하게 된다.
하지만 여러 개의 코어가 있는 시스템에서 병행성은 시스템이 각 코어에 스레드를 할당하기 때문에 스레드가 병렬적으로 실행된다.
암달의 법칙은 순차와 병렬 구성요소를 바탕으로 컴퓨팅 코어가 추가될 때 얻을 수 있는 성능의 추가치를 알아내는 식이다.
아래 식에서 S는 어플리케이션에서 순차적으로 진행되는 부분의 비율, N은 코어의 개수를 의미한다.
위 법칙의 흥미로운 점은, N(코어의 개수)가 무한대에 가까워질수록 속도는 S에 수렴한다.
이 점으로 인해, 어플리케이션에 순차 실행 부분이 클수록 코어를 추가하여 얻을 수 있는 성능 향상을 저하시킨다.
멀티 코어 시스템이 발전함에 따라, 프로그래머도 코어의 활용도를 높일 수 있도록 코드를 작성해야 하는데,
일반적으로 멀티 코어 시스템을 위해 프로그래밍하기 위해 고려해야 할 부분이 5가지 있다.
병렬 실행의 유형은 크게 두 가지가 존재한다.
스레드는 사용자 레벨과 커널 레벨의 지원을 구분하여 지원한다.
아래에서 사용자 레벨 스레드와 커널 레벨 스레드를 알아보고 두 스레드 간의 매핑 관계를 아래 모델들로 확인할 수 있다.
스레드는 User Level Thread와 Kernel Level Thread로 나뉘며, 각 특성은 아래와 같다.
User Level Threads
사용자 레벨 스레드는 사용자에 의해 생성되고, 커널은 위 스레드들의 존재를 알 수 없다.
따라서 시스템은 이들을 싱글 스레드 프로세스처럼 관리한다.
Kernel Level Threads
커널 레벨 스레드는 운영체제에 의해 작동하며, 커널을 통해 스레드를 관리한다.
프로세스와 프로세스 스레드에 대한 문맥 정보는 모두 커널에서 관리한다.
위 이유로 커널 레벨 스레드는 사용자 레벨 스레드보다 느리다.
다대일 모델은 다수의 유저 스레드를 한 개의 커널 스레드에 매핑한다.
스레드 관리를 유저 스레드 라이브러리로 하기 때문에 효율적이지만, 한 스레드가 봉쇄 시스템 콜을 만드는 경우, 전체 프로세스가 봉쇄된다.
멀티 스레드가 멀티 코어 시스템에서 병렬적으로 실행될 수 없어서 멀티 코어의 이점을 살릴 수 없다.
(동시성을 잃는다.)
일대일 모델은 각 사용자 스레드를 각각의 커널 스레드로 매핑한다.
다대일 모델에 비해 더 많은 병렬성을 제공하고 커널에서 각 유저 스레드를 별개의 프로세스로 인식하기 때문에 멀티 코어 시스템에서 병렬 수행이 가능하지만, 많은 수의 커널 스레드가 성능에 부담을 줄 수 있다.
대부분의 시스템에서 커널 스레드 수를 제한하는 것의 중요성이 줄어들어서 대부분의 운영체제는 일대일 모델을 사용한다.
다대다 모델은 여러 개의 사용자 스레드를 같은 수 또는 그 보다 작은 수의 커널 스레드로 매핑한다.
커널 스레드의 수는 어플리케이션이나 하드웨어에 따라 결정된다.
다대다 모델은 다대일, 일대일 모델의 단점을 어느 정도 해결하여, 개발자는 필요한 만큼 많은 사용자 스레드를 생성할 수 있다.
그리고 상응하는 커널 스레드가 멀티 프로세서에서 병렬로 수행될 수 있다.
다대다 모델은 많은 논의가 있었지만, 그 중 두 수준 모델이라고 불리는 모델이 가장 융통성 있는 것으로 보이지만, 실제로 구현하기가 어렵다.
스레드 라이브러리는 개발자에게 스레드를 생성하고 관리하기 위한 API를 제공한다.
스레드 라이브러리를 구현하는 데에는 두 가지 방법이 있는데,
주로 사용되는 세가지 라이브러리로 POSIX Pthreads, Windows, Java가 있는데,
Pthreads: 사용자 또는 커널 수준 라이브러리
Windows: Windows 시스템에서 사용 가능한 커널 수준 라이브러리
Java: 호스트 운영체제의 라이브러리를 사용하여 구현
추가로 각 라이브러리를 사용하여 다수의 스레드를 생성할 때 사용하는 두 가지 일반적인 전략이 있다.
Pthreads는 POSIX가 스레드 생성과 동기화를 위해 제정한 표준 API이다.
이는 스레드 동작에 대한 설명서와 같이 제공될 뿐 구현이 된 것은 아니다.
따라서, 이 설명서를 가지고 운영체제 설계자들은 Pthreads를 구현한다.
스레드 생성과 동기화를 위해 사용되는 함수는
등이 있다.
Windows 스레드 라이브러리에서 스레드를 생성하는 기술은 Pthreads의 기법과 유사하다.
스레드 생성과 동기화를 위해 사용되는 함수는
등이 있다.
Java 언어와 API는 스레드 생성과 관리를 풍부하게 지원하는데,
모든 Java 프로그램은 적어도 하나 이상의 단일 제어 스레드를 포함한다.
Java에서 스레드 생성과 동기화를 위해서는 Runnable을 구현하는 클래스의 인스턴스를 전달하고
아래 함수를 사용할 수 있다.(공룡책 예시 함수로 추가적인 함수들도 별도로 존재한다.)
// 생성 예시
class Task implements Runnable
{
public void run() {
System.out.println("I am a thread.")
}
}
Thread worker = new Thread(new Task());
worker.start();
멀티 코어 프로세싱의 지속적인 성장으로 수백, 수천 개의 스레드를 가진 어플리케이션이 등장했는데, 이러한 어플리케이션을 설계하는 것은 쉽지 않고 (4. 2)절에서 이야기 한 고려할 부분을 포함하여 더 많은 부분들을 고려하여야 한다.
이러한 부분들을 극복할 수 있는 방법 중 하나로 스레딩의 생성과 관리의 책임을 개발자가 아닌 컴파일러와 런타임 라이브러리에 넘기는 것이고, 이를 암묵적 스레딩이라 부른다.
위 방법을 사용하기 위해서는, 개발자가 병렬로 실행할 스레드가 아닌 작업을 식별해야 한다.
특정 작업을 스레드를 만들어서 처리하는 것은, 새로운 프로세스를 만드는 것보다 효율적인 방법이지만,
모든 요청마다 새 스레드를 만들어서 제공한다면 생성과 스케쥴링으로 인해 어플리케이션의 성능 저하를 야기한다.
이런 문제를 해결하기 위한 방법 중 하나인 스레드 풀은 프로세스를 시작할 때, 일정한 수의 스레드들을 미리 풀로 만들어둔다.
정해진 수의 스레드에서 작업 큐에 들어오는 작업을 하나씩 스레드가 맡아서 처리하고, 작업이 끝난 스레드는 다시 대기 큐에서 작업을 가져와 처리한다.
이로 인해, 많은 작업 요청이 동시에 발생하더라도 대기 큐에 있다가 여유있는 스레드가 작업을 처리하기 때문에 스레드의 전체 개수는 유지되며 어플리케이션의 성능도 저하되지 않는다.
fork-join 모델은 프로그램이 지정된 지점에서 작업을 병렬로 분기하여 처리하고, 그 후 다시 병합하여 순차 실행은 재개하도록 하는 방법이다.
위(4. 4절)에서 join 또는 pthread_join 함수를 사용하여, 동기 스레딩하는 방식도 fork-join 모델로 알려져있다.
일반적으로 명시적 스레드 생성이라고 보지만, 암시적 스레딩에도 사용할 수 있다.
암시적 스레딩에서 사용될 때는, fork 단계에서 잠재적 병렬 처리 작업이 식별된다.
Java는 재귀 분할-정복 알고리즘에 사용되도록 fork join 라이브러리를 도입하였다.
이 라이브러리를 사용하여 분할-정복 알고리즘을 구현하면 별도의 작업 없이 fork되고 문제의 일부가 할당된다.
위 라이브러리의 일반적인 동작은 아래와 같이 실행된다.
fork-join 모델은 특정 작업 단위에 도달할 때까지 재귀적으로 분기될 수 있다.
이 경우, 작업이 충분히 작은 크기여서 더 이상 추가 작업을 생성하지 않은 때를 신중하게 결정해야 한다.
Java의 fork-join 모델에서 흥미로운 점은 라이브러리에서 제공하는 작업 관리가 작업자 스레드 풀을 구성하고 사용 가능한 작업자 간의 부하의 균형을 조정한다.
또한, ForkJoinPool의 각 스레드는 fork된 작업의 큐를 유지, 관리하며 스레드 큐가 비어있으면 work stealing 알고리즘을 사용하여 다른 스레드 큐에서 작업을 가져올 수 있어서 모든 스레드 간의 부하를 분산시킬 수 있다.
OpenMP는 C, C++, 또는 FORTRAN으로 작성된 컴파일러 지시문의 집합이자 API이며 공유 메모리 환경에서 병렬 프로그래밍을 지원한다.
OpenMP는 병렬로 실행될 수 있는 블록을 찾아 병렬 영역이라고 부르며, 개발자가 병렬 영역에 컴파일러 지시문을 삽입한다.
지시문은 OpenMP 런타임 라이브러리에 해당 영역을 병렬로 실행하라고 지시한다.
#pragma omp parallel
{
printf("I am a parallel region.");
}
OpenMP는 병렬화를 위한 디렉티브를 다양한 방식으로 제공하며, 개발자가 병렬화 수준을 선택할 수 있게 한다.
Linux, Windows 및 macOS 시스템을 위한 다수의 오픈 소스와 컴파일러에서 사용할 수 있다.
GCD는 macOS 및 iOS 운영체제를 위해 Apple에서 개발한 기술이다.
개발자가 병렬로 실행될 코드 섹션을 식별할 수 있도록 하는 런타임 라이브러리, API 및 언어 확장의 조합이다.
GCD는 런타임 수행을 위해 태스크를 디스패치 큐에 넣어 스케쥴하고 태스크를 제거할 때, 스레드 풀에서 가용 스레드를 선택하여 태스크를 할당한다.
GCD는 두 가지 유형(직렬과 병행)의 디스패치 큐를 유지한다.
Intel TBB는 C++에서 병렬 어플리케이션 설계를 지원하는 템플릿 라이브러리이다.
라이브러리기 때문에 특별한 컴파일러나 언어 지원이 필요하지 않다.
개발자가 병렬로 실행할 수 있는 태스크를 지정해주면, TBB 스케쥴러는 해당 태스크를 하부 스레드에 매핑한다.
태스크 스케쥴러는 부하 균형 기능을 제공하고 캐시를 인지한다.
(캐시를 인지한다 = 빠르게 실행되는 태스크에 우선순위를 부여한다)
아래에서는 다중 스레드 프로그램을 설계할 때, 고려해야할 문제들에 대해 볼 수 있다.
멀티 스레드 프로그램에서 fork()를 하는 경우, 의미가 달라질 수 있다.
모든 스레드를 복사할 지, fork()를 호출한 스레드를 복사할 지 선택해야 하는데, 몇몇 UNIX 시스템에서는 이 둘 모두 지원한다.
fork()만 하여 프로세스의 복제본을 가지고 싶은 경우, 모든 스레드를 복제해서 만들어야 하지만
fork() 후 exec()을 호출하여 기존 프로세스를 대체할 경우, 모든 스레드를 복제하는 일은 불필요하다.
fork 시스템 콜은 스레드라는 개념이 없던 시기에 만들어졌기 때문에 thread-safe를 보장하지 않는다.
따라서, 위 두 가지 방식을 지원하지 않는 멀티 스레드 환경에서는 이를 고려하여 사용해야 한다.
시그널은 UNIX에서 프로세스에 어떤 이벤트가 발생했음을 알려주기 위해 사용한다.
신호는 동기식 또는 비동기식으로 전달될 수 있는데, 모든 신호는 다음과 같은 형태를 가진다.
모든 신호는 기본적으로 디폴트 시그널 핸들러에 의해 처리되며, 이는 사용자 정의 시그널 핸들러로 대체할 수 있다.
단일 스레드 프로그램에서의 신호 처리는 간단하다. 하지만 프로세스가 여러 스레드를 가지고 있는 경우 별도로 처리해야 하는데, 대부분의 UNIX는 스레드에 받아들일 신호와 봉쇄할 신호를 지정할 수 있는 선택권을 준다.
스레드 취소는 스레드가 끝나기 전에 강제 종료 시키는 작업을 뜻한다.
취소되어야 할 스레드를 목적 스레드라고 부르며, 취소는 두 가지 방식으로 발생할 수 있다.
Pthreads는 3가지 취소 모드를 지원하는데 아래와 같다.
위에서 설명된 비동기식 취소의 문제 때문에 Pthreads 문서에서는 비동기 취소를 권장하지 않는다.
Java의 스레드 취소는 Pthread의 지연 취소와 유사한 정책을 사용한다.
각각의 스레드는 고유한 스택을 갖기 때문에, 스택에 저장된 데이터는 스레드 별로 고유하다. 하지만 정적 변수와 전역 변수의 경우에는 프로세스 내의 모든 스레드에서 공유된다.
하지만 상황에 따라서 각 스레드가 자신만 액세스할 수 있는 데이터(정적, 전역 변수)를 가져야 할 때도 있다.
그 때, 스레드-로컬 저장장치(TLS)를 사용한다.
TLS는 API 또는 컴파일러가 지원할 때, 사용 가능하다.
(4. 3. 3)절에서 살펴본 다대다 모델에서는 스레드 라이브러리와 커널의 통신 문제를 반드시 해결해야 한다.
사용자 스레드 라이브러리와 커널 스레드 간의 통신 방법 중 하나는 스케줄러 액티베이션이라는 방법이다.
커널은 어플리케이션에 사용자 스레드와 커널 스레드 사이의 자료구조인 LWP의 집합을 제공하고 어플리케이션은 사용자 스레드를 사용 가능한 가상 처리기로 스케줄한다. 또한, 커널은 어플리케이션에게 이벤트에 대해 알려야 한다.
이 절차를 upcall이라 부르고, upcall은 스레드 라이브러리의 upcall 처리기에 의해 처리된다.
LWP는 Low-weight Process의 약자로 경량 프로세스라 부르는데,
이는 하나 이상의 사용자 스레드를 지원하고, 커널 스레드 하나에 매핑되어 커널에 의해 독립적으로 스케줄링된다.
스레드 라이브러리에 의해 관리되는 여러 사용자 수준 스레드는 하나 이상의 LWP 위에 배치될 수 있다.
하지만 일부 운영 체제에서는 커널 스레드를 나타내기도 하고, 사용자 스레드(Linux)를 나타내기도 한다.
Windows와 Linux 시스템에서 스레드를 어떻게 구현하는 지 탐구한다.
윈도우는 어플리케이션을 프로세스 형태로 실행하고, 프로세스는 하나 이상의 스레드를 가진다.
윈도우 스레드의 구성 요소
레지스터 힙합, 스택, 개별 데이터 저장 영역은 그 스레드의 문맥으로 불린다.
윈도우 스레드의 자료 구조
Linux는 clone()이라는 시스템 콜을 통해 스레드를 생성할 수 있는 기능을 제공하지만,
프로세스와 스레드를 구별하지 않고, 태스크라는 용어를 사용한다.
Linux 커널이 태스크를 표현하는 방식 때문에 다양한 공유 수준을 선택할 수 있는데,
이를 설정하지 않고 clone() 시스템 콜을 이용하면 fork()가 호출된 것과 유사한 기능을 제공한다.
4 .1 다중 스레딩이 단일 스레드 솔루션보다 더 나은 성능을 제공하는 세 가지 프로그래밍 예제를 제시하라.
4 .2 Amdahl의 법칙을 사용하여 (a) 2개의 처리 코어와 (b) 4개의 처리 코어에 대해 60%의 병렬 구성요소를 가진 응용 프로그램의 속도 향상 이득을 계산하라.
4 .3 4.1절에 설명된 다중 스레드 웹 서버는 작업 병렬 처리인가 혹은 데이터 병렬 처리인가?
4 .4 사용자 수준 스레드와 커널 수준 스레드의 2가지 차이점은 무엇인가? 한 유형의 스레드가 다른 유형의 스레드보다 유리한 상황은 언제인가?
4 .5 커널 수준 스레드 사이에서 문맥 교환을 위해 커널이 해야할 일을 설명하라.
4 .6 스레드가 생성될 때 어떤 자원이 사용되는가? 프로세스가 생성될 때 사용되는 자원과의 차이점은 무엇인가?
4 .7 다대다 모델을 사용하여 운영체제가 사용자 수준 스레드를 커널에 매핑하고 매핑은 LWP를 사용한다고 가정하자. 또한 개발자는 실시간 시스템에서 사용할 실시간 스레드를 만들 수 있다고 하자. 실시간 스레드를 LWP에 바인딩해야 하는가? 여러분의 답을 설명하라.