CS/OS

Operating System Concepts 정리 - Ch.04

VSFe 2021. 1. 15. 15:14

4.1 Introduction

Thread: CPU 이용의 기본 단위이다.

스레드 ID, Program Counter, Register 집합, 그리고 스택으로 구성된다.

당연히 스레드는 프로세스 내부에 존재하는 것이기 때문에, 같은 프로세스에 속한 다른 스레드와 Code Segment, Data Segment와 열린 파일, Signal과 같은 운영체제 자원들을 공유한다.

 

단일 스레드라면 당연히 왼쪽처럼 모든 자원을 홀로 소유하는 것 처럼 그려지겠지만, 멀티 스레드는 공유하는 자원과 본인이 보유하는 자원이 구분되기 떄문에 오른쪽으로 봐야 할 것 같다.

현대의 프로그램은 대부분 다중 스레드를 이용한다. (ex. 워드: 그래픽 표시 스레드, 입력 받는 스레드, 맞춤법 검사 스레드 등등...)

서버의 경우를 보자. 웹 서버는 클라이언트로 다양한 요청을 받는데, 만약 싱글 스레드라면? 한번에 하나의 요청만 받을 수 있으므로 운이 나쁘면 대기 시간이 길어질 수 밖에 없다.

예전에는 프로세스를 여러개 만들어서 해결했다. 즉, 요청이 들어오면, 요청을 수행할 별도의 프로세스를 만들어서 대응했다. 다만... 프로세스 생성 과정은 오버헤드가 꽤나 드는 작업이다. 그래서 이러지 말고 그냥 스레드를 만들어서 대응하는게 더 효율적이다.

참고로, 서버나 응용 프로그램 뿐만이 아닌 커널 또한 멀티 스레드로 구성되어 있다. 각각의 스레드가 장치를 관리하고, 인터럽트를 관리하도록 해 다양한 작업을 동시에 진행할 수 있도록 한다.

이미 충분히 장점이 많은 것 같지만, 그래도 일단 장점을 정리해보면,

  • Responsiveness: 프로그램의 일부에 문제가 생기거나, 긴 작업을 하고 있어도 다른 스레드를 통해 프로그램의 응답성을 유지할 수 있다.
  • Resource Sharing: 프로세스간 자원 공유는 Shared Memory와 Message Passing을 통해서만 이뤄질 수 있는데, 스레드는 자동적으로 같은 프로세스의 자원을 공유할 수 있다.
  • Economy: 프로세스 생성을 위해 자원과 메모리를 새로 할당하는 것은 상당한 시간이 걸리지만, 스레드는 저런 작업이 없기 때문에 더 경제적이다.
  • Scalability: 다중 처리기 구조에서는 각각의 스레드가 다른 처리기에서 병렬로 수행될 수 있기 때문에 더 효율적이다.

4.2 Multicore Programming

멀티 스레딩을 활용하면 멀티코어를 더 효율적으로 사용할 수 있고 병행성을 높일 수 있다.

 

 

그림을 보면 알겠지만, 코어가 1개면 결국 스레드만 돌아가면서 작업할 뿐이다. 하지만 코어가 여러개가 된다면, 다음과 같이 병행해서 작업을 할 수 있기 때문에 더 효율적이다.

중요한 단어가 나오는데, 하나는 동시성 (Concurrency), 다른 하나는 병렬성 (Parallelism) 이다.

동시성은 동시에 실행되는 것 처럼 보이는 것으로, 싱글 코어에서 멀티 스레드를 돌리는 것이다. 결국 Context Switch를 통해 스레드가 빠른 시간동안 바뀌게 되어 동시에 실행되는 것 처럼 보일 뿐이다.

병렬성은 실제로 여러 작업이 처리되는 것으로, 멀티 코어에서 멀티 스레드를 돌리는 것이다. 실제로 물리적으로 작업이 처리 된다.

 

이 그림을 보면 이해에 도움이 될 것이다.

(아님 말고...)

