[운영체제] 스레드 라이브러리와 스레딩 이슈들(Thread Libraies dand Threading Issues)

참고도서: Operating System Concepts (10/E) Abraham Silberschatz, Peter B. Galvin, Greg Gagne

Thread Library

스레드 라이브러리는 개발자가 스레드를 생성하고 관리할 수 있도록 API 를 제공해주는 역할을 한다. 스레드 라이브러리를 구현하는 것에는 두 가지 방법이 있다.

  1. 커널 지원 없이 user space 에서만 라이브러리를 제공하도록 하는 것. 이 방법으로 구현되는 스레드 라이브러리는 system call 을 호출하는 것이 아니라 라이브러리 내에 구현되어 있는 함수를 호출하게 된다.
  2. 운영체제에 의해 지원되는 커널 라이브러리를 제공하도록 하는 것. 이 방법으로 구현되는 스레드 라이브러리는 API를 통해 커널의 시스템콜을 요청하게 된다.

Types of Thread

스레드에는 두 가지 전략이 있다.

  1. Asynchornus Threading (비동기 스레딩): 부모 스레드가 자식 스레드를 생성한 이후에 하던 작업을 재개하면서, 자식 스레드와 concurrent 하게 실행된다. 따라서 자식 스레드의 종료 여부 또한 부모 스레드가 알지 못하고 두 스레드 사이에 공유되는 데이터가 존재하지 않는 경우가 많다.
  2. Synchronouse Threading (동기 스레딩): 부모 스레드가 자식 스레드의 작업이 끝날 때까지 작업을 멈추고 기다리게 된다. 만약에 부모 스레드가 다수의 자식 스레드를 만들었다면, 모두가 다 끝날 때까지 기다려야 한다. 이런 전략을 fork-join 전략이라고 부르기도 한다. 생성된 자식 스레드는 각각 독립적인 작업을 concurrent 하게 수행하게 되고, 종료된 자식 스레드의 작업 결과를 부모 스레드가 사용하는 경우가 많다. 따라서 부모 스레드와 자식 스레드 간에 데이터 공유가 많이 일어난다.

Pthread

Pthread는 POSIX에서 제정된 스레드의 생성과 동기화를 위한 표준 API이다. 여기서 ‘표준’ 이라는 표현을 사용했는데, 그 이유는 Pthread 는 스레드 동작에 관한 명세만 표현할 뿐이지, 구현에 대한 부분은 제시하지 않기 때문이다. Linux, Mac OS 등 다양한 운영체제들이 Pthread 를 구현하고 있다. Windows 에서는 직접적으로 구현하지는 않지만 써드파티 를 통해 구현된 Pthread를 사용하는 것이 가능하다.

#include <pthread.h>

pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);

pthread 라이브러리를 통해서 스레드를 생성하기 위해서는 위와 같이 create 함수를 통해서 쉽게 만들 수 있다. 각 인자는 다음과 같은 의미를 가진다.

  1. pthread_t *thread : 스레드 식별자
  2. const pthreadattrt *attr : 스레드 속성
  3. void *(*start_routine)(void *) : 스레드를 통해 수행할 함수
  4. void *arg : 해당 함수에 전달할 인자

Windows

Windows 스레드 라이브러리는 pthread 와 유사한 방법으로 스레드를 관리한다. 일반적으로 전역변수 하나를 두어서 스레드가 공유할 수 있는 데이터를 저장하고 미리 정의된 함수를 CreateThread 함수의 인자로 전달해 스레드가 어떤 함수를 수행할지 정의할 수 있다.

다수의 스레드가 생성되었을 때, 모든 프로세스의 종료를 기다리기 위해서 Windows 쓰레드는 특정한 함수를 통해서 부모 스레드가 block 되게 만든다.

WaitForMultipleObjects(N, Thandles, TRUE, INFINITE) ;

내부에 들어가는 인자들은 다음과 같다.

  1. N: 스레드 오브젝드 갯수
  2. Thandles: 스레드 오브젝트 포인터
  3. TRUE: 모든 스레드가 종료되었는지 신호를 확인하는 플래그
  4. INFINITE: 대기시간의 타임아웃

Java Thread

자바 스레드는 항상 적어도 하나의 스레드를 포함하고 있다. 모든 자바 프로그램은 JVM 위에서 스레드로 수행되기 때문에 어떤 운영체제이든 JVM을 제공하는 시스템이라면 어떤 시스템에서든 사용될 수 있다.

자바 프로그램에서 스레드를 생성하는 데에는 두 가지 기법이 있다.

  1. Thread 클래스에서 만들어지는 새 클래스를 만들고 run() 메서드를 정의하는 것
  2. Runnable 인터페이스를 구현하는 클래스를 정의하는 것.

