소연의_개발일지
article thumbnail

쓰레드 공부

공부 출처: 모두의 코드 C++ 쓰레드

 

https://modoocode.com/269

 

씹어먹는 C ++ - <15 - 1. 동시에 실행을 시킨다고? - C++ 쓰레드(thread)>

모두의 코드 씹어먹는 C ++ - <15 - 1. 동시에 실행을 시킨다고? - C++ 쓰레드(thread)> 작성일 : 2019-04-01 이 글은 115087 번 읽혔습니다. 에 대해 다룹니다. 안녕하세요 여러분! 이번 강좌에서는 여태까지

modoocode.com

이 페이지를 스스로 공부하기 위해 요약한 것입니다. 자세한 내용은 위 링크를 참고하세요.


멀티 쓰레드 프로그램

작업 관리자 프로그램에는 여러 프로세스들이 실행되고 있는 모습을 확인할 수 있다. 

프로세스란, 운영체계에서 실행되는 프로그램의 최소 단위라고 보면 된다. 

즉, 우리가 1개의 프로그램을 가리킬 때 보통 1개의 프로세스를 의미하는 경우가 많다.

 

이 프로세스들은 컴퓨터의 두뇌라고 하는 CPU의 코어(연산하는 부분)에서 실행되고 있다. 2005년 이전에는 서버용이 아닌 일반 소비자용 CPU의 경우 1개의 코어를 가지는 것이 대부분이었다. (즉 CPU가 한 번에 한 개의 연산을 수행)

 

그런데 CPU가 한번에 한가지의 연산밖에 못한다면, 여러 일을 어떻게 동시에 수행했을까? 그 비밀은 컨텍스트 스위칭에 있다. 프로그램을 연속적으로 작동하는 것이 아닌, 프로그램 하나가 잠시 실행되었다가 다음 프로그램으로 스위칭되는 방법이다.이러한 현상은 너무 빨리 일어나서 여러 응용 프로그램을 열어 놓고 "동시에" 실행되는 것처럼 착각하게 된다.

 

CPU는 운영체계가 처리하라고 시킨 명령어를 실행할 뿐, 어떤 프로그램을 실행시키고 스위치 할 지는 운영체계의 스케쥴러가 알아서 결정하게 된다.

 

쓰레드 

CPU 코어에서 돌아가는 프로그램 단위쓰레드(thread)라고 부른다. 즉, CPU의 코어 하나에서는 한 번에 한 개의 쓰레드의 명령을 실행시키게 된다. 

한 개의 프로세스는 최소 한개 이상의 쓰레드로 이루어져 있으며, 여러 개의 쓰레드로 구성될 수 있게 된다. 이렇게 여러개의 쓰레드로 구성된 프로그램을 멀티 쓰레드(multithread)프로그램이라 한다.

쓰레드와 프로세스의 가장 큰 차이점은 프로세스들은 서로 메모리를 공유하지 않는 것이다. 다시 말해, 프로세스1과 프로세스2가 있을 때, 프로세스1은 프로세스 2의 메모리에 접근할 수 없고, 마찬가지로 프로세스2도 프로세스1 메모리에 접근할 수 없다. 

프로세스는 서로의 메모리를 접근할 수 없지만, 같은 프로세스 내에 쓰레드끼리는 메모리를 공유한다.

하지만 쓰레드의 경우는 다르다. 한 프로세스 내에 쓰레드1, 2가 있다면, 서로 같은 메모리를 공유하게 된다.

→ 즉, 쓰레드1과 쓰레드2가 같은 변수에 접근할 수 있다.

 

CPU의 코어는 한 개가 아니다.

최근 CPU의 발전 방향은 CPU의 장착된 코어 갯수를 늘려가는 방식으로 발전해왔다.(각 코어 하나의 동작 속도를 높이기보다는)

 

컴퓨터의 코어에서 프로그램이 실행되는 모습은 아래와 같다.

이전 싱클 코어 CPU에서 아무리 멀티 쓰레드 프로그램이라 하더라도 결국 한 번에 한 쓰레드만 실행할 수 있었지만, 멀티 코어 CPU에서는 여러개의 코어에 각기 다른 쓰레드들이 들어가 동시에 여러개의 쓰레드들을 효율적으로 실행할 수 있다. 

 

멀티쓰레드를 사용하는 환경

  • 병렬 가능한 작업들
    • 예: 1부터 10000까지 더하는 작업을 10개로 나눠 각각의결과를 합치는 상황 → 10분의 1로 시간을 단축
    • 프로그램 논리 구조 상에서 의존 관계가 많을수록 병렬화가 어려워지고, 반대로 다른 연산의 결과와 상관없이 독립적으로 수행할 수 있는 구조가 많을수록 병렬화가 쉬워진다.
  • 대기시간이 긴 작업들
    • 인터넷에서 웹사이트를 크롤링하는 작업: 여러 개의 사이트를 동시에 크롤링할 수 있다.
    • 그림으로 그리면 아래와 같다
    • CPU 시간을 낭비하지 않고 효율적으로 작업할 수 있다.