물론 개발을 할때 싱글 스레드보다 어렵기 때문에, 다음과 같은 사항들을 신경써서 개발해야 한다.

  • Identifying tasks: 프로그램을 분석하여 독립된 병행가능 태스크로 나눠야 한다. 이상적으로 태스크는 서로 독립적이어야 개별 코어에서 병행 실행될 수 있다.
  • Balance: 각각의 스레드가 전체 작업에 균등한 기여도를 가지도록 태스크를 잘 나눠야 한다.
  • Data Spliting: 병행 실행을 위해 데이터 또한 개별 코어에서 사용될 수 있도록 나눠져야 한다.
  • Data Dependency: 태스크가 접근하는 데이터는 둘 이상의 태스크 사이에 종속성이 없는지 검토되어야 한다. 만약 종속적이면 동기화를 잘 해서 동시에 접근하지 못하도록 조절해야 한다.
  • Testing and Debugging: 프로그램이 병렬로 실행될 때, 다양한 실행 경로가 존재할 수 있으므로 싱글 스레드 프로그래밍에 비해 디버깅이 더 복잡하다.

일반적으로 병렬 실행은 데이터 병렬 실행 (Data Parallelism), 태스크 병렬 실행 (Task Parallelism)으로 구분된다.

데이터 병렬 실행은 동일한 데이터의 부분집합을 다수의 계산 코어에 분배한 뒤 각 코어에서 동일한 연산을 실행하는데 초점을 맞춘것이고, 태스크 병렬 실행은 각각의 태스크 (즉, 스레드)를 코어에 분배해 스레드가 고유의 연산을 실행할 수 있도록 한다.

물론 현실적으로 하나만 선택하는 경우는 없으며, 적당히 잘 분배해서 사용해야 한다.

Multithreading Model

스레드는 크게 사용자 스레드 (User Thread), 커널 스레드 (Kernel Thread)로 나눌 수 있다. 궁극적으로는 사용자 스레드와 커널 스레드는 어떤 연관 관계가 존재해야 한다.

Many-To-One Model

 

많은 사용자 수준 스레드를 하나의 커널 스레드로 사상함.

스레드 관리를 사용자 공간의 라이브러리로 넘기기 때문에 다소 효율적이긴 하나, 한 스레드가 Blocking System Call을 한 경우 전체 프로세스가 Block 되며, 한번에 한 스레드만 가능하기 때문에 멀티 코어 환경이라 하더라도 병렬로 스레드가 실행될 수 없다. → 즉, 현대에선 사용할 이유가 없다.

One-To-One Model

 

각 사용자 스레드 하나가 커널 스레드 하나로 대응됨.

Blocking System Call을 해도 다른 스레드는 문제 없이 실행 될 수 있기 때문에 병렬성 측면에선 좋음. 그러나 사용자 스레드 하나에 커널 스레드를 하나씩 대응해야 하므로, 커널 스레드를 생성하는 과정에서 발생할 수 있는 오버헤드가 대폭 증가함. (그래서 이를 해결하기 위해 시스템에 의해 지원되는 스레드의 수를 일부 제한 시킴.) Linux와 Windows가 이를 사용한다.

Many-To-Many Model

 

여러개의 사용자 수준 스레드를 멀티플렉스 함.

Many-To-One의 단점인 병렬성 확보 불가와, One-To-One의 단점인 과도한 스레드 생성을 보완한 모델이라고 할 수 있음.

4.4 Threads Library

프로그래머에게 스레드를 생성하고 관리하기 위한 API를 제공함.

스레드 라이브러리를 구현하는 데에는

  • 커널의 지원 없이 완전한 사용자 공간에서 라이브러리를 제공 → 모든 코드와 자료구조가 사용자 공간에 존재하기 때문에, 함수 호출은 지역 함수를 호출하는 것.
  • 운영체제에 의해 지원되는 커널 수준 라이브러를 구현 → 코드와 자료구조는 커널 공간에 존재하며, API 호출은 System Call로 귀결됨.

스레드 전략은 비동기 스레딩 (Asynchronous threading) 과 동기 스레딩 (Synchronous threading)으로 나뉠 수 있는데, 전자는 부모가 자식 스레드를 생성한 이후 독립적으로 실행하는 것이다. 따라서 부모는 자식의 종료를 알 필요가 없고, 독립적으로 돌아가기 때문에 데이터 공유는 거의 없다. 후자의 경우엔 부모가 자식이 모두 종료될 때 까지 기다리고 재개하는 것을 의미한다. 흔히 포크-조인 (fork-join) 전략이라고도 하는데, 모든 자식이 조인 될 때까지 기다리는 것을 의미한다. 또한 후자는 잦은 데이터 공유를 수반한다.

