[멀티쓰레드 프로그래밍] 멀티쓰레드 프로그래밍은 언제 해야할까?

Jin Hur·2022년 6월 29일
0

1. 오래 걸리는 일 하나와 빨리 끝나는 일 여럿을 같이 해야할 때

LoadScene() {
    Render();		// 로딩 중 애니메이션 렌더링
    LoadScene();	// 게임 장면 로드
    Render();
    LoadModel();	// 게임 모델 로드
    Render();
    LoadTexture();	// 게임 텍스쳐 로드
    Render();
    LoadAnimation();// 게임 애니메이션 로드
    Render();
    LoadSound();	// 게임 사운드 로드..
}
  • 위 코드에서 로드하는 파일의 크기가 크다면, 큰 파일을 로딩하는 동안에는 일시적으로 프레임률이 뚝뚝 끊길 것
  • 코드도 지저분함
  • 멀티쓰레드를 이용하여 두 문제를 해결할 수 있음
bool isStillLoading;	// 전역 변수(공유)

Thread1 {
    isStillLoading = true;
    while(isStillLoading) {
        FrameMove();
        Render();
    }
}

Thread2 {
    LoadScene();	// 게임 장면 로드
    LoadModel();	// 게임 모델 로드
    LoadTexture();	// 게임 텍스쳐 로드
    LoadAnimation();// 게임 애니메이션 로드
    LoadSound();	// 게임 사운드 로드..
}
  • 쓰레드1은 로딩 작업 중 애니메이션에 관련된 작업을 수행하고,
  • 쓰레드2는 게임 파일 로드에 대한 수행만 한다.
  • 이러한 방식은 동시에 두 가지 일을 하기에 로딩이 진행되는 동안 제법 부드러운 애니메이션을 선사할 수 있다.

2. 어떤 긴 처리를 진행하는 동안 다른 짧은 일을 처리해야할 때

  • 비즈니스 로직을 처리하는 중 디스크 액세스하는 경우가 있다. 디스크를 액세하는 쓰레든느 디스크의 처리 결과가 끝날 때까지 기다려야 한다. 이 시간 동안 CPU는 놀게 된다.
  • 이 노는 시간을 다른 쓰레드에게 분배하면 전반적인 실행 성능을 높일 수 있다.
  • 만약 디스크에 접근하는 과정이 많다면, 멀티쓰레딩이든 비동기 프로그래밍이든 디스크 접근 오버헤드를 해결할 방법을 적용해야 한다.

source: https://velog.io/@gojaegaebal/210309-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%8092%EC%9D%BC%EC%B0%A8-%EC%A0%95%EA%B8%80-%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Nodejs%EC%9D%98-%ED%8A%B9%EC%A7%95-%EB%B0%8F-%EC%9E%A5%EC%A0%90


3. 기기에 있는 CPU를 모두 활용해야할 때

서버의 코어가 여러 개일 때 싱글 쓰레드 프로그램으로 작성하며 작업의 부하를 분산시키지 않은 것은 바보같은 짓이다. 병렬적으로 수행할 수 있는 쓰레드 단위를 나눌 수 있을 땐 멀티쓰레드 프로그래밍을 통해 전반적인 성능을 높인다.

소수 출력 프로그램 1: 싱글 쓰레드

#include <vector>
#include <iostream>
#include <chrono>

using namespace std;
const int MaxCount = 1500000;

bool isPrimeNum (int num){
    if(num == 1)
        return false;
    if(num == 2 || num == 3)
        return true;
    
    for(int i=2; i*i <= num; i++){
        if(num % i == 0)
            return false;
    }

    return true;
}

void PrintNumbers(const vector<int>& primes) {
    for(int v : primes)
        cout << v << endl;
}

int main(){
    vector<int> primes;

    // 시간측정용
    auto t0 = chrono::system_clock::now();
    for(int i=1; i<=MaxCount; i++){
        if(isPrimeNum(i))
            primes.push_back(i);
    }
    auto t1 = chrono::system_clock::now();

    auto duration = chrono::duration_cast<chrono::milliseconds>(t1-t0).count();

    PrintNumbers(primes);

    cout << "소수 갯수: " << primes.size() << endl;
    cout << "경과 시간: " << duration << "ms" << endl;

    return 0;
}

