Search
Duplicate

운영체제/ 스레드

개요

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

동기(Motivation)

현대 컴퓨터에서 동작하는 거의 모든 소프트웨어 응용들은 다중 스레드를 이용한다. 하나의 응용은 몇 개의 실행 흐름을 가진 독립적인 프로세스로 구현된다.
응용은 멀티코어 시스템에서 처리 능력을 향상시키도록 설계될 수 있다. 이러한 응용은 다중 계산 코어를 사용하여 다수의 CPU-집중 작업을 병렬로 처리할 수 있다.
웹 서버는 클라이언트로부터 웹 페이지나 이미지, 소리 등에 대한 요청을 받는다. 하나의 분주한 웹 서버는 여러 개의 클라이언트들이 병행하게 접근할 수 있다.
만약 전통적인 단일 스레드로 프로세스가 작동한다면 자신의 단일 프로세스로 한 번에 하나의 클라이언트만 서비스할 수 있게 되어 클라이언트는 자신의 요구가 서비스되기까지 매우 긴 시간을 기다려야 한다.
하나의 해결책은 서버가 요청을 받아들이는 하나의 프로세스로 동작하게 하는 것이다. 즉, 서버에 서비스 요청이 들어오면 프로세스는 그 요청을 수행할 별도의 프로세스를 생성하는 것이다.
사실 이와 같은 방식으로 프로세스를 생성하는 것은 스레드가 대중화되기 전에는 매우 보편적이었다. 하지만 프로세스 생성 작업은 매우 많은 시간과 자원을 소비하는데, 새 프로세스가 해야 할 일이 기존 프로세스가 하는 일과 동일하다면 왜 이 많은 오버헤드를 감수하는가? 대부분의 경우 프로세스 안에 여러 스레드를 만들어 나가는 것이 더 효율적이다.
웹 서버가 다중 스레드화 되면 서버는 클라이언트의 요청을 listen하는 별도의 스레드를 생성한다. 요청이 들어오면 다른 프로세스를 생성하는 것이 아니라 요청을 서비스할 새로운 스레드를 생성하고 추가적인 요청을 listen 하기 위한 작업을 재개한다. 이 상황이 그림 4.2에 설명되어 있다.
스레드는 원격 프로시저 호출(RPC, Remote Procedure Call)에서도 매우 중요한 역하을 담당한다. RPC는 IPC(Interprocess Communication)를 마치 일반적인 함수나 프로시저 호출을 하듯 할 수 있게 해준다.
RPC 서버들은 대부분 다중 스레드 시스템으로 구현된다. 서버가 메시지를 받게 되면 그를 서비스해 주기 위한 새로운 스레드를 만들어 준다. 이렇게 해서 여러 요청들을 병행하게 처리한다. Java의 RMI 시스템도 유사하게 동작한다.
마지막으로 다수의 운영체제 커널은 현재 다중화 되어 있다. 커널 안에서 다수의 스레드가 동작하고 각 스레드는 장치 또는 인터럽트 처리 등의 특정 작업을 수행한다.
예컨대 Solaris는 커널 안에 인터럽트 처리를 위한 스레드 집합을 생성한다. Linux는 시스템의 여유 메모리를 관리하기 위해 커널 스레드를 사용한다.

장점(Benefits)

다중 스레드 프로그래밍의 이점은 다음 4가지 분류로 나눌 수 있따.
응답성(Responsiveness)
대화형 응용을 다중 스레드화 하면 응용 프로그램의 일부분이 봉쇄되거나 또는 응용 프로그램이 긴 작업을 수행하더라도 프로그램의 수행이 계속되는 것을 허용함으로써 사용자에 대한 응답성을 증가시킨다. 이 특징은 사용자 인터페이스를 설계하는데 있어 특히 유용하다.
자원 공유(Resource sharing)
프로세스는 공유 메모리와 메시지 전달 비법을 통하여만 자원을 공유할 수 있다. 이러한 기법은 프로그래머에 의해 명시적으로 처리되어야 한다. 그러나 스레드는 자동적으로 그들이 속한 프로세스의 자원들과 메모리를 공유한다. 코드와 데이터 공유의 이점은 한 응용 프로그램이 같은 주소 공간 내에 여러 개의 다른 작업을 하는 스레드를 가질 수 있다는 점이다.
경제성(Economy)
프로세스 생성을 위해 메모리와 자원을 할당하는 것은 비용이 많이 든다. 스레드는 자신이 속한 프로세스의 자원들을 공유하기 떄문에 스레드를 생성하고 문맥 교환하는 것이 보다 경제적이다. Solaris에서 프로세스를 생성하는데 걸리는 시간이 스레드를 생성하는데 걸리는 시간보다 약 30배 정도 오래 걸리며, 문맥 교환 시간은 5배 정도 오래 걸렸다.
규모 적응성(Scalability)
다중 스레드의 이점은 다중 처리기 구조에서 더욱 증가할 수 있다. 다중 처리기 구조에서는 각각의 스레드가 다른 처리기에서 병렬로 수행될 수 있기 때문이다. 단일 스레드 프로세스는 처리기가 아무리 많더라도 오직 한 처리기에서만 실행된다.

다중코어 프로그래밍

컴퓨터 설계의 초창기에는 더 좋은 컴퓨터 성능이 요구될 때마다 단일 CPU 시스템에서 다중 CPU 시스템으로 진화하였다. 최근의 유사한 경향은 하나의 칩 안에 여러 계산 코어를 넣는 것이다. 각 코어는 운영체제에게는 독립된 처리기로 보인다.
코어라 여러 CPU 칩 형태를 띠거나 칩 안에 여러 개가 존재하든지 이러한 시스템을 다중코어 또는 다중 처리기 시스템이라고 부른다.
다중 스레드 프로그래밍은 다중 계산 코어를 더 효율ㅈ거으로 사용할 수 있고 병행 실행을 더 향상시킬 수 있는 기법을 제공한다.
4개의 스레드로 실행되는 응용을 고려하자. 하나의 코어는 한 번에 오직 하나의 스레드만 실행할 수 있기 때문에 단일 코어 시스템 상에서 병행성은 단순히 스레드의 실행이 시간에 따라 교대로 실행된다는 것을 의미한다. (그림 4.3)
그러나 여러 코어를 가진 시스템에서는 시스템이 개별 스레드를 각 코어에 배정할 수 있기 때문에 병행성은 스레드들이 병렬적으로 실행될 수 있다는 것을 뜻한다. (그림 4.4)
현재 논의에서 병렬 실행과 병행 실행의 차이점에 주목하라. 하나 이상의 태스크를 동시에 수행할 수 있는 시스템에 대해 병렬적이라고 말한다. 대조적으로 병행 실행 시스템은 모든 태스크가 진행하게끔 함으로써 하나 이상의 태스크를 지원한다. 따라서 병렬 실행 없이 병행 실행하는 것이 가능하다.
SMP와 다중코어 시스템 구조가 출현하기 전에는 대부분의 컴퓨터 시스템이 하나의 처리기만을 가지고 있었다. CPU 스케줄러는 시스템의 프로세스 사이를 빠르게 오가며 모든 프로세스를 진행시켜 마치 병렬 실행하는 듯한 환상을 주도록 설계되었다. 이런 프로세스들은 병렬 실행되는 것이 아니라 병행 실행되는 것이다.
시스템에 존재하는 스레드의 개수가 수십 개에서 수천 개로 진화함에 따라 CPU 설계자들은 스레드의 성능을 향상시키는 하드웨어를 추가함으로써 시스템의 성능을 개선하였다.
현대 Intel CPU는 종종 코어당 2개의 스레드를 지원하는 반면 Oracle T4 CPU는 코어당 8개의 스레드를 지원한다. 이런 사실은 여러 스레드가 코어에 적재됨으로써 빠른 문맥 교환이 가능하다는 것을 의미한다.

Amdahl’s Law