c++에서 쓰레드 생성하기

 

개발환경

  • 운영체제: Window 10 64 bit
  • 개발언어: Visual C++ 2022
  • 개발 툴: Visual Studio Community 2022 (64-bit)
  • 추가 패키지: MFC 구성요소
#include <iostream>
#include <thread> // 쓰레드 
using std::thread; // 출력 위한 std 사용

void func1() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 1 작동중!\n";
    }
}
void func2() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 2 작동중!\n";
    }
}
void func3() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 3 작동중!\n";
    }
}

int main()
{
    thread t1(func1);
    thread t2(func2);
    thread t3(func3);

    t1.join();
    t2.join();
    t3.join();

}

실행 결과

 

 

C++에서 쓰레드를 생성하는 방법은 

#include <thread> 로 헤더파일을 추가해 주고, 

#include <thread> // 쓰레드

 

thread t1(func1); 처럼 thread 객체를 생성한다. 이렇게 전달받은 t1은 전달받은 함수 func1을 새로운 쓰레드에서 실행하게 된다.

thread t1(func1);

 

아래의 코드를 실행하면, func1, func2, func3이 각기 다른 쓰레드 상에서 실행되게 된다.

thread t1(func1);
thread t2(func2);
thread t3(func3);

 

이 쓰레드들이 동시에 실행된다고 해도, CPU 코어에 어떻게 할당되고 언제 컨텍스트 스위치를 할 것인지는 전적으로 운영체제의 마음에 달려있다. (쓰레드 3개를 만들었다고 해서 각기 다른 코어에 할당되는 것이 아니라, 한 코어에 쓰레드 3개가 컨텍스트 스위칭 할 수도 있다.)

 

위의 코드를 실행시키면, 쓰레드 1 작동중! 과 쓰레드 2 작동중! 과 쓰레드 3 작동중! 이 순서가 실행시킬 때마다 바뀌는 모습을 확인할 수 있다. 운영체제가 쓰레드들을 어떤 코드에 할당하고 실행시키는지에 따라 결과가 달라진다는 의미이다.

 

 

JOIN

join()은 해당하는 쓰레드들이 실행을 종료하면 리턴하는 함수이다. 따라서 t1.join()의 경우 t1이 종료하기 전 까지 리턴하지 않는다. t2와 t3도 마찬가지이다. 

    t1.join();
    t2.join();
    t3.join();

 

 

만약 join()을 하지 않는다면?(join을 주석 처리하고 코드를 실행시켜 보자.)

#include <iostream>
#include <thread> // 쓰레드 
using std::thread; // 출력 위한 std 사용

void func1() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 1 작동중!\n";
    }
}
void func2() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 2 작동중!\n";
    }
}
void func3() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 3 작동중!\n";
    }
}

int main()
{
    thread t1(func1);
    thread t2(func2);
    thread t3(func3);

    //t1.join();
    //t2.join();
    //t3.join();

}

 

실행 결과

 

위와 같이 오류가 나온다. 쓰레드들의 내용이 실행되기 전에 main 함수가 종료되었기 때문에 쓰레드 객체들(t1, t1, t3)의 소멸자가 호출되었음을 알 수 있다.

 

예외 발생 오류

C++ 표준에 따르면, join 되거나 detach 되지 않은 쓰레드들의 소멸자가 호출된다면 예외를 발생시키도록 명시되어 있다. 따라서, 쓰레드 객체들이 join이나 detach모두 되지 않았으므로 위와 같은 문제가 발생하게 된다.

DETACH

 

detach란 말 그대로, 해당 쓰레드들을 실행시킨 후, 잊어버리는 것이라 생각하면 된다. 대신 쓰레드는 백그라운드에서 돌아가게 된다. 아래 예제를 보자.

 

#include <iostream>
#include <thread> // 쓰레드 
using std::thread; // 출력 위한 std 사용

void func1() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 1 작동중!\n";
    }
}
void func2() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 2 작동중!\n";
    }
}
void func3() {
    for (int i = 0;i < 10;i++) {
        std::cout << "쓰레드 3 작동중!\n";
    }
}

int main()
{
    thread t1(func1);
    thread t2(func2);
    thread t3(func3);

    t1.detach();
    t2.detach();
    t3.detach();
    
    std::cout << "메인 함수 종료 \n";

}

 

실행 결과

기본적으로 프로세스가 종료될 때, 해당 프로세스 안에 있는 모든 쓰레드들은 종료 여부와 상관없이 자동으로 종료된다.

즉 main 함수에서 메인 함수 종료!를 출력하고, 프로세스가 종료하게 되면, func1, func2, func3 모두 더 이상 쓰레드 작동중!을 출력할 수 없게 된다.

 

쓰레드들을 detach 했기 때문에 main함수에서 다른 쓰레드들이 종료될 때까지 기다리지 않은 모습을 확인할 수 있다.

 

