[운영체제] Chapter 4.4 Thread Libraries

강현주·2025년 2월 20일

스레드 라이브러리(thread library)는 프로그래머에게 스레드를 만들고 관리하기 위한 API를 제공한다. 스레드 라이브러리를 구현하는 두 가지 주요한 방법이 있다.

  1. 커널 지원 없이 사용자 공간에서 라이브러리를 완전히 제공하는 것. 라이브러리의 모든 코드와 데이터 구조는 사용자 공간에 존재한다. 즉, 라이브러리에서 함수를 호출하면 시스템 호출이 아닌 사용자 공간에서 로컬 함수 호출이 발생한다.
  2. 운영 체제에서 직접 지원하는 커널 수준 라이브러리를 구현하는 것. 이 경우 라이브러리의 코드와 데이터 구조는 커널 공간에 존재한다. 라이브러리의 API에서 함수를 호출하면 일반적으로 커널에 대한 시스템 호출이 발생한다.

오늘날 사용되는 주요한 스레드 라이브러리는 POSIX Pthreads, Windows, Java이다. POSIX 표준 스레드 확장인 Pthreads는 사용자 수준 또는 커널 수준 라이브러리로 제공될 수 있다. Windows 스레드 라이브러리는 Window 시스템에서 사용할 수 있는 커널 수준 라이브러리이다. Java 스레드 API를 사용하면 Java 프로그램에서 직접 스레드를 만들고 관리할 수 있따. 그러나 대부분의 경우 JVM이 호스트 운영체제 위에서 실행되기 때문에 Java 스레드 API는 일반적으로 호스트 시스템에서 사용할 수 있는 스레드 라이브러리를 사용하여 구현된다. 즉, Window 시스템에서 Java 스레드는 Windows API를 사용하여 구현되고 UNIX, Linux, macOS 시스템은 Pthreads를 사용한다.

POSIX 및 Windows 스레딩의 경우 전역적으로 선언된 모든 데이터(즉, 함수 외부에서 선언된 데이터)는 동일한 프로세스에 속하는 모든 스레드에서 공유된다. Java에는 전역 데이터에 대한 동등한 개념이 없으므로 공유 데이터에 대한 액세스는 스레드 간에 명시적으로 arrange되어야 한다.

스레드 생성 예를 진행하기 전에, 여러 스레드를 생성하기 위한 두 가지 일반적인 전략 비동기 스레딩(asynchronous threading)과 동기 스레딩(synchronous threading)을 소개한다. 비동기 스레딩의 경우 부모가 자식 스레드를 생성하면 부모가 실행을 재개하여 부모와 자식이 동시에 독립적으로 실행된다. 스레드가 독립적이기 때문에 일반적으로 스레드 간에 데이터 공유가 거의 없다.

동기 스레딩은 부모 스레드가 하나 이상의 자식 스레드를 생성한 다음 모든 자식 스레드가 종료될 때까지 기다린 후에 재개된다. 여기서 부모가 만든 스레드는 동시에 작업을 수행하지만 부모는 이 작업이 완료될 때까지 계속할 수 없다. 각 스레드가 작업을 완료하면 종료되고 부모와 결합한다. 모든 자식이 결합된 후에야 부모가 실행을 재개할 수 있다. 일반적으로 동기 스레딩은 스레드 간에 상당한 양의 데이터 공유를 포함한다. 다음 모든 예는 동기 스레딩을 사용한다.

4.4.1 Pthreads

Pthreads는 스레드 생성 및 동기화를 위한 API를 정의하는 POSIX 표준(IEEE 1003.1c)을 말한다. 이든 구현이 아닌 스레드 동작에 대한 사양이다. 운영 체제 설계자는 원하는 대로 사양을 구현할 수 있다. 수 많은 시스템이 Pthreads 사양을 구현하는데, 대부분은 Linux와 macOS를 포함한 UNIX 타입의 시스템이다. Windows는 Pthreads를 기본적으로 지원하지 않지만 Windows용 타사 구현은 일부 사용이 가능하다.

그림 4.11의 C 프로그램은 별도의 스레드에서 음이 아닌 정수의 합계를 계산하는 멀티스레드 프로그램을 구성하기 위한 기본 Pthreads API를 보여준다. Pthreads 프로그램에서 별도의 스레드는 지정된 함수에서 실행을 시작한다. 그림 4.11에서는 runner() 함수이다. 이 프로그램이 시작되면 제어의 싱글 스레드가 main()에서 시작된다. 초기화 후 main()runner() 함수에서 제어를 시작하는 두 번째 스레드를 만든다. 두 스레드는 글로벌 데이터 sum을 공유한다.

