본문 바로가기
프로그래밍/C & C++

[C++] thread

by 별준 2021. 8. 7.

References

Contents

  • thread
  • join / detach
  • thread에 인자 전달하기
  • single thread vs multi thread

쓰레드를 사용하면 병렬 수행이 가능한 작업들을 단일 쓰레드 프로그램으로 수행하는 것보다 훨씬 빠르게 처리가 가능합니다. 병렬 수행 작업을 예로 들면, 1에서 1000000까지의 덧셈이 있습니다. 

 

CPU 코어에서의 덧셈 연산 한 번에 1초가 걸린다고 가정해봅시다. 그렇다면 단일 쓰레드의 경우에는 1000000초가 걸리게 됩니다.

반면에 멀티 쓰레드를 사용하여 10개의 쓰레드를 동시에 실행시켜서 작업을 수행한다면, 각 쓰레드에서 덧셈은 100000초가 걸리고, 마지막으로 다 합칠 때 10초가 걸려서 총 100004초가 걸리게 됩니다. 

 

따라서 싱글 쓰레드의 경우보다 속도가 약 10배가 향상됩니다.

이렇게 병렬화(Parallelize)가 가능한 작업이 있다면, 쓰레드를 사용하는 것이 더 좋은 성능을 보여줍니다.

 

우선 C++에서 쓰레드를 어떻게 사용하는지부터 살펴보겠습니다.


C++에서 쓰레드 생성 방법

C++11 부터 표준에 쓰레드가 추가되면서, 쓰레드 사용은 매우 편리해졌습니다.

간단한 쓰레드를 생성해보겠습니다.

#include <iostream>
#include <thread>

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

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

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

컴파일을 완료하고, 실행시키면 아래와 같은 출력을 확인할 수 있습니다. 저의 경우에는 처음엔 Thread 2가 먼저 돌다가, Thread 3으로 스위칭되고 Thread 1로 스위칭이 일어나서 실행되다가 다시 Thread 3으로 스위칭되었습니다.

C++에서 쓰레드 생성은 <thread> 헤더파일을 include 해주고, thread 객체를 생성하기만 하면 됩니다.

#include <thread>
std::thread t1(func1);

이렇게 생성된 t1은 인자로 전달받은 함수인 func1을 새로운 쓰레드에서 실행하게 됩니다.

 

위 코드에서는 func1, func2, func3 함수가 각각의 쓰레드에서 실행되게 됩니다.

한 가지 중요한 점은 이 쓰레드들이 CPU 코어에서 어떻게 할당되고, 언제 컨텍스트 스위칭(Context Switching)이 발생하는지는 OS에 따라 결정되게 됩니다. 또한, 프로그램이 실행할 때마다 출력 결과는 달라집니다. OS에서 쓰레드들을 어떤 코어에 할당하고, 어떤 순서로 스케쥴링할 지는 상황에 맞게 그때마다 바뀌기 때문에 그 결과를 정확히 예측할 수 없습니다. (이 경우에는 출력을 통해서 그때 그때 어떠한 쓰레드가 실행되는지 체크하고 있습니다.)

 


join

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

마지막으로 join은 해당하는 쓰레드들이 실행을 종료하면 리턴하는 함수입니다. 따라서, t1.join()의 경우에는 t1이 종료하기 전까지 리턴하지 않습니다. 

만약 t2가 t1보다 먼저 종료된다고 하더라도 상관은 없습니다. t1.join()이 끝나고, t2.join()을 수행하였을 때 쓰레드 t2가 이미 종료된 상태라면 바로 함수가 리턴하게 됩니다.

 

만약에 join을 하지 않는다면, 런타임 에러가 발생하게 됩니다. C++ 표준에서는 join되지 않은 쓰레드(detach도 동일)들의 소멸자가 호출된다면 예외를 발생시키도록 명시되어 있는데, 쓰레드가 종료되기 전에 main 함수가 종료되면서 쓰레드의 객체들(t1, t2, t3)의 소멸자가 호출되면서 예외가 발생한 것입니다.

 


detach

detach는 '해당 쓰레드를 실행시킨 후에 신경쓰지 않는다'라고 생각하면 됩니다. 즉, 쓰레드는 알아서 백그라운드에서 돌아가게 되는 것이죠. 우선 아래 예제를 실행시켜 보면,