=> 소수 갯수: 114155
=> 경과 시간: 312ms

만약 4코어 CPU에서 위 코드의 프로그램을 실행한다면, 하나의 코어만을 사용하는 셈이다.

이를 멀티쓰레드 프로그래밍을 적용하여 모든 코어를 적절히 사용하도록 한다.

불완전한 병렬 프로그래밍

#include <vector>
#include <iostream>
#include <chrono>
#include <thread>
#include <memory>

using namespace std;

#define _DEBUG

const int MaxCount = 1500000;
const int ThreadCount = 4;
// 각 쓰레드가 값을 꺼내는 곳
int num = 1;
vector<int> primes;


bool isPrimeNum(int num) {
    if (num == 1)
        return false;
    if (num == 2 || num == 3)
        return true;

    for (int i = 2; i * i <= num; i++) {
        if (num % i == 0)
            return false;
    }

    return true;
}

void PrintNumbers(const vector<int>& primes) {
    for (int v : primes)
        cout << v << endl;
}


void threadJob() {
    while (true) {
        int n;
        n = num;
        num++;

        if (n >= MaxCount)
            break;

        if (isPrimeNum(n))
            primes.push_back(n);
    }
}

int main() {

    auto t0 = chrono::system_clock::now();

    // 작동할 워커 쓰레드
    vector<thread*> threads;
    for (int i = 0; i < ThreadCount; i++) {
        thread* tptr = new thread(threadJob);
        threads.push_back(tptr);
    }

    // 모든 쓰레드가 일을 마칠 때까지 기다림
    for (int i = 0; i < ThreadCount; i++) {
        threads[i]->join();
    }
    auto t1 = chrono::system_clock::now();

    auto duration = chrono::duration_cast<chrono::milliseconds>(t1 - t0).count();

    PrintNumbers(primes);

    cout << "소수 갯수: " << primes.size() << endl;
    cout << "경과 시간: " << duration << "ms" << endl;

    // 모든 쓰레드 해제
    for (int i = 0; i < ThreadCount; i++) {
        delete threads[i];
    }

    return 0;
}

=> 하지만 위 코드는 에러가 발생한다..


쓰레드 정체

  • 문맥 교환을 하는 과정에서 적지 않은 양의 연산이 발생.
    1) 실행 중이던 쓰레드의 상태를 어딘가에 저장
    2) 과거에 실행하다가만 다른 쓰레드 중 하나를 고름
    3) 고른 쓰레드의 상태를 복원
    4) 다음 실행하던 지점으로 강제 이동

  • CPU가 2개고 쓰레드가 2개면 이론적으로는 문맥 교환을 전혀 할 필요가 없음

쓰레드를 다룰 때 주의 사항

  • 두 쓰레드가 공용 데이터에 접근해서 그 데이터 상태를 예측할 수 없게 하는 것을 경쟁 상태 또는 데이터 레이스(data race)라고 한다. 이는 가끔 부정확하게 연산 결과가 나오게 하는 원인이다.

  • 이는 경쟁 구간 또는 임계 영역을 적절히 설정하여, 락(lock) 또는 조건 변수(condition variable) 기법을 사용하여 상호 배제(mutual exclusion)을 보장해주면 된다.

소수 구하는 프로그램에서 잘못된 부분

  • 공유 데이터에 대한 임계 영역을 설정하지 않았음
  • 단순한 의도하지 않은 결과 문제 뿐 아니라 메모리 충돌(세그멘테이션 폴트) 문제가 발생..

병렬성과 시리얼 병목

뮤텍스가 보호하는 영역이 너무 넓으면 쓰레드가 여럿이라 하더라도 하나일 때와 별반 차이가 없다.
여러 CPU가 각 쓰레드의 연산을 실행하여 동시에 처리량을 올리는 것을 병렬성(parallelism)이라고 한다. 그런데 어떤 이유로 이러한 병렬성이 제대로 나오지 않는 것, 즉 병렬로 실행되게 프로그램을 만들었는데 정작 한 CPU만 연산을 수행하는 현상을 시리얼 병목(serial bottleneck)이라고 한다.

0개의 댓글