포스트

Operating System: Chapter 04 - 스레드와 병행성

Operating System: Chapter 04 - 스레드와 병행성

4.1. 개요

스레드는 CPU 이용의 기본 단위이다. 스레드는 스레드 ID, 프로그램 카운터, 레지스터 집합, 스택으로 구성된다.
스레드는 같은 프로세스에 속한 다른 스레드와 코드 섹션, 데이터 섹션, 열린 파일이나 신호와 같은 운영체제 자원들을 공유한다.
전통적인 프로세스는 하나의 제어 스레드를 가지고 있다. 만일 프로세스가 다수의 제어 스레드를 가진다면, 프로세스는 동시에 하나 이상의 작업을 수행할 수 있다.

4.1.1. 동기 (Motivation)

현대의 컴퓨터와 모바일 기기에서 동작하는 거의 모든 소프트웨어 응용들은 다중 스레드를 이용한다. 하나의 응용은 몇 개의 실행 흐름을 가진 독립적인 프로세스로 구현된다.
응용은 다중 코어 시스템에서 처리 능력을 향상시키도록 설계될 수 있다. 이러한 응용은 다중 계산 코어를 사용하여 다수의 CPU 집중 작업을 병렬로 처리할 수 있다.

하나의 응용 프로그램이 여러 개의 비슷한 작업을 수행할 필요가 있는 상황들이 또한 있다.
예를 들어, 웹 서버는 클라이언트로부터 웹 페이지나 이미지, 소리 등에 대한 요청을 받는다. 하나의 분주한 웹 서버는 여러 개의 클라이언트들이 병행하게 접근할 수 있다.
만약 웹 서버가 전통적인 단일 스레드 프로세스로 작동한다면, 자신의 단일 프로세스로 한 번에 하나의 클라이언트만 서비스할 수 있게 되어 클라이언트는 자신의 요구가 서비스되기까지 매우 긴 시간을 기다려야 한다.

하나의 해결책은 서버가 요청을 받아들이는 하나의 프로세스로서 동작하게 하는 것이다. 즉, 서버에게 서비스 요청이 들어오면, 프로세스는 그 요청을 수행할 별도의 프로세스를 생성하는 것이다.
이와 같은 방식으로 프로세스를 생성하는 것은 스레드가 대중화되기 전에는 매우 보편적이었다. 하지만 프로세스 생성 작업은 매우 많은 시간을 소비하고 많은 자원을 필요로 하는 일이다.
새 프로세스가 해야 할 일이 기존 프로세스가 하는 일과 동일하다면 이런 많은 오버헤드를 감수할 필요 없이, 프로세스 안에 여러 스레드를 만들어나가는 것이 더 효율적이다.
웹 서버가 다중 스레드화되면, 서버는 클라이언트의 요청을 listen하는 별도의 스레드를 생성한다. 요청이 들어오면 다른 프로세스를 생성하는 것이 아니라, 요청을 서비스할 새로운 스레드를 생성하고 추가적인 요청을 listen하기 위한 작업을 재개한다.

대부분의 운영체제 커널도 일반적으로 다중 스레드이다.
예를 들어 Linux 시스템에서 시스템을 부트하는 동안 여러 커널 스레드가 생성된다. 각 스레드는 장치 관리, 메모리 관리 또는 인터럽트 처리와 같은 특정 작업을 수행한다.
ps -ef 명령을 사용하여 실행 중인 Linux 시스템에서 커널 스레드를 표시할 수 있다. 이 명령의 출력을 검사하면 커널 스레드 kthreadd가 표시되며 다른 모든 커널 스레드의 부모 역할을 한다.

많은 응용 프로그램도 기본 정렬, 트리 및 그래프 알고리즘을 포함하여 다중 스레드를 활용할 수 있다. 또한 데이터 마이닝, 그래픽 및 인공지능에서 CPU 중심의 최신 문제를 해결해야 하는 프로그래머는 병렬로 실행되는 솔루션을 설계하여 최신 다중 코어 시스템의 성능을 활용할 수 있다.

4.1.2. 장점

다중 스레드 프로그래밍의 이점은 다음 4가지의 큰 분류로 나눌 수 있다.

응답성(responsiveness)

  • 대화형 응용 프로그램을 다중 스레드화하면 응용 프로그램의 일부분이 봉쇄되거나, 응용 프로그램이 긴 작업을 수행하더라도 프로그램의 수행이 계속되는 것을 허용함으로써, 사용자에 대한 응답성을 증가시킨다. 이 특징은 사용자 인터페이스를 설계하는 데 있어 특히 유용하다.
  • 예를 들어 사용자가 시간이 많이 걸리는 연산을 시작하는 단추를 클릭했을 때, 단일 스레드 응용은 그 연산이 완료될 때까지 사용자에게 응답하지 않는다. 하지만 시간이 오래 걸리는 연산이 별도의 비동기적 스레드에서 실행된다면 응용은 여전히 사용자에게 응답할 수 있다.

자원 공유(resource sharing)

  • 프로세스는 공유 메모리와 메시지 전달 기법을 통하여만 자원을 공유할 수 있다. 이러한 기법은 프로그래머에 의해 명시적으로 처리되어야 한다.
  • 스레드는 자동으로 그들이 속한 프로세스의 자원들과 메모리를 공유한다. 코드와 데이터 공유의 이점은 한 응용 프로그램이 같은 주소 공간 내에 여러 개의 다른 작업을 하는 스레드를 가질 수 있다는 점이다.

경제성(economy)

  • 프로세스 생성을 위해 메모리와 자원을 할당하는 것은 비용이 많이 든다.
  • 스레드는 자신이 속한 프로세스의 자원들을 공유하므로, 스레드를 생성하고 문맥 교환하는 것이 더욱더 경제적이다.
  • 일반적으로 스레드 생성은 프로세스 생성보다 시간과 메모리를 덜 소비한다. 또한 문맥 교환은 일반적으로 프로세스 사이보다 스레드 사이에서 더 빠르다.

규모 적응성(scalability)

  • 다중 처리기 구조에서는 각각의 스레드가 다른 처리기에서 병렬로 수행될 수 있다.
  • 단일 스레드 프로세스는 처리기가 아무리 많더라도 오직 한 처리기에서만 실행된다.

4.2. 다중 코어 프로그래밍

컴퓨터 설계의 역사 초기에 더 좋은 컴퓨팅 성능에 대한 요구에 부응하여 단일 CPU 시스템은 다중 CPU 시스템으로 발전하였다.
나중의 비슷한 시스템 설계 추세는 단일 컴퓨팅 칩에 여러 컴퓨팅 코어를 배치하는 것이다. 각 코어는 운영체제에 별도의 CPU로 보인다. 이러한 시스템을 다중 코어라고 하며 다중 스레드 프로그래밍은 이러한 여러 컴퓨팅 코어를 보아 효율적으로 사용하고 병행성을 향상시키는 방법을 제공한다.
스레드가 4개인 응용 프로그램을 고려해보자. 단일 컴퓨팅 코어가 있는 시스템에서는 단지 처리 코어가 한 번에 하나의 스레드만 실행할 수 있기 때문에 병행성은 시간이 지남에 따라 스레드 실행이 인터리브됨을 의미한다.
하지만 여러 코어가 있는 시스템에서 병행성은 시스템이 각 코어에 별도의 스레드를 할당할 수 있기 때문에 일부 스레드가 병렬로 실행될 수 있음을 의미한다.

현재 논의에서 병행성과 병렬성의 차이점에 주목해보자. 병행 시스템은 모든 작업이 진행되게 하여 둘 이상의 작업을 지원한다. 반면 병렬 시스템은 둘 이상의 작업을 동시에 수행할 수 있다. 따라서, 병렬성 없이 병행성을 가질 수 있다.
다중 처리기 및 다중 코어 아키텍처가 출현하기 전에 대부분 컴퓨터 시스템에는 단일 프로세서만 있었으며 CPU 스케줄러는 프로세스 간에 빠르게 전환해 각 프로세스가 진행되도록 하여 병렬성의 환상을 제공하였다.
이러한 프로세스는 병행하게 실행되었지만 병렬로 실행되지는 않았다.

