Notice
Recent Posts
Recent Comments
05-18 01:37
«   2024/05   »
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 27 28 29 30 31
Archives
Today
Total
관리 메뉴

Byeol Lo

4.4 Thread Libraries 본문

OS/OS Design

4.4 Thread Libraries

알 수 없는 사용자 2024. 4. 13. 22:57

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

 첫 번째 방법은 커널 지원이 없는 사용자 공간에 완전한 라이브러리를 제공하는 것이다. 라이브러리의 모든 코드와 데이터 구조는 사용자 공간에 있고, 라이브러리의 함수를 호출하면 사용자 공간에서 지역 함수 호출이 되고 이는 시스템 호출이 아니다. 두 번째 방법은 운영 체제에서 직접 지원하는 커널 수준 라이브러리를 구현하는 것이다. 이 경우 라이브러리의 코드와 데이터 구조는 커널 공간에 있다. 라이브러리의 API에서 함수를 호출하면 일반적으로 커널로의 시스템 호출이 발생한다.

 주로 사용되는 세 가지 주요 쓰레드 라이브러리는 POSIX의 Pthreads, Windows API, 그리고 Java가 있다. POSIX 표준의 쓰레드 확장인 Pthreads는 사용자 수준 또는 커널 수준 라이브러리로 제공될 수 있다. Windows 쓰레드 라이브러리는 Windows 시스템에서 사용할 수 있는 커널 수준 라이브러리다. Java 쓰레드 API를 사용하면 쓰레드를 직접 Java 프로그램에서 생성하고 관리할 수 있다. 그러나 대부분의 경우 JVM이 호스트 운영 체제 위에서 실행되므로 Java 쓰레드 API는 일반적으로 호스트 시스템에서 사용할 수 있는 쓰레드 라이브러리를 사용하여 구현된다.

 POSIX 및 Windows 쓰레딩의 경우, 어떤 함수 외부에서 선언된 데이터는 모두 같은 프로세스에 속한 모든 쓰레드들 사이에서 공유되지만, Java는 이러한 개념이 명확하게 정의되어 있지 않기 때문에 공유 데이터에 대한 접근은 쓰레드들 사이에 명시적으로 처리해줘야 한다.

 쓰레드 생성 예제를 진행하기 전에, 다중 쓰레드를 생성하는 두 가지 일반적인 전략(비동기 쓰레딩, 동기 쓰레딩)을 보자. 비동기 쓰레딩에서 부모가 자식 쓰레드를 생성하면, 부모는 실행을 계속하게 된다. 따라서 부모와 자식이 서로 독립적으로 동시에 실행되고 이 상황을 비동기 쓰레딩이라고 한다. 쓰레드가 독립적이기 때문에 보통 그들 간에는 데이터 공유가 거의 없다. 비동기 쓰레딩은 다중 쓰레드 서버에서 사용된 전략이며, 응답성 사용자 인터페이스를 디자인하는 데 일반적으로 사용된다.

 동기 쓰레딩은 부모 쓰레드가 하나 이상의 자식을 생성한 다음 모든 자식이 종료될 때까지 기다려야 하는 상황을 말한다. 여기서 부모가 생성한 쓰레드들은 동시에 작업을 수행하지만, 부모는 이 작업이 완료될 때까지 계속할 수 없다. 각 쓰레드가 작업을 완료하면 종료되고 부모와 결합한다. 모든 자식이 결합된 후에만 부모가 실행을 계속할 수 있다. 일반적으로 동기 쓰레딩은 쓰레드 간에 중요한 데이터 공유를 수반한다.

 

4.4.1 Pthreads

 보통 Pthreads는 스레드 생성과 동기화를 위한 API를 정의하는 POSIX 표준 (IEEE 1003.1c)을 가리킨다. 이것은 구현이 아닌 스레드 동작에 대한 명세이다. 운영 체제 설계자들은 해당 명세서를 원하는 방식으로 구현할 수 있다. 많은 시스템이 Pthreads 명세를 통해서 구현하고 있으며, 대부분은 Linux와 macOS를 포함한 UNIX 유형의 시스템이다. Windows는 Pthreads를 네이티브로 지원하지 않지만, Windows용 몇 가지 서드파티 구현이 있다.