모든 Pthreads 프로그램은 pthread.h 헤더 파일을 포함해야 한다. pthread_t tid문은 생성할 스레드의 식별자를 선언한다. 각 스레드에는 스택 크기와 스케줄링 정보를 포함한 속성 set이 있다. pthread_attr_t attr 선언은 스레드의 속성을 나타낸다. 함수 호출 pthread_attr_init(&attr)에서 속성을 설정한다. 속성을 명시적으로 설정하지 않았으므로, 제공된 기본 속성을 사용한다. pthread_create() 함수 호출로 별도의 스레드가 생성된다. 스레드 식별자와 스레드의 속성을 전달하는 것 외에도 새 스레드가 실행을 시작할 함수의 이름(이 경우 runner() 함수)도 전달한다. 마지막으로 명령줄에 제공된 정수 매개변수 argv[1]를 전달한다.

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

이 예제 프로그램은 단 하나의 스레드만 생성한다. 멀티코어 시스템의 지배력이 커지면서 여러 스레드를 포함하는 프로그램을 작성하는 것이 점점 더 일반화되었다. pthread_join() 함수를 사용하여 여러 스레드를 기다리는 간단한 방법은 간단한 for 루프 내에 연산을 묶는 것이다. 그림 4.12에서 나타낸 ead 코드를 사용하여 10개의 스레드에 조인할 수 있다.


4.4.2 Windows Threads

Windows 스레드 라이브러리를 사용하여 스레드를 만드는 기술은 여러 면에서 Pthreads 기술과 유사하다. 그림 4.13의 C 프로그램에서 Windows 스레드 API를 설명한다. Windows API를 사용할 때 windows.h 헤더 파일을 포함해야 한다는 점을 유의해라.