4.2.1. 프로그래밍 도전과제

다중 코어 시스템으로 발전하는 추세는 시스템 설계자뿐만 아니라 응용 프로그래머에게도 다중 코어의 활용도를 높일 수 있도록 압력을 행사하고 있다.
운영체제 설계자는 병렬 수행이 될 수 있도록 여러 코어를 활용하는 스케줄링 알고리즘을 개발해야 한다.
응용 프로그래머는 기존 프로그램을 다중 스레드를 사용하도록 수정해야 하고, 새로운 다중 스레드 프로그램을 설계해야 하는 도전에 당면해 있다.

일반적으로 다중 코어 시스템을 위해 프로그래밍하기 위해서는 5개의 극복해야 할 도전 과제가 있다.

  • 태스크 인식: 응용을 분석하여 독립된 병행 가능 태스크로 나눌 수 있는 영역을 찾는 작업이 필요하다. 이상적으로 태스크는 서로 독립적이며 개별 코어에서 병렬 실행될 수 있어야 한다.
  • 균형: 병렬로 실행될 수 있는 태스크를 찾는 것도 중요하지만 찾아진 부분들이 전체 작업에 균등한 기여도를 가지도록 태스크로 나누는 것도 매우 중요하다. 어떤 경우에는 다른 태스크에 비해 기여도가 적은 작업이 있을 수 있으며 이러한 작업을 실행하기 위해 별도의 코어를 사용하는 것은 그만한 가치가 없다.
  • 데이터 분리: 응용이 독립된 태스크로 나누어지는 것처럼, 태스크가 접근하고 조작하는 데이터 또한 개별 코어에서 사용할 수 있도록 나누어져야 한다.
  • 데이터 종속성: 태스크가 접근하는 데이터는 둘 이상의 태스크 사이에 종속성이 없는지 검토되어야 한다. 한 태스크가 다른 태스크로부터 오는 데이터에 종속적인 경우에는 프로그래머가 데이터 종속성을 수용할 수 있도록 태스크의 수행을 잘 동기화해야 한다.
  • 시험 및 디버깅: 프로그램이 다중 코어에서 병렬로 실행되면 다양한 실행 경로가 존재할 수 있다. 그런 병행 프로그램을 시험하고 디버깅하는 것은 단일 스레드 응용을 시험하고 디버깅하는 것보다 근본적으로 훨씬 어렵다.

이러한 도전 과제 때문에 많은 소프트웨어 개발자들은 다중 코어 시스템의 확산으로 인하여 향후 소프트웨어를 설계하기 위한 완전히 새로운 접근법이 필요하다고 주장한다.

4.2.2. 병렬 실행의 유형

일반적으로 데이터 병렬 실행과 태스크 병렬 실행의 두 가지 유형이 존재한다.
데이터 병렬 실행은 동일한 데이터의 부분집합을 다수의 계산 코어에 분배한 뒤 각 코어에서 동일한 연산을 실행하는 데 초점을 맞춘다.

  • 크기가 N인 배열의 내용을 더하는 경우를 생각해보자.
  • 단일 코어 시스템에서는 하나의 스레드가 원소 0부터 N-1을 더하면 된다.
  • 듀얼 코어 시스템에서는 코어 0에서 실행되는 스레드 A는 원소 0부터 N/2-1까지 더하고, 코어 1에서 실행되는 스레드 B는 원소 N/2부터 N-1까지 더할 수 있다. 두 스레드는 각자 계산 코어에서 병렬로 실행된다.

태스크 병렬 실행은 데이터가 아니라 태스크를 다수의 코어에 분배한다. 각 스레드는 고유의 연산을 실행한다.
다른 스레드들이 동일한 데이터에 대해 연산을 실행할 수 있고, 혹은 서로 다른 데이터에 연산을 실행할 수도 있다.

기본적으로 데이터 병렬 처리에는 여러 코어에 데이터를 분배하는 것이 포함되고, 태스크 병렬 처리에는 여러 코어에 태스크를 분배하는 것이 포함된다.
그러나 데이터와 태스크 병렬 처리는 상호 배타적이지 않으며 실제로 응용 프로그램은 이 두 가지 전략을 혼합하여 사용할 수 있다.

4.3. 다중 스레드 모델

스레드를 위한 지원은 사용자 스레드(User Thread)를 위해서는 사용자 수준에서, 또는 커널 스레드(Kernel Thread)를 위해서는 커널 수준에서 제공된다.
사용자 스레드는 커널 위에서 지원되며 커널의 지원 없이 관리된다. 반면 커널 스레드는 운영체제에 의해 직접 지원되고 관리된다.

궁극적으로 사용자 스레드와 커널 스레드는 어떤 연관 관계가 존재해야 한다.

4.3.1. 다대일 모델

다대일 모델은 많은 사용자 수준 스레드를 하나의 커널 스레드로 사상한다. 스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 행해진다. 따라서 효율적이다.
하지만, 한 스레드가 봉쇄형 시스템 콜을 할 경우, 전체 프로세스가 봉쇄된다. 또한, 한 번에 하나의 스레드만이 커널에 접근할 수 있기 때문에 다중 스레드가 다중 코어 시스템에서 병렬로 실행될 수 있다.
그린 스레드(Solaris 시스템을 위한 스레드 라이브러리)가 다대일 모델을 사용하였다. 그러나 다중 처리 코어가 대부분의 컴퓨터 시스템에서 표준이 되었고 다중 처리 코어의 이점을 살릴 수 없기 때문에 이 모델을 사용 중인 시스템은 거의 존재하지 않는다.

4.3.2. 일대일 모델

일대일 모델은 각 사용자 스레드를 각각 하나의 커널 스레드로 사상한다. 이 모델은 하나의 스레드가 봉쇄적 시스템 콜을 호출하더라도 다른 스레드가 실행될 수 있기 때문에 다대일 모델보다 더 많은 병렬성을 제공한다. 또한 이 모델은 다중 처리기에서 다중 스레드가 병렬로 수행되는 것을 허용한다.
이 모델의 유일한 단점은 사용자 스레드를 만드려면 해당 커널 스레드를 만들어야 하며 많은 수의 커널 스레드가 시스템 성능에 부담을 줄 수 있다는 것이다.
Linux는 Windows 운영체제 제품군과 함께 일대일 모델을 구현한다.

4.3.3. 다대다 모델

다대다 모델은 여러 개의 사용자 수준 스레드를 그보다 적은 수, 혹은 같은 수의 커널 스레드로 멀티플렉싱한다.
커널 스레드의 수는 응용 프로그램이나 특정 기계에 따라 결정된다.

이러한 설계가 병행 실행에 미치는 영향을 생각해보자.
다대일 모델은 개발자가 원하는 만큼의 사용자 수준 스레드를 생성하도록 허용하지만, 커널은 한 번에 하나의 커널 스레드만 스케줄할 수 있기 때문에 진정한 병렬 실행을 획득할 수 없다.
일대일 모델은 더 많은 병행 실행을 제공하지만, 개발자가 한 응용 내에 너무 많은 스레드를 생성하지 않도록 주의해야 한다.
다대다 모델은 이러한 두 가지의 단점들을 어느 정도 해결했다. 개발자는 필요한 만큼 많은 사용자 수준 스레드를 생성할 수 있다. 그리고 상응하는 커널 스레드가 다중 처리기에서 병렬로 수행될 수 있다. 또한, 스레드가 봉쇄형 시스템 콜을 발생시켰을 때, 커널이 다른 스레드의 수행을 스케줄할 수 있다.

다대다 모델의 변형은 여전히 많은 사용자 스레드를 적거나 같은 수의 커널 스레드로 멀티플렉싱하지만, 한 사용자 스레드가 하나의 커널 스레드에만 연관되는 것을 허용한다. 이 변형은 때로 두 수준 모델(two-level model)이라 불린다.