Pthreads

사용자 또는 커널 수준 라이브러리로 제공될 수 있고, 전역 변수로 선언된 데이터, 함수 외부에 선언된 데이터는 같은 프로세스에 속한 모든 스레드가 공유함.

참고로 Pthreads는 명세이지 구현한건 아님. 각각의 운영체제는 각자의 방법으로 구현을 하고 있고, Windows는 공식 구현은 없지만 제3자가 구현한 버전이 있음.

활용 예에 대해선 따로 공부해서 작성해보려고 한다.

4.5 Implicit Threading

멀티 스레드 설계를 도와주는 다른 방법은 스레드의 생성과 관리의 책임을 컴파일러와 런타임 라이브러리에게 넘겨주는 것이다. 우리는 이것을 암묵적 스레딩 (Implicit Threading) 이라고 한다.

이 절에서는 암묵적 스레딩을 도와주는 접근법을 알아볼 것이다.

Thread Pool

웹서버를 떠올려 보자. 요청이 들어올 때 마다 스레드를 만든다고 해보자. 사실 스레드는 요청이 끝나면 폐기될 것이기 때문에, 굳이 이걸 만들어야 하나? 라는 생각이 들 수도 있다. 거기에, 이론상 무한정 만드는 것은 불가능하다. 이러한 문제를 해결할 수 있는 방법 중 하나가 바로 스레드 풀 (Thread Pool) 이다.

기본 아이디어는 프로세스를 시작할 때 일정한 수의 스레드를 미리 풀로 만들어주고, 요청이 들어오면 스레드 하나를 요청에 할당하는 것이다. 당연히 작업이 끝나면 스레드를 제거하지 않고 풀에 다시 복귀시키면 될 것 이고, 풀 내에 남은 스레드가 없으면 대기 시킨다.

이렇게 되면, 다음과 같은 장점을 가질 것이다.

  • 새로운 스레드를 만들고, 파괴하는데 소요되는 오버헤드가 사라짐.
  • 최대 스레드 갯수에 제한을 둠으로써 많은 수의 스레드를 병렬적으로 동작시킬 수 없는 시스템에 도움이 된다.
  • 특정 태스크의 실행 여부와 상관 없이 태스크를 실행할 수 있으므로, 일정 시간 후에 실행되거나 주기적으로 실행되도록 스케쥴링 할 수 있다.

스레드 풀 내 스레드의 수는 CPU 수, 물리 메모리 용량, 최대 동시 요청 클라이언트의 수 등을 고려해서 설계하면 될 것이다. 만약 조금 더 정교하게 한다면 풀의 활용도를 기반으로 풀의 크기를 변경하도록 할 수 있을 것이다.

예시로 Java Thread Pool에 대해서 보자.

import java.util.concurrent.*;

public class ThreadPoolExample {
	public static void main(String[] args) {
    	int numTasks = Integer.parseInt(args[0].trim());
        
        /* Create Thread Pool */
        ExecutorService pool = Excutors.newCachedThreadPool();
        for (int i = 0; i < numTasks; i++) pool.execute(new Task());
        
        /* Shut down the pool once all threads have completed */
        pool.shutdown();
    }
}

생성은 다음 메소드를 통해 이루어진다.

  • newSingleThreadExecutor(): 사이즈가 1인 스레드 풀을 생성한다.
  • newFixedThreadPool(int size): 인수를 크기로 하는 스레드 풀을 생성한다.
  • newCachedThreadPool(): 크기의 제한이 없는 스레드 풀을 생성함.

각각의 매소드는 ExecutorService 인터페이스의 구현체를 리턴하는데, 요 친구는 Executor를 상속받은 친구로, execute()와 shutdown()을 명시한다.

Fork Join

포크 조인에 대해서는 이전에 언급했던 적이 있었다. 부모 스레드가 자식 스레드를 생성하고, 자식 스레드가 모두 종료되기 전 까지 Wait 상태로 머무른다고 했었는데, 이걸 활용해서도 암묵적 스레딩이 가능할 것이다.

포크 조인은 'Divide And Conquer' 알고리즘을 스레드에서 활용한 것이라고 생각하면 좋다! 부모 스레드는 태스크를 분할해서 자식에게 할당하고, 자식은 또 그걸 분할해서...

 

 

