KoreanFoodie's Study

C++ 기초 개념 15-1 : 쓰레드(thread)의 기초와 실습 본문

Tutorials/C++ : Beginner

C++ 기초 개념 15-1 : 쓰레드(thread)의 기초와 실습

GoldGiver 2022. 4. 19. 10:23
모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!

프로세스와 쓰레드

CPU 코어에서 돌아가는 프로그램 단위를 쓰레드(Thread) 라고 부른다. 즉, CPU 의 코어 하나에서는 한 번에 한 개의 쓰레드의 명령을 실행시키게 된다.
프로세스는 여러 개의 쓰레드로 이루어지는데, 프로세스와 쓰레드의 차이는 메모리를 공유하느냐 하지 않느냐의 차이이다.
각 코어에서는 코어가 돌아가는데, 컨텍스트 스위칭 (Context Switching) 을 통해 쓰레드가 번갈아가며 실행된다.


병렬화(Parallelizable) 가능한 작업

쓰레드를 이용해 병렬화를 적용하면 더욱 빠른 작업이 가능한 경우가 있다. 예를 들어, 다음과 같이 피보나치 수열을 구하는 프로그램이 있다고 가정해 보자.

#include <iostream>

int main() {

	int bef = 1, cur = 1;

	for (int i = 0; i < 98; ++i) {
		int temp = cur;
		cur = cur + bef;
		bef = temp;
	}

	std::cout << "F100 : " << cur << std::endl;
}


이런 경우, 피보나치 수열에서 F(N) 을 구하기 위해선 F(N-1) 과 F(N-2) 의 값을 먼저 구해야 한다. 이처럼, 어떠한 연산 (연산 A) 을 수행하기 위해 다른 연산 (연산 B) 의 결과가 필요한 상황을 A 가 B 에 의존(dependent) 한다고 한다. 따라서 연산들이 서로 독립적일 수록 병렬화가 쉬워진다.
다음과 같이 인터넷에서 웹사이트를 긁어 모으는 프로그램을 생각해 보자.

using namespace std;
int main() {

	// 다운받으려는 웹사이트와 내용을 저장하는 맵
	map<string, string> url_and_content;
	for (auto itr = url_and_content.begin(); itr != url_and_content.end(); ++itr) {
		const string& url = itr->first;

		// download 함수는 인자로 전달받은 url 에 있는 사이트를 다운받아 리턴한다.
		itr->second = download(url);
	}
}


인터넷은 CPU 처리 속도에 비해 느리고, 웹사이트 내용을 다운 받는 작업은 서로 독립적이므로, 쓰레드 여러 개를 사용해서 다음과 같이 CPU 를 효율적으로 사용하도록 만들 수 있다.


C++ 에서 쓰레드 생성

C++ 11 부터 표준에 쓰레드가 추가되었다.

#include <iostream>
#include <thread>
using std::thread;

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();
}


먼저 함수를 이용해 쓰레드 객체를 생성하고, join 을 통해 해당 쓰레드가 실행을 종료하면 리턴하도록 만들었다.

다음과 같은 코드를 실행시키면 운영체제에 따라 3개의 코어에 쓰레드가 할당될 수도 있고, 하나의 코어에 할당되어 컨텍스트 스위칭이 일어날 수도 있다. 어찌되었든, 3 개의 쓰레드가 호출되는 순서는 실행할 때마다 계속 달라질 수 있다.
만약에 join 을 하지 않는다면 어떻게 될까?

위처럼, 쓰레드들의 내용이 실행 완료되기 전에 main 함수가 종료되어 쓰레드 객체들 (t1, t2, t3) 의 소멸자가 호출된 것이다. C++ 표준에 의하면, join 되거나 detach 되지 않는 쓰레드들의 소멸자가 호출된다면 예외를 발생시키도록 명시되어 있다.
detach 는, 말 그대로 해당 쓰레드를 실행시킨 후, 잊어버리는 것(알아서 백 그라운드에서 돌아가게 되는 것) 이다.

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

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

다음을 추가하면,

또는

와 같이 여러 결과가 나온다.

기본적으로 프로세스가 종료될 때, 해당 프로세스 안에 있는 모든 쓰레드들은 종료 여부와 상관없이 자동으로 종료된다. 즉, main 함수에서 메인 함수 종료! 를 출력하고, 프로세스가 종료하게 되면, func1, func2, func3 모두 더 이상 쓰레드 작동중! 을 출력할 수 없게 된다.

쓰레드에 인자 전달하기

#include <cstdio>
#include <iostream>
#include <thread>
#include <vector>
using std::thread;
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;

  // Get ID of threads
  thread::id this_id = std::this_thread::get_id();
  printf("From Thread %x, %d to %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;
  }

  // Store partial sum for each thread
  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 Sum : " << total << std::endl;
}

위의 코드를 보면, worker 라는, 범위값을 더해주는 함수를 만들고, workers 벡터에 thread 가 연산을 하도록 만들어 주고 있음을 알 수 있다. 결과물은 다음과 같다.

