여기서는 하나의 프로세스에서 다중 스레드를 포함하는 특성에 대해서 소개를 하고 있습니다.
다중 CPU를 제공하는 최신 다중 코어 시스템에서 스레드 사용을 통한 병렬 처리에 대해서 알아볼 것입니다.
스레드는 CPU 이용의 기본 단위 입니다.
스레드는 다음과 같이 구성됩니다.
스레드는 다음과 같은 자원들을 다른 스레드들과 공유합니다.
웹 서버는 여러 개의 클라이언트들이 병행하게 접근할 수 있습니다.
만약 웹 서버가 전통적인 단일 스레드 프로세스로 작동한다면, 자신의 단일 프로세스로 한번에 하나의 클라이언트만 서비스 할 수 있게 되어 비효율적이다.
하나의 해결책은 서버가 요청을 받아들이는 하나의 프로세스로 동작하게 하는 것이다.
즉, 서버에게 서비스 요청이 들어오면, 프로세스는 그 요청을 수행할 별도의 프로세스를 생성하는 것이다. 멀티 태스킹으로 동작하게 하는 것이다.
하지만 각 프로세스들이 비슷한 일을 처리함에도 불구하고 프로세스를 생성하기 위해 많은 시간을 소비하고 많은 자원을 사용하는 것은 낭비다.
따라서, 이러한 상황에서는 프로세스 안에 여러 스레드를 만들어 나가는 것이 더 효율적입니다.
웹 서버가 다중 스레드화 되면, 서버는 클라이언트의 요청을 listen
하는 별도의 스레드를 생성합니다.
많은 응용 프로그램도 기본 정렬, 트리 및 그래프 알고리즘을 포함하여 다중 스레드를 활용합니다.
장점으로 다음의 4가지 큰 부류로 나눌 수 있습니다.
다중 스레드 프로그래밍은 다중 코어를 활용하여 보다 효율적으로 코어를 사용하고 병행성을 향상시키는 기법을 제공합니다.
여기서부터는 병행성과 병렬성의 단어의 차이를 구분해야 합니다.
다중 코어 시스템을 위해 프로그래밍하기 위해서는 5개의 극복해야 할 도전 과제가 있습니다.
병렬 실행은 다음 두 가지 유형이 존재합니다.
스레드는 다음과 같이 두 가지로 나눌 수 있습니다.
이 두 스레드의 연관 관계를 확립하는 세 가지 일반적인 방법이 있습니다.
이것들에 대해서 살펴볼 것입니다.
다대일(many-to-one) 모델은 많은 사용자 수준 스레드를 하나의 커널 스레드로 사상합니다. 스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 행해집니다.
해당 방식의 문제는 한 스레드가 봉쇄형 시스템 콜(동기식)을 할 경우, 전체 프로세스가 봉쇄된다는 것입니다.
또한, 한번에 하나의 스레드만이 커널에 접근할 수 있기 때문에, 다중 스레드가 다중 코어 시스템에서 병렬로 실행될 수 없습니다.
과거에 Solaris 시스템을 위한 라이브러리, Java 초기 버전에서 채택되어 사용되었지만 현재는 이 모델을 사용 중인 시스템은 거의 존재하지 않는다고 합니다.
일대일(one-to-one) 모델은 각 사용자 스레드를 각각 하나의 커널 스레드로 사상합니다.
이 모델을 다대일 모델보다 더 많은 병렬성을 제공합니다.
이 모델의 유일한 단점은 사용자 스레드를 만들려면 해당 커널 스레드를 만들어야 하며 많은 수의 커널 스레드가 시스템 성능에 부담을 줄 수 있다는 것입니다.
Linux는 Windows 운영체제 제품군과 함께 해당 모델을 구현한다고 합니다.
다대다(many-to-many) 모델은 여러 개의 사용자 수준 스레드를 그보다 작은 수, 혹은 같은 수의 커널 스레드로 멀티플렉스 합니다.
이 설계가 병행 실행에 어떠한 영향을 미치는지 알아보기 위해서 다른 모델을 다시 살펴봅시다.
다대일 모델은 커널이 한 번에 하나의 커널 스레드만 스케줄 할 수 있으므로 진정한 병렬 실행을 획득할 수 없었습니다.
일대일 모델은 더 많은 병렬 실행을 제공하지만, 너무 많은 스레드를 생성하지 않도록 주의해야 했습니다.
다대다 모델은 이러한 두 가지 단점을 어느정도 해결했습니다.
개발자는 필요한만큼 많은 사용자 수준 스레드를 생성할 수 있습니다. 그리고 상응하는 커널 스레드가 다중 처리에서 병렬로 수행될 수 있습니다.
또한, 스레드가 봉쇄형 시스템 콜을 발생시켰을 때, 커널이 다른 스레드의 수행을 스케줄 할 수 있습니다.
다대다 모델의 변형은 두 수준 모델(two-level model)을 허용합니다.
두 수준 모델이라고 하는 것은 필요에 따라 한 사용자 스레드가 하나의 커널 스레드에만 연관되는 모델을 의미합니다. (일대일 모델 + 다대다 모델)
다대다 모델이 가장 융통성 있는 것을 보이지만 실제로는 구현이 어렵고 처리 코어 수가 증가함에 따라 커널 스레드 수를 제한하는 것의 중요성이 줄어들었습니다.
결과적으로 대부분의 운영체제는 이제 일대일 모델을 사용합니다.
스레드 라이브러리(threads library)는 프로그래머에게 스레드를 생성하고 관리하기 위한 API를 제공합니다.
스레드 라이브러리를 구현하는 방법은 두 가지가 있습니다.
당연히, 커널 공간에서 제공할 경우 "시스템 콜"을 부르는 결과가 생깁니다.
현재 POSIX Pthreads, Windows 및 Java의 세 종류 라이브러리가 주로 사용된다.
앞으로 다룰 내용은 이것들에 관한 내용일 것입니다.
그전에, 스레드를 생성하는 일반적인 두 가지 전략에 대해서 알아보고 갑시다.
비동기 스레딩은 부모가 자식 스레드를 생성한 후 부모는 자신의 실행을 재개하여 부모와 자식 스레드가 서로 독립적으로 병행하게 실행합니다. 스레드가 독립적이기 때문에 스레드 사이의 데이터 공유는 거의 없습니다.
동기 스레딩은 부모 스레드가 하나 이상의 자식 스레드를 생성하고 자식 스레드 모두가 종료할 때 까지 기다렸다가 자신의 실행을 재개하는 방식을 말합니다.
부모가 생성한 스레드는 병행하게 실행되지만 부모는 자식들의 작업이 끝날 때까지 실행을 계속할 수 없습니다. 부모 스레드는 오직 모든 자식 스레드와 조인한 후에야 실행을 재개할 수 있습니다.
Pthreads는 POSIX가 스레드 생성과 동기화를 위해 제정한 표준 API입니다.
Linux와 macOS를 포함한 많은 시스템이 Pthreads 명세를 구현하고 있습니다.
해당 프로그램은 main()
함수의 최초(부모) 스레드와 runner()
함수의 스레드 두 개를 가지게 됩니다.
이 프로그램은 스레드 생성/조인 전략을 상용합니다. 합산 스레드를 생성한 후 pthread_join()
함수를 호출하여 합산 스레드가 종료하기를 부모 스레드가 기다립니다.
합산 스레드는 pthread_exit()
함수를 호출하여 종료하게 됩니다.
여러 스레드를 실행하려면 다음과 같이 반복문 사용을 고려해 볼 수 있습니다.
Windows 스레드 라이브러리를 사용하는 기술도 Pthreads 기법과 유사합니다.
Windows 스레드도 Pthreads 버전과 마찬가지로 개별 스레드가 공유하는 데이터는 전역 변수로 선언됩니다.
Pthreads 기법에서 사용했던 pthread_join()
문은 Windows API에서는 WaitForSingleObject()
함수를 이용합니다.
여러 스레드의 종료를 기다려야 한다면 WaitForMultipleObject()
함수가 사용되고 다음 4가지 매개변수를 전달받습니다.
INFINITE
)Java 프로그램은 적어도 하나의 단일 제어 스레드를 포함하고 있습니다. 단지 main()
함수로만 이루어진 단순한 Java 프로그램조차 JVM 내의 하나의 단일 스레드로 수행됩니다.
Java 프로그램에서 스레드를 명시적으로 생성하는 데에는 두 가지 기법이 있는데,
run()
메서드를 재정의여기서는 Runnable 인터페이스와 Thread 클래스가 무엇인지 정리하고 Runnable 인터페이스의 존재 의미에 대해서 살펴보고 넘어가겠습니다.
Thread 클래스
run()
메서드를 오버라이드하여 스레드가 실행할 코드를 정의합니다.start()
메서드를 호출하여 스레드를 실행시킵니다.class MyThread extends Thread {
public void run() {
// 스레드가 실행할 코드
}
}
MyThread t = new MyThread();
t.start();
Runnable 인터페이스
run()
메서드를 구현해야 하며, 이 메서드 내에 스레드가 실행할 코드를 작성합니다.start()
메서드를 호출하여 스레드를 실행시킵니다. class MyRunnable implements Runnable {
public void run() {
// 스레드가 실행할 코드
}
}
Runnable r = new MyRunnable();
Thread t = new Thread(r);
t.start();
이렇게만 보면, Thread 클래스만 사용하면 될 것 같은데 왜 굳이 Runnable 인터페이스를 사용하는 것일까요?
Runnable을 사용하는 이유
즉 정리하자면,
"Ruunable" 인터페이스는 멀티스레딩 환경에서 스레드가 실행할 작업을 정의합니다. 즉, 함수형 인터페이스입니다.
"Thread"는 스레드 생성 및 관리를 "Runnable"은 작업 코드를 관리하는 것으로 분리하면서 재사용성, 가독성 및 유지보수성을 높여줍니다.
Thread 객체를 명시적으로 생성하는 대신 Executor 인터페이스를 중심을 스레드 생성을 구성할 수 있습니다.
public interface Executor
{
void excute(Runnable command);
}
Executor service = new Executor;
service.execute(new Task());
이 방법의 장점은 스레드 생성을 실행에서 분리할 뿐만 아니라 병행하게 실행되는 작업 간의 통신 기법을 제공한다는 것입니다.
Windows와 Pthreads에서 공유 데이터는 단순히 전역적으로 선언되기 떄문에 동일한 프로세스에 속한 스레드 간의 데이터 공유는 쉽게 가능했고, Java는 매개변수로 전달할 수는 있었지만 스레드가 결과를 반환할 수는 없었습니다.
이것을 위해서 java.util.concurrent
패키지는 Callable
인터페이스를 추가로 정의하며, 결과를 반환할 수 있다는 점을 제외하고 Runnable과 유사하게 동작합니다.
Callable 작업에서 반환된 결과를 Future 객체라고 합니다.
여기서 태스크를 전달할 때 submit()
함수를 사용했는데, execute()
와 다르게 submit()
함수는 결과를 Future 객체로 반환합니다.
암묵적 스레딩은 병행 및 병렬 응용의 설계를 도와주는 한 가지 방법으로 스레딩의 생성과 관리 책임을 응용 개발자로부터 컴파일러와 실행시간 라이브러리에게 넘겨주는 것입니다.
이러한 전략은 일반적으로 응용 프로그래램 개발자가 병렬로 실행할 수 있는 스레드가 아닌 작업을 식별해야 합니다.
작업은 일반적으로 함수로 작성되며, 런타임 라이브러리는 일반저긍로 다대다 모델을 사용하여 별도의 스레드에 매핑합니다.
장점은 다음과 같습니다.
즉, 스레드의 생성과 관리를 개발자로부터 분리하면서 역할 분리가 이루어지는 것입니다.
다중 스레드는 여러 문제를 가지고 있습니다.
이러한 문제들을 해결해 줄 수 있는 방법의 하나가 스레드 풀(pool)입니다.
스레드 풀의 기본 아이디어는 프로세스를 시작할 때 아예 일정한 수의 스레드들을 미리 풀로 만들어 두는 것입니다.
서버는 스레드를 생성하지 않고 요청을 받으면 대신 스레드 풀에 제출하고 추가 요청 대기를 재개합니다.
이러한 방식을 아래와 같은 장점을 가지게 됩니다.
간단하게 정리하자면, 만들어 놓은 스레드를 재활용하기 때문에 속도가 빠를 수 있고, 스레드 풀을 스레드들의 상위 객체로 분리하여 관리하기 편리하다는 것입니다.
동작의 경우, 스레드를 직접 실행했을 때와 유사합니다. 다만, 스레드안에서 동작하는 함수의 매개변수를 스레드 풀의 API에게 건네줍니다.
이후 Java의 스레드 풀 생성 방식으로 java.util.concurrent
패키지에 있는 API를 사용하는 것에 대한 설명이 나오는데 넘어가겠습니다.
앞서 join()
함수를 사용하여 스레드 생성을 하는 것을 확인했는데, 이러한 전략은 종종 fork-join 모델로 알려져 있습니다.
이 메소드를 사용하면 메인 부모 스레드가 하나 이상의 자식 스레드를 생성(fork)한 다음 자식의 종료를 기다린 후 join하고 그 시점부터 자식의 결과를 확인하고 결합할 수 있습니다.
이러한 방식은 명시적 스레드 생성이라고 특정짓기도 하지만, 암시적 스레딩에서도 사용될 수 있습니다.
어떤 식으로든 이 fork-join 모델은 라이브러리가 생성할 실제 스레드 수를 결정하는 동기 버전의 스레드 풀입니다.
이러한 방식으로 사용할 수 있는 경우는 무엇이 있을까요?
해당 방식은 재귀 분할-정복 알고리즘과 굉장히 잘 어울립니다.
실제로, Java는 Quicksort 및 MergeSort와 같은 재귀 분할-정복 알고리즘과 함께 사용되도록 설계된 버전 1.7 API에 fork join 라이브러리를 도입하였습니다.
세부적인 구현은 생략하도록 하겠습니다.
Java는 THRESHOLD
값을 설정하여 그것보다 큰 양을 다뤄야할 때 해당 기능을 사용하도록 설계되어 있습니다.
JAVA의 fork-join 모델은 이와같이 라이브러리가 작업자 스레드 풀을 생성하고 사용 가능한 작업자 간 부하의 균형을 조정하는 작업 관리를 수행합니다.
OpenMP는 C, C++, 또는 FORTERAN으로 작성된 API와 컴파일러 디렉티브의 집합입니다. OpenMP는 공유 메모리 환경에서 병렬 프로그래밍을 할 수 있도록 도움을 줍니다.
OpenMP는 병렬로 실행될 수 있는 블록을 찾아 병렬 영역(parallel regions)이라고 부릅니다.
컴파일러 디렉티브를 병렬 영역에 삽입하여 해당 영역의 실행을 병렬로 실행하게 합니다.
#include <omp.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
/* sequential code */
#pragma op parallel
{
printf(" I am a parallel region.");
}
/* sequential code */
return 0;
}
OpenMP가 다음과 같은 컴파일러 디렉티브를 만나게 되면
#pragma op parallel
기본적으로 시스템의 코어 개수만큼 스레드를 생성합니다. 개발자가 필요한 스레드의 개수를 직접 지정하는 식으로 OpenMP는 개발자가 병렬화 수준을 선택할 수 있게도 해줍니다.
이후, GCD라고 하는 macOS 및 iOS 운영체데를 위해 사용하는 기술이 나옵니다. 이것은 디스패치 큐를 사용하여 직렬 혹은 병렬 + 우선순위 조합을 이용하여 스레드 풀의 스레드에게 태스크를 할당해주는 방법을 제시합니다.
또 다른 설명으로는 Intel TBB(Threading building block)에 대해서 언급하고 있습니다. 태스크 스케줄러에 대한 소개가 나오면서 기능적인 이점과 간단한 사용법에 대해서 언급하고 있습니다.
여기까지만 정리를 하고 넘어가도록 하겠습니다.
다중 스레드 프로그램을 설계할 때 고려해야 될 몇 가지 문제들을 알아보자.
하나의 프로그램의 스레드가 fork()를 호출하면 새로운 프로세스는 모든 스레드를 복제해야 하는가, 아니면 한 개의 스레드만 가지는 프로세스여야만 하는가?
fork()
는 두 가지 버전을 제공합니다.
fork()
를 호출한 스레드만 복제하는 것상황에 맞게 설계하면 되겠지만, 만약 exec
를 바로 실행할 것이라면 아마도 fork()
를 호출한 스레드만 사용하면 될 것이다.
왜냐하면 exec
에서 지정한 프로그램이 곧 모든 것을 다시 대체할 것이기 때문이다.
신호는 UNIX에서 프로세스에 어떤 이벤트가 일어났음을 알려주기 위해 사용됩니다.
신호는 동기식과 비동기식으로 전달될 수 있는데, 방식과 상관없이 다음과 같은 형태로 전달됩니다.
동기식 신호의 경우 불법적인 메모리 접근, 0으로 나누기 등과 같이 실행 중인 프로그램에서 발생합니다. 동기식 신호는 신호를 발생시킨 연산을 수행한 동일한 프로세스에 전달됩니다.
비동기식 신호의 경우 신호가 실행 중인 프로세스 외부로부터 발생하면 그 프로세스는 신호를 비동기식으로 전달 받습니다.
모든 신호는 둘 중 하나의 처리기에 의해 처리됩니다.
모든 신호마다 커널이 실행시키는 디폴트 신호 처리기가 있고, 이것은 사용자 정의 신호 처리기를 사용하여 대체될 수 있습니다.
다중 스레드 프로그램에서는 프로그램에 여러 스레드가 존재할텐데 어느 스레드에 신호를 보내야 될 지 알아보자.
일반적으로 다음과 같은 선택이 존재합니다.
이것은 신호의 유형에 따라 달라질 수 있는데, 예를 들어보면 동기식 신호는 그 신호를 야기한 스레드에게만 전달되어야 하는 것이고 비동기식 신호는 명확하지 않으므로 모든 스레드에게 전달되어야 할 수 있는 것입니다.
신호를 전달하는 데 사용되는 표준 UNIX 함수는
kill(pid_t pid, int signal)
입니다. 이 함수는 특정 신호가 전달될 프로세스(pid)를 지정합니다.
대부분의 다중 스레드 UNIX는 스레드에 받아들일 신호와 봉쇄될 신호를 지정할 수 있는 선택권을 줍니다.
이러한 구성의 경우, 신호는 오직 한 번만 처리되어야 하기 때문에 비동기식 신호를 받았을 때 봉쇄되지 않는 스레드들 중 첫번째 것에만 신호가 전달되는 로직이 생깁니다.
Windows는 신호를 명시적으로 지원하지 않지만 비동기식 프로시저 호출(asynchronous procedure calls, APC)이라는 것을 사용해서 이를 대리 실행(emulate)할 수 있습니다.
APC는 사용자 스레드들이 특정 이벤트의 발생을 전달받았을 때 호출된 함수를 지정할 수 있게 합니다.
차이를 보자면, UNIX에서는 신호가 어느 프로세스에게 건네줘야되는 지를 결정해야되지만 APC는 프로세스에 전달되는 것이 아니라 특정 스레드에게 전달되기 떄문에 좀 더 간단합니다.
스레드 취소(thread cancellation)는 스레드가 끝나기 전에 그것을 강제 종료시키는 작업을 일컫습니다.
취소되어야 할 스레드를 목적 스레드(target thread)라고 하는데, 목적 스레드의 취소는 다음과 같은 두 가지 방법으로 발생할 수 있습니다.
스레드 취소를 어렵게 만드는 것은 할당된 자원 문제입니다.
만약 목적 스레드가 다른 스레드와 자원을 공유하고 있는 상황이라고 해보자면 더욱 심각하다.
운영체제는 취소된 스레드로부터 시스템 자원을 회수할 수 있지만, 모든 시스템 자원을 회수하지 못하는 경우도 있습니다.
지연 취소의 경우에는 목적 스레드를 취소한다고 결정나고 바로 제거되는 것이 아니라 안전하다고 판단되는 시점에서 취소 여부를 검사할 수 있다.
취소는 활성화, 비활성화를 할 수 있으며 경우에 맞춰서 사용하면 된다.
기본 취소 유형은 지연 취소입니다. 그러나 스레드가 취소 점에 도달한 경우에만 취소가 발생합니다.
POSIX 및 표준 C 라이브러리에서 대부분의 블로킹 시스템 콜은 취소 점으로 정의되며, Linux 시스템에서 man pthreads 명령을 호출할 때 나열됩니다.
예를 들자면, read()
시스템 콜은 read()
에서 입력을 기다리는 동안 봉쇄된 스레드의 취소를 허용하는 취소 점입니다.
Pthreads는 스레드가 취소될 때 정리 핸들러(cleanup handler)라고 하는 함수가 호출되게 할 수 있습니다. 이 기능을 사용하면 스레드가 종료되기 전에 스레드가 획득한 모든 자원을 해제할 수 있습니다.
Java의 스레드 취소는 Pthread의 지연 취소와 유사한 정책을 사용합니다. Java의 스레드를 취소하려면 interrupt()
메서드를 호출하여 대상 스레드의 인터럽트 상태를 true로 설정합니다.
각 스레드는 프로세스의 데이터를 공유하면서 사용하지만, 각자의 저장 공간도 사용합니다. 이것을 스레드-로컬 저장장치(thread-local storage, TLS)라고 부릅니다.
어떤 면에서 TLS는 정적 데이터와 유사해 보입니다. 차이점은 TLS 데이터는 스레드마다 고유하다는 것입니다.
ThreadLocal<T> 클래스
를 사용하여 TLS 저장소에 데이터를 저장할 수 있습니다.
다중 스레드 프로그램과 관련하여 마지막으로 고려할 문제는 스레드 라이브러리와 커널의 통신 문제입니다.
다대다 또는 두 수준 모델을 구현하는 많은 시스템은 사용자와 커널 스레드 사이에 중간 자료구조를 둡니다. 이 자료구조는 통상 경량 프로세스 또는 LWP라고 불립니다.
이것은 마치 가상 처리기(virtual processor)처럼 보입니다.
각 LWP는 하나의 커널 스레드에 부속되어 있으며 물리 처리기에서 스케줄하는 대상은 바로 이 커널 스레드입니다.
커널 스레드가 봉쇄되면 LWP도 같이 봉쇄됩니다. 이 연관을 따라 LWP에 부속된 사용자 수준 스레드도 역시 봉쇄됩니다.
간단하게 고려해서 하나의 처리기상에서 실행되는 CPU 중심 응용을 고려해 봅시다.
이 상황에서는 한순간에 오직 하나의 스레드만이 실행될 수 있습니다. 따라서 하나의 LWP이면 충분합니다.
그러나 입출력 중심 응용은 여러 개의 LWP를 필요로 할 수 있습니다. 동시에 발생하는 봉쇄형 시스템 콜마다 하나의 LWP가 필요합니다.
예를 들어 서로 다른 5개의 파일 읽기 요청이 발생했다고 가정해 봅시다. 만일 프로세스가 4개의 LWP만 가지고 있다면 다섯 번째 요청은 하나의 LWP라도 커널에 복귀할 때까지 기다려야 합니다.
사용자 스레드 라이브러리와 커널 스레드 간의 통신 방법의 하나는 스케줄러 액티베이션이라고 알려진 방법입니다.
다음과 같이 동작을 정리할 수 있습니다.
upcall
이 부분에 대해선 현재 개념이 이해가 되지 않아서 나중에 정리를 하도록 하겠습니다.