📀 운영체제

07 ~ 08. 쓰레드의 이해: Chapter 4. Thread & Concurrency (Part 1 ~ Part 2)

락꿈사 2022. 5. 30. 19:21

4.1 개요

  • 스레드
    • Light weight process
    • CPU 점유의 기본 단위
    • 스레드 ID, 프로세스 카운터(PC), 레지스터 집합, 스택으로 구성됨
    • 스레드끼리 열린 파일과 같은 운영체제 자원을 공유함

 

 

동기

  • 클라이언트 - 서버 프로세스의 환경에서
    • 웹 서버가 단일 스레드 프로세스로 작동하는 경우
      • 한 번에 한 클라이언트만 서비스 할 수 있음
      • 클라이언트는 request가 서비스 되기까지 오랜 시간 기다려야 함
    • 2가지 해결책
      1. 요청을 수행할 별도의 프로세스를 생성하는 방법
        • 서버에서 서비스 요청이 들어오면 그 요청을 수행할 별도의 프로세스를 생성하는 것
          • 프로세스 생성 작업은 많은 시간을 소비하고 많은 자원을 필요로 함
          • 비효율적
      2. 프로세스 안에 여러 스레드를 만들어 나가는 방법
        • 서버는 클라이언트의 요청을 listen하는 별도의 스레드를 생성
        • 요청이 들어오면 요청을 서비스할 새로운 스레드 생성
        • 추가적인 요청을 listen하기 위한 작업을 재개
          • 효율적

 

 

장점

  • 다중 스레드의 장점 4가지
    1. 응답성responsiveness
      • 응용 프로그램의 일부분이 block 되거나, 응용 프로그램이 긴 작업을 수행하더라도 프로그램의 수행이 계속됨
      • 사용자에 대한 응답성 증가
    2. 자원 공유resource sharing
      • 프로세스는 공유 메모리와 메시지 전달 기법을 통하여만 자원을 공유할 수 있음
      • 스레드는 자동으로 그들이 속한 프로세스의 자원들과 메모리를 공유함
    3. 경제성economy
      • 프로세스 생성을 위해 메모리와 자원을 할당하는 것은 비용이 많이 듬
      • 스레드는 자신이 속한 프로세스의 자원을 공유함
      • 따라서 스레드를 생성하고 context switch를 하는 것에 경제적임
    4. 규모 적응성scalability
      • 다중 처리기 구조에서 각각의 스레드가 다른 처리기에서 병렬로 수행될 수 있음

 

 

4.2 다중 코어 프로그래밍

  • 다중 코어
    • 단일 컴퓨팅 칩에 여러 컴퓨팅 코어를 배치하는 시스템
  • 다중 스레드 프로그래밍은 다중 코어를 보다 효율적으로 사용하고 병행성을 향상시키는 기법을 제공함
    • 병행 시스템과 병렬 시스템
      • 병행 시스템: 모든 작업이 진행되게 하여 둘 이상의 작업을 지원하는 것
      • 병렬 시스템: 둘 이상의 작업을 동시에 수행하는 것 
    • 단일 컴퓨팅 코어가 있는 시스템의 경우
      • 처리 코어가 단 한번에 단 하나의 스레드만 실행할 수 있음
      • 병행성 o / 병렬성 x
    • 여러 코어가 있는 시스템의 경우
      • 시스템이 각 코어에 별도의 스레드를 할당할 수 있음
      • 일부 스레드가 병렬로 실행될 수 있음
      • 병행성 o / 병렬성 o

 

 

프로그래밍 도전 과제

  • 다중 코어 시스템 프로그래밍을 위해 극복해야 할 5가지 과제
    1. 테스크 인식identifying tasks
      • 응용을 분석하여 독립된 병행 가능 태스크로 나눌 수 있는 영역을 찾는 작업이 필요함
    2. 균형balance
      • 찾아진 부분들이 전체 작업에 균등한 기여도를 가지도록 태스크를 나누어야 함
    3. 데이터 분리data spliting
      • 태스크가 접근하고 조작하는 데이터 또한 개별 코어에서 사용할 수 있도록 나누어야 함
    4. 데이터 종속성
      • 한 태스크가 다른 태스크로부터 오는 데이터에 종속적인 경우 프로그래머가 데이터 종속성을 수용할 수 있도록 태스크의 수행을 잘 동기화 해야 함
    5. 시험 및 디버깅testing and debugging
      • 병행 프로그래밍을 시험하고 디버깅 하는 것은 단일 스레드 응용을 시험하고 디버깅 하는 것보다 훨씬 어려움

 

 