일반적으로 두 번쨰 방법이 사용된다. Thread 객체를 만들고 해당 객체에 대해 start() 메서드를 호출하게 되면, JVM 에 새로운 스레드가 할당 된 뒤, run() 메세드가 호출되게 된다.

Implicit Threading

엄청나게 많은 수의 스레드를 운용해야하는 응용프로그램들이 점점 많아지면서, 개발자가 직접 모든 병행 프로그래밍 문제를 해결하고 관리하는 것은 쉽지 않다. 따라서 개발자가 스레드의 생성과 관리의 일부를 컴파일러나 런타임 라이브러리가 담당하도록 할 수 있는데, 이런 전략을 implicit therading 이라고 한다.

Thread Pools

웹 서버에서 사용되는 멀티스레딩에서 여러 스레드가 각자 다른 서비스에 응답하도록 할 수 있었다. 하지만 여전히 문제들이 남아있다.

  1. 스레드를 생성하는 데 걸리는 시간. 스레드는 일반적으로 생성되었다가 쉽게 폐기되기 때문에 스레드를 새성하는데, 그 정도의 시간을 투자하는 것이 그다지 효율성이 좋다고는 할 수 없다.
  2. 어떤 요청마다 새로운 스레드를 계속해서 생성하려면, 그 한계치를 정해주어야 한다.

이런 문제들을 효과적으로 해결하기 위해 고안된 방법이 Thread Pool 이다. Thread Pool은 일정한 수의 스레드를 미리 여러개 만들어둔다. 그리고 대기상태에 있다가, 어떤 요청을 받게되면 스레드 풀에서 대기중이던 스레드 하나가 해당 요청에 응답해서 작업을 시작하게 된다. 작업이 끝나게 되면 해당 스레드는 사라지는 것이 아니라 다시 대기 상태에 들어가게 된다.

Thread Pool 의 장점은 다음과 같다.

  1. 새로운 스레드를 만드는 것보다 대기 중인 스레드를 사용하는 것이 더 빠르다.
  2. 스레드 개수에 제한을 둘 수 있다.
  3. 스레드가 수행할 태스크를 쉽게 스케줄링할 수 있다.

Open MP

OpenMP는 C, C++, FORTRAM 작성된 프로그램이 공유메모리 환경에서 병렬프로그래밍을 할 수 있도록 해주는 API이다. OpenMP는 Parallel Region 이라는 코드 블럭을 정의하게 되는데, 이 코드블럭은 코드 중에서 병렬로 실행하게 되는 부분을 의미한다.

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

위 코드에서 처럼 ‘#pragma omp parallel for ’ 라는 Parallel Region을 코드에 정의해주면 OpenMP는 for 반복문 안에 들어 있는 연산을 생성된 스레드들에게 분배해서 나누어주게 된다.

Grand Central Dispatch

GCD 라고 불리는 Grand Central Dispatch 는 Mac OS X 와 IOS 운영체제에서 사용되는 기술이다. GCD는 병렬작업을 스레드에게 분배하기 위해서 작업해야할 태스크를 dispatch queue 에 넣어서 관리하게 되는데, 큐 안에 들어있는 작업을 제거한 뒤 Thread Pool 에서 사용 가능한 스레드를 선택해서 테스크를 부여하게 된다. GCD가 관리하는 dispatch queue 에는 두 가지 종류가 있다.

Serial Queue (직렬 큐)

이름에서 나타난 것 처럼, 직렬큐는 순서대로 어떤 작업을 수행되게 하는데 유용하게 사용된다. 직렬 큐에 넣어진 태스크는 FIFO 로 관리된다. 만약 어떤 태스크가 큐에서 제거된다면, 그 다음에 대기중인 태스크는 앞선 태그크가 모두 완료된 이후에 큐에서 빠져나올 수 있다. 각 프로세스는 main queue 라고 불리는 각자의 queue 를 가지고 있고 개발자는 프로세스에 추가적으로 직렬 큐를 만들어 넣을 수 있다.

Concurrent Queue (병행 큐)

병행 큐는 한 번에 여러 태스크가 큐에서 제거되어서 작업이 이루어질 수 있도록 한다. 시스템 내부에서 사용되는 세 종류의 병행 큐가 있는데, 이 큐들을 우선순위에 따라 나누어서 태스크를 대기시키게 된다.

Threading Issue

fork() and exec()