#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 (i = 1; i <= upper; i++)
    sum += i;
  
  pthread exit(0);
}

 위에 나와 있는 C 프로그램은 별도의 스레드에서 양의 정수의 합을 계산하는 다중 스레드 프로그램을 구성하기 위한 기본 Pthreads API를 보여준다. Pthreads 프로그램에서 별도의 스레드는 특정 함수에서 실행을 시작한다. 위에서는 이 역할을 하는게 runner() 함수이다. 이 프로그램이 시작되면 main()에서 제어가 시작된다. 초기화 후, main()은 runner() 함수에서 제어를 시작하는 두 번째 스레드를 생성한다. 이때 이 두 스레드는 전역 데이터 sum을 공유한다.

 모든 Pthreads 프로그램은 pthread.h 헤더 파일을 포함해야 한다. pthread_t tid 문은 생성할 스레드의 식별자를 선언한다. 각 스레드에는 스택 크기 스케줄링 정보와 같은 속성 집합이 있다. pthread_attr_t attr 선언은 스레드의 속성을 선언한다. pthread_attr_init(&attr) 함수 호출을 통해 속성을 설정하고, 만약 이러한 명시적으로 속성을 설정(pthread_attr_set)하지 않는다면, 제공된 기본 속성을 사용한다. pthread_create() 함수 호출로 별도의 스레드가 생성되게 된다. 스레드 식별자와 스레드의 속성뿐만 아니라 새 스레드가 실행을 시작할 함수의 이름(runner)을 전달한다. 마지막으로, 명령행에서 제공된 정수 매개변수 argv[1]를 전달한다.

 이 시점에서 프로그램에는 두 개의 스레드가 있다. main()에 있는 초기(또는 부모) 스레드와 runner() 함수에서 합산 연산을 수행하는 합산(또는 자식) 스레드이다. 이 프로그램은 스레드 생성/결합 전략을 따른다. 합산 스레드를 생성한 후 부모 스레드가 pthread_join() 함수를 호출하여 스레드가 종료될 때까지 대기(동기 스레딩)한다. 합산 스레드는 pthread_exit() 함수를 호출하여 종료된다. 합산 스레드가 반환된 후 부모 스레드는 공유 데이터 sum의 값을 출력한다.

 이 예제 프로그램은 단일 스레드만 생성한다. 다중 코어 시스템이 지배적으로 사용되면서, 여러 스레드를 포함하는 프로그램을 작성하는 것이 점점 더 일반화되었다. 여러 스레드에 대한 pthread_join() 함수를 사용하여 간단한 for 루프 안에 작업을 묶는 간단한 방법이 있다. 예를 들어, 아래처럼 Pthread 코드를 사용하여 열 개의 스레드에 대해 join할 수 있다.

#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 Threads

 Windows 스레드 라이브러리를 사용하여 스레드를 생성하는 기법은 여러 가지 측면에서 Pthreads 기법과 유사하다. Windows API를 사용할 때는 windows.h 헤더 파일을 포함해야 함에 주목하자.