암달의 법칙Amdahl's Law

  • S: 순차적으로 처리해야만 하는 process의 비율
  • N: 코어의 갯수
  • 성능 speedup <= 1 / (S + (1-S)/N)
  • 예시
    • 75%의 병렬 실행 구성 요소와 25%의 순차 실행 구성 요소를 가진 응용이 있다고 가정
      • 코어가 2개인 경우: 1.6배의 속도 상향
      • 코어가 4개인 경우: 2.28배 속도 향상
  • N이 무한대로 가까워지면 속도는 1/S에 수렴함 -> 응용의 순차 실행 부분은 코어를 추가하여 얻을 수 있는 성능 향상에 불균형적인 여향을 미침

 

 

병렬 실행의 유형

  • 데이터 병렬 실행
    • 동일한 데이터의 부분집합을 다수의 계산 코어에 분배한 뒤 각 코어에서 동일한 연산을 실행
  • 태스크 병렬 실행
    • 데이터가 아니라 태스크(스레드)를 다수의 코어에 분배

 

 

4.3 다중 스레드 모델

  • 사용자 스레드user thread
    • 커널 위에서 지원되며 커널의 지원 없이 관리됨
  • 커널 스레드kernel thread
    • 운영체제에 의해 직접 지원되고 관리됨
    • 현대의 거의 모든 운영체제들은 커널 스레드를 지원함
  • 사용자 스레드와 커널 스레드의 3가지 연관 관계 
    1. 다대일 모델
    2. 일대일 모델
    3. 다데다 모델

 

 

다대일 모델 

  • 많은 사용자 수준 스레드를 하나의 커널 스레드가 담당
  • 스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 행해짐
  • 한 스레드가 blocked system call을 할 경우 전체 프로세스가 봉쇄됨
  • ex) green thread

 

 

일대일 모델

  • 각 사용자 스레드를 각각 하나의 커널 스레드가 담당

 

 

다대다 모델

  • 여러 개의 사용자 스레드를 그보다 작은 수, 혹은 같은 수의 커널 스레드로 멀티 스레드

 

 

4.4 스레드 라이브러리

  • 스레드 라이브러리
    • 프로그래머에게 스레드를 생성하고 관리하기 위한 API를 제공함
  • 3가지 종류
    1. POSIX Pthread
    2. Windows 
    3. JAVA 
      • JAVA 스레드 API는 JAVA 프로그램에서 직접 스레드 생성과 관리를 가능하게 함
      • 대부분의 JVM 구현은 호스트 운영체제에서 실행되기 때문에 JAVA 스레드 API는 통상 호스트 시스템에서 사용 가능한 스레드 라이브러리를 이용하여 구현됨
        • Windows 시스템에서 JAVA 스레드는 Windows API를 사용하여 구현됨
        • UNIX, LINUX, macOS 시스템에서 JAVA 스레드는 Pthread를 사용하여 구현됨

 

 

Pthreads

  • Pthreads: POSIX (IEEE 1003.1c)가 스레드 생성과 동기화를 위해 제정된 표준 API
  • 스레드 동작에 관한 명세일 뿐이지 그것 자체를 구현한 것은 아님
  • 이러한 명세를 가지고 Linux, macOS를 포함한 많은 시스템에서 Pthreads 명세를 구현함
  • EX1) Pthreads를 사용한 다중 스레드 C 프로그램
    • 두개의 스레드를 가짐
      • main() 함수의 부모 스레드
      • runner() 함수에서 합을 계산하는 자식 스레드
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

int sum;
void *runner(void *param);

// 여기서 argv를 받아서
int main(int argc, char const *argv[])
{
    pthread_t tid;
    pthread_attr_t attr;

    pthread_attr_init(&attr);
    // 받아온 argv의 첫번째 인자를 runner에 thread로 넘겨줌 
    // runner 함수가 실행됨
    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;
    // 1~n 까지의 합을 구해줌
    for (i=1; i<=upper; i++){
        sum += i;
    }
    pthread_exit(0);
}

  • EX2)  Pthreads를 사용한 다중 스레드 C 프로그램
    • 두개의 프로세스를 가짐
      • main() 프로세스의 부모 프로세스
        • 부모 프로세스의 value에는 아무런 값도 넣지 않았으므로 value = 0이 됨
      • pid = fork() 된 자식 프로세스
        • 두개의 스레드를 가짐
          • 자식 프로세스의 메인 스레드
          • 자식 프로세스에서 pthread_create()된 스레드
        • 스레드는 자원(전역 변수)을 공유하므로 runner() 함수에서 value = 5를 넣어주는 프로세스를 통해 자식 프로세스들의 value는 5가 됨
#include <pthread.h>
#include <stdio.h>
#include <wait.h>
#include <unistd.h>

int value = 0;
void * runner(void *param);