t1.detach();
t2.detach();
t3.detach();

std::cout << "메인 함수 종료\n";

위 부분이 그냥 쭈르륵 실행되어서 쓰레드들이 채 문자열을 표시하기 전에 프로세스가 종료된 것이다.

 


쓰레드에 인자 전달하기

1부터 10000까지의 합을 여러 쓰레드들을 소환해서 빠르게 계산하는 방법을 살펴보도록 하자.

#include <cstdio>
#include <iostream>
#include <thread> // 쓰레드 
#include <vector>
using std::thread; // 출력 위한 std 사용
using std::vector;

void worker(vector<int>::iterator start, vector<int>::iterator end,
    int* result) {
    int sum = 0;
    for (auto itr = start; itr < end; ++itr) {
        sum += *itr;
    }
    *result = sum;

    // 쓰레드의 id를 구한다.
    thread::id this_id = std::this_thread::get_id();
    printf("쓰레드 %x에서 %d부터 %d까지 계산한 결과 : %d\n", this_id, *start, *(end - 1), sum);

}

int main()
{
    vector<int> data(10000);
    for (int i = 0; i < 10000; i++) {
        data[i] = i;
    }

    // 각 쓰레드에서 계산된 부분 합을 저장하는 벡터
    vector<int> partial_sums(4);
    
    vector<thread> workers;
    for (int i = 0;i < 4; i++) {
        workers.push_back(thread(worker, data.begin() + i * 2500,
            data.begin() + (i + 1) * 2500, &partial_sums[i]));
    }

    for (int i = 0;i < 4; i++) {
        workers[i].join();
    }

    int total = 0;
    for (int i = 0;i < 4;i++) {
        total += partial_sums[i];
    }

    std::cout << "전체 합: " << total << std::endl;
}

 

실행결과

 

코드 설명

void worker(vector<int>::iterator start, vector<int>::iterator end, int* result)

worker 함수는 덧셈을 수행할 데이터의 시작점과 끝점을 받아서 해당 범위 내의 모든 원소들을 더한 후, 그 결과를 result에 저장하게 된다.

!참고: 쓰레드의 리턴값은 없다

쓰레드는 리턴값이 없기 때문에 어떠한 결과를 반환하고 싶다면 포인터의 형태로 전달하면 된다.

 

 vector<thread> workers;
    for (int i = 0;i < 4; i++) {
        workers.push_back(thread(worker, data.begin() + i * 2500,
            data.begin() + (i + 1) * 2500, &partial_sums[i]));
    }

다음에 main 함수에서 각 쓰레드에게 임무를 할당한다. 각 worker들이 덧셈을 수행해야 하는 범위는 data.begin() + i * 2500, data.begin() + (i+1) * 2500 임을 알 수 있다. 

각각 0 ~ 2499, 2500 ~ 4999, 5000 ~ 7499, 7500 ~ 10000 이렇게 할당하게 된다.

 

쓰레드를 생성할 때 함수에 인자들을 전달하는 방법은 thread 생성자의 첫 번째 인자로 함수( Callable은 다 가능)을 전달하고, 이어서 해당 함수에 전달할 인자들을 써주면 된다.

 

worker함수 내에서 해당 원소들이 for문 안에 들어가 덧셈을 수행하고 있다. 

int sum = 0;
    for (auto itr = start; itr < end; ++itr) {
        sum += *itr;
    }
    *result = sum;

 

쓰레드 고유 아이디 번호 할당

각 쓰레드에는 고유 아이디 번호가 할당된다. this_thread::get_id 함수를 통해 현재 내가 돌아가고 있는 쓰레드의 아이디를 확인할 수 있다.

 

printf("쓰레드 %x에서 %d부터 %d까지 계산한 결과 : %d\n", this_id, *start, *(end - 1), sum);

여기서 printf를 사용한 이유는, 쓰레드를 사용하는 과정 중간에 실행되는 쓰레드들이 바뀌면서 메세지가 뒤섞이기 때문이다. 만약 std::cout << A;를 한다면 A가 출력되는 동안 다른 쓰레드가 내용을 출력할 수 없게 보장을 해 준다. 

반면에 printf는 "..."안에 있는 문자열을 출력할 때, 컨텍스트 스위치가 되더라도 다른 쓸데드들이 그 사이에 메세지를 집어넣지 못하게 막는다. 즉, 방해받지 않고 메세지를 제대로 출력할 수 있다. 

 

for (int i = 0;i < 4; i++) {
        workers[i].join();
    }

    int total = 0;
    for (int i = 0;i < 4;i++) {
        total += partial_sums[i];
    }

마지막으로 main 함수에서 모든 쓰레드들이 종료될 때까지 기다린다.

그리고 total 변수에 부분합들을 더한 후, 출력한다.

 

 


오늘은 쓰레드, 멀티쓰레드, 그리고  c++예제를 공부해 봤다.

 

profile

소연의_개발일지

@ssoyxon

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!