운영체제::Treads & Concurrency

안준성·2023년 6월 15일
0

OperatingSystem

목록 보기
5/22

현대의 거의 모든 운영체제는 한 프로세스가 다중 스레드를 포함하는 특성을 제공한다.

1. 개요

스레드는 cpu 이용의 기본 단위이다.
스레드는 스레드 id, 프로그램 카운터, 레지스터 집합, 스택으로 구성된다.
스레드는 다른 스레드와 코드, 데이터 섹션, 열린파일 같은 자원들을 공유한다.

  • 단일 및 다중 스레드 프로세스

1.1 Motivation

프로세스 생성 작업은 매우 많은 시간을 소비하고 많은 자원을 필요로 하는 일이다.
요청이 들어오면 프로세스를 생성하는 것이 아니라,
스레드를 생성해서 서비스한다.

대부분의 운영체제 커널도 다중 스레드다.
Linux에서 시스템을 부트하는 동안 여러 커널 스레드가 생성되어
각 장치 관리, 메모리 관리, 인터럽트 처리와 같은 특정 작업을 수행한다.

1.2 Benefits

다중 스레드 프로그래밍의 이점은 4가지 큰 부류로 나눌 수 있다.

  1. 응답성 : 시간이 오래 걸리는 연산을 별도의 비동기 스레드에서 실행하여 계속 사용자에게 응답할 수 있다.
  2. resource sharing : 프로세스는 공유 메모리와 메시지 전달 기법을 통해서만 자원을 공유할 수 있다.(이는 프로그래머가 명시적으로 처리해줘야 한다) 그러나 스레드는 자동으로 그들이 속한 프로세스의 자원과 메모리를 공유한다.
  3. 경제성 : 스레드는 자원을 공유하기 때문에,
    스레드를 생성하고 문맥 교환하는 것이 프로세스에 비해 더욱 경제적이다.
  4. scalability(규모 적응성) : 다중 스레드의 이점은 다중 처리기 구조에서 더욱 증가할 수 있다. (다중 처리기에선 각각의 스레드가 다른 처리기에서 병렬로 수행될 수 있기 때문이다.)

2. Multicore Programming

단일 cpu -> 다중 cpu -> 다중 코어로 발전했다.
병행 시스템은 모든 작업이 진행되게 하여 둘 이상의 작업을 지원한다.
이에 반해 병렬 시스템은 둘 이상의 작업을 동시에 수행할 수 있다.
다중 처리기 및 다중 코어 전에는 cpu 스케줄러가 프로세스 간을 빠르게 전환해 병렬성의 환상을 제공하였다.
이러한 프로세스는 병행하게 실행되었지만 병렬로 실행되지는 않았다.

2.1 프로그래밍 도전과제

일반적으로 다중 코어 시스템을 프로그래밍하기 위해선 5개의 극복해야 할 도전과제가 있다.

  • task 인식 : 어플리케이션을 독립된 병행 태스크로 나눌 수 있는 영역을 찾는 작업이 필요
  • balance : 나눈 태스크들이 균등한 기여도를 가져야 한다.
  • 데이터 분리 : 태스크를 나눈 것처럼, 태스크가 접근하고 조작하는 데이터 또한 개별 코어에서 사용할 수 있도록 나누어져야 한다.
  • 데이터 종속성 : 태스크가 접근하는 데이터는 둘 이상의 태스크 사이에 종속성이 없는지 검토해야 한다.
  • testing & debugging : 프로그램이 다중 코어에서 병렬로 실행될 때, 다양한 실행 경로가 존재할 수 있다.

2.2 병렬 실행의 유형

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

그러나 이러한 병렬처리는 상호 배타적이지 않으면 두 가지를 혼합하여 사용할 수 있다.

3. Multithreading Models

user threads를 위한 지원은 사용자 수준에서,
kernel threads를 위해서는 커널 수준에서 제공된다.
유저 스레드는 커널 위에서 지원되고 커널 스레드는 운영체제에 의해 직접 지원 및 관리된다.