From Thread 2, 0 to 2499 : 3123750 
From Thread 3, 2500 to 4999 : 9373750 
From Thread 4, 5000 to 7499 : 15623750 
From Thread 5, 7500 to 9999 : 21873750 
Total Sum : 49995000

그런데 worker 함수에서, printf 대신 std::cout 을 쓰게되면 다음과 같은 결과가 나올 수 있다.

쓰레드 쓰레드 쓰레드 쓰레드 7f2d6ea5c700 에서 7f2d6f25d700 에서 7f2d6fa5e70075005000 에서  부터  부터 2500 부터 4999 까지 계산한 결과 : 93737509999 까지 계산한 결과 : 218737507499 까지 계산한 결과 : 156237507f2d7025f700 에서 
0 부터 2499 까지 계산한 결과 : 3123750

이러한 결과가 나오는 이유는, std::cout 이 << 를 실행하는 과정 중간 중간에 다른 쓰레드들의 동작이 끼어들 수 있기 때문이다. 즉, std::cout << A; 의 경우, A 가 출력되는 도중에 다른 쓰레드가 내용을 출력할 수 없게 보장을 해 준다(컨텍스트 스위치가 되더라도). 하지만 std::cout << A << B; 의 경우에는 A를 출력한 이후 B를 출력하기 전에 다른 쓰레드가 내용을 출력할 수 있다.
반면, printf 의 경우는 문자열을 출력할 때 컨텍스트 스위치가 일어나도 다른 쓰레드들이 그 사이에 메시지를 넣을 수 없게 막는다.
그런데, 만약 다른 쓰레드들이 같은 메모리에 서로 접근하고 데이터를 쓴다면 어떤 일이 발생할까? 다음 포스팅에서는 임계 영역(Critical Section), 뮤텍스(Mutex), 데드락(DeadLock) 등에 대해 다루고자 한다.

보너스 : 피보나치 수열을 쓰레드를 이용해 구하기

다음과 같은 코드를 통해 효율을 비교하였다 : 싱글 쓰레드에서 피보나치 수열을 구하기 vs f(x-1), f(x-2) 각각을 별도의 쓰레드에서 계산 후 합치기

#include <iostream>
#include <cstdio>
#include <thread>
#include <vector>
#include <time.h>
using std::thread;
using std::vector;

typedef long long LL;

LL fibo_recur(int x) {

	if (x <= 2) 
		return 1;

	LL	res = fibo_recur(x-1) + fibo_recur(x-2);
	return res;
}

LL fibo_recur2(int x, LL* result) {

	if (x <= 2) {
		*result = 1;
		return 1;
	}

	*result = fibo_recur(x-1) + fibo_recur(x-2);
	return *result;
}

int main() {

	// clock_t start, end;
	time_t start, end;
	int x;
	LL result;

	x = 50;

	// single thread
	start = clock();

	result = fibo_recur(x);

	end = clock();

	std::cout << "Result of fibo in single thread (" << x << ") : " << result << " / Time elapsed : " <<  (double)(end - start) << std::endl;


	// multi thread
	LL res1, res2;
	start = clock();

	thread f1(fibo_recur2, x-2, &res1);
	thread f2(fibo_recur2, x-1, &res2);
	f1.join();
	f2.join();
	result = res1 + res2;

	end = clock();


	std::cout << "Result of fibo in multi thread (" << x << ") : " << result << " / Time elapsed : " <<  (double)(end - start) << std::endl;
}

50번째 피보나치 수열을 구한다고 했을 때, 싱글 쓰레드와 멀티 쓰레드는 다음과 같은 성능 차이를 보인다 :

Result of fibo in single thread (50) : 12586269025 / Time elapsed : 46.883s
Result of fibo in multi thread (50) : 12586269025 / Time elapsed : 30.708s


만약 코드를 조금 바꿔서, 멀티 쓰레드를 사용할 경우, 재귀적으로 쓰레드를 생성한다면 어떻게 될까? 즉, 다음과 같은 식의 함수를 돌려보자.

LL fibo_multi(int x, LL* result) {
	if (x <= 2) {
		*result = 1;
		return 1;
	}
	LL res1, res2;
	thread f1(fibo_multi, x-1, &res1);
	thread f2(fibo_multi, x-2, &res2);
	f1.join();
	f2.join();

	*result = res1 + res2;
	return *result;
}


3 가지의 방식의 코드는 각각 다음과 같은 결과를 보인다(50번째 수열에 대해)

Result of fibo in single thread (50) : 12586269025 / Time elapsed : 40.725s
Result of fibo in multi thread (50) : 12586269025 / Time elapsed : 26.319s
Result of fibo in recursive multi thread (50) : 12586269025 / Time elapsed : 26.201s


51번째 수열에 대해서는, 오히려 재귀적으로 쓰레드를 만드는 방식이 더 비효율 적임을 알 수 있다.

Result of fibo in single thread (51) : 20365011074 / Time elapsed : 69.107s
Result of fibo in multi thread (51) : 20365011074 / Time elapsed : 44.808s
Result of fibo in recursive multi thread (51) : 20365011074 / Time elapsed : 45.641s
Comments