Pthreads 버전과 마찬가지로, 개별 스레드에서 공유하는 데이터(이 경우 Sum는 전역적으로 선언된다(DWORD 데이터 타입은 부호 없는 32비트 정수). 또한 개별 스레드에서 수행되는 Summation() 함수도 정의한다. 이 함수에는 Windows가 LPVOID로 정의하는 void에 대한 포인터가 전달된다. 이 함수를 수행하는 스레드는 전역 데이터 Sum을 0에서 Summation()에 전달된 매개변수까지의 합계 값으로 설정한다.

스레드는 CreateThread() 함수를 사용하여 Windows API에서 생성되고, Pthread에서와 마찬가지로 스레드의 속성 set이 이 함수에 전달된다. 이러한 속성에는 보안 정보, 스택 크기, 스레드가 일시 중단된 상태에서 시작되는지 여부를 나타내는 플래그가 포함된다. Summation 스레드가 생성되면 부모는 Sum 값을 출력하기 전에 스레드가 완료될 때까지 기다려야 한다. 이 값은 Summation에서 설정하기 때문이다. Pthread 프로그램(그림 4.11)에서 부모 스레드가 pthread_join() 문을 사용하여 Summation 스레드를 기다리도록 했다는 점을 기억해라. Windows API에서 WaitForSingleObject() 함수를 사용하여 이와 동일한 작업을 수행하는데, 이 함수는 Summation 스레드가 종료될 때까지 생성 스레드를 차단한다.

여러 스레드가 완료될 때까지 기다려야 하는 상황에서는 WaitForMultipleObject() 함수가 사용된다. 이 함수에는 네 개의 매개변수가 전달된다.

  1. 기다릴 object의 수
  2. object 배열에 대한 포인터
  3. 모든 object가 신호를 받았는지 여부를 나타내는 플래그
  4. 타임아웃 기간(또는 INFINITE)

예를 들어, THandles이 크기 N의 스레드 HANDLE의 object 배열인 경우 부모 스레드는 다음 명령문을 사용하여 모든 자식 스레드가 완료될 때까지 기다릴 수 있다.

WaitForMultipleObjects(N, THandles, TRUE, INFINITE);

4.4.3 Java Threads

스레드는 Java 프로그램에서 프로그램 실행의 기본 모델이며, Java 언어와 해당 API는 스레드 생성 및 관리를 위한 풍부한 기능 세트를 제공한다. 모든 Java 프로그램은 최소한 하나의 제어 스레드로 구성된다. 심지어 main() 메서드만으로 구성된 간단한 Java 프로그램조차도 JVM에서 싱글 스레드로 실행된다. Java 스레드는 Windows, Linux, macOS를 포함하여 JVM을 제공하는 모든 시스템에서 사용할 수 있다. Java API는 Android 애플리케이션에서도 사용할 수 있다.

Java 프로그램에서 스레드를 명시적으로 생성하는 데는 두 가지 기술이 있다.

  1. Thread 클래스에서 파생된 새 클래스를 만들고 run() 메서드 재정의
  2. Runnable 인터페이스를 구현하는 클래스 정의(일반적으로 사용됨)

Runnable 인터페이스는 public void run() 시그니처가 있는 싱글 추상 메서드를 정의한다. Runnable을 구현하는 클래스의 run() 메서드에 있는 코드는 별도의 스레드에서 실행된다.

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

Java에서 스레드를 생성하는 것은 Thread 객체를 생성하고 Runnable을 구현하는 클래스의 인스턴스를 전달한 다음 Thread 객체에서 start() 메서드를 호출하는 것을 포함한다.

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

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

  1. 메모리를 할당하고 JVM에 새 스레드 초기화
  2. run() 메서드를 호출하여 스레드가 JVM에서 실행될 수 있도록 함 (우리가 run() 메서드를 직접 호출하지 않음. 대신, start() 메서드를 호출하고 이 메서드가 run()를 호출함)

Pthreds와 Windows 라이브러리의 부모 스레드는 각각 pthread_join()WaitForSingleObject()를 사용하여 Summation 스레드가 완료될 때까지 기다린 후에 진행하였다. Java의 join() 메서드는 비슷한 기능을 제공한다.

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

4.4.3.1 Java Executor Framework

Java는 지금까지 설명한 접근 방식을 사용하여 스레드 생성을 지원해왔다. 그러나 Java는 버전 1.5와 해당 API부터 개발자에게 스레드 생성 및 통신에 대해 훨씬 더 큰 제어력을 제공하는 여러 새로운 동시성 기능을 도입했다. 이러한 도구는 java.util.concurrent 패키지에서 사용할 수 있다.

Thread 객체를 명시적으로 생성하는 대신, 스래드 생성은 Executor 인터페이스 중심으로 구성된다.

public interface Executor {
	void execute(Runnable command);
}

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

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

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

동일한 프로세스에 속하는 스레드 간의 데이터 공유는 공유 데이터가 단순히 전역적으로 선언되기 때문에 Windows와 Pthreads에서 쉽게 발생한다. 순수 객체 지향 언어인 Java에는 이러한 전역 데이터 개념이 없습니다. Runnable을 구현하는 클래스에 매개변수를 전달할 수 있지만 Java 스레드는 결과를 반환할 수 없다. 이러한 요구 사항을 해결하기 위해 java.util.concurrent 패키지는 Runnable과 유사하게 동작하지만 결과를 반환할 수 있는 Callable 인터페이스를 추가로 정의한다. Callable task에서 반환된 결과는 Future 객체라고 한다. Future 인터페이스에 정의된 get() 메서드에서 결과를 가져올 수 있다. 그림 4.14의 프로그램은 이러한 Java 기능을 사용한 합계 프로그램을 보여준다.

Summation 클래스는 V call() 메서드를 지정하는 Callable 인터페이스를 구현한다. 이 call() 메서드의 코드는 별도의 스레드에서 실행된다. 이 코드를 실행하려면 ExecutorService 타입의 newSingleThreadExecutor 객체를 만들고 submit() 메서드를 사용하여 Callable task에 전달한다(execute() 메서드는 결과 반환X, submit() 메서드는 결과를 Future로 반환). 스레드에 callable task를 submit하면 반환되는 Future 객체의 get() 메서드를 호출하여 결과를 기다린다.

처음에는 이 스레드 생성 모델이 단순히 스레드를 생성하고 종료 시에 결합하는 것보다 훨씬 복잡해 보이지만, 이 정도의 복잡성을 감수하는 것은 이점을 제공한다. CallableFuture를 사용하면 스레드가 결과를 반환할 수 있고, 스레드 생성과 스레드가 생성하는 결과를 분리한다.

0개의 댓글