int main(int argc, char const *argv[])
{
    pid_t pid;
    pthread_t tid;
    pthread_attr_t attr;

    pid = fork();

    if (pid == 0){
        pthread_attr_init(&attr);
        pthread_create(&tid, &attr, runner, NULL);
        pthread_join(tid, NULL);
        printf("CHILD: value = %d\n", value);
    }
    else if (pid > 0){
        wait(NULL);
        printf("PARENT: value = %d\n", value);
    }
    return 0;
}

void *runner(void *param){
    value = 5;
    pthread_exit(0);
}

 

 

Java 스레드

  • 스레드는 JAVA 프로그램 실행의 근본적인 모델이며 Java언어와 API는 스레드의 생성과 관리를 지원하는 풍부한 특성을 제공함
  • main() 함수로만 이루어진 단순한 Java 프로그램조차 JVM 내의 하나의 단일 스레드로 수행됨
  • Java 스레드는 JVM을 제공하는 어떠한 시스템에서도 사용할 수 있음
  • Java 프로그램에서 스레드를 명시적으로 생성하는 2가지 기법
    1. Thread 클래스에서 파생된 새 클래스를 만들고 run() 메소드를 재정의 하는 방법
    2. Runnable 인터페이스를 구현하는 클래스를 정의하고 단일 추상 메소드 Public void run()를 정의하는 방법
    3. Lambda expression으로 새로운 클래스르 선언하지 않고 새로운 메소드를 사용하는 방법
  • 1번 방법(Thread 클래스에서 파생된 새 클래스를 만들고 run() 메소드를 재정의) 코드 
package com.os_study.ch04;

class MyThread1 extends Thread{
    @Override
    public void run() {
        try{
            while(true){
                System.out.println("Hello, MyThread!");
                Thread.sleep(500);
            }
        }
        catch (InterruptedException ie){
            System.out.println("I'm interrupted!");
        }
    }
}

public class ThreadExample1{
    public static void main(String[] args) {
        // Thread class 생성
        // fork()와 똑같움
        // main 메소드에서 실행되고 있던 main thread가 새로운 thread를 생성
        MyThread1 myThread1 = new MyThread1();
        // start()가 run()을 호출해줌
        // 아직 thread 간의 context switch가 일어나지 않음
        myThread1.start();
        // 따라서 아래 문장이 실행됨
        System.out.println("Hello, MyChild");
        // context switch가 일어나서 run()이 실행됨.
    }
}

  • 2번 방법(Runnable 인터페이스를 구현하는 클래스를 정의하고 단일 추상 메소드 Public void run()를 정의) 코드
package com.os_study.ch04;

class MyThread2 implements Runnable{
    // Runnable 이라는 인터페이스에는 run() 메소드 하나밖에 없음
    public void run() {
        try{
            while(true){
                System.out.println("Hello, Runnable!");
                Thread.sleep(500);
            }
        }
        catch (InterruptedException ie){
            System.out.println("I'm interrupted!");
        }
    }
}

public class ThreadExample2{
    public static void main(String[] args) {
        // MyThread2 인스턴스를 new MyThread2()를 사용하여 생성 후
        // Thread 클래스 생성자의 파라미터로 넘겨줌
        // main 메소드에서 실행되고 있던 main thread가 새로운 thread를 생성
        Thread thread = new Thread(new MyThread2());
        // start()가 Thread 클래스의 run()을 호출해 줌 -> MyThread2의 run()을 호출해 줌
        // 아직 thread 간의 context switch가 일어나지 않음
        thread.start();
        // 따라서 아래 문장이 실행됨
        System.out.println("Hello, MyRunnable Child!");
        // context switch가 일어나서 run()이 실행됨.
    }
}

  • 3번 방법(Lambda expression으로 새로운 클래스르 선언하지 않고 새로운 메소드를 사용) 코드
package com.os_study.ch04;

public class ThreadExample3 {
    public static void main(String[] args) {
        Runnable task = () ->{
            try {
                while (true){
                    System.out.println("Hello, Lambda Runnable!");
                    Thread.sleep(500);
                }
            }
            catch (InterruptedException ie){
                System.out.println("I'm Interrupted!");
            }
        };

        Thread thread = new Thread(task);
        thread.start();
        System.out.println("Hello, My Lambda Child!");
    }
}

  • EX_1 ) join()을 사용하여 자식 스레드가 끝날 때 까지 wait하는 코드
package com.os_study.ch04;