Amdahl’s Law는 순차 실행(병렬 실행이 아닌) 구성요소와 병렬 실행 구성요소로 이루어진 응용에 추가의 계산 코어를 더했을 때 얻을 수 있는 잠재적인 성능 이득을 나타내는 공식이다.
N개의 처리 코어를 가진 시스템에서 실행되는 응용 중 반드시 순차적으로 실행되어야만 하는 구성요소를 S라고 하면 이 공식은 다음과 같이 표현된다.
speedup1S+(1S)Nspeedup \leq {1 \over S + {(1-S) \over N}}
예컨대 75%의 병렬 실행 구성요소와 25% 순차 실행 구성요소를 가진 응용이 있다고 가정하자. 이 응용을 코어가 2개인 시스템에서 실행시킬 경우, 약 1.6배의 속도 향상을 얻을 수 있다. 만일 2개의 코어를 추가한다면(총 4개의 코어) 속도는 2.28배 빨라진다.
Amdahl’s Law가 시사하는 흥미로운 사실 중 하나는 N이 무한대에 가까워지면 속도는 1s{1 \over s}에 수렴한다는 것이다. 예컨대 응용의 40%가 순차 실행 부분이라면 코어를 아무리 추가한다고 하더라도 최대 2.5배 이상의 속도 향상은 얻을 수 없다.
이러한 사실이 바로 Amdahl’s Law 이면에 존재하는 근본적인 원칙이다. 응용의 순차 실행 부분은 코어를 추가하여 얻을 수 있는 성능 향상에 불균형적인 영향을 미친다.
(이것은 맨먼스 미신에 나오는 임’산부가 9명 있어도 아이는 1달만에 나오지 않는다’는 것과 일맥상통한다. 사람을 늘려서 시간을 단축할 수 있으려면 일 자체가 병렬적이어야 함)
혹자는 Amdahl’s Law는 현재 다중코어 시스템을 설계하는데 사용되는 하드웨어 성능 개선 기술을 고려하지 않는다고 주장한다. 그러한 주장은 또한 현대 컴퓨터 시스템의 코어 개수가 증가할 수록 응용에 적용할 수 없을 것이라고 주장한다.

프로그래밍 도전과제

다중코어 시스템으로 발전하는 추세는 시스템 설계자 뿐만 아니라 응용 프로그래머에게도 다중코어의 활용도를 높일 수 있도록 압력을 행사하고 있다. 일반적으로 다중코어 시스템을 위해 프로그램이 하기 위해 5개의 극복해야 할 도전 과제가 있다.
태스크 인식(Identifying tasks)
응용을 분석하여 독립된 병행가능 태스크로 나눌 수 있는 영역을 찾는 작업이 필요하다. 이상적으로 태스크는 서로 독립적이고 따라서 개별 코어에서 병렬 실행될 수 있어야 한다.
균형(Balance)
병렬로 실행될 수 있는 태스크를 찾아내는 것도 중요하지만 찾아진 부분들이 전체 작업에 균등한 기여도를 가지도록 태스크로 나누는 것도 매우 중요하다. 어떤 경우에는 다른 태스크에 비해 기여도가 적은 작업이 있을 수 있으며 이러한 작업을 실행하기 위해 별도의 코어를 사용하는 것은 그만한 가치가 없다.
데이터 분리(Data Spliting)
응용이 독립된 태스크로 나누어지는 것처럼 태스크가 접근하고 조작하는 데이터 또한 개별 코어에서 사용할 수 있도록 나누어져야 한다.
데이터 종속성(Data dependency)
태스크가 접근하는 데이터는 둘 이상의 태스크 사이에 종속성이 없는지 검토되어야 한다. 한 태스크가 다른 태스크로부터 오는 데이터에 종속적인 경우에는 프로그래머가 데이터 종속성을 수용할 수 있도록 태스크의 수행을 잘 동기화해야 한다.
시험 및 디버깅(Testing and debugging)
프로그램이 다중코어에서 병렬로 실행될 때 다양한 실행 경로가 존재할 수 있다. 그런 병행 프로그램을 시험하고 디버깅하는 것은 단일 스레드 응용을 시험하고 디버깅하는 것보다 근본적으로 훨씬 어렵다.

병렬 실행의 유형

일반적으로 데이터 병렬 실행과 태스크 병렬 실행의 두 가지 유형이 존재한다. 데이터 병렬 실행은 동일한 데이터의 부분집하을 다수의 계산 코어에 분재한 뒤 각 코어에서 동일한 연산을 실행하는데 초점을 맞춘다.
예컨대 크기가 N인 배열의 내용을 더하는 경우를 생각해 보자. (생략) 두 스레드는 각자 계산코어에서 병렬로 실행된다.
태스크 병렬 실행은 데이터가 아니라 태스크(스레드)를 다수의 코어에 분배한다. 각 스레드는 고유의 연산을 실행한다. 다른 스레드들이 동일한 데이터에 대해 연산을 실행할 수 있고 혹은 서로 다른 데이터에 연산을 실행할 수도 있다.
위의 예를 다시 고려해 보자. 위의 상황과는 대조적으로 배열에 대해 각자 고유한 통계 연산을 수행하는 2개의 스레드가 필요할 수 있다. 이 스레드들은 개별 코어에서 병렬로 실행되지만 각각 고유한 연산을 실행한다.
근본적으로 데이터 병렬 실행은 데이터가 코어에 분배되어야 하고 태스크 병렬 실행은 태스크가 분배되어야 한다. 그러나 현실적으로는 어느 한쪽의 특성만을 가지는 응용은 거의 없으며 대부분 이 두 전략을 혼용한다.

다중 스레드 모델

지금까지 일반적 의미에서 스레드를 다루어왔다. 그러나 스레드를 위한 지원은 사용자 스레드(user threads)를 위해서는 사용자 수준에서, 커널 스레드(kernel threads)를 위해서는 커널 수준에서 제공된다.
사용자 스레드는 커널 위에서 지원되며 커널의 지원 없이 관리된다. 반면 커널 스레드는 운영체제에 의해 직접 운영되고 관리된다. Windows, Linux, Max OS X, Solaris를 포함한 거의 모든 현대 운영체제들은 커널 스레드를 지원한다.
궁극적으로 사용자 스레드와 커널 스레드는 어떤 연관 관계가 존재해야 한다.

다대일 모델(Many to One Model)

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

일대일 모델(One to One Model)

일대일 모델(그림 4.6)은 각 사용자 스레드를 각각 하나의 커널 스레드로 사상한다. 이 모델은 하나의 스레드가 봉쇄적 시스템 콜을 하더라도 다른 스레드가 실행될 수 있기 때문에 다대일 모델보다 더 많은 병렬성을 제공한다.
또한 이 모델은 다중 처리기에서 다중 스레드가 병렬로 수행되는 것을 허용한다.
이 모델의 단 하나의 단점은 사용자 수준 스레드를 생성할 때 그에 따른 커널 스레드를 생성해야 한다는 점이다. 커널 스레드를 생성하는 오버헤드가 응용 프로그램의 성능을 저하시킬 수 있으므로 이 모델의 대부분의 구현은 시스템에 의해 지원되는 스레드의 수를 제한한다.
Windows 계열의 운영체제들과 Linux가 일대일 모델을 구현하고 있다.

다대다 모델(Many to Many Model)

다대다 모델(그림 4.7)은 여러 개의 사용자 수준 스레드를 그보다 작은 수 혹은 같은 수의 커널 스레드로 멀티플렉스한다. 커널 스레드의 수는 응용 프로그램이나 특정 기계에 따라 결정된다. (응용 프로그램은 단일 처리기보다 다중 처리기에서 더 많은 커널 스레드를 할당받을 수 있다)
이러한 설계가 병행 실행에 미치는 영향을 생각해 보자. 다대일 모델은 개발자가 원하는 만큼의 사용자 수준 스레드를 생성하도록 허용하지만, 한 번에 하나의 스레드만이 커널에 의해서 스케쥴 되기 때문에 진정한 병렬 실행을 획득할 수 없다.
일대일 모델은 더 많은 병렬 실행을 제공하지만 개발자가 한 응용 내에 너무 많은 스레드를 생성하지 않도록 주의해야 한다.(그리고 몇몇 시스템에서는 생성할 수 있는 스레드의 수가 제한될 수 있다)
다대다 모델은 이러한 두 가지의 단점들을 어느 정도 해결했다. 개발자는 필요한 만큼 많은 사용자 수준 스레드를 생성할 수 있다. 그리고 상응하는 커널 스레드가 다중 처리기에서 병렬로 수행될 수 있다. 또한 스레드가 봉쇄형 시스템 콜을 발생시켰을 때, 커널이 달느 스레드의 수행을 스케줄 할 수 있다.
다대다 모델의 변형은 여전히 많은 사용자 스레드를 적거나 같은 수의 커널 스레드로 멀티플렉스 시키지만 또한 한 사용자 스레드가 하나의 커널 스레드에만 연관되는 것을 허용한다.
이 변형은 때로 두 수준 모델(two-level model)이라고 불리며(그림 4.8) Solaris 운영체제는 Solaris 9 이전 버전에서 이 모델을 지원하였으나 Solaris 9 부터는 일대일 모델을 사용한다.