만약 어떤 프로세스가 fork 로 새로운 자녀프로세스를 복제할 때, 해당 프로세스에 존재하는 스레드들은 어떻게 될까? 프로세스 안에 단일한 스레드만 존재한다면, 큰 문제는 아니겠지만, 다수의 스레드가 존재할 경우에는 모든 스레드를 다 복제해야할지, 아니면 특정한 스레드만 복제해야할지에 대한 의문이 생길 것이다. 기본적으로 시스템은 하나의 스레드를 복제하거나, 전체를 모두 복제하는 경우 모두를 지원한다. 그리고 어떤 복제를 수행할지는 전적으로 응용프로그램의 선택에 달려있다.

만약 새로 복제되는 프로세스가 부모 프로세스가 하던 작업을 이어받아 수행한다면 모든 스레드를 당연히 복제해야 할 것이다. 하지만 만약 만들어진 자녀 프로세스가 생성되자마자 다른 작업 없이 exec() system call을 통해 다른 프로세스의 내용으로 대체된다면, 부모 프로세스에 존재하던 모든 스레드를 다 복제하는 것은 의미가 없는 일일 것이다.

Signal Handling

UNIX 시스템에서는 프로세스에 어떤 이벤트를 전달하기 위해서 Signal을 사용한다. 모든 signal에 대한 default handler는 이미 커널에 의해 정의되어 있지만, 사용자가 새로운 함수를 만들어 다른 방식으로 siganl을 처리할 수도 있다. 어떤 프로세스 내에 엄청나게 많은 스레드가 존재한다고 해보자. 만약 해당 프로세스에 signal 이 전달된다면 어떤 스레드가 이 signal을 처리해야할지 고민될 것이다. 일반적으로는 다음과 같은 시나리오가 있다.

  1. Signal 이 적용될 스레드가 처리한다.
  2. 모든 스레드에게 동일한 Signal을 전달해서 각각 처리한다.
  3. 특정한 몇몇 스레드을 선택해서 해당 스레드들이 Signal 을 처리한다.
  4. 하나의 스레드가 모든 Signal을 전달 받아서 처리한다.

Thread Cancelation

Thread cancellation은 실행 중인 스레드의 작업이 종료되기 전에 강제로 해당 스레드를 종료시키는 것이다. 웹브라우저에서 여러 정보들이 로딩되고 있을 때, 정지버튼을 누르면 모든 장업이 중단되는 것이 그 예이다. 우리는 작업을 중단해야할 스레드를 Target Thread 라고 한다. 그런데 만약 스레드를 중단시켜버리면, 스레드에 할당되어 있는 시스템 지원들을 다 회수하지 못할 가능성이 있다는 문제가 있다. 만약에 어떤 스레드가 다른 스레드에서 사용할 데이터를 갱신하고 있을 때, 작업이 중단된다면, 데이터의 손실이 생길 것이다.

Thread Cancellation에는 두 종류가 있다.

  1. Asynchronous Cancellation (비동기식 취소): 한 스레드가 Target thread 를 즉시 강제종료 시킨다.
  2. Deffered Cancellation (지연 취소): Target thread 가 주기적으로 스스로 중단이 가능한 상태인지 확인한다. 확인결과 스레드가 최소되어도 안전하다면, 작업을 중단한다.

Thread Local Storage (TLS)

TLS 라고도 불리는 Thread Local Storage 는 스레드가 자기 자신만 사용할 수 있도록 하는 데이터이다. 스레드는 프로세스의 자원을 함께 공유하기 때문에, TLS가 필요한 순간들이 있다. 지역변수로 해결할 수 있다고 생각할지 모르겠지만, 지역 변수는 특정 함수의 실행동안에만 살아있기 때문에 의미가 다르다.

Scheduler Activations

커널과 스레드 라이브러리 사이에 통신을 위해 Light Weight Process(LWP) 라는 자료구조를 둔다. 이 통신을 효과적으로 하기 위해서 Scheduler Activation 이라는 방법을 사용한다. 마치 Thread Pool 처럼 커널은 LWP 의 집합을 응용프로그램에 제공하고, 응용프로그램은 자신이 가지고 있는 user-thread 를 사용가능한 LWP에 연결해서 통신을 하게 한다.

커널은 어떤 이벤트가 일어났을 때, LWP 를 통해서 응용프로그램에게 그 이벤트를 알려준다. 이런 작업을 upcall 이라고 하고, 요청된 upcall 은 스레드 라이브러리에 구성된 upcall handler에 의해 LWP에서 실행된다. 응용프로그램의 스레드가 block 될 때도 upcall이 발생한다. 이 경우에는 커널이 block된 스레드의 상태를 저장하기 위해 새로운 LWP를 스레드에 할당해서 해당 LWP에서 upcall handler 가 처리되도록 한다.


Written by@전여훈 (Click Me!)
고민이 담긴 코드를 만들자, 고민하기 위해 공부하자.