다대다 모델이 논의된 모델 중 가장 융통성 있는 것으로 보이지만 실제로는 구현하기가 어렵다.
또한 대부분의 시스템에서 처리 코어 수가 증가함에 따라 커널 스레드의 수를 제한하는 것의 중요성이 줄어들었다. 결과적으로 대부분의 운영체제는 이제 일대일 모델을 사용한다.

4.4. 스레드 라이브러리

스레드 라이브러리는 프로그래머에게 스레드를 생성하고 관리하기 위한 API를 제공한다.
스레드 라이브러리를 구현하는 데에는 주된 두 가지 방법이 있다.

  • 커널의 지원 없이 완전히 사용자 공간에서만 라이브러리를 제공하는 것
    • 라이브러리를 위한 모든 코드와 자료구조는 사용자 공간에 존재한다.
    • 라이브러리의 함수를 호출하는 것은 시스템 콜이 아니라 사용자 공간의 지역 함수를 호출한다.
  • 운영체제에 의해 지원되는 커널 수준 라이브러리를 구현하는 것
    • 라이브러리를 위한 코드와 자료구조가 커널 공간에 존재한다.
    • 라이브러리 API를 호출하는 것은 커널 시스템 콜을 부르는 결과를 낳는다.

현재 POSIX pthread, Windows 및 Java의 세 종류 라이브러리가 주로 사용된다.
POSIX 표준안의 스레드 확장판인 pthreads는 사용자 또는 커널 수준 라이브러리로서 제공될 수 있다.
Windows 스레드 라이브러리는 Windows 시스템에서 사용 가능한 커널 수준 라이브러리이다.
Java 스레드 API는 Java 프로그램에서 직접 스레드 생성과 관리를 가능하게 한다. 그러나 대부분의 JVM 구현은 호스트 운영체제에서 실행되기 때문에 Java 스레드 API는 통상 호스트 시스템에서 사용 가능한 스레드 라이브러리를 이용하여 구현된다.
Windows 시스템에서 Java 스레드는 Windows API를 사용하여 구현된다는 것을 의미한다. UNIX, Linux 및 macOS 시스템에서는 통상 pthreads를 사용한다.

POSIX와 Windows 스레드의 경우 전역 변수로 선언된 데이터, 즉 함수 외부에 선언된 데이터는 같은 프로세스에 속한 모든 스레드가 공유한다.
Java는 상응하는 전역 데이터 액세스라는 개념이 없기 때문에 공유 데이터에 대한 접근이 스레드 사이에 명시적으로 조율되어야 한다.

비동기 스레딩은 부모가 자식 스레드를 생성한 후 부모는 자신의 실행을 재개하여 부모와 자식 스레드가 서로 독립적으로 병행하게 실행된다. 스레드 독립적이기 때문에 스레드 사이의 데이터 공유는 거의 없다.
비동기 스레딩은 다중 스레드 서버에서 사용되는 전략이고, 반응형 사용자 인터페이스를 설계하는 데에도 흔히 사용된다.

동기 스레딩은 부모 스레드가 하나 이상의 자식 스레드를 생성하고 자식 스레드 모두가 종료할 때까지 기다렸다가 자신의 실행을 재개하는 방식을 말한다.
여기서 부모가 생성한 스레드는 병행하게 실행되지만 부모는 자식들의 작업이 끝날 때까지 실행을 계속할 수 없다. 부모 스레드는 오직 모든 자식 스레드가 조인한 후에야 실행을 재개할 수 있다.
통상 동기 스레딩은 스레드 사이의 상당한 양의 데이터 공유를 수반한다. 예를 들어 부모 스레드는 자식들이 계산한 결과를 통합할 수 있다.

4.4.1. pthreads

pthreads는 POSIX가 스레드 생성과 동기화를 위해 제정한 표준 API이다. 이것은 스레드의 동작에 관한 명세일 뿐 그것 자체를 구현한 것은 아니다.
Linux와 macOS를 포함한 많은 시스템이 pthreads 명세를 구현하고 있다. Windows는 자체적으로 pthreads를 지원하지 않더라도 타사가 구현한 버전을 구할 수 있다.

아래 C 프로그램은 별도의 스레드에서 음이 아닌 정수의 합을 구하는 다중 스레드 프로그램을 제작하기 위한 기본적인 pthreads API를 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

int sum;
void *runner(void *param);

int main(int argc, char *argv[]) {
    pthread_t tid;
    pthread_attr_t attr;

    pthread_attr_init(&attr);
    pthread_create(&tid, &attr, runner, argv[1]);
    pthread_join(tid, NULL);

    printf("sum = %d\n", sum);
}

void *runner(void *param) {
    int i, upper = atoi(param);
    sum = 0;

    for(int i = 1;i <= upper; i++) sum += i;

    pthread_exit(0);
}

pthreads 프로그램에서 별도의 스레드는 지정된 함수(runner)에서 실행을 시작한다.
이 프로그램이 실행을 시작하면 하나의 제어 스레드가 main 함수에서 시작하며, 약간의 초기화 후 main 함수는 runner 함수에서 실행을 시작하는 두 번째 스레드를 생성한다.
두 스레드는 전역 변수 sum을 공유한다.

모든 pthreads 프로그램은 pthread.h 헤더 파일을 포함해야 한다.
pthread_t tid 문장은 우리가 생성할 스레드를 위한 식별자를 선언한다.
각 스레드는 스택의 크기와 스케줄링 정보를 포함한 속성의 집합을 갖는다. pthread_attr_t attr 선언은 스레드를 위한 속성을 나타낸다.
속성은 pthread_attr_init 함수로 설정하며, 우리는 속성을 지정하지 않았기 때문에 디폴트 속성을 사용한다.
별도의 스레드는 pthread_create 함수로 생성한다. 스레드 식별자와 스레드의 속성뿐 아니라 새로운 스레드가 실행을 시작할 함수의 이름도 전달한다.
마지막으로 명령어 라인상에 제공된 정수 매개변수인 argv[1]을 전달한다.

이 시점에서 프로그램은 main 함수의 최초 스레드와 runner 함수에서 합을 계산하는 합 스레드의 두개 스레드를 가지게 된다.
이 프로그램은 스레드 생성/조인 전략을 사용한다. 이 전략에 따라 합산 스레드를 생성한 후에 pthread_join 함수를 호출하여 합산 스레드가 종료하기를 부모 스레드는 기다린다.
합산 스레드는 pthread_exit 함수를 호출하여 종료하며, 합산 스레드가 복귀하면 부모 스레드는 공유 데이터 sum의 값을 출력한다.

이 예제 프로그램은 단지 하나의 스레드만 생성한다.
pthread_join 함수를 사용하여 여러 개의 스레드를 기다리는 간단한 방법은 for 반복문으로 둘러싸는 것이다.

1
2
3
4
5
#define NUM_THREADS 10

pthread_t workers[NUM_THREADS];

for(int i = 0;i < NUM_THREADS; i++) pthread_join(workers[i], NULL);

4.4.2. Windows 스레드

Windows 스레드 라이브러리를 이용하여 스레드를 생성하는 기술은 많은 점에서 pthreads 기법과 유사하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <Windows.h>
#include <stdio.h>

DWORD sum;

DWORD WINAPI Summation(LPVOID param) {
    DWORD upper = *(DWORD *)param;
    for(DWORD i = 1;i<=upper; i++) sum += i;

    return 0;
}

int main(int argc, char *argv[]) {
    DWORD threadId;
    HANDLE threadHandle;
    int param;

    param = atoi(argv[1]);
    threadHandle = CreateThread(NULL, 0, Summation, &param, 0, &threadId);

    WaitForSingleObject(threadHandle, INFINITE);

    CloseHandle(threadHandle);

    printf("sum = %d\n", sum);
}