#include <iostream>
#include <thread>

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

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

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

다음의 출력,

또는,

를 확인할 수 있습니다. (여러가지의 결과가 나오게 됩니다.)

 

기본적으로 프로세스가 종료될 때, 해당 프로세스 안에서 돌아가는 쓰레드들은 종료 여부와 상관없이 자동으로 종료되게 됩니다. 즉, main 함수가 종료하게 되면, func1, func2, func3을 실행 중인 쓰레드도 종료되게 됩니다.

 

쓰레드를 datach 하게 되면, main 함수에서는 더 이상 쓰레드가 종료될 때까지 기다리지 않습니다. 

따라서, 두 번째 출력 결과처럼 쓰레드가 실행되기 전에 main 함수를 순차적으로 실행하다가 'main 함수 종료'를 출력하고 종료하게 됩니다. 

첫 번째 출력은 운 좋게 프로세스가 종료되기 전에 생성된 쓰레드에서 적당히 메세지를 출력하고 프로세스가 종료된 케이스입니다. Thread 1, Thread 2에서 한번씩만 출력하고 종료된 것을 확인할 수 있습니다.


thread에서 인자 전달 방법

이번 예제에서 0부터 999999 까지의 합을 여러 쓰레들을 통해서 빠르게 계산하는 방법을 살펴보겠습니다.

#include <iostream>
#include <thread>
#include <vector>

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

	*result = sum;

	// thread id
	std::thread::id this_id = std::this_thread::get_id();
	printf("쓰레드 %p 에서 %lld 부터 %lld 까지 계산한 결과 : %lld\n", this_id, *start, *(end - 1), sum);
}

int main(void) {
	const int MAX = 1000000;
	std::vector<long long> data(MAX);
	for (int i = 0; i < MAX; i++)
		data[i] = i;

	// thread를 이용한 합 구하기
	const int NUM_OF_THRHEAD = 10;
	std::vector<long long> partial_sums(NUM_OF_THRHEAD);

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

	for (int i = 0; i < NUM_OF_THRHEAD; i++)
		workers[i].join();

	long long total = 0;
	for (int i = 0; i < NUM_OF_THRHEAD; i++)
		total += partial_sums[i];

	std::cout << "Total Sum : " << total << std::endl;

	return 0;
}

위 코드를 컴파일 후, 실행시키면 아래와 같은 출력을 확인할 수 있습니다.

void worker(std::vector<long long>::iterator start, std::vector<long long>::iterator end, long long* result);

위 예제에서 worker 함수는 덧셈을 수행할 데이터의 시작과 끝을 인자로 전달받아서 범위 내의 원소들을 모두 더하는 작업을 수행하고, 그 결과는 result에 저장하게 됩니다.

(참고로 쓰레드는 리턴값이란 것이 없기 때문에 어떠한 결과를 반환하고 싶다면 포인터의 형태로 전달받아야 합니다.)

 

아래는 main 함수 내에서 각 쓰레드를 실행시키는 부분입니다.

 

먼저 실행시킬 쓰레드의 개수를 설정하고 그 개수만큼 결과를 받아올 vector를 생성한 후, for문을 통해서 각 worker들의 쓰레드를 생성하고 실행시킵니다. 

const int NUM_OF_THRHEAD = 10;

std::vector<long long> partial_sums(NUM_OF_THRHEAD);
std::vector<std::thread> workers;
for (int i = 0; i < NUM_OF_THRHEAD; i++) {
	workers.push_back(
		std::thread(
			worker, 
			data.begin() + i * (MAX / NUM_OF_THRHEAD), 
			data.begin() + (i + 1) * (MAX / NUM_OF_THRHEAD),
			&partial_sums[i]));
}

7번째 line부터 살펴보면, 쓰레드를 생성할 때 함수의 인자들을 전달하는 방법을 보여주고 있습니다. 

방법은 매우 간단하며, thread의 첫번째 인자로 함수를 전달하고, 그 뒤에 이어서 함수에 전달할 인자들을 나란히 써주면 됩니다. 