스레드 라이브러리(Thread Library)

스레드 라이브러리는 프로그래머에게 스레드를 생성하고 관리하기 위한 API를 제공한다. 스레드 라이브러리를 구현하는데는 주된 두 가지 방법이 있다.
첫 번째 방법은 커널의 지원 없이 완전히 사용자 공간에서만 라이브러리를 제공하는 것이다. 라이브러리를 모든 코드와 자료 구조는 사용자 공간에 존재한다.
라이브러리의 함수를 호출하는 것은 시스템 호출이 아니라 사용자 공간의 지역 함수를 호출하게 된다는 것을 의미한다.
두 번째 방법은 운영체제에 의해 지원되는 커널 수준 라이브러리를 구현하는 것이다. 이 경우 라이브러리를 위한 코드와 자료 구조는 커널 공간에 존재한다. 라이브러리 API를 호출하는 것은 커널 시스템 호출을 부르는 결과를 낳는다.
현재 POSIX Pthreads, WIndows 및 Java의 세 종류 라이브러리가 주로 사용된다.
POSIX 표준안의 스레드 확장판인 Pthreads는 사용자 또는 커널 수준 라이브러리로서 제공될 수 있다.
Windows 스레드 라이브러리는 Windows 시스템에서 사용 가능한 커널 수준 라이브러리이다.
Java 스레드 API는 Java 프로그램에서 직접 스레드 생성과 관리를 가능하게 한다. 그러나 대부분의 JVM 구현은 호스트 운영체제에서 실행되기 때문에 Java 스레드 API는 통상 호스트 시스템에서 사용 가능한 스레드 라이브러리를 이용하여 구현된다.
Windows 시스템에서 Java 스레드는 Windows API를 사용하여 구현된다는 것을 의미한다. UNIX와 Linux 시스템에서는 종종 Pthreads를 사용한다.
POSIX와 Windows 스레드의 경우 전역 변수로 선언된 데이터, 즉 함수 외부에 선언된 데이터는 같은 프로세스에 속한 모든 스레드가 공유한다. Java는 전역 데이터의 개념이 없기 떄문에 공유 데이터에 대한 접근이 스레드 사이에 명시적으로 조정 되어야 한다.
함수에 국한된 지역 변수로 선언된 데이터는 통상 스택에 저장된다. 각 스레드는 자신의 스택을 가지고 있으므로 자신만의 지역 데이터의 복사본을 가진다.
이 절의 나머지 부분에서는 이 세 가지 스레드 라이브러리를 이용한 기본적인 스레드 생성을 설명한다. 설명을 위한 예로 다음과 같이 잘 알려진 함수를 이용하여 별도의 스레드에서 음이 아닌 정수 합을 구하는 다중 스레드 프로그램을 설계한다.
sum=i=0Nisum = \sum_{i=0}^{N} i
스레드 생성의 예를 가지고 진행하기 전에 다수의 스레드를 생성하는 비동기 스레딩과 동기 스레딩의 두 가지 일반적인 전략을 소개한다. 비동기 스레딩은 부모가 자식 스레드를 생성한 후 부모는 자신의 실행을 재개하여 부모와 자식 스레드가 병행하게 실행된다.
각 스레드는 모든 다른 스레드와 독립적으로 실행하고 부모 스레드는 자식의 종료를 알 필요가 없다. 스레드가 독립적이기 때문에 스레드 사이의 데이터 공유는 거의 없다. 비동기 스레딩은 그림 4.2에 묘사된 다중 스레드 서버에서 사용되는 전략이다.
동기 스레딩은 부모 스레드가 하나 이상의 자식 스레드를 생성하고 자식 스레드 모두가 종료할 때까지 기다렸다가 자신의 실행을 재개하는 방식을 말한다. 이 방식은 소위 포크-조인(fork-join) 전략이라 불린다.
여기서 부모가 생성한 스레드는 병행하게 실행되지만 부모는 자식들의 작업이 끝날 때까지 실행을 계속할 수 없다. 부모 스레드는 오직 모든 자식 스레드가 조인한 후에야 실행을 재개할 수 있다.
통상 동기 스레딩은 스레드 사이의 상당한 양의 데이터 공유를 수반한다. 예컨대 부모 스레드는 자식들이 계산한 결과를 통합할 수 있다. 이후 모든 예에서는 동기화 스레딩을 사용한다.

Pthreads

Pthreads는 POSIX(IEEE 1003.1c)가 스레드 생성과 동기화를 위해 제정한 표준 API이다. 이것은 스레드의 동작에 관한 명세일 뿐이지 그것 자체를 구현한 것은 아니다.
이 명세를 가지고 운영체제 설계자들은 그들 나름대로 그것을 구현할 수 있다. Linux, Mac OS X 및 Solaris를 포함한 많은 시스템들이 Pthreads 명세를 구현하고 있다. Windows는 자체적으로 Pthreads를 지원하지 않더라도 제 3자가 구현한 버전을 구할 수 있다.
아래 코드의 C 프로그램은 별도의 스레드에서 음이 아닌 정수 합을 구하는 다중 스레드 프로그램을 제작하기 위한 기본적인 Pthreads API를 보여 주고 있다.
Pthreads 프로그램에서 별도의 스레드는 지정된 함수에서 실행을 시작한다. 아래 코드에서는 이 함수가 runner() 함수이다.
이 프로그램이 실행을 싲가하면 하나의 제어 스레드가 main() 함수에서 시작한다. 약간의 초기화 후에 main()은 runner() 함수에서 실행을 시작하는 두 번째 스레드를 생성한다. 두 스레드는 전역 변수 sum을 공유한다.
#include <pthread.h> #include <stdio.h> int sum; /* this data is shared by the thread(s) */ void *runner(void *param); /* the thread */ int main(int argc, char *argv[]) { pthread_t tid; /* the thread identifier */ pthread_attr_t attr; /* set of attributes for the thread */ if (argc != 2) { fprintf(stderr, "usage: a.out<integer value>\n"); return -1; } if (atoi(argv[1]) < 0 { fprintf(stderr, "%d must be >= 0\n", atoi(argv[1])); } /* get the defaults attributes */ pthread_attr_init(&attr); /* create the thread */ pthread_create(&tid, &attr, runner, argv[1]); /* now wiat for the thread to exit */ pthread_join(tid, NULL); printf("sum = %d\n", sum); } /* The thread will begin control in this function */ void *runner(void *param) { int i, upper = atoi(param); sum = 0; for (int i = 1; i <= upper; i++) sum += i; ptread_exit(0); }
C
이 프로그램을 좀 더 자세히 살펴보자. 모든 Pthreads 프로그램은 pthread.h 헤더 파일을 포함해야 한다.
pthreadad_t tid 문장은 우리가 생성할 스레드를 위한 식별자를 선언한다. 각 스레드는 스택의 크기와 스케줄링 정보를 포함한 속성의 집합을 갖는다.
ptread_attr_t attr 선언은 스레드를 위한 속성을 나타낸다. 우리는 pthread_attr_init(&attr) 함수에서 속성을 지정한다. 우리는 속성을 지정하지 않았기 때문에 디폴트 속성을 사용한다.
별도의 스레드는 pthread_create()를 이용하여 생성한다. 스레드 식별자와 스레드의 속성 뿐 아니라 새로운 스레드가 실행을 싲가할 하뭇의 이름도 전달한다. (이 경우는 runner()) 마지막으로 명령어 라인 상에 제공된 정수 매개변수인 argv[1]을 전달한다.
이 시점에서 프로그램은 main() 함수의 최초(또는 부모) 스레드와 runner() 함수에서 합을 계산하는 합(또는 자식) 스레드의 두 개 스레드를 가지게 된다. 이 프로그램은 앞서 설명한 포크-조인 전략을 사용한다.
합 스레드를 생성한 후에 pthread_join() 함수를 호출하여 합 스레드가 종료하기를 부모 스레드는 기다린다. 합 스레드는 pthread_exit() 함수를 호출하여 종료하게 된다.
합 스레드가 복귀하면 부모 스레드는 공유 데이터 sum의 값을 출력한다.
이 예제 프로그램은 단지 하나의 스레드만 생성한다. 다중 코어 시스템의 성장과 함께 여러 스레드를 가지는 프로그램을 작성하는 것이 점점 평범한 일이 될 것이다.
pthread_join() 함수를 써서 여러 개의 스레드를 기다리는 간단한 방법은 for 반복문으로 둘러싸는 것이다. 예컨대 10개의 스레드를 기다려야 한다면 아래처럼 코드를 작성할 수 있다.
#define NUM_THREADS 10 /* an array of threads to be joined upon */ pthread t workers[NUM_THREADS]; for (int i = 0; i < NUM_THREADS; i++) pthread join(workers[i], NULL);
C