pthreads와 마찬가지로 개별 스레드가 공유하는 데이터는 전역 변수로 선언된다.
또한 별도의 스레드에서 실행될 Summation 함수도 정의한다. 이 함수는 void형을 가리키는 포인터 변수를 인자로 전달받는다. 이 데이터형을 Windows에서 LPVOID로 정의한다.
이 함수를 실행하는 스레드는 0부터 Summation 함수에 전달된 매개변수까지의 합을 전역 데이터 sum에 저장한다.

Windows API에서 스레드는 CreateThread 함수에 의해 생성되고, pthread와 마찬가지로 이 함수에 스레드를 위한 속성의 집합(보안 정보, 스택 크기 및 스레드가 보류 상태에서 시작하는지를 나타낼 수 있는 플래그 등)이 전달된다. 이 프로그램에서는 디폴트 값을 사용한다.
합산 스레드가 생성되면 부모는 합산 스레드에 의해 지정되어야 할 sum 값을 출력하기 전에 합산 스레드가 종료하기를 기다려야 한다. Windows API에서는 WaitForSingleObject 함수를 이용하여 수행하는데 이 함수는 합산 스레드가 종료할 때까지 생성 스레드가 봉쇄되도록 한다.

여러 스레드의 종료를 기다려야 한다면 WaitForMultipleObjects 함수가 사용된다. 이 함수는 다음과 같은 4개의 매개변수를 전달받는다.

  • 기다려야 하는 객체의 개수
  • 객체 배열을 가리키는 포인터
  • 모든 객체가 신호를 보내왔는지를 나타내는 플래그
  • 대기해야 하는 타임아웃 시간(혹은 INFINITE)

4.4.3. Java 스레드

스레드는 Java 프로그램의 프로그램 실행의 근본적인 모델이고, Java 언어와 API는 스레드의 생성과 관리를 지원하는 풍부한 특성을 제공한다.
모든 Java 프로그램은 적어도 하나의 단일 제어 스레드를 포함하고 있다. 단지 main 함수로만 이루어진 단순한 Java 프로그램조차 JVM 내의 하나의 단일 스레드로 수행된다.
Java 스레드는 JVM을 제공하는 어떠한 시스템에서도 사용할 수 있다. JVM을 제공하는 시스템에는 Windows, Linux, macOS, Android 등이 포함된다.

Java 프로그램에서 스레드를 명시적으로 생성하는 데에는 두 가지 기법이 있다.
한 가지 방법은 Thread 클래스에서 파생된 새 클래스를 만들고 run 메소드를 재정의하는 것이다.
대안 기법은 Runnable 인터페이스를 구현하는 클래스를 정의하는 것이다. 이 인터페이스는 public void run의 서명을 가진 단일 추상 메소드를 정의한다. Runnable을 구현하는 클래스의 run 메소드 코드는 별도의 스레드에서 실행된다.

Java에서 스레드를 생성하려면 Thread 객체를 생성하고 Runnable을 구현하는 클래스의 인스턴스를 전달한 다음 Thread 객체의 start 메소드를 호출해야 한다.

Thread 객체에 대해 start 메소드를 호출하면 두 가지 작업이 수행된다.

  • 메모리가 할당되고, JVM 내에 새로운 스레드가 초기화된다.
  • run 메소드를 호출하면 스레드가 JVM에 의해 수행될 자격을 갖게 한다. run 메소드는 직접 호출하지 않고, start 메소드를 호출하고 이 메소드가 우리 대신 run 메소드를 호출한다.

Java에서는 join 메소드를 활용하여 자식 스레드가 완료되기를 기다린다.

4.4.3.1. Java Executor 프레임워크

[생략]

4.5. 암묵적 스레딩

다중 코어 처리의 지속적 성장에 따라 수백 또는 심지어 수천 개의 스레드를 가진 응용이 등장하게 되었다.
그러한 응용을 설계하는 것은 사소한 일이 아니며 프로그래머는 여러 도전과제뿐 아니라 추가적인 어려움을 극복해야 한다.

이러한 어려움을 극복하고 병행 및 병렬 응용의 설계를 도와주는 한 가지 방법은 스레딩의 생성과 관리 책임을 응용 개발자로부터 컴파일러와 실행시간 라이브러리에게 넘겨주는 것이다.
암묵적 스레딩이라고 불리는 이 전략은 점점 널리 사용되고 있다. 이러한 전략은 일반적으로 응용 프로그램 개발자가 병렬로 실행할 수 있는 스레드가 아닌 작업을 식별해야 한다.
작업은 일반적으로 함수로 작성되며, 런타임 라이브러리는 일반적으로 다대다 모델을 사용하여 별도의 스레드에 매핑된다.
이 방법의 장점은 개발자는 병렬 작업만 식별하면 되고 라이브러리는 스레드 생성 및 관리에 대한 특정 세부 사항을 결정한다는 것이다.

4.5.1. 스레드 풀

웹 서버는 요청을 받을 때마다 그 요청을 위해 새로운 스레드를 만들어 준다. 새로운 스레드를 매 요청마다 만드는 것은, 그때마다 새로운 프로세스를 만들어주는 것보다는 확실히 더 진보된 방법임이 틀림없지만, 다중 스레드 서버는 아직도 많은 문제를 가지고 있다.
첫 번째 문제는 서비스할 때마다 스레드를 생성하는 데 소요되는 시간이다. 특히 이 스레드는 이 일만 끝나면 곧장 용도 폐기될 것이라는 점을 염두에 두면 더 그렇다.
두 번째 문제는 더 심각하다. 모든 요청마다 새 스레드를 만들어서 서비스해준다면 시스템에서 동시에 실행할 수 있는 최대 스레드 수가 몇 개까지 가능할 수 있는 것인지 한계를 정해야 한다. 스레드를 무한정 만들면 언젠가는 CPU 시간, 메모리 공간 등의 시스템 자원이 고갈된다.
이러한 문제들을 해결해 줄 수 있는 방법의 하나가 스레드 풀이다.

스레드 풀의 기본 아이디어는, 프로세스를 시작할 때 아예 일정한 수의 스레드들을 미리 풀로 만들어두는 것이다.
이 스레드들은 평소에는 하는 일 없이 일을 기다리게 되며, 서버는 스레드를 생성하지 않고 요청을 받으면 대시 스레드 풀에 제출하고 추가 요청 대기를 재개한다.
풀에 사용 가능한 스레드가 있으면 깨어나고 요청이 즉시 서비스된다. 풀에 사용 가능한 스레드가 없으면 사용 가능한 스레드가 생길 때까지 작업이 대기된다.
스레드가 서비스를 완료하면 풀로 돌아가서 더 많은 작업을 기다린다. 풀에 제출된 작업을 비동기적으로 실행할 수 있는 경우 스레드 풀이 제대로 작동한다.

스레드 풀은 아래와 같은 장점을 가진다.

  • 새 스레드를 만들어주기보다 기존 스레드로 서비스해주는 것이 종종 더 빠르다.
  • 스레드 풀은 임의 시각에 존재할 스레드 개수에 제한을 둔다. 이러한 제한은 많은 수의 스레드를 병렬 처리할 수 없는 시스템에 도움이 된다.
  • 태스크를 생성하는 방법을 태스크로부터 분리하면, 태스크 실행을 다르게 할 수 있다. 예를 들어 태스크를 일정 시간 후에 실행되도록 스케줄하거나 혹은 주기적으로 실행시킬 수 있다.

스레드 풀에 있는 스레드의 개수는 CPU 수, 물리 메모리 용량, 동시 요청 클라이언트 최대 개수 등을 고려하여 정해질 수 있다.
더 정교하게 하려면 풀의 활용도를 보며 동적으로 풀의 크기를 바꾸어줄 수도 있다. 그러한 구조는 시스템 부하가 적을 때에는 더 작은 풀을 유지하도록 함으로써 메모리 등의 소모를 더 줄일 수 있다.

Windows API는 스레드 풀과 관련된 여러 함수를 제공한다. 스레드 풀을 위한, 별도의 스레드에서 실행될 함수가 정의된다.

1
2
3
DWORD WINAPI PoolFunction(PVOID Param) {
    ...
}