궁극적으로 유저 스레드와 커널 스레드는 연관 관계가 존재해야 한다.

3.1 다대일 모델


스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 행해진다.
하지만 한번에 하나의 스레드만이 커널에 접근할 수 있기 때문에 다중 코어 시스템에서 병렬 실행될 수 없다.

3.2 일대일 모델


각 유저 스레드를 각각의 커널 스레드로 매핑한다.
이 모델의 유일한 단점은
유저 스레드를 만드려면 커널 스레드를 만들어야 해서 시스템 성능에 부담을 줄 수 있다는 것이다.

3.3 다대다 모델

여러 개의 유저 스레드를 그보다 작거나 같은 수의 커널 스레드로 멀티플렉스 한다.
다대일과 일대일 모델의 단점을 어느정도 해결했다.

가장 융통성 있는 것으로 보이지만 구현이 어렵다.
또한 코어 수가 증가하면서 커널 스레드 수를 제한하는 것의 중요성이 줄어들었다.
결과적으로 이제는 대부분 일대일 모델을 사용한다.

4. Threads Library

스레드 라이브러리는 스레드를 생성하고 관리하기 위한 API를 제공한다.
구현하는 데에 주된 두 가지 방법이 있다.

첫 번째는 커널의 지원 없이 완전히 사용자 공간에서만 라이브러리를 제공하는 것이다.
라이브러리의 함수를 호출하는 것은 사용자 공간의 지역 함수를 호출하게 된다는 것을 의미한다.

두 번째는 운영체제에 의해 지원되는 커널 수준 라이브러리를 구현하는 것이다.
코드와 자료구조가 커널 공간에 존재하여, API를 호출하는 것은 시스템 콜을 부르는 결과를 낳는다.

현재 POSIX Pthreads, Windows, Java 세 종류의 라이브러리가 주로 사용된다.

다수의 스레드를 생성하는 비동기 스레딩동기 스레딩의 두 가지 일반적인 전략을 소개한다.
비동기 스레딩은 부모가 자식 스레드를 생성한 후
부모는 자신의 실행을 재개하여 서로 독립적으로 실행된다.
독립적이기 때문에 스레드 사이의 데이터 공유는 거의 없다.
비동기 스레딩은 반응형 ui를 설계하는 데에도 흔히 사용된다.

동기 스레딩은 부모 스레드가 하나 이상의 자식 스레드를 생성하고
모든 자식 스레드가 종료할 때까지 기다렸다가 자신의 실행을 재개하는 방식이다.
통상 동기 스레딩은 스레드 사이의 상당한 양의 데이터 공유를 수반한다.
예를 들어 부모 스레드는 자식들이 계산한 결과를 통합할 수 있다.
이후의 모든 예에서는 동기화 스레딩을 사용한다.

4.1 Pthreads

POSIX(IEEE 1003.1c)가 제정한 표준 API이다.
스레드의 동작에 관한 명세일 뿐, 구현한 것은 아니다.
이 명세를 가지고 운영체제 설계자들이 나름대로 구현할 수 있다.

  1 #include <pthread.h>
  2 #include <stdio.h>
  3
  4 #include <stdlib.h>
  5
  6 int sum; // 스레드에 의해 공유되는 데이터
  7 void *runner(void *param); // threads call this func
  8
  9 int main(int argc, char *argv[]) {
 10     pthread_t tid; // thread identifier
 11     pthread_attr_t attr; // set of thread attributes
 12
 13     // set the default attribute of the thread
 14     pthread_attr_init(&attr);
 15
 16     // create the thread
 17     pthread_create(&tid, &attr, runner, argv[1]);
 18
 19     // wait for the thread to exit
 20     pthread_join(tid, NULL);
 21
 22     printf("sum = %d\n", sum);
 23 }
 24
 25 // thread will execute in this func
 26 void *runner(void *param)
 27 {
 28     int i, upper = atoi(param);
 29     sum = 0;
 30
 31     for (i = 1; i <= upper; i++)
 32         sum += i;
 33
 34     pthread_exit(0);
 35 }