Windows 스레드

Windows 스레드 라이브러리를 사용하여 스레드를 생성하는 기술은 많은 점에서 Pthreads 기법과 유사하다. 아래 코드의 C 프로그램을 이용하여 Windows 스레드 API를 설명한다. Windows API를 사용할 때 window.h 헤더 파일을 포함해야 한다는 것에 주목하라.
#include <windows.h> #include <stdio.h> DWORD Sum; /* data is shared by the thread(s) */ /* the thread run in this separate function */ DWORD WINAPI Sumation(LPVOID Param) { DWORD Upper = *(DWORD*)Param; for (DWORD i = 0; i <= Upper; i++) Sum += i; return 0; } int main(int argc, char *argv[]) { DWORD ThreadId; HANDLE ThreadHandle; int Param; // perform some basic error checking if (argc != 2) { fprintf(stderr, "An integer paramter is required\n"); return -1; } Param = atoi(argv[1]); if (Param < 0) { fprintf(stderr, "An integer >= 0 is required\n"); return -1; } // create the thread ThreadHandle = CreateThread( NULL, // // default security attributes 0, // default stack size Summation, // thread function &Param, // parameter to thread function 0, // default creation flags &ThreadId); // returns the thread identifier if (THreadHandle != NULL) { // now wait for the tread to finish WaitForSingleObject(ThreadHandle, INFINITE); // close the thread handle CloseHandle(ThreadHandle); printf("sum = %d\n", Sum); } }
C
앞선 Pthreads 버전과 마찬가지로 개별 스레드가 서로 공유하는 데이터는 전역 변수로 선언된다. (DWORD 데이터 형은 부호가 없는 32비트 정수이다)
우리는 또한 별도의 스레드에서 수행될 Summation 함수도 정의한다. 이 함수는 void 형을 가리키는 포인터 변수를 인자로 받는다. 이 데이터형을 Windows에서는 LPVOID로 정의한다.
이 함수를 실행하는 스레드는 0부터 Summation() 함수에 전달된 매개변수까지의 합ㅇㄹ 전역 데이터 Sum에 저장한다.
Windows API에서 스레드는 CreateTHread() 함수에 의해 생성되고 Pthreads와 마찬가지로 이 함수에게 스레드를 위한 속성의 집합이 전달된다. 이 속성에는 보안 정보, 스택의 크기 및 스레드가 보류 상태(suspended state)에서 시작하는 지를 나타낼 수 있는 플래그 등이 포함된다.
이 프로그램에서는 이 속성들을 위한 디폴트 값을 사용한다(디폴트 값은 스레드를 보류 상태로 지정하지 않고 CPU에 의해 실행될 수 있는 준비 완료 상태로 지정한다)
합 스레드가 생성되면 부모는 합 스레드에 지정되어야 할 Sum의 값을 출력하기 전에 합 스레드가 종료하기를 기다려야 한다.
Pthreads 프로그램이 ptread_join() 문을 이용하여 부모 스레드가 합 스레드가 종료될 때까지 기다리게 한 것을 기억하라. 이와 동일한 작업을 Windows API에서는 WaitForSingleObject() 함수를 이용하여 수행하는 데, 이 함수는 합 스레드가 종료할 때까지 생성 스레드가 봉쇄되도록 한다.
여러 스레드의 종료를 기다려야 한다면 WaitForMultipleObjects() 함수가 사용된다. 이 함수는 다음과 같은 4개의 매개변수를 전달 받는다.
1.
기다려야 하는 객체의 개수
2.
객체 배열을 가리키는 포인터
3.
모든 객체가 신호를 보내왔는지를 나타내는 플래그
4.
대기해야 하는 타임아웃 시간(또는 INFINTRE)
예컨대 THandles의 크기가 N인 스레드 HANDLE 객체의 배열이라면 부모 스레드는 모든 자식이 종료하기를 기다려야 할 경우 다음과 같이 호출할 수 있다.
WaitForMultipleObjects(N, THandles, TRUE, INFINITE);
C

Java 스레드

