현대적인 운영 체제들은 한 개의 Process가 여러 Thread를 다룰 수 있도록 하는 기능을 제공한다. 4장 Thread & Concurrency에서는 아래 5가지 목표에 대해서 다루게 된다.
- 다중 스레드 컴퓨터 시스템의 기초를 이루는 CPU의 기본 단위인 스레드를 소개한다.
- 다중 스레드 프로그래밍과 관련된 여러 쟁점을 검토한다.
- 암시적 스레딩을 지원하는 몇 가지 전략(threading pool, fork-join, Grand Central Dispatch)을 탐구한다.
- Windows, Linux의 스레드 지원에 대해 알아본다.
- Pthreads API 및 Windows와 Java 스레드 라이브러리에 대해 논의한다.
스레드는 CPU 이용의 기본 단위이다. 스레드 ID, 프로그램 카운터, 레지스터 집합, 그리고 스택으로 구성된다. 스레드는 같은 프로세스에 속한 다른 스레드와 코드, 데이터 섹션 등의 운영체제 자원들을 공유한다. 프로세스에는 싱글 스레드와 멀티 스레드가 존재한다.
최신 컴퓨터와 모바일 장치에서 실행되는 대부분의 소프트웨어 응용 프로그램은 다중 스레드이다. 어플리케이션은 일반적으로 여러 개의 제어 스레드가 있는 별도의 프로세스로 구현된다. 멀티스레드 어플리케이션의 몇 가지 예는 아래와 같다.
-
이미지 컬렉션에서 썸네일을 만드는 응용 프로그램은 별도의 스레드를 사용하여 개별 이미지에서 썸네일을 생성할 수 있음
-
다른 스레드가 네트워크에서 데이터를 검색하는 동안 웹 브라우저에는 하나의 스레드가 디스플레이 이미지 또는 텍스트를 담당
-
워드 프로세서는 그래픽을 표시하는 스레드, 사용자의 키 입력에 응답하는 다른 스레드, 백그라운드에서 철자 및 문법 검사를 수행하는 세 번째 스레드를 포함할 수 있음
하나의 응용 프로그램이 여러 개의 비슷한 작업을 수행할 필요가 있는 상황에서 멀티 스레딩은 좋은 방법이다. 다른 해결책은 서버의 요청을 받아들이는 하나의 프로세스로 동작하게 하는 것이다. 즉 서버에게 서비스 요청이 들어오면, 프로세스는 그 요청을 수행할 별도의 프로세스를 생성하는 것이다. 이 방식은 스레드의 다중화 전에는 매우 보편적인 방법이었다.
그러나 프로세스 생성 작업은 매우 많은 시간을 소비하고 많은 자원을 필요로 하는 일이다. 하지만 새 프로세스가 해야 할 일이 기존 프로세스가 하는 일과 동일하므로 대부분은 하나의 프로세스 안에 여러 스레드를 만들어나가는 것이 더 효율적이다. 또한, 현재의 운영체제 커널은 다중화되어 있다. 커널 안에 다수의 스레드가 동작하고 각 스레드는 장치 또는 인터럽트 처리 등의 특정 작업을 수행한다.
멀티스레드 프로그래밍의 이점은 크게 4가지이다.
- Responsiveness(반응성) : Process의 일부가 차단된 경우, 특히 사용자 인터페이스에 중요한 작업을 계속 실행할 수 있음
- Resource Sharing(자원 공유) : Thread는 Process의 리소스를 공유하며, shared memory 혹은 message passing보다 쉬움
- Economy(효율성) : Thread는 Process 생성보다 비용이 적게 들고 더욱 빠르게 생성할 수 있으며, Context Switching보다 오버헤드가 낮음
- Scalability(확장성) : 멀티스레딩의 이점은 스레드가 서로 다른 코어에서 병렬로 실행되는 멀티프로세서 아키텍처에서 극대화 되며, 단일 스레드 프로세스는 사용 가능한 프로세서 수에 관계없이 한 프로세서에서만 실행할 수 있음
단일 프로세스 칩에 여러 코어를 달아 운영 체제에서 별개의 CPU로 인식하게 하는 것을 multicore 시스템이라 한다. 동시성 시스템은 여러 작업을 수행할 수 있게 하는 것이고 병렬화는 여러 작업을 동시에 수행하는 것이다. 즉, 병렬화 없이도 동시성을 이룰 수 있다. 싱글 코어에서는 이는 context switching으로 이루어졌다.
하나의 코어는 한 번에 오직 하나의 스레드만 실행할 수 있기 때문에 단일 코어 시스템상에서 병행성은 단순히 스레드의 실행이 시간에 따라 교대로 실행된다는 것을 의미한다. 그러나 여러 코어를 가진 시스템에서는 시스템이 개별 스레드를 각 코어에 배정할 수 있기 때문에 병행성은 스레드들이 병령적으로 실행될 수 있다는 것을 뜻한다.
multicore system의 프로그래밍 난점은 아래 5가지이다.
- Identifying tasks(작업 식별) : 동시 작업으로 나눌 수 있는 영역을 찾기 위한 응용 프로그램을 검토하는 작업이 포함되며, 이상적으로는 작업이 서로 독립적이므로 개별 코어에서 병렬로 실행될 수 있음
- Balance(균형 잡기) : 병렬로 실행할 수 있는 작업을 식별하는 동안, 프로그래머는 작업이 동일한 값의 동일한 작업을 수행하도록 보장해야 하며, 경우에 따라서는 특정 task가 다른 task만큼 전체 프로세스에 많은 가치를 부여하지 않을 수 있음
- Data splitting(데이터 분할) : 애플리케이션이 개별 task로 분할되는 것과 마찬가지로, 작업에 의해 액세스되고 조작되는 데이터는 개별 코어에서 실행되도록 분할해야 함
- Data dependency(데이터 의존성) : 작업에서 액세스하는 데이터는 둘 이상의 작업 간의 종속성을 검사해야 하며, 한 작업이 다른 작업의 데이터에 의존하는 경우, 프로그래머는 작업의 실행이 데이터 종속성을 수용하도록 동기화되어야 함(6장에서 자세히 다루게 됨)
- Testing and debugging(테스팅과 디버깅) : 프로그램이 여러 코어에서 병렬로 실행되면 다양한 실행 경로를 사용할 수 있으며, 이러한 동시성 프로그램을 테스트하고 디버깅하는 것은 단일 스레드 응용프로그램을 테스트하고 디버깅하는 것보다 본질적으로 더 어려움
Amdahl의 법칙은 직렬(비병렬) 및 병렬 구성 요소를 모두 갖춘 애플리케이션에 컴퓨팅 코어를 추가함으로써 얻을 수 있는 잠재적 성능 향상을 나타내는 공식이다.
예를 들어, 75%의 병렬 및 25%의 직렬 애플리케이션을 사용한다고 가정하자. 두 개의 프로세싱 코어가 있는 시스템에서 이 애플리케이션을 실행하면 1.6배의 속도를 얻을 수 있다. 두 개의 코어를 추가하면(총 4개), 속도가 2.28배 빨라진다. 아래 그래프는 여러 다른 시나리오에서 Amdahl의 법칙을 보여준다.
Amdahl의 법칙에 대한 한 가지 흥미로운 사실은 N이 무한대에 가까워질수록 속도가 1µS로 수렴된다는 것이다. 예를 들어 애플리케이션의 50%가 연속적으로 수행되는 경우, 추가되는 처리 코어 수에 관계없이 최대 속도 상승은 2배이다. 이것은 Amdahl의 법칙에 대한 기본 원리이다(애플리케이션의 직렬 부분은 컴퓨팅 코어를 더 추가함으로써 우리가 얻는 성능에 불균형적인 영향을 미칠 수 있다)
Parallelism에는 Data parallelism(데이터 병렬화), Task Parallelism(작업 병렬화) 2가지가 있으며 서로 상호배타적이지 않다.
-
동일한 데이터의 다른 하위 집합에서 동일한 작업을 수행
-
동기식 연산(Synchronous computation)을 수행
-
모든 데이터 세트에서 동작하는 실행 스레드가 하나뿐이기 때문에 속도 향상은 더욱 빠름
-
Data parallelism(데이터 병렬화)의 양은 입력 크기에 비례함
-
멀티프로세서 시스템의 최적 부하 균형을 위해 설계됨
-
동일하거나 다른 데이터에 대해 다른 작업을 수행
-
비동기 연산(Asynchronous computation)을 수행
-
각 프로세서가 동일하거나 다른 데이터 집합에서 서로 다른 스레드 또는 프로세스를 실행하므로 속도 향상은 적음
-
Task parallelism(작업 병렬화)의 양은 독립적인 업무의 수에 비례함
질문(2021.06.13)
-
암달의 법칙이 GPU에 적용되지 않는 이유 -> CPU는 Task(규모 있는 작업)를 돌리는 게 목적이지만 GPU는 단순한 걸 하나씩(array의 각 파트 병렬 연산) 병렬로 연산하는 작업을 하기 때문이다.
-
GPU를 이용하려면 데이터 병렬화를 해야 하는가? -> 데이터를 나눠서 작업할 때 한 작업이 다른 작업이 계산하고 있는 데이터를 건들지 않는 것이 중요하다. 따라서 단위를 잘 쪼갤 수 있으면 된다.
-
GPU 그래픽 그릴 때 픽셀 단위로 병렬화의 그 단위인가? -> 픽셀은 최종적으로 비춰지는 단위이다. 픽셀에 맞추지 않은 채로 계산 후 픽셀에 맞춰서 뿌려준다.(텍스쳐, 폴리곤) 이것을 GPU 파이프라이닝이라고 한다.
-
데이터 병렬화와 함수형 프로그래밍 -> 순수함수가 필요한 이유는 데이터 병렬화에 효율적이며 데이터 병렬화가 보장되기 때문이다.
지금까지 스레드를 일반적인 의미로 다루었으나, user thread는 user-level에서 제공되고, kernel thread는 kernel-level에서 제공된다.
user thread는 커널 위에서 지원되며, 커널의 지원없이 관리된다. 반면에 kernel thread는 운영체제에서 직접 지원되고 관리된다. 최신 운영체제는 kernel thread를 지원한다.
그림 4.6에 나와있는 것처럼 user thread와 kernel thread간 여러 관계성이 있다.
3가지 일반적인 방법인 다대일 모델, 일대일 모델, 다대다 모델을 살펴보자.
다대일 모델(그림 4.7)은 많은 user-level 스레드를 하나의 kernel thread에 연결한다. 스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 수행되어 효율적이다.(4.4절 스레드 라이브러리에 대해 설명한다).
그러나 스레드가 blocking system call을 수행하면 전체 프로세스가 차단된다.
한 번에 하나의 스레드만 커널에 접근할 수 있기 때문에 다중 스레드는 멀티 코어 시스템에서 병렬로 실행할 수 없다. Green thread (Solaris 시스템에서 사용하고 있고, Java의 초기 버전에서 채택된 스레드 라이브러리)는 다대일 모델을 사용한다. 그러나 현재 대부분의 컴퓨터 시스템에서 표준이 된 다중 프로세스 코어를 활용할 수 없어서 현재 사용되는 시스템은 거의 없다.
일대일 모델(그림 4.8)은 각 user thread를 각 kernel thread에 연결한다. 스레드가 blocking system call을 실행할 때 다른 스레드가 실행될 수 있도록 함으로써 다대일 모델보다 더 많은 동시성을 제공하며, 여러 스레드가 다중 프로세서에서 병렬로 실행될 수 있다.
이 모델의 유일한 단점은 user thread
를 생성하려면 해당 kernel thread를 생성해야하며 많은 수의 kernel thread가 시스템 성능에 부담을 주는 것이다.
리눅스와 윈도우 운영체제는 일대일 모델로 구현되어있다.
다대다 모델(그림 4.9)은 많은 user-level 스레드를 더 적거나 같은 수의 kernel thread로 다중화한다. kernel thread의 수는 특정 응용 프로그램이나 특정 시스템에 따라 다를 수 있다(응용 프로그램에는 코어가 4개인 시스템보다 처리 코어가 8개인 시스템에서 더 많은 커널 스레드가 할당될 수 있다).
디자인이 동시성에 미치는 영향을 보자.
다대일 모델을 사용하면 개발자가 원하는 만큼 많은 user thread를 만들 수 있지만 커널이 한 번에 하나의 kernel thread만 예약할 수 있기 때문에 병렬처리가 불가능하다.
일대일 모델을 더 큰 동시성을 허용하지만 개발자는 애플리케이션 내에서 너무 많은 스레드를 생성하지 않도록 주의해야한다(일부 시스템에서는 생성할 수 있는 수가 제한이 있을 수 있다).
다대다 모델에는 이러한 단점이 없다. 개발자는 필요한만큼 많은 user thread를 생성할 수 있으며, 해당 kernal thread는 다중 프로세서에서 병렬로 실행될 수 있다. 또한 스레드가 blocking system call을 수행할 때 커널은 실행을 위해 다른 스레드를 예약할 수 있다.
다대다 모델의 변형 모델은 많은 user-level 스레드를 더 작거나 같은 수의 kernel thread로 다중화 하지만, 한 user-level 스레드를 한 kernal thread에 바인딩할 수 있다. 이를 two-level model이라고 한다(그림 4.10)
다대다 모델이 논의된 모델 중 가장 유연한 것처럼 보이지만 실제로 구현하긴 어렵다. 또한 대부분의 시스템에 나타나는 처리 코어 수가 증가함에 따라 커널 스레드 수를 제한하는 것이 덜 중요해졌다.
결과적으로 대부분의 운영 체제는 이제 일대일 모델을 사용한다. 그러나 4.5절에서 볼 수 있듯이 일부 동시성 라이브러리는 개발자가 다대다 모델을 사용하여 스레드에 매핑되는 작업을 식별하도록 한다.
스레드 라이브러리는 프로그래머에게 스레드 생성 및 관리를 위한 API를 제공한다.
- 커널 지원없이 사용자 공간에 라이브러리를 제공하는 것.
라이브러리의 모든 코드 및 데이터 구조는 사용자 공간에 존재한다. 즉, 라이브러리에서 함수를 호출하면 system call이 아닌 사용자 공간에서 로컬 function call이 발생한다.
- 운영 체제에서 직접 지원하는 kernel-level 라이브러리를 구현하는 것.
라이브러리에 대한 코드 및 데이터 구조가 커널 공간에 존재한다. 라이브러리용 API에서 함수를 호출하면 커널에 system call이 발생한다.
요즘은 POSIX Pthreads, Windows 및 Java 세 가지 주요 스레드 라이브러리가 사용된다.
POSIX Pthreads: POSIX 표준 thread의 확장판이다. kernel 수준의 library이다. UNIX와 Linux system에서는 Pthreads로 구현된다.
Win32 threads: Windows system에서 제공되는 kernel 수준의 library이다.
Java threads: Java는 JVM 위에서 돌아가므로, JVM이 실행되고 있는 OS와 hardware에 기반해서 구현된다.
POSIX 및 Windows 스레딩의 경우 전역으로 선언된 모든 데이터 (즉, 함수 외부에서 선언 됨)는 동일한 프로세스에 속한 모든 스레드간에 공유된다. Java에는 전역 데이터에 대한 동등한 개념이 없기 때문에 공유 데이터에 대한 액세스는 스레드간에 명시적으로 나열되어야 한다.
이 섹션의 나머지 부분에서는 세 개의 스레드 라이브러리를 사용하여 기본 스레드 생성에 대해 설명한다. 예시로, 잘 알려진 합산 함수를 사용하여 별도의 스레드에서 음이 아닌 정수의 합산을 수행하는 다중 스레드 프로그램을 설계한다.
스레드 생성 예제를 진행하기 전에 다중 스레드 생성을 위한 두 가지 일반적인 전략인 비동기 스레딩과 동기 스레딩에 대해 알아보자.
비동기 스레딩을 사용하면 부모가 자식 스레드를 생성하면 부모가 실행을 재개하여 부모와 자식이 서로 독립적으로 동시 실행되도록 한다. 스레드는 독립적이기 때문에 일반적으로 스레드간에 데이터 공유가 거의 없다.
동기 스레딩은 부모 스레드가 하나 이상의 자식을 만든 다음 다시 시작하기 전에 모든 자식이 종료될 때까지 기다려야 한다. 여기서 부모가 생성한 스레드는 동시에 작업을 수행하지만 작업이 완료될 때까지 부모는 진행될 수 없다. 각 스레드가 작업을 마치면 부모와 결합된다. 모든 하위가 결합된 후에만 상위가 실행을 재개할 수 있다.
동기 스레딩에는 스레드간에 상당한 데이터 공유가 된다. 예를 들어, 부모 스레드는 다양한 자식이 계산한 결과를 결합할 수 있다.
Pthread는 스레드 생성 및 동기화를 위한 API를 정의하는 POSIX 표준 (IEEE 1003.1c)을 나타낸다. 구현이 아닌 스레드 동작에 대한 사양(specification) 이다.
운영체제 설계자는 원하는 방식으로 사양을 구현할 수 있다. 수많은 시스템이 Pthreads 사양을 구현한다. 대부분은 Linux 및 macOS를 포함한 UNIX 유형 시스템이다. Windows는 기본적으로 Pthread를 지원하지 않지만 Windows용 third-party 구현을 사용할 수 있다.
그림 4.11에 표시된 C 프로그램은 별도의 스레드에서 음이 아닌 정수의 합계를 계산하는 다중 스레드 프로그램을 구현한 기본 Pthreads API다. Pthreads 프로그램에서 개별 스레드는 지정된 함수에서 실행을 시작한다. 위의 그림에서는 runner()
함수에서 시작한다.
프로그램이 시작되면 단일 제어 스레드가 main()
에서 시작된다. 초기화 후 main()
은 runner()
함수에서 시작하는 두 번째 스레드를 만든다. 두 스레드 모두 글로벌 데이터 합을 공유한다.
좀 더 자세히 살펴보면 모든 Pthreads 프로그램은 pthread.h
헤더 파일을 포함한다. pthread_t tid
문은 우리가 만들 스레드의 식별자를 선언합니다. 각 스레드에는 스택 크기 및 스케줄링 정보를 포함한 속성이 있다. pthread_attr_t attr
선언은 스레드의 속성을 나타낸다. pthread_attr_init (& attr)
에서 속성을 설정한다.
명시적으로 속성을 설정하지 않으면 제공된 기본 속성을 사용한다. 별도의 스레드는 pthread_create()
함수 호출로 생성된다. 스레드 식별자 및 스레드 속성을 전달하는 것 외에도 새 스레드가 실행을 시작할 함수의 이름(이 경우 runner()
함수)도 전달한다. 마지막으로, command line에서 입력한 정수 매개 변수 argv[1]
을 전달한다.
이 시점에서 프로그램에는 main()
의 초기(또는 부모) 스레드와 runner()
함수에서 합계 연산을 수행하는 summation(또는 자식) 스레드의 두 스레드가 있다. 쓰레드 생성/조인 전략을 따르며, summation 쓰레드를 생성한 후 부모 쓰레드는 pthread_join()
함수를 호출하여 종료될 때까지 기다린다. summation 스레드는 pthread_exit()
함수를 호출할 때 종료된다. summation 스레드가 반환되면 부모 스레드는 공유 데이터 합의 값을 출력한다.
이 예제 프로그램은 단일 스레드만 생성한다. 멀티 코어 시스템의 지배력이 증가함에 따라 여러 스레드를 포함하는 프로그램 작성이 점점 더 보편화되었다. pthread_join()
함수를 사용하여 여러 스레드를 기다리는 간단한 방법은 간단한 for 루프 내에 작업을 묶는 것이다. 예를 들어, 그림 4.12에 표시된 Pthread 코드를 사용하여 10개의 스레드를 결합할 수 있다.
질문(2021.06.20)
-
다대다에 대한 질문 -> 중간의 관리자가 시시때때 놀고 있는 스레드를 확인해서 매핑시켜준다.
-
일대일을 사용하는 이유는? -> 코어가 하나라더라도 일대일 가지는 장점이 있다. -> 일대일을 사용하게 되면 각각 매핑이 되어있어서 커널 스레드를 문맥으로 나누어서 사용할 수 있다. -> 각각의 커널로 인식하게 해서 작업할 수 있는 환경을 만들어 준다. -> 메모리 관점보다는 논리적인 관점에서 보는게 맞을 거 같다.
JVM은 보통 host OS 위에 구현된다. 이러한 설정은 JVM이 아래에 있는 OS 구현을 숨기고, JVM만 지원된다면 어떤 플랫폼에서든 Java프로그램을 실행할 수 있는 일관성 있고 추상화된 환경을 제공해준다. JVM의 사양(specification)은 Java의 스레드가 아래에 있는 OS와 어떻게 매핑할지 정하지 않고, 각 구현체에서 결정한다. 예를 들어 윈도우 OS는 one-to-one 모델을 사용한다. 따라서 윈도우 환경에서 돌아가는 JVM은 각 스레드를 커널 스레드와 매핑시킨다. 또한, Java 스레드 라이브러리와 host OS 사이에도 관계가 있을 것이다. 예를 들어, 윈도우 패밀리 OS에서는 Java 스레드를 만들 때 윈도우 API를 사용할 것이고, 리눅스나 맥OS에서는 PthreadAPI를 사용할 것이다.
멀티코어 프로세싱이 발전하면서 수백, 수천 개에 달할 정도로 아주 많은 스레드를 포함하는 애플리케이션들이 등장하고 있다. 이런 애플리케이션을 설계하는 것은 여간 힘든 일이 아니다. 섹션 4.2에 나왔던 문제점 뿐만 아니라, 다른 문제들도 해결해야 한다. 이는 프로그램 정확성(correctness)과 관련돼있는데, 나중에 챕터 6과 챕터 8에서 살펴볼 것이다.
이런 문제들을 다루고 애플리케이션의 동시성과 병렬성에 더 나은 지원을 해주기 위한 방법 중 하나는, 생성과 관리를 애플리케이션 개발자가 하는 대신, 컴파일러와 런타임 라이브러리가 하도록 하는 것이다. 이런 전략을 암묵적 스레딩(implicit threading)이라 부르며, 점점 인기가 많아지는 추세다. 암묵적 스레딩의 전략으로 크게 다섯 가지가 있다.
- Thread Pools
- Fork Join
- OpenMP
- Grand Central Dispatch
- Intel Threading Building Blocks
위 전략들은 보통 개발자에게 태스크(task)를 구현하게 하고 해당 태스크를 병렬적으로 실행시키는 것이다. 태스크는 보통 함수로 만들어지는데 이는 런타임 라이브러리에서 스레드로 매핑해준다. 보통 섹션 4.3에서 살펴봤던 many-to-many 모델을 사용한다. 이런 접근의 장점은 개발자가 병렬적으로 실행시킬 태스크만 정의해주면 된다는 점이고 라이브러리가 스레드 생성 및 관리에 대한 구체적인 상세내용들을 결정해준다는 것이다.
섹션 4.1에서 우리는 멀티스레드 웹 서버를 예로 들었다(책 참고). 이런 상황에서 서버가 요청을 받을 때마다 요청을 처리할 새로운 스레드를 생성한다. 여러 개의 프로세스를 생성하는 것보다 여러 개의 스레드를 생성하는 것이 더 유리하지만, 멀티 스레드 서버는 잠재적인 문제가 있다. 첫째로 스레드를 만드는 데 시간이 걸리고, 해당 작업이 끝나면 스레드는 사라진다. 둘째로 각각의 동시 요청(concurrent request)을 새로운 스레드에서 처리하면 스레드가 끝없이 생성되고 시스템 자원이 고갈될 수 있다. 해결 방법 중 하나는 스레드 풀을 사용하는 것이다.
스레드 풀의 기본적인 개념은 풀(pool)에 특정 개수의 스레드를 생성해놓고 작업을 위해 기다리도록 하는 것이다. 서버가 요청을 받으면 스레드를 만드는 대신 스레드 풀에 요청을 전달하고 다음 요청을 기다린다. 스레드 풀에 가용 스레드가 있으면 요청이 즉시 처리된다. 만약 가용 스레드가 없으면 빈자리가 날 때까지 큐에 대기한다. 스레드가 작업을 완료하면, 풀에 돌아간 뒤 다음 작업을 기다린다. 스레드 풀은 풀에 전달된 작업이 비동기적으로 실행될 때 잘 동작한다.
스레드 풀의 장점
- 이미 존재하는 스레드를 사용하기 때문에 새로 생성하는 것보다 빠르다.
- 스레드 풀은 스레드 수를 제한한다. 시스템이 많은 수의 동시 스레드(concurrent threads)를 지원할 수 없을 때 중요하다.
- 작업의 생성과 수행을 분리시키면 각 태스크마다 다른 실행 전략을 사용할 수 있다. 예를 들어, 어떤 작업은 특정 시간만큼 기다렸다 실행하고 어떤 작업은 즉시 실행할 수 있다.
스레드 풀의 스레드 개수는 cpu개수 메모리 용량, 예상 동시 요청 등을 기반으로 하여 휴리스틱 적으로 적용할 수 있다. 좀 더 복잡한 아키텍처에서는 사용 패턴에 따라 동적으로 조정할 수도 있다. 이러면 시스템의 로드율이 낮을 때는 더 작은 풀을 가져 메모리 소모를 줄인다. 이 중 하나는 애플의 GCD라는 건데 나중 세션에서 다룬다.
4.4에서 살펴봤던 스레드 생성 전략은 흔히 포크-조인 모델이라 불리는 것이다. 메인 부모 스레드가 여러 개의 자식 스레드를 생성(fork)하고 자식 스레드가 종료되길 기다렸다가 join하는 것이다. 이러한 동기적 모델은 보통 명시적 스레드 생성으로 특정되기는 하지만, 암묵적 스레딩의 훌륭한 후보이기도 하다. 후자의 상황에서는 아래의 그림처럼 스레드들은 병렬 작업으로 지정되고, 포크 단계(fork stage)에서 직접 구성되지 않는다. 라이브러리는 여러 개의 스레드를 관리한다. 또한 스레드 생성과 작업 할당의 책임도 가진다. 라이브러리가 실제 스레드 수를 결정한다는 점에서 포크-조인 모델은 스레드 풀의 동기적 버전으로 볼 수도 있다.
OpenMP는 C, C++ 또는 FORTAN으로 작성된 프로그램의 API이자 컴파일러 지시어(directives)의 집합으로, 공유 메모리 환경에서 병렬 프로그래밍을 지원한다. OpenMP는 병렬로 실행될 수 있는 코드 블록을 병렬 영역(parallel regions)으로 정의한다. 개발자는 컴파일러 지시어를 자신의 코드 내부에 있는 공유 병렬 영역에 삽입한다. 그리고 이러한 명령어는 OpenMP 런타임 라이브러리가 병렬 영역을 실행시키도록 지시한다.
OpenMP가 지시문을 만나면 프로세서 코어의 개수만큼 스레드를 생성한다. 듀얼코어면 두 개, 쿼드코어면 네 개 와 같은 식으로. 모든 스레드는 동시에(simutaneously) 병렬 영역을 실행한다. 각 스레드가 병렬 영역을 벗어나면 종료된다.
OpenMP는 병렬 루프 같은 추가적인 명령어를 지원한다. 이외에도 수동으로 스레드 개수를 조절한다던가, 데이터의 공유 여부를 설정할 수 있다. OpenMP는 리눅스 윈도우, 맥os를 위한 여러 가지 오픈소스 및 상용 컴파일러에서 사용할 수 있다.
멀티쓰레드 프로그램을 설계할 때, 고려되어야할 몇 가지 이슈들에 대해서 살펴보자.
3장에서는 fork() 시스템 호출을 사용하여 별도의 프로세스를 만드는 방법을 설명했다. 하지만 fork() 및 exec() 시스템 호출의 의미는 다중 스레드 프로그램에서는 달라진다. 일부 UNIX 시스템은 두 가지 버전의 fork()를 채택했는데 하나는 모든 스레드를 복제하는 것이고 다른 하나는 fork() 시스템 호출을 호출한 스레드만 복제한다.
exec() 시스템 호출은 일반적으로 3장에서 설명한 것과 같은 방식으로 작동한다. 스레드가 exec() 시스템 호출을 호출하면 exec() 매개변수에 지정된 프로그램이 모든 프로세스를 대체한다.
fork직후 exec()가 호출되면 exec()에 대한 매개변수에 지정된 프로그램이 프로세스를 대체하므로 모든 스레드를 복제할 필요가 없기 때문에 호출한 스레드만 복제하는 것이 적절하다. 그러나 fork 이후 별도의 프로세스가 exec()를 호출하지 않으면 별도의 프로세스가 모든 스레드를 복제해야한다.
Signal은 특정 이벤트가 발생했음을 프로세스에 알리기 위해 UNIX 시스템에서 사용된다. Signal은 Signal을 받는 이벤트의 소스와 원인에 따라 동기식 또는 비동기식으로 수신될 수 있다. 동기식이든 비동기식이든 모든 Signal는 동일한 패턴을 따른다. (synchronous & asyncronous) 고급 프로그래밍 언어에서 에러 핸들링이 결국 내부적으로는 동기식으로 돌아간다.
- 특정 이벤트가 발생하면 Signal은 생성된다.
- Signal이 프로세스에 전달된다.
- 일단 전달되면 Signal을 처리되어야 한다.
동기 Signal의 예로는 잘못된 메모리 액세스 및 0으로 나누기 등이 포함된다. 실행 중인 프로그램이 이러한 작업 중 하나를 수행하면 Signal이 생성됩니다. 동기 Signal은 Signal을 발생시킨 동일한 프로세스로 전달된다.
실행 중인 프로세스 외부의 이벤트에 의해 Signal이 생성되면 해당 프로세스는 Signal을 비동기적으로 수신한다. 이러한 Signal의 예로는 특정 키 입력(예: <control><C>
)으로 프로세스를 종료하고 타이머가 만료되도록 하는 것이 있다. 일반적으로 비동기 Signal는 다른 프로세스로 전송된다.
Signal은 두 가지 핸들러 중 하나에 의해 처리된다.
- 디폴트 Signal 핸들러
- 사용자 정의 Signal 핸들러(운영 체제가 정의한 내용을 바탕으로 하는게 아닌 )
모든 Signal은 Signal을 처리할 때 커널이 실행하는 디폴트 Signal 핸들러를 갖고 있다. 이 디폴트 핸들러는 사용자 정의 Signal 핸들러에 의해 오버라이드될 수 있다.
단일 스레드 프로그램에서 Signal을 처리하는 것은 간단하다. 단일 스레드에서 Signal은 항상 프로세스에 전달된다. 그러나 Signal 전달은 다중 스레드 프로그램에서 더 복잡하다. 그렇다면 Signal는 어디로 전달되어야 할까?
일반적으로 다음과 같은 옵션이 있다.
- Signal은 적용되는 스레드에 Signal을 전달한다. (이게 무슨말? : Deliver the signal to the thread to which the signal applies.)
- 프로세스의 모든 스레드에 Signal을 전달한다.
- 프로세스의 특정 스레드에 Signal을 전달한다.
- 프로스세의 모든 Signal을 수신할 특정 스레드를 할당한다.
스레드 취소는 스레드가 완료되기 전에 종료하는 것을 포함한다. 예를 들어 여러 스레드가 동시에 데이터베이스를 검색하고 하나의 스레드가 결과를 반환하면 나머지 스레드가 취소될 수 있다. 취소될 예정인 스레드를 Target 스레드라고 한다. Target 스레드의 취소는 2가지 방식으로 처리된다.
- 비동기 취소(Asyncronous Cancellation): Target 스레드를 즉시 종료한다.
- 지연 취소(Deferred Cancellation): Target 스레드의 종료 가능 여부를 주기적으로 확인하며 가능할 때 종료한다.
문제는 취소된 스레드에 자원이 할당된 상황이나 데이터를 업데이트하는 중에 다른 스레드와 공유 중인 스레드가 취소된 상황에서 발생한다. 이것은 비동기식 취소에서 특히 문제가 된다. 종종 운영 체제는 취소된 스레드에서 시스템 리소스를 회수하지만 모든 리소스를 회수하지는 않는다. 따라서 스레드를 비동기적으로 취소하면 필요한 시스템 전체 리소스가 해제되지 않을 수 있다. 반대로 지연 취소를 사용하면 한 스레드가 취소는 대상 스레드의 취소 가능 여부를 결정한 후에만 발생한다. 스레드는 안전하게 취소할 수 있는 지점에서 이 검사를 수행할 수 있다.
스레드 취소를 호출하면 취소가 요청되지만 실제 취소는 스레드 상태에 따라 다르다.
스레드에서 취소가 비활성화된 경우 스레드가 활성화할 때까지 취소가 보류 상태로 유지된다. 디폴트 취소 유형은 지연 취소다. 하지만, 취소는 스레드가 cancellation point에 진입했을 때만 일어난다.
프로세스에 속한 스레드는 프로세스의 데이터를 공유한다. 실제로 이 데이터 공유는 다중 스레드 프로그래밍의 이점 중 하나다. 그러나 어떤 상황에서는 각 스레드에 특정 데이터의 자체 복사본이 필요할 수 있다. 이러한 데이터를 TLS(thread-local-stroage)라고 한다. 예를 들어 트랜잭션 처리 시스템에서 각 트랜잭션을 별도의 스레드에서 처리할 수 있고 각 트랜잭션에는 고유 식별자가 할당될 수 있다. 각 스레드를 고유한 트랜잭션 식별자와 연결하기 위해 TLS를 사용할 수 있다.
TLS를 로컬 변수와 혼동하기 쉽지만 로컬 변수는 단일 함수 호출 중에만 볼 수 있는 반면 TLS 데이터는 함수 호출 전체에서 볼 수 있다. 또한 개발자가 스레드 생성 프로세스를 제어할 수 없는 경우(예: 스레드 풀과 같은 implicit한 기술을 사용하는 경우) 대체 접근 방식이 필요하다. (암시적 기술이 뭐죠? : 4.5장 참고)
어떤 면에서 TLS는 정적 데이터와 유사하지만 차이점은 TLS 데이터는 각 스레드에 고유하다는 것이다. 대부분의 스레드 라이브러리와 컴파일러는 TLS를 지원한다. 예를 들어, Java는 ThreadLocal<T>
객체에 대해 set()
및 get()
메서드가 있는 ThreadLocal<T>
클래스를 제공한다. Pthreads에는 각 스레드에 고유한 키를 제공하는 pthread 키 t
유형이 포함된다. 그런 다음 이 키를 사용하여 TLS 데이터에 액세스할 수 있다. Microsoft의 C# 언어는 스레드 로컬 데이터를 선언하기 위해 스토리지 속성 ThreadStatic
을 추가하기만 하면 된다. gcc 컴파일러는 TLS 데이터 선언을 위한 스토리지 클래스 키워드 스레드를 제공한다. 예를 들어 각 스레드에 고유 식별자를 할당하려면 다음과 같이 선언한다.
static thread int threadID;
다중 스레드 프로그램에서 고려해야 할 마지막 문제는 커널과 스레드 라이브러리 사이의 통신에 관한 것이며, 이는 섹션 4.3.3에서 논의된 M:M 및 Two-level 모델에 필요할 수 있다. 이러한 조정을 통해 커널 스레드의 수를 동적으로 조정하여 최상의 성능을 보장할 수 있다. M:M 또는 Two-level 모델을 구현하는 많은 시스템은 사용자와 커널 스레드 사이에 중간 자료 구조를 배치한다. 일반적으로 경량 프로세스 또는 LWP(Light Weight Process)로 알려진 이 자료 구조는 그림 4.20에 나와 있다.
user thread
라이브러리에서 LWP는 응용 프로그램이 실행되도록 user thread
를 예약할 수 있는 가상 프로세서다. 각 LWP는 kernal thread
에 연결되며 운영 체제가 물리적 프로세서에서 실행하도록 예약한다. kernal thread
가 차단되면(예: I/O 작업이 완료되기를 기다리는 동안) LWP도 차단된다. 체인 위로 LWP에 연결된 user-thread
도 차단된다.
응용 프로그램을 효율적으로 실행하려면 여러 LWP가 필요할 수 있다. 단일 프로세서에서는 한 번에 하나의 스레드만 실행할 수 있으므로 하나의 LWP로 충분하다. 그러나 I/O 집약적인 응용 프로그램을 실행하려면 여러 LWP가 필요할 수 있다. 일반적으로 각 동시 차단 시스템 호출에는 LWP가 필요하다. 예를 들어 5개의 서로 다른 파일 읽기 요청이 동시에 발생한다고 가정해보자. 모두 커널에서 I/O 완료를 기다릴 수 있으므로 5개의 LWP가 필요하다. 프로세스에 4개의 LWP만 있는 경우 다섯 번째 요청은 LWP 중 하나가 커널에서 반환될 때까지 기다려야 한다.
user thread
라이브러리와 커널 간의 통신을 위한 한 가지 방식을 스케줄러 활성화(Scheduler Activation)라고 한다. 다음과 같이 작동하는데 커널은 응용 프로그램에 가상 프로세서(LWP) 집합을 제공하고 응용 프로그램은 사용 가능한 가상 프로세서에 user thread
를 예약할 수 있다. 또한 커널은 특정 이벤트에 대해 응용 프로그램에 알려야 한다. 이 절차를 upcall
이라고 합니다. upcall
은 upcall
핸들러가 있는 스레드 라이브러리에 의해 처리되며 upcall
핸들러는 가상 프로세서에서 실행되어야 한다.
upcall
을 트리거하는 이벤트 중 하나는 애플리케이션 스레드가 차단될 때 발생한다. 이 시나리오에서 커널은 스레드가 막으려는 것을 알리고 특정 스레드를 식별하도록 애플리케이션에 upcall
을 수행한다. 그런 다음 커널은 응용 프로그램에 새 가상 프로세서를 할당한다. 응용 프로그램은 이 새로운 가상 프로세서에서 upcall
핸들러를 실행하여 차단 스레드의 상태를 저장하고 차단 스레드가 실행 중인 가상 프로세서와의 연결을 끊는다. 그런 다음 upcall
핸들러는 새 가상 프로세서에서 실행할 수 있는 다른 스레드를 예약한다. 차단 스레드가 기다리고 있던 이벤트가 발생하면 커널은 이전에 차단된 스레드가 이제 실행할 수 있음을 알리는 스레드 라이브러리에 대한 또 다른 upcall
을 수행한다. 이 이벤트에 대한 upcall
핸들러에는 가상 프로세서도 필요하며 커널은 새 가상 프로세서를 할당하거나 user thread
중 하나를 선점하고 가상 프로세서에서 upcall
핸들러를 실행할 수 있다. 차단되지 않은 스레드를 실행할 수 있는 것으로 표시한 후 애플리케이션은 사용 가능한 가상 프로세서에서 실행되도록 적절한 스레드를 예약한다.