각 스레드는 스택의 크기와 스케줄링 정보를 포함한 속성의 집합을 갖는다.

4.2 Windows Threads

많은 점에서 Pthreads와 유사하다.

1 #include <windows.h>
  2 #include <stdio.h>
  3 DWORD Sum; // 공유되는 데이터
  4
  5 // 스레드가 실행할 함수
  6 DWORD WINAPI Summation(LPVOID Param) {
  7     DWORD Upper = *(DWORD*)Param;
  8     for (DWORD i = 1; i <= Upper; i++)
  9         sum += i;
 10     return 0;
 11 }
 12
 13 int main(int argc, char *argv[]) {
 14     DWORD ThreadId;
 15     HANDLE ThreadHandle;
 16     int Param;
 17
 18     Param = atoi(argv[1]);
 19
 20     // create the thread
 21     ThreadHandle = CreateThread(
 22             NULL, // default security attributes
 23             0, // default stack size
 24             Summation, // thread function
 25             &Param, // parameter to thread function
 26             0, // default creation flags
 27             &ThreadId); // returns the thread identifier
 28
 29     // wait for the thread to finish
 30     WaitForSingleObject(ThreadHandle, INFINITE);
 31
 32     // close the thread handle
 33     CloseHandle(ThreadHandle);
 34
 35     printf("sum = %d\n", Sum);
 36 }

개별 스레드가 공유하는 데이터는 전역변수로 선언된다.
여러 스레드의 종료를 기다려야 한다면 WaitForMultipleObjects()함수가 사용된다.

4.3 Java Thread

Java 프로그램은 적어도 하나의 단일 제어 스레드를 포함하고 있다.
Java 스레드는 JVM을 제공하는 시스템이라면 사용할 수 있다.

Java에서 스레드를 명시적으로 생성하는 데에는 두 가지 기법이 있다.
한 가지는 Thread 클래스를 상속하고 run() 메소드를 오버라이드하는 것이다.
보다 일반적으로 사용되는 기법은 Runnable 인터페이스를 구현하는 클래스를 정의하는 것이다.
run() 메소드 코드는 별도의 스레드에서 실행된다.

Java에서 스레드를 생성하려면

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

새 스레드 객체에 start()를 호출하면 두 가지 작업이 수행된다.

  1. 메모리가 할당되고, JVM 내에 새로운 스레드가 초기화된다.
  2. run()을 호출하면 스레드가 JVM에 의해 수행될 자격을 갖게 한다. (run()을 직접 호출하는 게 아닌 start()에 의해 자동으로 호출된다.)
    join()으로 자식스레드를 기다린다.
    4.3.1 Java Excutor Framework
    1.5와 API부터 스레드에 대한 제어 기능을 크게 향상시키는 새로운 기능을 도입하였다. Thread 객체를 명시적으로 생성하는 대신 Executor 인터페이스를 중심으로 스레드 생성을 구성한다.
    public interface Executor {
    	void execute(Runnable command);
    }