스레드는 Java 프로그램의 프로그램 실행의 근본적인 모델이고, Java 언어와 API는 스레드의 생성과 관리를 지원하는 풍부한 특성을 제공한다.
모든 Java 프로그램은 적어도 하나의 단일 제어 스레드를 포함하고 있다. 단지 main 함수로만 이루어진 단순한 Java 프로그램조차 JVM 내의 하나의 단일 스레드로 수행된다.
Java 스레드는 JVM을 제공하는 어떠한 시스템에서도 사용할 수 있다.
Java 프로그램에서 스레드를 생성하는 기법에는 두 가지가 있다. 한 가지 방법은 Thread 클래스로부터 파생된 새로운 클래스를 생성하고, Thread 클래스의 run() 메서드를 무효화(override)하는 것이다.
다른 방법은 더 흔히 사용되는 방법으로 Runnable 인터페이스를 구현하는 클래스를 정의하는 것이다. Runnable 인터페이스는 다음과 같이 정의된다.
public interface Runnable { public abstract void run(); }
Java
클래스가 Runnable을 구현할 때 run() 메서드를 구현해야 한다. run() 메서드를 구현하는 코드는 별도의 슬드로서 실행된다.
아래 코드는 음이 아는 정수의 합을 결정하는 다중 스레드 프로그램의 Java 버전을 보이고 있다. Summation 클래스가 Runnable 인터페이스를 구현한다. 스레드의 생성은 Thread 클래스의 객체 인스턴스를 생성하고 Runnable 객체의 컨스트럭터를 전달함으로써 이루어진다.
class Sum { private int sum; public int getSum() { return sum; } public void setSum(int sum) { this.sum = sum; } } class Summation implements Runnable { private int upper; private Sum sumValue; public Summation(int upper, Sum sumValue) { this.upper = upper; this.sumValue = sumValue; } public void run() { int sum = 0; for (int i = 0; i <= upper; i++) sum += i; sumValue.setSum(sum); } } public class Driver { public static void main(String[] args) { if (args.length > 0) { if (Integer.parseInt(args[0]) < 0) System.err.println(args[0] + " must be >= 0"); else { // create the object to be shared Sum sumObject = new Sum(); int upper = Integer.parseInt(args[0]); Thread thrd = new Thread(new Summation(upper, sumObject)); thrd.start(); try { thrd.join(); System.out.Println("The sum of " + upper + " is " + sumObject.getSum()); } catch (InterruptedException ie) { } } } else System.err.println("m of " + upper + " is " + sumObject.get()); // 책의 코드가 뭔가 이상한데 이게 catch 문 안에 들어가야 하는거 아닌가? } }
Java
구체적으로 말하면 Thread 객체를 생성하는 것이 새로운 스레드를 생성하는 것은 아니다. 오히려 start() 메서드가 새로운 스레드를 생성한다. 새로운 객체를 위해 start() 메서드를 호출하면 다음과 같은 두 가지 작업을 수행한다.
1.
메모리가 할당되고 JVM 내에 새로운 스레드가 초기화 된다.
2.
run() 메서드를 호출하면 스레드가 JVM에 의해 수행될 자격을 갖게 한다. (주의: 절대로 run() 메서드를 직접 호출하지 말라. start() 메서드를 호출하라. 그러면 start() 메서드가 run() 메서드를 호출한다)
합 프로그램이 실행될 때 JVM은 2개의 스레드를 생성한다. 첫 번째 스레드는 부모 스레드이고 main() 메서드에서 실행을 시작한다. 두 번째 스레드는 Thread 객체의 start() 메서드가 호출될 때 생성된다.
이 자식 스레드는 Summation 클래스의 run() 메서드에서 실행을 시작한다. 이 합의 결과를 출력한 뒤 이 스레드는 run() 메서드를 빠져나올 때 종료한다.
Windows와 Pthreads에서는 공유 데이터가 단순히 전역 변수로 선언되기 때문에 스레드간의 데이터 공유를 쉽게 할 수 있다. Java는 순수 객체 지향 언어이기 때문에 그런 전역 변수의 개념을 제공하지 않는다.
만약 Java 프로그램에서 둘 이상의 스레드가 데이터를 공유해야 한다면 공유 객체에 대한 참조를 적당한 스레드에게 전달함으로써 공유한다. 위 코드에서는 main 스레드와 합 스레드가 Sum 클래스의 객체 인스턴스를 공유한다.
이 공유 객체는 getSum()과 setSum() 메서드를 통하여 참조된다. 왜 Integer 객체를 사용하지 않고 새로운 sum 클래스를 정의하는지 의아할 것이다. 그것은 Integer 클래스는 변경할 수 없기 때문이다. 즉 값이 한 번 지정되면 그 값을 변경할 수 없다.
Pthreads와 Windows 라이브러리의 부모 스레드가 더 이상 진행하기 전에 합 스레드가 끝나기를 기다리기 위하여 각각 pthread_join()과 WaitForSingleObject()를 사용했던 것을 기억하라. Java의 join() 메서드가 유사한 기능을 제공한다. join() 메서드는 InterruptException을 발생시킬 수 있다는 것을 주의하라. (이 예외는 무시하도록 선택할 수 있다)
부모가 여러 스레드가 끝나기를 기다려야 한다면 Pthread와 같은 방식으로 join() 메서드를 for 반복문으로 둘러쌀 수 있다.

암묵적 스레딩

다중코어 처리의 지속적 성장에 따라 수백 또는 심지어 수천 개의 스레드를 가진 응용이 등장하게 되었다. 그러한 응용을 설계하는 것은 사소한 일이 아니며 프로그래머는 여러 어려움을 극복해야 한다.
이러한 어려움을 극복하고 다중 스레드 응용의 설계를 도와주는 한 가지 방법은 스레딩의 생성과 관리 책임을 응용 개발자로부터 컴파일러와 실행시간 라이브러리에게 넘겨주는 것이다. 암묵적 스레딩이라고 불리는 이 전략은 현재 널리 사용되고 있다.
여기서는 암묵적 스레딩을 이용하여 다중코어 처리기를 활용할 수 있는 다중 스레드 프로그램을 설계하는 3가지 접근법을 탐구한다.

스레드 풀

앞서 다중 스레드로 구성된 웹브라우저를 설명하였는데, 그러한 웹 서버는 요청을 받을 때마다 그 요청을 위해 새로운 스레드를 만들어준다. 새로운 스레드를 매 요청마다 만들어주는 것은 그 때마다 새로운 프로세스를 만들어주는 것보다 진보한 방법이지만 아직도 여러 문제를 가지고 있다.
첫 번째 문제는 서비스할 때마다 스레드를 생성하는데 소요되는 시간이다. —특히 이 스레드는 이 일만 끝나면 용도 폐기될 것이라는 점을 염두에 두면 더 그렇다.
두 번째 이슈는 더 심각한 문제이다. 모든 요청마다 새 스레드를 만들어서 서비스해 준다면 시스템에서 동시에 실행할 수 있는 최대 스레드 수가 몇 개까지 가능할 수 있는 것인지 한계를 정해야 한다. 스레드를 무한정 만들면 언젠가는 CPU 시간, 메모리 공간 같은 시스템 자원이 고갈된다.
이러한 문제들을 해결해 줄 수 있는 방법 중 하나가 스레드 풀(pool)이다.
스레드 풀의 기본 아이디어는 프로세스를 시작할 때 아예 일정한 수의 스레드들을 미리 풀로 만들어두는 것이다. 이 스레드들은 평소에는 하는 일 없이 일감을 기다리게 된다.
그러다가 한 개의 요청이 들어오면 이 풀에서 한 스레드에게 그것을 할당한다. 요청을 다 서비스해 주었으면 그 스레드는 다시 풀로 돌아가 다음 작업을 기다린다.
이렇게 하다가 풀에 남아 있는 스레드가 바닥나면 서버는 가용(free) 스레드가 하나 생길 때까지 기다려야 한다.
이와 같은 스레드 풀은 아래와 같은 장점을 가지게 된다.
1.
새 스레드를 만들어 주기보다 기존 스레드로 서비스해 주는 것이 더 빠르다.
2.
스레드 풀은 임의 시각에 존재할 스레드 개수에 제한을 둔다. 이러한 제한은 많은 수의 스레드를 병렬 처리할 수 없는 시스템에 도움이 된다.
3.
태스크를 생성하는 방법을 태스크로부터 분리하면 태스크를 실행을 다르게 할 수 있다. 예컨대 태스크를 일정 시간 후에 실행되도록 스레드풀하거나 혹은 주기적으로 실행시킬 수 있다.
스레드 풀에 있는 스레드의 개수는 CPU 수, 물리 메모리 용량 동시 요청 클라이언트 최대 개수 등을 고려하여 정해질 수 있다. 더 정교하게 하려면 풀의 활용도를 보며 동적으로 풀의 크기를 바꾸어 줄 수도 있다.
그러한 구조는 시스템 부하가 적을 때는 더 작은 풀을 유지하도록 함으로써 메모리 등의 소모를 더 줄일 수 있다. 그러한 구조 중 하나인 Apple의 Grand Central Dispatch에 대해 후반에 논의한다.
Windows API는 스레드 풀과 관련된 여러 함수를 제공한다. 스레드 풀 API를 사용하는 것은 Thread_Create() 함수를 이용하여 스레드를 생성하는 것과 유사하다. 여기서 별도의 스레드에서 실행될 함수가 정의된다. 이 함수는 다음과 같은 형식을 따른다.
DWORD WINAPI PoolCuntion(AVOID Param) { // this function runs as a separate thread }
C
PoolFunction()을 가리키는 포인터가 스레드 풀 API 중 하나의 함수에게 전달되고 풀 중의 한 스레드가 이 함수를 실행한다. 그러한 스레드 풀 API 중 한 함수가 QueueUserWorkItem() 함수이며 이 함수는 다음과 같은 세 매개변수를 전달받는다.
LPTHREAD_START_ROUTINE Function: 별도의 스레드가 실행할 함수에 대한 포인터
PVOID Param: Function에 전달될 매개변수
ULONG Flags: 스레드 풀이 스레드를 생성하고 관리하는 방법을 가리키는 플래그
호출 예는 다음과 같다.
QueueUserWorkItem(&PollFunction, NULL, 0);
C
이 호출은 프로그래머를 대신하여 스레드 풀의 한 스레드가 PoolFunction() 함수를 호출하게 한다. 이 예에서는 PoolFunction() 함수에게 아무런 매개변수도 전달하지 않는다. 플래그를 0으로 지정했기 때문에 스레드 생성에 관해 스레드 풀에게 특별한 지시를 하지 않는다.
Windows 스레드 풀 API의 다른 함수들은 주기적으로 함수를 호출하거나 비동기식인 입출력 요청이 완료되었을 때 함수를 호출하는 등의 기능을 제공한다. Java API의 java.util.concurrent 패키지도 역시 스레드 풀 유틸리티를 제공한다.

OpenMP

OpenMP는 C, C++ 또는 FORTRAN으로 작성된 API와 컴파일러 디렉티브의 집합이다. OpenMP는 공유 메모리 환경에서 병렬 프로그래밍을 할 수 있도록 도움을 준다.
OpenMP는 병렬로 실행될 수 있는 블록을 찾아 병렬 영역(parallel regions)이라고 부른다. 응용 개발자는 자신들의 코드 중 병렬 영역에 컴파일러 디렉티브를 삽입한다. 이 디렉티브는 OpenMP 실행시간 라이브러리에 해당하는 영역을 병렬로 실행하라고 지시한다.
다음 C 프로그램은 printf() 문을 포함하고 있는 병렬 영역 위에 컴파일러 디렉티브가 사용되고 있는 것을 보이고 있다.
#include <omp.h> #include <stdio.h> int main(int argc, char *argv[]) { /* sequential code */ #pragma omp parallel { printf("I am a parallel region"); } /* sequential code */ return 0; }
C
OpenMP가 다음과 같은 컴파일러 디렉티브를 만나게 되면
#pragma mp parallel
C
시스템의 코어 개수만큼 스레드를 생성한다. 따라서 듀얼 코어 시스템에서는 2개, 쿼드 코어 시스템에서는 4개 등 스레드가 생성된다. 모든 스레드는 동시에 병렬 영역을 실행하게 된다. 각 스레드가 병렬 영역을 빠져 나가면 스레드는 종료된다.
OpenMP는 병렬 처리 반복문 등을 포함하여 코드 영역을 실행하기 위한 추가적인 디렉티브를 제공한다. 예컨대 배열 a와 b가 있다고 하자. 우리는 각 원소들의 합을 구해서 배열 c에 저장했으면 한다.
다음과 같은 코드 세그먼트를 사용하여 이 작업을 병렬로 실행할 수 있다. 이 코드에서 병렬화 시킬 for 반복문을 위한 컴파일러 디렉티브를 사용하고 있다.
#pragma omp parallel for for (i = 0; i < N; i++) { c[i] = a[i] + b[i]; }
C
OpenMP는 for 반복문 안에 들어 있는 연산을 디렉티브에 따라 생성된 스레드들에게 분배한다.
#pragma omp paralle for
C
병렬화를 위한 디렉티브를 제공할 뿐만 아니라 OpenMP는 개발자가 병렬화 수준을 선택할 수 있게 한다. 예컨대 개발자는 필요한 스레드의 개수를 직접 정할 수 있다. 개발자는 또한 데이터를 스레드들이 공유할 것인지 혹은 특정 스레드만 사용할 것인지도 정할 수 있다.
OpenMP는 Linux, Windows 및 Mac OS X 시스템을 위한 다수의 공개 소스와 사용 컴파일러에서 사용할 수 있다.

Grand Central Dispatch

Grand Central Dispatch(GCD)는 Applie의 Mac OS X와 iOS 운영체제를 위한 기술로 C 언어, API 및 실행시간 라이브러리 각각을 확장하여 조합한 기술이다. GCD는 개발자가 병렬로 실행될 수 있는 영역을 식별할 수 있게 도움을 준다. OpenMP 처럼 GCD는 스레딩의 상세 구현의 대부분의 관리한다.
GCD는 C와 C++ 언어를 확장한 블록(block)이라는 것을 식별한다. 블록은 단순히 독립적으로 실행될 수 있는 작업의 단위를 말한다. 블록은 한 쌍의 중괄호 앞에 삽입된 삽입기호(^)로 표시한다. 간단한 예는 다음과 같다.
^{ printf("I am a block"); }
C
GCD는 블록을 디스패치 큐에 넣어서 실행될 수 있도록 스케줄 한다. 큐에서 블록을 제거할 때 관리하고 있는 스레드 풀에서 가용 스레드를 선택하여 할당한다. GCD는 직렬(serial)과 병행(concurrent)의 두 가지 유형의 디스패치 큐를 유지한다.
직렬 큐에 넣어진 블록은 FIFO 순서대로 제거된다. 블록은 큐에서 제거되면 다른 블록이 제거되기 전에 실행을 반드시 완료해야 한다. 각 프로세스는 각자 직렬 큐를 가진다 —main queue라고 함
병행 큐에 넣어진 큐는 마찬가지로 FIFO 순서로 제거되지만 여러 블록이 동시에 제거될 수도 있다. 따라서 여러 블록이 동시에 병렬로 실행되는 것이 가능하다.
시스템 전반에 걸쳐 3개의 병행 큐가 존재하고 낮은, 디폴트, 높은 우선순위에 따라 구분된다. 우선순위는 블록의 상대적 중요도를 나타내는 근사 값이다. 아주 간단하게 높은 우선순위의 블록이 높은 우선순위의 디스패치 큐에 넣어진다.
다음 코드 세그먼트는 디폴트 우선순위 병행 큐를 획득하고 dispatch_async() 함수를 사용하여 큐에 블록을 넣는 작업을 보이고 있다.
dispatch queue t queue = dispatch get global queue (DISPATCH QUEUE PRIORITY DEFAULT, 0); dispatch async(queue, ^{ printf("I am a block"); });
C
내부적으로 GCD의 스레드 풀은 POSIX 스레드로 구성된다. GCD는 응용의 요청과 시스템 수용 정도에 따라 스레드의 개수를 늘리거나 줄이면서 능동ㅈ거으로 풀을 관리한다.

다른 접근 방식들

스레드 풀, OpenMP 및 Grand Central Dispatch는 다중 스레드 응용을 관리하기 위해 등장한 많은 기술 중의 소수의 사례이다. 다른 상용 방식에는 Intel의 Threading Building Blocks(TBB)와 Microsoft의 많은 제품들과 같은 병렬과 병행 라이브러리 등이 존재한다.
Java 언어와 API도 병행 프로그래밍을 지원하기 위해 상당한 발전이 있었다. 가장 대표적인 예로 java.util.concurrent 패키지를 들 수 있는데 이 패키지는 암묵적 스레드 생성과 관리를 지원한다.

스레드와 관련된 문제들

Fork() 및 Exec() 시스템 호출

우리는 3장에서 fork()가 별도의 복제된 프로세스를 생성하는데 어떻게 쓰이는지 살펴보았다. 다중 스레드 프로그램에서는 fork()와 exec()의 의미가 달라질 수 있다.
만일 한 프로그램의 스레드가 fork()를 호출하면 새로운 프로ㅔㅅ스는 모든 스레드를 복제해야 하는가 아니면 ㅎ나 개의 스레드만 가지는 프로세스여야 하는가?
몇몇 UNIX 기종은 이 두 가지 버전 fork()를 모두 제공한다. 하나는 모든 스레드를 복사하는 것과 다른 하나는 fork()를 호출한 스레드만 복제하는 것이다.
exec() 시스템 호출은 보통 3장에서 기술한 것과 같은 방법으로 수해오딘다. 즉 어떤 스레드가 exec() 시스템 호출을 부르면 exec()의 매개변수로 지정된 프로그램이 모든 스레드를 포함한 전체 프로세스를 대체시킨다.
두 버전의 fork() 중 어느 쪽을 택할 것인지는 응용 프로그램에게 달려있다.
fork()를 부르자마다 다시 exec를 부른다면 모든 스레드를 다 복제해서 만들어주는 것은 불필요하다. 왜냐하면 exec에서 지정한 프로그램이 곧 모든 것을 다시 대체할 것이기 때문이다. 이 경우에는 fork() 시스템 콜을 호출한 스레드만 복사해주는 것이 적절하다.
그러나 새 프로세스가 fork() 후 exec를 하지 않는다면 새 프로세스는 모든 스레드들을 복제해야 한다.

신호 처리(Signal Handling)

신호는 UNIX에서 프로세스에게 어떤 사건이 일어났음을 알려주기 위해 사용된다. 신호는 알려줄 사건의 근원지나 이유에 따라 동기식 또는 비동기식으로 전다도리 수 있다. 동기식이건 비동기식이건 모든 신호는 다음과 같은 형태로 전달된다.
1.
신호는 특정 사건이 일어나야 생성된다.
2.
생성된 신호가 프로세스에게 전달된다.
3.
신호가 전달되면 반드시 처리되어야 한다.
동기식 신호의 예로는 불법적인 메모리 접근, 0으로 나누기 등이 있다. 실행 중인 프로그램이 이러한 행동을 하면 신호가 발생된다. 동기식 신호는 신호를 발생시킨 연산을 수행한 동일한 프로세스에게 전달된다. (동기식이라고 간주되는 이유이다)
신호가 실행 중인 프로세스 외부로부터 발새오디면 그 프로세스는 신호를 비동기식으로 전달 받는다. 이러한 신호의 예는 <control><C> 같은 특수한 키를 눌러서 프로세스를 강제 종료시키거나 타이머가 만료되는 경우가 포함된다. 비동기식 신호는 통상 다른 프로세스에게 전달된다. 모든 신호는 둘 중 하나의 처리기에 의해 처리된다.
1.
디폴트 신호 처리기
2.
사용자 정의 신호 처리기
모든 신호마다 커널이 실행시키는 디폴트 신호 처리기가 있다. 이 디폴트 처리기는 신호를 처리하기 위해 호출되는 사용자 정의 처리기에 의해 대체될 수 있다. 신호는 다른 방식으로 처리될 수 있다.
어떤 신호들 —윈도우 크기 변경— 은 그냥 무시될 수도 있다. 어떤 신호 —불법적인 메모리 접근— 는 그 프로세스를 강제 종료 시켜 처리될 수 있다.
단일 스레드 프로그램에서의 신호 처리는 간단하다. 신호는 항상 프로세스에게 전달된다. 그러나 프로세스가 여러 스레드를 가지고 있는 다중 스레드 프로그램에서의 신호 처리는 더욱 복잡하다. 어느 스레드에게 신호를 전달해야 하는가? 일반적으로 다음과 같은 선택이 존재한다.
1.
신호가 적용될 스레드에게 전달한다.
2.
모든 스레드에게 전달한다.
3.
몇몇 스레드들에게만 선택적으로 전달한다.
4.
특정 스레드가 모든 신호를 전달받도록 지정한다.
신호를 전달하는 방법은 신호의 유형에 따라 다르다. 예컨대 동기식 신호는 그 신호를 야기한 스레드에게 전달되어야 하고 다른 스레드에게 전달되면 안 된다.
그러나 비동기식 신호의 경우는 명확하지 않다. <control><C> 같은 키를 쳐서 그 프로세스를 강제 종료하는 신호와 같은 어떤 비동기식 신호는 그 프로세스 내 모든 스레드에게 전달외어야 한다. 신호를 전달하는데 사용되는 표준 UNIX 함수는 kill(pid_t pid, int signal); 이다.
이 함수는 특정 신호가 전달될 프로세스(pid)를 지정한다.
대부분의 다중 스레드 UNIX는 스레드에게 받아들일 신호와 봉쇄할 신호를 지정할 수 있는 선택권을 준다. 따라서 어떤 경우에는 비동기식 신호를 봉쇄하지 않고 있는 스레드들에게만 신호를 전달해야 할 수 있다.
하지만 신호는 오직 한 번만 처리되어야 하기 때문에 그 신호를 봉쇄하지 않고 있는 첫 번째 스레드에게만 신호가 전달된다. POSIX Pthreads는 tid로 지정된 스레드에게만 전달이 되도록 허용하는 다음과 같은 함수를 제공한다. pthread_kill(pthread_t tid, int signal)
Windows는 신호를 명시적으로 지원하지는 않지만 비동기식 프로시저 호출(Asynchronous Procedure Calls, APC)이라는 것을 사용해서 이를 대리 실행(emulate)할 수 있다. APC는 사용자 스레드들이 특정 사건의 발생을 전달 받았을 때 호출될 함수를 지정할 수 있게 한다.
이름이 의미하는 바와 같이 APC는 UNIX의 비동기식 신호와 유사하다. 그러나 UNIX에서는 다중 스레드 환경에서 신호를 어떻게 처리해야 할지를 고민해야 하지만 APC는 프로세스에게 전달되는 것이 아니라 특정 스레드에게 전달되기 때문에 좀 더 간한다다.

취소(Cancellation)

스레드 취소(thread cancellation)은 스레드가 끝나기 전에 그것을 강제 종료시키는 작업을 일컫는다.
예컨대 여러 스레드들이 데이터베이스를 병렬로 검색하고 있다가 그 중 한 스레드가 결과를 찾았다면 나머지 스레드들은 취소되어도 된다.
또 다른 경우는 웹 브라우저에서 사용자가 웹 페이지를 더는 적재하지 않기 위해 스톱(stop) 버튼을 클릭할 수도 있다. 종종 웹 페이지는 여러 스레드들을 사용하여 적재된다. (각 이미지는 별도의 스레드에 의해 적재된다)
이처럼 취소되어야 할 스레드를 목적 스레드(target thread)라고 부른다. 목적 스레드의 취소는 다음과 같은 두 가지 방식으로 발생할 수 있다.
1.
비동기식 취소(asynchronous cancellation)
한 스레드가 즉시 목적 스레드를 강제 종료 시킨다.
2.
지연 취소(Deferred cancelation)
목적 스레드가 주기적으로 자신이 강제 종료되어야 할지를 점검한다. 이 경우 목적 스레드가 질서정연하게 강제 종료될 수 있는 기회가 만들어진다.
스레드 취소를 어렵게 만드는 것은 취소 스레드들에게 할당된 자원 문제이다. 또한 스레드가 다른 스레드와 공유하는 자료구조를 갱신하는 도중에 취소 요청이 와도 문제가 된다.
후자의 문제는 비동기식 추소의 경우 더 심각하다. 종종 운영체제는 취소된 스레드로부터 시스템 자원을 회수할 수도 있지만 모든 시스템 자원을 다 회수하지 못하는 경우도 있다.
따라서 비동기식으로 스레드를 취소하면 필요한 시스템 자원을 다 사용 가능한 상태로 만들지 못할 수도 있다.
이와 반대로 지연 취소의 경우 한 스레드가 목적 스레드를 취소해야 한다고 표시하지만 실제 취소는 목적 스레드가 취소 여부를 결정하기 위한 플래그를 검사한 이후에야 일어난다. 스레드는 자신이 취소되어도 안전하다고 판단되는 시점에서 취소 여부를 검사할 수 있다.
Pthreads에서는 pthread_cancel() 함수를 사용하여 스레드를 취소할 수 있다. 목적 스레드의 식별자가 이 함수의 매개변수로 전달된다. 다음 코드는 스레드를 생성하고 이어서 취소하는 예를 보이고 있다.
pthread t tid; /* create the thread */ pthread create(&tid, 0, worker, NULL); ... /* cancel the thread */ pthread cancel(tid);
C
그러나 pthread_cancel() 함수 호출은 단지 목적 스레드를 취소하겠다고 요청한 것만을 의미한다. 실제 취소는 이 요청의 처리를 어떻게 설정했느냐에 따라 달라진다.
Pthreads는 3가지 취소 모드를 지원한다. 아래 테이블에 보이는 것처럼 각 모드는 상태와 유형으로 정의된다. 스레드는 API를 사용하여 취소 상태와 유형을 지정할 수 있다.
모드
상태
유형
off
사용 불가능
-
지연(Deferred)
사용 가능
지연(Deferred)
비동기식(Asynchronous)
사용 가능
비동기식(Asynchronous)
테이블에서 확인할 수 있는 것처럼 Pthreads는 스레드가 취소가 가능한지 불가능한지를 결정할 수 있게 한다. 분명한 것은 취소가 사용 불가능한 경우에는 취소가 될 수 없다는 것이다. 그러나 취소 요청은 대기 상태에 있게 되고, 나중에 스레드가 취소 가능하게 만들게 되면 요청에 반응하게 된다.
디폴트 취소 요청은 지연 취소이다. 이 유형에서 취소는 스레드가 취소점(cancellation point)에 도달했을 때만 취소 작업이 일어나게 된다. 취소점을 만드는 한 가지 방법은 pthread_testcancel() 함수를 호출하는 것이다.
취소 요청이 대기 중이라는 것이 발견되면 정리 처리기(cleanup handler)라고 알려진 함수가 호출된다. 이 함수는 스레드가 획득한 모든 자원을 스레드가 종료되기 전에 반환하게 만든다.
다음 코드는 지연 취소를 사용하여 취소 요청을 어떻게 처리할 수 있는지 보이고 있다.
while (1) { /* do some work for awhile */ /* ... */ /* check if there is a cancellation request */ pthread testcancel(); }
C
전에 설명했던 쟁점들 때문에 Pthreads는 비동기식 취소를 권장하지 않는다. 따라서 우리는 이에 대해 논의하지 않는다. 흥미있는 점은 Linux 시스템 상에서 Pthreads API를 사용할 경우 스레드 취소가 신호를 통해 처리된다는 점이다.

스레드 국지 저장소(Thread-Local Storage)

한 프로세스에 속한 스레드들은 그 프로세스의 데이터를 모두 공유한다. 이와 같은 데이터 공유는 다중 스레드 프로그래밍의 큰 장점 중 하나이다.
그러나 상황에 따라서는 각 스레드가 자기만 액세스할 수 있는 데이터를 가져야 할 필요도 있다. 그러한 데이터를 스레드 국지 저장소(thread-local storage, TLS)라고 부른다.
예컨대 트랜잭션 처리 시스템에서 각 트랜잭션을 독립된 스레드가 처리해 준다고 가정해 보자. 더욱이 각 트랜잭션은 고유한 식별자가 주어진다고 가정하자. 이때 각 스레드마다 고유한 식별자를 연관시키기 위해선느 스레드 국지 저장소가 있어야만 한다.
TLS는 지역 변수와 혼동하기 쉽다. 그러나 지역 변수가 하나의 함수가 호출되는 동안에만 보이는 반면 TLS는 전체 함수 호출에 걸쳐 보인다. 어떤 면에서 TLS는 정적 데이터와 유사하다. 차이점은 TLS 데이터는 각 스레드의 고유한 정보를 저장한다는 것이다.
Windows나 Pthreads를 포함한 대부분의 스레드 라이브러리들은 어떤 형태로는 이와 같은 스레드 국지 저장소를 지원한다. Java도 마찬가지다.

스케줄러 액티베이션(Scheduler Activations)

다중 스레드 프로그램과 관련하여 마지막으로 고려할 문제는 스레드 라이브러리와 커널의 통신 문제이다. 이 통신은 4.2.3 절에 논의한 다대다 및 두 수준 모델에서 반드시 해결해야 할 문제이다.
이러한 통신의 조정은 응용 프로그램이 최고의 성능을 보이도록 보장하기 위해 커널 스레드의 수를 동적으로 조절하는 것을 가능하게 한다.
다대다 또는 두 수준 모델을 구현하는 많은 시스템들은 사용자와 커널 스레드 사이에 중간 자료 구조를 둔다. 이 자료구조는 통상 경량 프로세스 또는 LWP라고 불리며 그림 4.13에 도시되어 있다.
사용자가 스레드 라이브러리에게 LWP 방식은 응용이 사용자 스레드를 수행하기 위해 스케줄 할 가상 처리기(virtual processor)처럼 보인다. 각 LWP는 하나의 커널 스레드에 부속되어 있으며 물리 처리기에서 스케줄하는 대상은 바로 이 커널 스레드이다.
입출력이 완료되기를 기다리는 동안 같이 커널 스레드가 봉쇄되면 LWP도 같이 봉쇄된다. 이 연관을 따라 LWP에 부속된 사용자 수준 스레드도 역시 봉쇄된다.
응용은 효율적으로 실행되기 위해 임의의 개수의 LWP를 필요로 할 수도 있다. 하나의 처리기 상에서 실행되는 CPU 중심 응용을 고려해 보자. 이 시나리오게서 한 순간에 오직 하나의 스레드만이 실행될 수 있다. 따라서 하나의 LWP이면 충분하다.
그러나 입출력 중심 응용은 여러 개의 LWP를 필요로 할 수도 있다. 통상 동시에 발생하는 봉쇄형 시스템 호출마다 하나의 LWP가 필요하다. 예컨대 서로 다른 5개의 파일 읽기 요청이 발생했다고 가정하자.
모든 LWP가 입출력 완료를 기다리면서 커널 안에서 대기할 수 있기 때문에 5개의 LWP가 필요하다. 만일 프로세스가 4개의 LWP만을 가지고 있다면 다섯 번째 요청은 하나의 LWP라도 커널에서 복귀할 때까지 기다려야 한다.
사용자 스레드 라이브러리와 커널 스레드 간의 통신 방법 중의 하나는 스케줄러 액티베이션이라고 알려진 방법이다. 이것은 다음과 같이 동작한다.
커널은 응용에게 가상 처리기(LWP)의 집합을 제공하고 응용은 사용자 스레드를 가용한 가상 처리기로 스케줄 한다. 게다가 커널은 응용에게 특정 사건에 대해 알려줘야 한다. 이 프로시저를 upcall이라고 부른다.
upcall은 스레드 라이브러리의 upcall 처리기에 의해 처리되고, upcall 처리기는 가상 처리기 상에서 실행되어야 한다. upcall을 일으키는 한 사건은 응용 스레드가 봉쇄하려고 할 때 발생한다.
이 시나리오에서 커널은 스레드가 봉쇄하려고 한다는 사실과 그 스레드의 식별자를 알려주는 upcall을 한다. 그런 후에 커널은 새로운 가상 처리기를 응용에게 할당한다. 응용은 이 새로운 가상 처리기 상에서 upcall 처리기를 수행하고 이 upcall 처리기는 봉쇄 스레드의 상태를 저장하고 이 스레드가 실행 중이던 가상 처리기를 반환한다.
그리고 upcall 처리기는 새로운 가상 처리기에서 실행 가능한 다른 스레드를 스케줄한다. 봉쇄 스레드가 기다리던 사건이 발생하면 커널은 이전에 봉쇄되었던 스레드가 이제 실행 가능하다는 사실을 알려주는 또 다른 upcall을 스레드 라이브러리에게 한다.
이 사건을 처리하는 upcall 처리기도 가상 처리기가 필요하고 커널은 새로운 가상 처리기를 할당하거나 사용자 스레드 하나를 선점하여 그 처리기에서 이 upcall 처리기를 실행한다.
봉쇄가 풀린 스레드를 실행 가능 상태로 표시한 후에 응용은 가용한 가상 처리기 상에서 다른 실행 가능한 스레드를 실행한다.

운영체제 사례

Windows 스레드

Windows 응용들은 프로세스 형태로 실행되며 이들 각 프로세스는 한 개 또는 그 이상의 스레드를 가질 수 있다. Windows는 4.3.2절에서 기술한 일대일 대응을 사용하며 사용자 수준 스레드 하나마다 커널 스레드 하나가 대응된다. 스레드의 일반적인 구성 요소는 다음과 같다.
각 스레드를 유일하게 지목하는 스레드 ID
처리기의 상태를 나타내는 레지스터 집합
사용자 모드에서 실행될 때 필요한 사용자 스택, 커널 모드에서 실해도리 때 필요한 커널 스택
실행 시간 라이브러리와 동적 링크 라이브러리(DLL) 등이 사용하는 개별 데이터 저장 영역
레지스터 집합, 스택, 개별 데이터 저장 영역들은 그 스레드의 문맥으로 불린다. 스레드를 위해서는 아래와 같은 자료구조를 가지고 있다.
ETHREAD - 실행 스레드 블록(exceutive thread block)
KTHREAD - 커널 스레드 블록(kernel thread block)
TEB - 스레드 환경 블록(thread environment block)
ETHREAD의 주요 내용으로는 그 스레드가 속한 프로세스를 가리키는 포인터와 그 스레드가 실행을 시작해야 할 루틴의 주소 등이다. 이외에도 ETHREAD는 KTHREAD에 대한 포인터도 가지고 있다.
KTHREAD는 스레드의 스케줄링 및 동기화 정보를 가지고 있다. 또한 이 스레드가 커널 모드에서 실행될 떄 사용되는 커널 스택과 TEB에 대한 포인터를 가지고 있다.
ETHREAD와 KTHREAD는 모두 커널 안에 존재한다. 이는 커널만이 이들을 접근할 수 있다는 것을 의미한다. TEB는 사용자 모드에서 실행될 때 접근되는 사용자 공간 자료 구조이다.
다른 필드 중에서 TEB는 스레드 식별자, 사용자 모드 스택, 및 스레드 국지 저장소를 저장하기 위한 배열을 가지고 있다.
Windows 스레드의 구조가 그림 4.14에 도시되어 있다.

Linux 스레드

3장에서 설명한 것처럼 프로세스를 복제하는 기능을 가진 fork() 시스템 호출을 제공한다. Linux는 clone() 시스템 호출을 이용하여 스레드를 생성할 수 있는 기능도 제공한다.
그러나 Linux는 프로세스와 스레드를 구별하지 않는다. 사실 Linux는 프로그램 내의 제어 흐름을 나타내기 위하여 프로세스나 스레드보다 태스크라는 용어를 사용한다.
clone()이 호출될 때 부모와 자식 태스크가 자료구조를 얼마나 공유할 지 결정하는 플래그의 집합이 전달된다. 그 중 일부 플래그들이 그림 4.15에 나와 있다.
예컨대 clone()이 CLONE_FS, CLONE_VM, CLONESIGHAND와 CLONE_FILES를 전달 받았다고 가정하자. 부모 태스크와 자식 태스크는 같은 파일 시스템 정보, 같은 메모리 공간, 같은 신호 처리기와 같은 열린 파일의 집합을 공유하게 된다.
이러한 식으로 clone()을 사용하는 것은 부모 태스크가 자식 태스크와 거의 모든 자원을 공유하기 때문에 이 장에서 설명한 스레드를 생성하는 것과 같은 결과가 된다.
그러나 아무 플래그 없이 clone()이 호출되면 공유는 일어나지 않게 되고 fork() 시스템 호출이 제공하는 기능과 유사한 기능성을 제공한다.
Linux 커널이 태스크를 표현하는 방식 때문에 다양한 공유 수준이 가능하다. 시스템의 태스크마다 고유한 커널 자료 구조가(struct task_struct)가 존재한다.
이 자료 구조는 태스크의 데이터를 저장하는 것이 아니라 데이터가 저장되어 있는 다른 자료 구조를 가리키는 포인터를 포함한다. 다른 자료 구조에는 열린 파일의 리스트를 나타내는 자료 구조, 신호 처리 정보 및 가상 메모리 등이 있다.
fork()가 호출되면 부모 프로세스의 관련된 자료 구조를 복사함으로써 새로운 태스크를 생성한다. clone() 시스템 호출을 호출하여 새로운 태스크를 생성할 수도 있다. 그러나 모든 데이터를 복사하는 것이 아니라 clone()에게 전달된 플래그에 따라 부모 태스크의 자료 구조를 가리키게 된다.