#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); 
}

 별도의 스레드에서 공유되는 데이터는 전역으로 선언된다(DWORD 데이터 형식은 부호 없는 32비트 정수). 또한 별도의 스레드에서 수행될 Summation() 함수를 정의한다. 이 함수는 void에 대한 포인터를 받으며, Windows에서는 이를 LPVOID로 정의한다. 이 함수를 수행하는 스레드는 Sum 값을 0부터 Summation()에 전달된 매개변수까지의 합계 값으로 설정한다.

 Windows API에서 스레드는 CreateThread() 함수를 사용하여 생성되며, Pthreads와 마찬가지로 스레드의 속성 집합이 이 함수에 전달된다. 이러한 속성에는 보안 정보, 스택의 크기스레드를 중단된 상태에서 시작할지 여부를 나타내는 플래그가 포함된다. 이 프로그램에서는 이러한 속성들에 대한 기본값을 사용한다. (기본값은 스레드를 처음에 중단된 상태로 설정하지 않고, 대신에 CPU 스케줄러에서 실행할 수 있도록 함) 일단 합계 스레드가 생성되면, 부모는 이 스레드가 종료될 때까지 기다려야 하며, 값이 합계 스레드에 의해 설정되기 때문에 Sum의 값을 출력할 수 있다. Pthread 프로그램 (그림 4.11)에서는 부모 스레드가 pthread_join() 문을 사용하여 합계 스레드를 기다렸다. Windows API에서는 이와 동등한 동작을 기다리는 것으로 수행합니다. 이는 WaitForSingleObject() 함수를 사용하여, 생성 스레드가 합계 스레드가 종료될 때까지 블록되도록 만드는 것이다.

 만약 여러 스레드가 완료될 때 기다려야 하는 상황에서는 WaitForMultipleObjects() 함수를 사용하고, 이는 네 개의 매개변수를 전달 받는다.

  1. 기다리는 객체의 수
  2. 객체 배열에 대한 포인터
  3. 모든 객체가 시그널 되었는지 여부를 나타내는 플래그
  4. 타임아웃 기간 (또는 INFINITE)

 예를 들어, THandles가 크기가 N인 스레드 HANDLE 객체의 배열인 경우, 생성 스레드는 다음 문장을 사용하여 모든 자식 스레드가 완료될 때까지 기다릴 수 있다(WaitForMultipleObjects(N, THandles, TRUE, INFINITE);).

 

4.4.3 Java Threads

 자바에서 프로그램 실행의 기본 모델은 스레드이다. 자바와 해당 API는 스레드의 생성과 관리를 위한 다양한 기능을 제공해준다. 모든 자바 프로그램은 최소한 하나의 제어 스레드를 포함하며, 단순한 main() 메서드만으로 구성된 간단한 자바 프로그램조차도 JVM에서 하나의 스레드로 실행된다. 자바 스레드는 Windows, Linux, macOS를 포함한 JVM을 제공하는 모든 시스템에서 사용할 수 있으며, 안드로이드 애플리케이션에서도 자바 스레드 API를 사용할 수 있다.

 자바 프로그램에서 스레드를 명시적으로 생성하는 데는 두 가지 기법이 있다. 하나는 Thread 클래스에서 파생된 새 클래스를 생성하고 그 run() 메서드를 오버라이딩하는 것이고, 다른 기법은 Runnable 인터페이스를 구현하는 클래스를 정의하는 것이다. 이 인터페이스는 public void run() 시그니처를 갖는 단일 추상 메서드를 정의한다. Runnable을 구현하는 클래스의 run() 메서드 내의 코드가 별도의 스레드에서 실행된다. 아래에 예시가 나와 있다.

class Task implements Runnable
{
  public void run() {
    System.out.println("I am a thread.");
  }
}

 

 자바에서도 스레드 생성은 Runnable 을 구현한 클래스의 인스터스를 전달하여 Thread 객체를 생성한 다음 Thread 객체의 start() 메서드를 호출하는 것으로 이루어진다.

Thread worker = new Thread(new Task());
worker.start();

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

  1. JVM에서 새로운 스레드를 할당하고 초기화
  2. run() 메소드를 호출하여 스레드가 JVM에 의해 실행될 수 있도록 만듦

 Pthreads와 Windows 라이브러리의 부모 스레드는 각각 pthread_join() 및 WaitForSingleObject()를 사용하여 합계 스레드가 완료될 때까지 대기한다. Java의 join() 메서드는 유사한 기능을 제공한다.

try {
  worker.join();
}
catch (InterruptedExcepton ie) { }

부모가 여러 스레드가 완료될 때까지 기다려야 하는 경우 Pthreads에 표시된 것과 유사한 for 루프에 join() 메서드를 포함할 수 있습니다.

 

4.4.3.1 Java Executor Framework

 자바는 초기부터 우리가 지금까지 설명한 방식으로 스레드 생성을 지원해 왔지만, 1.5 버전부터 API와 함께, 자바는 개발자에게 스레드 생성과 통신에 대한 훨씬 더 큰 제어를 제공하는 여러 가지 새로운 currency 기능을 도입했다. 이 도구들은 java.util.concurrent 패키지에서 사용할 수 있다. Thread 객체를 명시적으로 생성하는 대신, 스레드 생성은 Executor 인터페이스를 중심으로 구성된다.

 이 인터페이스를 구현하는 클래스는 execute() 메서드를 정의해야 한다. 이 메서드는 Runnable 객체를 전달받는다. 자바 개발자들에게는 이것이 의미하는 바는 별도의 Thread 객체를 생성하고 그 start() 메서드를 호출하는 대신 Executor를 사용하는 것이다. Executor는 다음과 같이 사용된다.