Executor는 다음과 같이 사용된다. 
```java
Executor service = new Executor;
service.execute(new Task());

이 방법의 장점은 스레드의 생성과 실행을 분리하고
병행하게 실행되는 작업 간의 통신 기법을 제공한다는 점이다.

순수한 객체 지향 언어인 Java에는 전역 데이터에 대한 개념이 없다.
이에 Callable 인터페이스를 추가로 정의하여 스레드가 결과를 반환할 수 있게 하여 통신한다.
반환된 결과를 Future 객체라고 하는데 Future 인터페이스에 정의된 get()을 통해 결과를 검색할 수 있다.

이 접근 방식은 스레드 생성과 스레드가 만든 결과를 분리한다.
결과를 확인하기 위해 스레드 종료를 기다리는 대신
결과가 가용해지는 것만 기다리면 된다.

5. Implicit Threading(암묵적 스레딩)

멀티 스레딩 프로그램 설계의 여러움을 극복하는 한 가지 방법은
스레딩의 생성과 관리를 컴파일러와 런타임 라이브러리에게 넘겨주는 것이다.
암묵적 스레딩이라고 불리는 이 전략은 점점 널리 사용되고 있다.
이 방법의 장점은 개발자는 병렬 작업만 식별하면 된다는 것이다.

5.1 Thread pool

프로세스를 시작할 때 일정한 수의 스레드를 풀에 만들어둔다.
장점은 다음과 같다.

  • 종종 더 빠르다
  • 존재할 스레드 개수에 제한을 두어 특정 시스템에 도움이 된다.
  • 태스크의 생성과 실행을 분리해 태스크를 스케줄하기 용이하다.

5.2 Fork Join

4절에서 다루는 스레드 전략은 fork-join 모델로 알려져 있다.
부모 스레드가 자식 스레드를 생성(fork)한 다음 자식의 종료를 기다린 후
join하고 자식의 결과를 확인하고 결합할 수 있다.
이는 암시적 스레딩에도 사용될 수 있는데,
fork 단계에서 스레드가 직접 구축되지 않고 대신
병렬 작업이 식별된다.
fork-join 모델은 라이브러리가 생성할 스레드 수를 결정하는
동기식 스레드 풀이다.

5.3 OpenMP

OpenMP는 C, C++, FORTRAN등으로 작성된 API와 컴파일러 디렉티브의 집합이다.
OpenMP는 공유 메모리 환경에서 병렬 프로그래밍을 할 수 있도록 도움을 준다.
OpenMP는 병렬로 실행될 수 있는 블록을 찾아 병렬 영역이라고 한다.
응용 개발자는 병렬 영역에 컴파일러 디렉티브를 넣고,
이 디렉티브는 OpenMP에 해당 영역을 병렬로 실행하라고 지시한다.

컴파일러 디렉티브를 만나게 되면
코어 개수만큼 스레드를 생성해 병렬 영역을 병렬 실행한다.
(병렬 영역을 빠져나가면 스레드는 종료된다)

5.4 Grand Central Dispatch

GCD는 개발자가 병렬로 실행될 코드 섹션(태스크)을 식별할 수 있도록 하는 런타임 라이브러리, API 및 언어 확장의 조합이다.

GCD는 실행시간 수행을 위해 태스크를 디스패치 큐에 넣어서 스케줄한다.
GCD는 직렬병행의 두 가지 유형의 디스패치 큐를 유지한다.

직렬 큐에 넣어진 태스크는 FIFO 순으로 제거된다.
각 프로세스에는 고유한 직렬 큐(메인 큐라고 함)가 있으며,
로컬인 추가 직렬 큐를 만들 수 있다.
직렬 큐를 개인 디스패치 큐라고도 하는 이유이다.

병행 큐에 넣어진 태스크도 FIFO 순으로 제거되지만
한 번에 여러 태스크가 제거되어 병렬로 실행될 수 있게 한다.
다수의 시스넴 전체의 병행 큐(전역 디스패치 큐라고도 함)가 존재하며,
4가지 주요 서비스 품질 클래스로 나뉜다.

  • 사용자 대화형 클래스
  • 사용자 시작 클래스
  • 유틸리티 클래스
  • 백그라운드 클래스

5.5 Intel Thread Building Blocks

Intel TBB는 C++에서 병렬 응용 프로그램 설계를 지원하는 템플릿 라이브러리이다.
TBB 라이브러리는 루프 반복을 개별적인 chunk로 나누고,
해당 청크에 대해 연산을 수행하는 많은 태스크를 만든다.
이 방법의 장점은 개발자는 parallel_for 루프를 지정하여 병렬로 실행할 수 있는 작업을 식별만 하면 된다는 점이다.

6. 스레드와 관련된 문제들

다중 스레드 프로그램을 설계할 때 고려해야 할 몇 가지 문제들을 알아보자.

6.1 Fork() 및 Exec() 시스템 콜

다중 스레드 프로그램에서는 fork()와 exec()의 의미가 달라질 수 있다.

6.2 신호 처리

신호는 프로세스에 어떤 이벤트가 일어났음을 알려주기 위해 사용된다.

모든 신호마다 커널이 실행시키는 디폴트 신호 처리기가 있다.
이 디폴트 처리기는 신호를 처리하기 위하여 호출되는 사용자 정의 처리기에 의해 대체될 수 있다.

단일 스레드에서의 신호처리는 간단하지만 다중 스레드는 다르다.
어느 스레드에 신호를 전달해야 하는가?

전달 방법은 신호의 유형에 따라 다르다.
Windows는 신호를 명시적으로 지원하진 않지만 비동기식 프로시저 호출을 사용해서 이를 대리 실행한다.

6.3 스레드 취소

스레드 취소란 스레드가 끝나기 전에 그것을 강제 종료시키는 작업을 일컫는다.
이 처럼 취소되어야 할 스레드를 target thread라고 부른다.
스레드의 취소는 두 가지 방식으로 발생할 수 있다.

  • 비동기식 취소 : 한 스레드가 즉시 타겟 스레드를 강제 종료시킨다.
  • 지연 취소 : 타겟 스레드가 주기적으로 자신이 종료되어야 하는지 점검한다.

하지만 스레드들에 할당된 자원들이 스레드 취소를 어렵게 만든다.
또한 다른 스레드와 공유하는 자료구조를 갱신하는 중이라면 더더욱 큰 문제다.
종종 취소된 스레드로부터 자원을 회수하지 못하는 경우도 있다.
따라서 비동기식 취소는 위험하다.

이와 반대로 지연 취소는 취소되어도 안전한 시점에 취소 여부를 검사해 안전하다.
기본 취소 유형은 지연 취소이다.

6.4 Thread-Local Storage

상황에 따라선 각 스레드가 자기만 액세스할 수 있는 데이터를 가져야 할 필요도 있다.
그러한 데이터를 TLS라고 부른다.
TLS를 지역 변수와 혼동하기 쉽지만 TLS는 전체 함수 호출에 걸쳐 보인다.
또한 TLS는 정적 데이터와 유사하지만 TLS 데이터는 스레드마다 고유하다는 차이점이 있다.

6.5 Scheduler Activations

다중 스레드 프로그램에서 마지막으로 고려할 문제는
스레드 라이브러리와 커널의 통신 문제이다.

다대다를 구현하는 많은 시스템은 사용자와 커널 스레드 사이에
중간 자료구조를 둔다.
이 자료구조는 통산 경량 프로세스 또는 LWP라고 불린다.

사용자 스레드 라이브러리와 커널 스레드 간의 통신 방법의 하나는
스케줄러 액티베이션이라고 알려진 방법이다.
커널은 응용에 LWP의 집합을 제공하고
응용은 사용자 스레드를 가용한 LWP로 스케줄한다.
게다가 커널은 응용에게 특정 이벤트에 대해 알려줘야 한다.
이 프로시저를 upcall이라고 한다.
Upcall은 스레드 라이브러리의 upcall 처리기에 의해 처리되고,
upcall처리기는 가상 처리기상에서 실행되어야 한다.


스터디 요약

하이퍼스레딩에서는 하나의 코어에 논리적인 스레드를 만들어
단일코어지만 병렬적인 일을 할 수 있다.

DMA 기술을 사용하면 cpu를 거치지 않고 바로 메모리에 접근할 수 있다.

멀티스레드에서의 race condition

profile
안녕하세요 준갑습니성

0개의 댓글