public class ThreadExample4 {
    public static void main(String[] args) {
        Runnable task = () ->{
            for(int i=0; i<5; i++){
                System.out.println("Hello, Lambda Runnable!");
            }
        };

        Thread thread = new Thread(task);
        // child thread가 수행되기 시작
        thread.start();
        try {
            // wait()과 같은 역할
            // child thread가 수행 종료될 때 까지 대기
            thread.join();
        }
        catch (InterruptedException ie){
            System.out.println("Parent thread is interrupted!");
        }
        // child thread가 수행 종료된 후 수행됨
        System.out.println("Hello, My Joined Child!");
    }
}

  • EX_2 ) interrupt()을 사용하여 자식 스레드를 종료시키는 코드
package com.os_study.ch04;

public class ThreadExample5 {
    public static void main(String[] args) throws InterruptedException{
        Runnable task = () ->{
            try{
                while(true){
                    // 0.1.초마다 println 수행
                    System.out.println("Hello, Lambda Runnable!");
                    Thread.sleep(100);
                }
            }
            // Interrupted가 걸리면 여기로 빠짐
            catch (InterruptedException ie){
                System.out.println("I'm Interrupted!");
            }
        };
        Thread thread = new Thread(task);
        // child thread가 수행되기 시작
        thread.start();
        // main thread가 0.5초 기다림
        Thread.sleep(500);
        // child thread에 interrupt를 걸어줌
        thread.interrupt();
        // child thread가 수행 종료된 후 수행됨
        System.out.println("Hello, My Interrupted Child!");
    }
}

 

 

4.5 암묵적 스레딩

  • 암묵적 스레딩: 스레딩의 생성과 관리 책임을 개발자로부터 컴파일러와 런타임 라이브러리에게 넘겨주는 것

 

스레드 풀

  • 프로세스를 시작할 때 아예 일정한 수의 스레드를 미리 풀로 만들어 둠
  • 이 스레드들은 평소에 하는 일 없이 일감을 기다림
  • 서버가 요청을 받으면 스레드 풀에 요청을 제출하고 추가 요청 대기를 계속함
    • 풀에 사용가능한 스레드가 있으면 깨어나고 요청이 즉시 서비스 됨
    • 풀에 사용가능한 스레드가 없으면 사용 가능한 스레드가 생길 때 까지 작업이 대기됨

 

Fork Join

  • fork - join 모델
    • fork: 메인 부모 스레드가 하나 이상의 자식 스레드 생성
    • join: 자식의 종료를 기다린 후 join 하고 그 시점부터 자식의 결과를 확인하고 결합할 수 있음
  • 명시적 스레드 생성의 특징
  • 암시적 스레딩에서도 사용될 수 있음

 

 

Open MP

  • C, C++, FORTRAN으로 작성된 API와 컴파일러 디렉티브의 잡합
  • 병렬로 실행될 수 있는 블록을 찾아 병렬 영역Parallel region이라고 부름
  • 코드 안에 컴파일러 디렉티브를 삽입함
  • 이 디렉티브는 OpenMP 런타임 라이브러리에 해당 영역을 병렬로 실행하라고 지시함
  • EX1) OpenMP 사용 코드
#include <omp.h> 
#include <stdio.h>

int main(int argc, char const *argv[])
{
    // 컴파일러 디렉티브
    #pragma omp parallel
    {
        printf("I am a parallel region!\n");
    }

    return 0;
}

  • EX2) OpenMP를 사용하여  4개의 스레드를 만드는 코드
#include <omp.h>
#include <stdio.h>

int main(int argc, char const *argv[])
{
    omp_set_num_threads(4);

    #pragma omp parallel
    {
        printf("OpenMP thread: %d\n", omp_get_thread_num());
    }

    return 0;
}

  • EX3) OpenMP를 사용하여 Parallel한 연산 수행 코드 
#include <stdio.h>
#include <omp.h>

#define SIZE 100000000

int a[SIZE], b[SIZE], c[SIZE];

int main(int argc, char const *argv[])
{
    int i;

    for (i=0; i<SIZE; i++){
        a[i] = b[i] = i;
    }

    #pragma omp parallel for
    for (i = 0; i<SIZE; i++){
        c[i] = a[i] + b[i];
    }
    
    return 0;
}
  • EX4) OpenMP를 사용한 일반적인 연산 수행 코드 
#include <stdio.h>
#include <omp.h>

#define SIZE 100000000

int a[SIZE], b[SIZE], c[SIZE];

int main(int argc, char const *argv[])
{
    int i;

    for (i=0; i<SIZE; i++){
        a[i] = b[i] = i;
    }

    for (i = 0; i<SIZE; i++){
        c[i] = a[i] + b[i];
    }
    
    return 0;
}
  • EX3과 EX4의 연산 수행 시간 비교
    • Parallel한 연산을 수행한 코드가 real time에서 수행 시간이 더 짧았음


Grand Central Dispatch

  • macOS와 iOS 운영체제를 위해 Apple에서 개발한 기술