Executor service = new Executor;
service.execute (new Task());

 Executor 프레임워크는 생산자-소비자 모델에 기반하는데, Runnable 인터페이스를 구현하는 작업들이 생성되고, 이러한 작업을 실행한는 스레드들이 이를 소비한다. 이 접근 방식의 장점은 스레드 생성과 실행을 분리할 뿐만 아니라 동시 작업 간 통신 메커니즘을 제공한다는 점이다.

 Windows와 Pthreads에서는 동일한 프로세스에 속한 스레드 간 데이터 공유가 간단히 이루어진다. 왜냐하면 공유 데이터는 단순히 전역으로 선언되기 때문이며, 순수 객체 지향 언어인 Java에는 이러한 전역 데이터의 개념이 없다. Runnable 인터페이스를 구현하는 클래스에 매개변수를 전달할 수는 있지만 Java 스레드는 결과를 반환할 수 없다. 이러한 필요를 해결하기 위해 java.util.concurrent 패키지는 Callable 인터페이스를 추가로 정의한다. 이 인터페이스는 Runnable과 유사하게 동작하지만 결과를 반환할 수 있다. Callable 작업에서 반환되는 결과는 Future 객체로 알려져 있다. 결과는 Future 인터페이스에 정의된 get() 메서드를 통해 검색할 수 있다.

 Summation 클래스는 Callable 인터페이스를 구현하는데, 이 인터페이스는 V call() 메서드를 지정한다. 이 call() 메서드의 코드가 별도의 스레드에서 실행된다. 이 코드를 실행하려면 Executors 클래스의 정적 메서드로 제공되는 newSingleThreadExecutor 객체를 생성하고, ExecutorService 타입으로 만든 후에 그 submit() 메서드를 사용하여 Callable 작업을 전달한다. (execute()와 submit() 메서드의 주요 차이점은 전자는 결과를 반환하지 않고, 후자는 결과를 Future로 반환한다는 것) 작업을 스레드에 제출한 후에는 반환되는 Future 객체의 get() 메서드를 호출하여 그 결과를 기다린다.

import java.util.concurrent.*;

class Summation implements Callable<Integer>
{
    private int upper;
    public Summation(int upper) {
        this.upper = upper;
    }
    
    public Integer call() {
        int sum = 0;
        for (int i = 1; i <= upper; i++)
            sum += i;
        
        return new Integer(sum);
    }
}

public class Driver
{
    public static void main(String[] args) {
        int upper = Integer.parseInt(args[0]);
        
        ExecutorService pool = Executors.newSingleThreadExecuter();
        Future<Integer> result = pool.submit (new Summation(upper));
        
        try {
            System.out.println("sum = " + result.get());
        } catch (InterruptedException | ExecutionException ie) { }
    }
}

 처음에는 이 스레드 생성 모델이 단순히 스레드를 생성하고 종료를 기다리는 것보다 복잡해 보일 수 있다. 그러나 이런 적은 정도의 복잡성을 감수하는 것은 이득이 있다. 앞에서 보았듯이 Callable과 Future를 사용하면 스레드가 결과를 반환할 수 있으며, 이 접근 방식은 스레드의 생성과 그들이 생성하는 결과를 분리한다. 스레드가 종료될 때까지 결과를 기다리는 대신 부모는 결과가 사용 가능해질 때까지만 기다리고, 마지막에서 볼 수 있듯이 이 프레임워크는 다른 기능과 결합하여 많은 수의 스레드를 관리하는 강력한 도구를 만들 수 있다.

'OS > OS Design' 카테고리의 다른 글

4.6 Threading Issues  (0) 2024.05.13
4.5 Implicit Threading  (0) 2024.05.06
4.3 Multithreading Models  (0) 2024.04.13
4.2 Multicore Programming  (0) 2024.04.13
4.1 Thread & Concurrency Overview  (0) 2024.04.13
Comments