쓰레드들이 실행되면 각 worker 함수 내부에서 해당 범위의 원소들의 덧셈을 수행하고, 그 결과 값을 result에 저장합니다. 그리고 각 쓰레드는 고유 ID 번호가 할당됩니다. 따라서 어떤 쓰레드에서 작업중인지 확인하고 싶다면 std::this_thread::get_id 함수를 통해서 현재 실행중인 쓰레드의 ID를 알 수 있습니다.

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

출력은 printf를 사용했는데, 만약 std::cout을 사용하게 된다면 아래의 결과를 볼 수 있습니다.... !

이는 std::cout 의 << 를 실행하는 과정 중간 중간에 계속해서 컨텍스트 스위칭이 발생하기 때문입니다. 그 결과, 각 쓰레드의 메세지가 뒤섞여서 출력되게 됩니다. 

반면에 printf는 큰 따옴표 안에 있는 문자열을 출력할 때, 컨텍스트 스위치가 되더라도 그 사이에 메세지를 집어넣지 못하게 막습니다. 

(참고 : https://stackoverflow.com/questions/23586682/how-to-use-printf-in-multiple-threads)

 


Single Thread vs. Multi Thread

0부터 999999부터 더하는 작업을 싱글 쓰레드와 멀티 쓰레드에서 어떠한 성능 차이가 있는지 비교해보겠습니다.

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

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

	*result = sum;
}

int main(void) {
	const int MAX = 1000000;
	std::vector<long long> data(MAX);
	for (int i = 0; i < MAX; i++)
		data[i] = i;

	// thread 없이 합 구하기
	long long total = 0;
	auto t1 = std::chrono::system_clock::now();

	worker(data.begin(), data.begin() + MAX, &total);

	auto t2 = std::chrono::system_clock::now();

	float ms = std::chrono::duration_cast<std::chrono::duration<float>>(t2 - t1).count() * 1000.0F;
	printf("----- 합 구하기 (no thread) -----\n");
	printf("걸린 시간 : %.6f ms\n\n", ms);

	// thread를 이용한 합 구하기
	const int NUM_OF_THRHEAD = 10;
	std::vector<long long> partial_sums(NUM_OF_THRHEAD);
	std::vector<std::thread> workers;
	total = 0;
	t1 = std::chrono::system_clock::now();

	for (int i = 0; i < NUM_OF_THRHEAD; i++) {
		workers.push_back(
			std::thread(
				worker, data.begin() + i * (MAX / NUM_OF_THRHEAD),
				data.begin() + (i + 1) * (MAX / NUM_OF_THRHEAD),
				&partial_sums[i]));
	}

	for (int i = 0; i < NUM_OF_THRHEAD; i++)
		workers[i].join();

	for (int i = 0; i < NUM_OF_THRHEAD; i++)
		total += partial_sums[i];

	t2 = std::chrono::system_clock::now();

	ms = std::chrono::duration_cast<std::chrono::duration<float>>(t2 - t1).count() * 1000.0F;
	printf("----- 합 구하기 (%d thread) -----\n", NUM_OF_THRHEAD);
	printf("걸린 시간 : %.6f ms\n", ms);

	return 0;
}

위 코드를 컴파일하고 실행시키면 다음의 결과를 확인할 수 있습니다. 쓰레드는 10개를 사용했습니다.

글 초반에 10배 정도의 성능 차이가 발생할 것으로 예상했지만, 대락 3~4배의 성능 차이를 보여주고 있습니다.

이는 아마도 컨텍스트 스위칭과 thread 객체를 생성하고 실행하는데 발생하는 오버헤드로 인한 결과로 예상됩니다.

이 때문에 MAX 값을 10000 정도로 매우 작은 범위로 감소시키면, 오버헤드 때문에 오히려 싱글 쓰레드가 더 빠른 성능을 보여주기도 합니다.

(위 결과는 디버거 모드에서 측정되었습니다. 릴리즈 모드로 실행하면 적어도 MAX 값이 100000000 정도는 되어야지 멀티 쓰레드가 더 성능이 좋습니다.)

 

이처럼 병렬로 수행이 가능한 작업의 경우에는 멀티 쓰레드를 사용하는 경우 더 좋은 성능을 보여주고 있습니다.

이번 게시글에서는 간단한 덧셈 작업만을 예시로 살펴봤는데, 이어지는 글들에서 더욱 자세하게 쓰레드에 대해서 살펴보도록 하겠습니다.

 

댓글