4.6 Threading Issues

멀티 스레딩 프로그래밍 난이도가 싱글 스레딩 보다 어려울 수 밖에 없는건 당연한 소리다. 단원 내내 한 소리이긴 하지만... 일단 여기서는 설계할 떄 고려해야 할 몇 가지 문제들을 알아보자.

The fork() and exec() System Call

앞에서 다뤘던 것 과는 달리, 멀티 스레딩 환경에선 다른 의미가 될 수 있다.

만약에 특정 스레드가 fork()를 실행하면 새로운 프로세스는 모든 스레드를 복사해야 할까? 아니면 호출한 친구 하나만 복사하면 될까? 결론은 둘 다 지원한다.

exec()의 경우엔, 3장에서 서술했던 것과 동일한 프로세스를 가진다. 즉, 모든 스레드를 포함한 전체 프로세스를 통째로 교차해버린다.

실예로, fork()하고 exec()를 한다면 굳이 모든 스레드를 복사할 이유가 없을 것이다. 다만, fork() 이후 exec()를 실행하지 않는다면? 이런 경우엔 모든 스레드를 복사할 필요가 있을 것이다.

Signal Handling

시그널 (Signal)은 프로세스에게 어떤 사건이 일어났음을 알려주기 위해 사용된다.

설명을 또 하기 귀찮으니까... (????) 이전에 만들었던 발표 자료를 재탕하자.

 

그렇다고 한다.

싱글 스레드라면 시그널을 그냥 프로세스에 전달하면 되는데, 멀티 스레드면 어떻게 해야할까?

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

Syncronous (Segmentaion Falut, Abort)의 경우 야기한 스레드로 전달이 되나, Asyncronous (Ctrl + C 등등...)의 경우엔 전달한 대상이 애매하다. 그래서 모든 스레드로 전달하곤 한다.

대부분의 UNIX의 경우, 각각의 스레드에게 받아들일 신호와 봉쇄할 신호를 지정할 수 있는 선택권을 준다. 따라서 Asyncoronous Signal을 봉쇄하지 않고 있는 스레드들에게만 신호를 전달해야 할 수 있다.

pthread_kill(pthread_t tid, int signal)

Cancellation

스레드가 끝나기 전에 강제 종료시키는 작업을 일컫는다.

취소되어야 할 스레드를 목표 스레드 (Target Thread)라고 한다. 목표 스레드의 취소는 두 가지 방법으로 이루어진다.

  • 비동기식 취소 (Asynchronous Cancellation): 한 스레드가 즉시 목표 스레드를 강제 종료 시킨다.
  • 지연 취소 (Deferred Cancellation): 목표 스레드가 주기적으로 강제 종료되어야 할 지를 점검한다. 이 경우 스스로 강제 종료될 수 있는 기회가 만들어진다.

자원 문제는 어떤식으로 해결할까? 일단 전자의 경우엔 최악의 경우 OS가 스레드로부터 자원을 회수하지 못 할 수 있다. 후자의 경우 목표 스레드가 플래그를 검사한 이후에 발생하므로 스스로 안전한 시점에 취소를 할 수 있다.

Pthread의 사례를 보자.

pthread_t tid;
pthread_create(&tid, 0, worker, NULL);
/* Some Code... */
pthread_cancel(tid);

사실, pthread_cancel은 단순히 목표 스레드를 취소하는 메소드가 아니다.

 

다음과 같은 세가지 모드가 있고, 해당 모드에 따라 취소 여부, 방법을 결정한다. default는 지연 취소이다.

지연취소에서 취소는 스레드가 취소점에 도달했을 때만 작업이 발생하게 된다. 그렇다면 어떻게 만들 수 있을까? pthread_testcancel()을 호출하는 것이 답이다. 취소점에 도달하게 되어 취소 요청이 대기 중인게 확인 되면, 정리 처리기(cleanup handler)가 호출되어 모든 자원을 종료되기 전에 반환하도록 한다.

while(1) {
	/* Some Code*/
    pthread_testcancel();
}

 

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

Operating System Concepts 정리 - Ch.05  (0) 2021.01.21
Operating System Concepts 정리 - Ch.03  (0) 2021.01.10
Operating System Concepts 정리 - Ch.02  (2) 2021.01.02