PoolFunction을 가리키는 포인터가 스레드 풀 API 중 하나의 함수에 전달되고 풀 중의 한 스레드가 이 함수를 실행한다. 그러한 스레드 풀 API 중 한 함수가 QueueUserWorkItem 함수이며, 이 함수는 다음과 같은 세 매개변수를 전달받는다.

  • LPTHREAD_START_ROUTINE Function: 별도의 스레드가 실행할 함수에 대한 포인터
  • PVOID Param: Function에 전달될 매개변수
  • ULONG Flags: 스레드 풀이 스레드를 생성하고 관리하는 방법을 가리키는 플래그

호출의 예는 다음과 같다.

  • QueueUserWorkItem(&PoolFunction, NULL, 0);

이 호출은 프로그래머를 대신하여 스레드 풀의 한 스레드가 PoolFunction 함수를 호출하게 한다. 이 예에서는 PoolFunction 함수에 아무런 매개변수도 전달하지 않는다. 플래그를 0으로 지정했으므로 스레드 생성에 관해 스레드 풀에 특별한 지시를 하지 않는다.

Windows 스레드 풀 API의 다른 함수들은 주기적으로 함수를 호출하거나 비동기식 입출력 요청이 완료되었을 때 함수를 호출하는 등의 기능을 제공한다.

4.5.1.1. Java 스레드 풀

[생략]

4.5.2. Fork Join

4.4절에서 다루는 스레드 생성 전략은 종종 fork-join 모델로 알려져 있다. 이 메소드를 사용하면 메인 부모 스레드가 하나 이상의 자식 스레드를 생성한 다음 자식의 종료를 기다린 후 join하고 그 시점부터 자식의 결과를 확인하고 결합할 수 있다.
이 동기식 모델은 종종 명시적 스레드 생성이라고 특징지어지지만 암시적 스레딩에도 사용될 수 있다. 후자의 상황에서, fork 단계에서는 스레드가 직접 구축되지 않고 대신 병렬 작업이 식별된다.
라이브러리는 생성되는 스레드 수를 관리하며 스레드에 작업 배정을 책임진다. 어떤 식으로든 이 fork-join 모델은 라이브러리가 생성할 실제 스레드 수를 결정하는 동기 버전의 스레드 풀이다.

4.5.2.1. Java에서의 Fork Join

[생략]

4.5.3. OpenMP

OpenMP는 C, C++ 또는 Fortran으로 작성된 API와 컴파일 디렉티브의 집합이다. OpenMP는 공유 메모리 환경에서 병렬 프로그래밍을 할 수 있도록 도움을 준다.
OpenMP는 병렬로 실행될 수 있는 블록을 찾아 병렬 영역(parallel regions)이라고 부른다. 응용 개발자는 자신들의 코드 중 병렬 영역에 컴파일러 디렉티브를 삽입한다. 이 디렉티브는 OpenMP 실행 시간 라이브러리에 해당 영역을 병렬로 실행하라고 지시한다.

다음 C 프로그램은 printf 문을 포함하고 있는 병렬 영역 위에 컴파일러 디렉티브가 사용되고 있는 것을 보인다.

1
2
3
4
5
6
7
8
9
10
11
#include <omp.h>
#include <stdio.h>

int main(int argc, char *argv[] {
    #pragma omp parallel
    {
        printf("I am a parallel region.");
    }

    return 0;
}

OpenMP가 컴파일러 디렉티브를 만나면 시스템의 코어 개수만큼 스레드를 생성한다. 모든 스레드는 동시에 병렬 영역을 실행하게 되며, 각 스레드가 병렬 영역을 빠져나가면 스레드는 종료된다.

OpenMP는 병렬 처리 반복문 등을 포함하여 코드 영역을 실행하기 위한 추가적인 디렉티브를 제공한다.
배열 a와 b가 있고, 각 원소의 합을 구해서 배열 c에 저장하고자 한다면, 다음과 같은 코드 세그먼트를 이용하여 이 작업을 병렬로 실행할 수 있다.

1
2
3
4
#pragma omp parallel for
for(i = 0;i < N; i++) {
    c[i] = a[i] + b[i];
}

OpenMP는 병렬화를 위한 디렉티브를 제공할 뿐만 아니라, 개발자가 병렬화 수준을 선택할 수 있게 한다. 예를 들면, 개발자는 필요한 스레드의 개수를 직접 정할 수 있다. 개발자는 또한 데이터를 스레드들이 공유할 것인지 혹은 특정 스레드만 사용할 것인지도 정할 수 있다.
OpenMP는 Linux, Windows 및 macOS 시스템을 위한 다수의 오픈 소스와 상용 컴파일러에서 사용할 수 있다.

4.5.4. Grand Central Dispatch

GCD는 macOS 및 iOS 운영체제를 위해 Apple에서 개발한 기술로, 개발자가 병렬로 실행될 코드 섹션(태스크)을 식별할 수 있도록 하는 런타임 라이브러리, API 및 언어 확장의 조합이다.
OpenMP와 마찬가지로 GCD는 스레딩에 대한 대부분의 세부 사항을 관리한다.

GCD는 실행 시간 수행을 위해 태스크를 디스패치 큐에 넣어서 스케줄한다. 큐에서 태스크를 제거할 때 관리하는 스레드 풀에서 가용 스레드를 선택하여 태스크를 할당한다.
GCD는 직렬과 병행의 두 가지 유형의 디스패치 큐를 유지한다.

직렬 큐에 넣어진 태스크는 FIFO 순서대로 제거된다. 태스크는 큐에서 제거되면 다른 태스크가 제거되기 전에 실행을 반드시 완료해야 한다.
각 프로세스에는 고유한 직렬 큐가 있으며 개발자는 특정 프로세스에 로컬인 추가 직렬 큐를 만들 수 있다. 직렬 큐를 개인 디스패치 큐라고도 한다. 직렬 큐는 여러 작업을 순차적으로 실행하는 데 유용하다.

병행 큐에 넣어진 태스크도 FIFO 순서대로 제거되지만 한 번에 여러 태스크가 제거되어 병렬로 실행될 수 있게 한다. 다수의 시스템 전체의 병행 큐(전역 디스패치 큐)가 존재하며 4가지 주요 서비스 품질 클래스로 나뉜다.

  • QOS_CLASS_USER_INTERACTIVE: 반응형 사용자 인터페이스를 보장하기 위하여 사용자와 상호 작용하는 태스크
  • QOS_CLASS_USER_INITIATED: 반응형보다 빨리 서비스될 필요는 없는, 사용자가 시작한 태스크
  • QOS_CLASS_UTILITY: 완료하는 데 시간이 오래 걸리지만 즉각적인 결과를 요구하지 않는 태스크
  • QOS_CLASS_BACKGROUND: 사용자에게 보이지 않으며 시간에 민감하지 않은 태스크

디스패치 큐에 제출된 태스크는 다음 두 가지 방법 중 하나로 표현된다.

  • C, C++ 및 Objective-C 언어의 경우 GCD는 블록으로 알려진 언어 확장을 식별하는데, 단순히 모든게 완비된 작업 단위이다. 블록은 한 쌍의 중괄호 앞에 삽입된 캐럿으로 명시된다.
  • Swift 프로그래밍 언어의 경우 작업은 모든 게 완비된 기능 단위를 표현한다는 점에서 블록과 유사한 closure를 사용하여 정의된다.

내부적으로 GCD의 스레드 풀은 POSIX 스레드로 구성된다. GCD는 풀을 적극적으로 관리하여 응용 프로그램 요구 및 시스템 용량에 따라 스레드 수가 늘어나거나 줄어들게 한다.
GCD는 libdispatch 라이브러리에 의해 구현되며, 이후 FreeBSD 운영체제로 이식되었다.

4.5.5. Intel 스레드 빌딩 블록

Intel TBB(Thread Building Block)는 C++에서 병렬 응용 프로그램 설계를 지원하는 템플릿 라이브러리이다. 이것은 라이브러리이므로 특별한 컴파일러나 언어 지원이 필요하지 않다.
개발자는 병렬로 실행할 수 있는 태스크를 지정하고 TBB 태스크 스케줄러는 이러한 태스크를 하부 스레드에 매핑한다.
또한 태스크 스케줄러는 부하 균형 기능을 제공하고 캐시를 인지한다. 캐시를 인지한다는 것은 캐시 메모리에 데이터가 저장되어 보다 빠르게 실행되는 태스크에 우선순위를 부여한다는 것을 의미한다.
TBB는 병렬 루프 구조, 원자적 연산 및 상호 배제 잠금을 위한 템플릿을 포함하여 다양한 기능을 제공한다. 또한 해시 맵, 큐 및 벡터를 포함한 병행 자료구조를 제공하며, 이 자료구조는 C++ 표준 템플릿 라이브러리 자료구조의 상응하는 스레드 안전 버전으로 사용할 수 있다.

병렬 for 루프를 예로 들어보자. 우선 매개변수 value에 대해 연산을 수행하는 apply(float value) 함수가 있다고 가정한다. float 값을 포함하는 n 크기의 배열 v가 있다면, 다음 직렬 for 루프를 사용하여 v의 각 값을 apply 함수에 전달할 수 있다.

1
2
3
for(int i = 0;i < n; i++) {
    apply(v[i]);
}

개발자는 각 처리 코어에 배열 v의 다른 영역을 배정하여 다중 코어 시스템에서 데이터 병렬 처리를 수동으로 적용할 수 있다. 그러나 이 방법은 병렬성을 획득하기 위하여 물리적 하드웨어에 밀접하게 의존해야 하며, 특정 아키텍처의 처리 코어 수에 맞게 알고리즘이 수정되고 다시 컴파일되어야 한다.

대안으로 개발자는 TBB를 사용할 수 있다. TBB는 두 가지 값이 전달되는 parallel_for 템플릿을 제공한다.

  • parallel_for(range body)
  • 여기서 range는 반복될 원소의 범위를 가리킨다.
  • body는 원소의 부분 영역에 적용될 연산을 나타낸다.

다음과 같이 parallel_for 템플릿을 사용하여 위의 직렬 for 루프를 다시 작성할 수 있다.

  • parallel_for(size_t(0), n, [=](size_t i) {apply(v[i]);});
  • 처음 두 매개변수는 반복 공간이 0에서 n-1까지임을 지정한다.
  • 두번째 매개변수는 C++ lambda 함수이다. [=](size_t i) 수식은 매개변수 i, 즉 반복 공간에 속한 각각의 값을 가정한다. i의 각 값은 v의 어떤 배열 원소가 apply(v[i]) 함수에 매개변수로 전달될지 식별하는 데 사용된다.

TBB 라이브러리는 루프 반복을 개별적인 청크로 나누고 해당 청크에 대해 연산을 수행하는 많은 태스크를 만든다. 또한 TBB는 여러 스레드를 생성하고 사용 가능한 스레드에 태스크를 배정한다.
이 방법의 장점은 개발자는 parallel_for 루프를 지정하여 병렬로 실행할 수 있는 작업을 식별만 하고 라이브러리가 작업을 병렬로 실행되는 개별적인 태스크로 나누는 데 필요한 상세 사항만 관리한다는 것이다.
Intel TBB에는 Windows, Linux 및 macOS에서 실행되는 상용 및 오픈 소스 버전이 있다.

4.6. 스레드와 관련된 문제들

4.6.1. Fork 및 Exec 시스템 콜

다중 스레드 프로그램에서는 forkexec의 의미가 달라질 수 있다.

만약 한 프로그램의 스레드가 fork를 호출하면 새로운 프로세스는 모든 스레드를 복제해야 하는가, 아니면 한 개의 스레드만 가지는 프로세스여야 하는가?
몇몇 UNIX 기종은 이 두 가지 버전 fork를 모두 제공하기도 한다. 하나는 모든 스레드를 복사하는 것이고, 다른 하나는 fork를 호출한 스레드만 복제하는 것이다.

exec 시스템 콜은 보통 3장에서 기술한 것과 같은 방법으로 수행된다. 즉 어떤 스레드가 exec 시스템 콜을 호출하면 exec의 매개변수로 지정된 프로그램이 모든 스레드를 포함한 전체 프로그램을 대체시킨다.

두 버전의 fork 중 어느 쪽을 택할지는 응용 프로그램에 달려 있다.
fork를 부르자마자 다시 exec을 부른다면 모든 스레드를 다 복제해서 만들어주는 것은 불필요하다. 왜냐하면 exec에서 지정한 프로그램이 곧 모든 것을 다시 대체할 것이기 때문이다.
이 경우에는 fork 시스템 콜을 호출한 스레드만 복사해주는 것이 적절하다. 그러나 새 프로세스가 forkexec을 하지 않는다면 새 프로세스는 모든 스레드들을 복제해야 한다.

4.6.2. 신호 처리

신호는 UNIX에서 프로세스에 어떤 이벤트가 일어났음을 알려주기 위해 사용된다. 신호는 알려줄 이벤트의 근원지나 이유에 따라 동기식 또는 비동기식으로 전달될 수 있다.
동기식이건 비동기식이건 모든 신호는 다음과 같은 형태로 전달된다.

  • 신호는 특정 이벤트가 일어나야 생성된다.
  • 생성된 신호가 프로세스에 전달된다.
  • 신호가 전달되면 반드시 처리되어야 한다.

동기식 신호의 예로는 불법적인 메모리 접근, 0으로 나누기 등이 있다. 실행 중인 프로그램이 이러한 행동을 하면 신호가 발생한다.
동기식 신호는 신호를 발생시킨 연산을 수행한 동일한 프로세스에 전달된다.

신호가 실행 중인 프로세스 외부로부터 발생하면 그 프로세스는 신호를 비동기식으로 전달받는다. 이러한 신호의 예는 Ctrl+C 같은 특수한 키를 눌러서 프로세스를 강제 종료시키거나 타이머가 만료되는 경우가 포함된다.
비동기식 신호는 통상 다른 프로세스에 전달된다.

모든 신호는 둘 중 하나의 처리기에 의해 처리된다.

  • 디폴트 신호 처리기
  • 사용자 정의 신호 처리기

모든 신호마다 커널이 실행시키는 디폴트 신호 처리기가 있다. 이 디폴트 처리기는 신호를 처리하기 위하여 호출되는 사용자 정의 처리기에 의해 대체될 수 있다.
신호는 다른 방식으로 처리될 수 있다. 일부 신호는 무시될 수 있지만 다른 신호는 프로그램을 종료하여 처리한다.

단일 스레드 프로그램에서의 신호 처리는 간단하다. 신호는 항상 프로세스에 전달된다.
그러나 프로세스가 여러 스레드를 가지고 있는 다중 스레드 프로그램에서의 신호 처리는 더욱 복잡하다. 어느 스레드에 신호를 전달해야 할까?

일반적으로 다음과 같은 선택이 존재한다.

  • 신호가 적용될 스레드에게 전달한다.
  • 모든 스레드에 전달한다.
  • 몇몇 스레드들에만 선택적으로 전달한다.
  • 특정 스레드가 모든 신호를 전달받도록 지정한다.

신호를 전달하는 방법은 신호의 유형에 따라 다르다. 예를 들어 동기식 신호는 그 신호를 야기한 스레드에 전달되어야 하고 다른 스레드에 전달되면 안 된다.
그러나 비동기식 신호의 경우는 명확하지 않다. Ctrl+C 같은 키를 쳐서 그 프로세스를 강제 종료하는 신호와 같은 어떤 비동기식 신호는 그 프로세스 내 모든 스레드에 전달되어야 한다.

신호를 전달하는 데 사용되는 표준 UNIX 함수는 kill이다.

  • kill(pid_t pid, int signal)

이 함수는 특정 신호가 전달될 프로세스를 지정한다. 대부분의 다중 스레드 UNIX는 스레드에 받아들일 신호와 봉쇄할 신호를 지정할 수 있는 선택권을 준다. 따라서 어떤 경우에는 비동기식 신호를 봉쇄하지 않고 있는 스레드들에게만 신호를 전달해야 할 수 있다.
하지만 신호는 오직 한 번만 처리되어야 하기 때문에 그 신호를 봉쇄하지 않고 있는 첫 번째 스레드에만 신호가 전달된다. POSIX Pthreads는 tid로 지정된 스레드에만 전달이 되도록 허용하는 다음과 같은 함수를 제공한다.

  • pthread_kill(pthread_t tid, int signal)

Windows는 신호를 명시적으로 지원하지 않지만 비동기식 프로시저 호출(APC; Asynchronous Procedure Call)을 사용해서 이를 대리 실행(emulate)할 수 있다.
APC는 사용자 스레드들이 특정 이벤트의 발생을 전달받았을 때 호출될 함수를 지정할 수 있게 한다. 이름이 의미하는 바와 같이 APC는 UNIX의 비동기식 신호와 유사하다.
그러나 UNIX에서는 다중 스레드 환경에서 신호를 어떻게 처리해야 할지를 고민해야 하지만 APC는 프로세스에 전달되는 것이 아니라 특정 스레드에 전달되기 때문에 좀 더 간단하다.

4.6.3. 스레드 취소

스레드 취소는 스레드가 끝나기 전에 그것을 강제 종료시키는 작업을 일컫는다. 취소되어야 할 스레드를 목적 스레드(target thread)라고 부른다. 목적 스레드의 취소는 다음과 같은 두 가지 방식으로 발생할 수 있다.

  • 비동기식 취소: 한 스레드가 즉시 목적 스레드를 강제 종료시킨다.
  • 지연 취소: 목적 스레드가 주기적으로 자신이 강제 종료되어야 할지를 점검한다. 이 경우 목적 스레드가 질서정연하게 강제 종료될 수 있는 기회가 만들어진다.

스레드 취소를 어렵게 만드는 것은 취소 스레드들에 할당된 자원 문제이다. 또한 스레드가 다른 스레드와 공유하는 자료구조를 갱신하는 도중에 취소 요청이 와도 문제가 된다.
후자의 문제는 비동기식 취소의 경우 더 심각하다. 종종 운영체제는 취소된 스레드로부터 시스템 자원을 회수할 수도 있지만, 모든 시스템 자원을 다 회수하지 못하는 경우도 있다.
따라서 비동기식으로 스레드를 취소하면 필요한 시스템 자원을 다 사용 가능한 상태로 만들지 못할 수도 있다.

이와 반대로 지연 취소의 경우에는 한 스레드가 목적 스레드를 취소해야 한다고 표시하지만 실제 취소는 목적 스레드가 취소 여부를 결정하기 위한 플래그를 검사한 이후에야 일어난다.
스레드는 자신이 취소되어도 안전하다고 판단되는 시점에서 취소 여부를 검사할 수 있다.

Pthreads에서는 pthread_cancel 함수를 사용하여 스레드를 취소할 수 있다. 목적 스레드의 식별자가 이 함수의 매개변수로 전달된다.
pthread_cancel을 호출하면 대상 스레드를 취소하라는 요청만 표시된다. 그러나 실제 취소는 요청을 처리하기 위해 대상 스레드가 설정되는 방식에 달려 있다.
대상 스레드가 최종적으로 취소되면 취소 스레드의 pthread_join 호출이 반환된다.

Pthreads는 3가지 취소 모드를 지원한다. 각 모드는 상태 및 유형으로 정의되며, API를 사용하여 취소 상태 및 유형을 설정할 수 있다.

  • off 모드: 사용 불가능 상태
  • 지연 모드: 사용 가능 상태
  • 비동기식 모드: 사용 가능 상태

Pthreads는 스레드가 취소를 활성 또는 비활성하는 것을 허용한다. 취소가 비활성화되어 있으면 스레드를 취소할 수 없다. 그러나 취소 요청은 계속 보류 상태로 유지되므로 스레드는 나중에 취소를 활성화하고 요청에 응답할 수 있다.

기본 취소 유형은 지연 취소이다. 그러나 스레드가 취소 점에 도달한 경우에만 취소가 발생한다. POSIX 및 표준 C 라이브러리에서 대부분의 블로킹 시스템 콜은 취소 점으로 정의되며, Linux 시스템에서 man pthreads 명령을 호출할 때 나열된다.
예를 들어, read 시스템 콜은 read에서 입력을 기다리는 동안 봉쇄된 스레드의 취소를 허용하는 취소 점이다.

취소 점을 설정하는 한 가지 기법은 pthread_testcancel 함수를 호출하는 것이다. 취소 요청이 보류 중인 것으로 확인되면 pthread_testcancel 호출이 복귀하지 않고 스레드가 종료된다. 그렇지 않으면 함수 호출이 복귀되고 스레드가 계속 실행된다.
또한 Pthreads는 스레드가 취소될 때 정리 핸들러라고 하는 함수가 호출되게 할 수 있다. 이 기능을 사용하면 스레드가 종료되기 전에 스레드가 획득한 모든 자원을 해제할 수 있다.

Pthreads 문서에서는 비동기 취소를 권장하지 않는다. 흥미로운 점은 Linux 시스템에서 Pthreads API를 사용한 스레드 취소가 시그널을 통해 처리된다는 것이다.

4.6.4. TLS(Thread Local Storage)

한 프로세스에 속한 스레드들은 그 프로세스의 데이터를 모두 공유한다. 이와 같은 데이터 공유는 다중 스레드 프로그래밍의 큰 장점 중 하나이다.
그러나 상황에 따라서는 각 스레드가 자기만 액세스할 수 있는 데이터를 가져야 할 필요도 있다. 그러한 데이터를 TLS라고 부른다.

TLS를 지역 변수와 혼동하기 쉽다. 그러나 지역 변수는 하나의 함수가 호출되는 동안에만 보이지만, TLS는 전체 함수에 걸쳐 보인다. 또한 개발자가 스레드 생성 과정에 대해 제어할 수 없는 경우 다른 방법이 필요하다.

어떤 면에서 TLS는 정적 데이터와 유사하다. 차이점은 TLS 데이터는 스레드마다 고유하다는 것이다.
대부분의 스레드 라이브러리 및 컴파일러는 TLS를 지원한다. Pthread에는 pthread_key_t 유형이 포함되어 있으며 각 스레드에 고유한 키를 제공한다. 그런 다음 이 키를 사용하여 TLS 데이터에 접근할 수 있다.
C#은 스레드 로컬 데이터를 선언하려면 스토리지 속성(ThreadStatic)을 추가하기만 하면 된다.
gcc 컴파일러는 TLS 데이터를 선언하기 위한 스토리지 클래스 키워드 __thread를 제공한다.

4.6.5. 스케줄러 액티베이션

다중 스레드 프로그램과 관련하여 마지막으로 고려할 문제는 스레드 라이브러리와 커널의 통신 문제이다. 이 통신은 다대다 및 두 수준 모델에서 반드시 해결해야 할 문제이다. 이러한 통신의 조정은 응용 프로그램이 최고의 성능을 보이도록 보장하기 위하여 커널 스레드의 수를 동적으로 조절하는 것을 가능하게 한다.

다대다 또는 두 수준 모델을 구현하는 많은 시스템은 사용자와 커널 스레드 사이에 중간 자료구조를 둔다. 이 자료구조는 통상 경량 프로세스 또는 LWP라고 불린다.
사용자 스레드 라이브러리에 LWP 방식은 응용 프로그램이 사용자 스레드를 수행하기 위하여 스케줄 할 가상 처리기처럼 보인다. 각 LWP는 하나의 커널 스레드에 부속되어 있으며 물리 처리기에서 스케줄하는 대상은 바로 이 커널 스레드이다.
입출력이 완료되기를 기다리는 동안 같이 커널 스레드가 봉쇄되면 LWP도 같이 봉쇄된다. 이 연관을 따라 LWP에 부속된 사용자 수준 스레드도 역시 봉쇄된다.

응용 프로그램은 효율적으로 실행되기 위해 임의의 개수의 LWP를 필요로 할 수도 있다.
하나의 처리기 상에서 실행되는 CPU 중심 응용을 고려해보자. 이 시나리오에서 한순간에 오직 하나의 스레드만이 실행될 수 있다. 따라서 하나의 LWP면 충분하다.
그러나 입출력 중심 응용은 여러 개의 LWP를 필요로 할 수도 있다. 통상 동시에 발생하는 봉쇄형 시스템 콜마다 하나의 LWP가 필요하다. 예를 들어 서로 다른 5개의 파일 읽기 요청이 발생했다고 가정하자. 모든 LWP가 입출력 완료를 기다리면서 커널 안에서 대기할 수 있기 때문에 5개의 LWP가 필요하다. 만일 프로세스가 4개의 LWP를 가지고 있다면 다섯 번째 요청은 하나의 LWP라도 커널에서 복귀할 때까지 기다려야 한다.

사용자 스레드 라이브러리와 커널 스레드 간의 통신 방법 중 하나는 스케줄러 액티베이션이라고 알려진 방법이다. 이것은 다음과 같이 동작한다.

  • 커널은 응용에 가상 처리기의 집합을 제공하고 응용은 사용자 스레드를 가용한 가상 처리기로 스케줄한다.
  • 커널은 응용 프로그램에게 특정 이벤트에 대해 알려줘야 한다. 이 프로시저를 upcall이라고 한다.
  • upcall은 스레드 라이브러리의 처리기에 의해 처리되고, upcall 처리기는 가상 처리기상에서 실행되어야 한다.

upcall을 일으키는 한 이벤트는 응용 스레드가 봉쇄하려고 할 때 발생한다. 이 시나리오에서 커널은 스레드가 봉쇄하려 한다는 사실과 그 스레드의 식별자를 알려주는 upcall을 한다. 그런 후 커널은 새로운 가상 처리기를 응용에 할당한다.
응용은 이 새로운 가상 처리기 상에서 upcall 처리기를 수행하고 이 upcall 처리기는 봉쇄 스레드의 상태를 저장하고 이 스레드가 실행 중이던 가상 처리기를 반환한다. 그리고 upcall 처리기는 새로운 가상 처리기에서 실행 가능한 다른 스레드를 스케줄한다.
봉쇄 스레드가 기다리던 이벤트가 발생하면 커널은 이전에 봉쇄되었던 스레드가 이제 실행할 수 있다는 사실을 알려주는 또 다른 upcall을 스레드 라이브러리에 보낸다. 이 이벤트를 처리하는 upcall 처리기도 가상 처리기가 필요하고 커널은 새로운 가상 처리기를 할당하거나 사용자 스레드 하나를 선점하여 그 처리기에서 이 upcall 처리기를 실행한다.
봉쇄가 풀린 스레드를 실행 가능한 상태로 표시한 후에 응용은 가용한 가상 처리기상에서 다른 실행 가능한 스레드를 실행한다.

4.7. 운영체제 사례

4.7.1. Windows 스레드

Windows 응용 프로그램들은 프로세스 형태로 실행되며 이들 각 프로세스는 한 개 이상 또는 그 이상의 스레드를 가질 수 있다.
Windows는 일대일 대응을 사용하며 사용자 수준 스레드 하나마다 커널 스레드 하나가 대응된다.

스레드의 일반적인 구성 요소는 다음과 같다.

  • 각 스레드를 유일하게 지목하는 스레드 ID
  • 처리기의 상태를 나타내는 레지스터 집합
  • 프로그램 카운터
  • 사용자 모드에서 실행될 때 필요한 사용자 스택, 커널 모드에서 실행될 때 필요한 커널 스택
  • 실행 시간 라이브러리와 동적 링크 라이브러리 등이 사용하는 개별 데이터 저장 영역

레지스터리 집합, 스택, 개별 데이터 저장 영역들은 그 스레드의 문맥(Context)으로 불린다.

스레드의 주요 자료구조는 다음과 같다.

  • ETHREAD: 실행 스레드 블록
  • KTHREAD: 커널 스레드 블록
  • TEB: 스레드 환경 블록

ETHREAD에는 그 스레드가 속한 프로세스를 가리키는 포인터와 그 스레드가 실행을 시작해야 할 루틴의 주소 등이다. 이외에도 KTHREAD에 대한 포인터를 가진다.

KTHREAD는 스레드의 스케줄링 및 동기화 정보를 가진다. 또한 이 스레드가 커널 모드에서 실행될 때 사용되는 커널 스택과 TEB에 대한 포인터를 가진다.

ETHREADKTHREAD는 모두 커널 안에 존재하며, 이는 커널만이 이들을 접근할 수 있다는 것을 의미한다.
TEB는 사용자 모드에서 실행될 때 접근되는 사용자 공간 자료구조이며, 스레드 식별자, 사용자 모드 스택 및 스레드 로컬 저장소를 저장하기 위한 배열을 가지고 있다.

4.7.2. Linux 스레드

Linux는 프로세스를 복제하는 기능을 가진 fork 시스템 콜을 제공한다. Linux는 clone 시스템 콜을 이용하여 스레드를 생성할 수 있는 기능도 제공한다.
그러나 Linux는 프로세스와 스레드를 구별하지 않는다. 사실 Linux는 프로그램 내의 제어 흐름을 나타내기 위해 프로세스나 스레드보다는 태스크라는 용어를 사용한다.

clone이 호출될 때 부모와 자식 태스크가 자료구조를 얼마나 공유할지 결정하는 플래그의 집합이 전달된다.
CLONE_FS, CLONE_VM, CLONE_SIGHAND, CLONE_FILES를 전달받았다면 부모 태스크와 자식 태스크는 파일 시스템, 메모리 공간, 신호 처리기, 열린 파일의 집합을 공유하게 된다. 이러한 식으로 clone을 사용하는 것은 부모 태스크와 자식 태스크가 거의 모든 자원을 공유하기 때문에 스레드를 생성하는 것과 같은 결과가 된다.
그러나 아무 플래그 없이 clone이 호출되면 공유는 일어나지 않고 fork 시스템 콜이 제공하는 기능과 유사한 기능성을 제공한다.

Linux 커널이 태스크를 표현하는 방식 때문에 다양한 공유 수준이 가능하다.
시스템의 태스크마다 고유한 커널 자료구조(struct task_struct)가 존재한다. 이 자료구조는 태스크의 데이터를 저장하는 것이 아니라 데이터가 저장된 다른 자료구조를 가리키는 포인터를 포함한다. 다른 자료구조에는 열린 파일의 리스트를 나타내는 자료구조, 신호 처리 정보 및 가상 메모리 등이 있다.
fork가 호출되면 부모 프로세스의 관련된 자료구조를 복사함으로써 새로운 태스크를 생성한다. clone 시스템 콜을 호출하여 새로운 태스크를 생성할 수도 있다. 그러나 모든 데이터를 복사하는 것이 아니라 clone에게 전달된 플래그에 따라 부모 태스크의 자료구조를 가리키게 된다.

clone 시스템 콜의 융통성은 컨테이너 개념으로 확장될 수 있다. 컨테이너는 운영체제가 제공하는 가상화 기법으로 하나의 Linux 커널 아래 서로 격리되어 실행되는 여러 개의 Linux 시스템을 생성하는 것을 가능하게 한다.
clone에 전달된 특정 플래그가 부모와 자식 태스크 사이의 정보 공유량에 따라 프로세스처럼 작동하는 태스크를 생성하느냐 또는 스레드를 생성하느냐를 구분하는 것처럼, Linux 컨테이너를 생성하도록 clone에 전달할 수 있는 다른 플래그가 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

"Operating System" 카테고리의 게시물