멀티쓰레드는 C++ 프로그래밍 언어 내에 멀티스레드 라이브러리에 존재한다. C++11 에 추가되었으며 표준으로 존재한다.
C++ 11에서 지원되기 전에는 각 OS 마다 사용할 수 있는 방법이 달랐다.
windows에서는 WIN32 라는 라이브러리에서 지원하는 API를 사용해서 프로그래밍 해야했고, Linux는 pthread API를 사용해서 프로그래밍 했다. 현재는 C++11 에 공개된 <thread> 라이브러리를 이용하면 된다.
thread 를 지원하는 방법은 각 OS 마다 달랐다.
먼저 windows 에서 쓰레드를 어떻게 지원했냐 하면
- windows 에서 쓰레드는 프로세스를 구성하는 원소이다.
- 모든 프로세스는 시작 시 한 개의 쓰레드를 갖고 실행된다.
- 운영체제가 직접 쓰레드를 스케줄링.
- 멀티 CPU(또는 core)라면 여러 개의 쓰레드를 동시에 실행시켜준다.
반면에 Linux에서는
- 쓰레드 개념이 없다. 프로세스이다.
- 프로세스를 만들 때 옵션을 부여하여 쓰레드를 사용할 수 있다.
예를 들면, CODE, DATA, HEAP을 공유하는 프로세스를 생성하는 방법이다. 이는 다른 운영체제의 쓰레드와 같다.
이제는 C++의 <thread> 를 이용해서 OS를 구분짓지 않고 코딩할 수 있다.
(다른 언어에서도 thread를 지원하는 언어가 있다. 나는 C++로 공부했기 때문에 C++을 사용할 것이다.)
멀티스레드 프로그래밍을 시작해보자.
#include <iostream>
#include <thread>
#include <vector>
void f(int a)
{
std::cout << "Hello MultiThread! - " << a << std::endl;
}
int main()
{
std::vector<std::thread>v;
for (int i = 0; i < 10; ++i) {
v.push_back(std::thread{f, i});
}
for (auto& t : v)
t.join();
}
실행 결과

이 코드와 결과에서 주목할 점이 두 가지가 있다.
첫 번째는 분명 코드 안의 for문에서는 0부터 9까지 i가 증가하는데, 실제 출력 결과는 출력된 수들이 차례대로 증가하지 않는 점이다.
이는 스레드 객체들이 어떤 순서로 실행될지 보장된 바가 전혀 없기 때문이다. 이 코드를 다시 실행한다면 또 다른 결과가 나올 것이다.
두 번째는 join() 이라는 멤버 함수이다.
위의 코드에서
for (auto& t : v)
t.join();
해당 부분을 빼고 실행해보자.

이와 같은 오류 창이 뜰 것이다.
문제가 발생하는 이유는 스레드가 실행중인데 main() 함수(메인 쓰레드)가 종료되었기 때문이다. 이를 방지하려면 join() 함수를 호출해서 스레드를 인스턴스한 함수에서 기다려주어야 한다. 선택이 아닌 필수이다.
join()을 비롯한 멀티쓰레드 프로그래밍을 하기 위해 알아야 할 여러 함수들이 있다. 다음과 같다.
- join() : thread의 종료를 기다리는 함수. 쓰레드를 기다려주지 않고 메인 쓰레드가 먼저 종료된다면 오류가 발생한다.
- std::therad::hardware_concurrency() : 논리(가상) 코어의 개수를 return 한다.
작업 관리자의 성능 탭에서 이 수를 확인할 수도 있다. 여기서 확인할 수 있는 점은 실제 코어 개수와 논리 코어의 개수는 다르다는 것이다. 하이퍼 스레딩이 가능한 코어를 카운팅 한 것인데.. 인텔도 버렸다고 선언한 기술이다;
아래는 해당 함수를 호출한 코드이고 출력 결과를 확인할 수 있다. 작업 관리자의 논리 프로세서의 수와 같은 값이 출력된다.
std::cout << "논리 프로세서의 개수 - " << std::thread::hardware_concurrency() << std::endl;


- joinable() : 쓰레드의 종료를 확인하는 함수로 thread_id 가 0이 아닐 때 true 를 return 한다.
- get_id() : 커널에서 다루는 쓰레드의 id. 순차적인 값은 아니나, 쓰레드끼리 겹치지 않음이 보장된다.
- detach() : 쓰레드 객체에서 쓰레드를 분리하는 함수. 쓰레드의 종료와 상관없이 쓰레드 객체는 소멸할 수 있다.
그러나 쓰레드의 종료를 join()이나 joinable() 로 알 수 없다.
또한 this_thread 로 현재 실행하는 스레드에 대한 함수를 호출할 수 있는 namespace 가 제공된다.
- std::this_thread::get_id() : 자기 자신의 쓰레드 id를 return 한다.
- this_thread::sleep_for() : 정해진 시간동안 쓰레드의 실행을 멈춘다. busy wating 방지에 쓰인다.
예를 들어, 1초의 대기가 필요한 상황에서 아무것도 하지 않고 해당 쓰레드가 실행되는 것은 낭비이다. sleep_for()를 사용해서 이러한 낭비를 방지한다. - this_thread::sleep_until() : 정해진 시간까지 쓰레드의 실행을 멈춤. 정확한 시간을 적어 깨어나게 하는 기능이다. 이 함수 또한 busy wating을 방지할 수 있다.
- this_thread::yield() : 다른 쓰레드에게 실행 시간을 양보한다. 쓰레드에서 수행하는 작업의 우선순위가 낮다면 바로 실행할 필요가 없기 때문에 yield()를 사용해서 다른 쓰레드에게 양보한다. do while 문으로 특정 조건이 성립될 때 까지 yield()를 호출하는 방식으로 사용할 수 있다.
쓰레드의 여러가지 생성 방법을 알아보자.
#include <iostream>
#include <thread>
void f() {};
class F {
public:
void operator()() {};
};
int main()
{
std::thread t1{ f }; // 함수
std::thread t2{ F() }; // 함수 객체
std::thread t3; // 객체 생성 후
t3 = std::thread{ f }; // 나중에 함수 할당
}
쓰레드 생성 시에 함수를 인자로 넘겨 생성할 수 있다. 함수 객체 또한 인자로 넘겨 생성 가능하다.
아무런 인자도 넘기지 않고 생성 후, 나중에 함수를 할당할 수도 있다. 이 때는 함수를 할당하기 전 까지는 실제 쓰레드가 생성되지는 않고, 함수가 할당된 후에 실제 쓰레드가 생성된다.
이제 진짜 멀티쓰레드 프로그래밍으로 성능을 향상 시켜보자.
1을 5천만번 더한 함수를 두 개의 쓰레드에 할당시키면 1억을 빠르게 만들 수 있겠지?! 이얏호!~
std:C++20 / Release / x64 환경으로 실행했다.
#include <iostream>
#include <thread>
int res;
void sum()
{
for (int i = 0; i < 50000000; ++i) {
res += 1;
}
}
int main()
{
std::thread t1{ sum };
std::thread t2{ sum };
t1.join();
t2.join();
std::cout << "합계 - " << res << std::endl;
}

Volatile
엥? 이 결과는 뭘까? 1억이 나와야 하는데.. 5천만이 나왔다!
사실 이 프로그램은 Release 모드로 실행했기 때문에 컴파일러가 자동으로 최적화를 수행한다.
for 문의 내용을 보고 한 번에 5천만씩 더했기 때문에 이런 결과가 나온 것이다. (그래두 1억 나와야 하는 거 아닌가...? 라는 생각이 든다면 님 말이 맞는데 일단지금은volatile 얘기하고있으니깐조금만기다려주세요)
volatile 은 변수를 컴파일러 최적화에서 제외할 때 사용한다.
volatile 의 주요 특징은 반드시 메모리를 읽고 쓴다는 점이다.
변수가 읽힐 때 마다 매번 실제 메모리에서 값을 읽고, 쓸 때마다 값을 실제 메모리에 기록한다.
위의 문제는 최적화가 이루어져 발생한 문제이므로
아래와 같이 전역변수 int res 에 volatile을 키워드를 선언해서 최적화를 막아보자! 그럼 1억이 나올 것이다!
volatile int res;


volatile로 선언해서 컴파일러가 최적화를 하진 않았지만 원하는 값인 1억이 나오지 않았다.
사실 위의 volatile로 선언하지 않은 코드의 실행 결과 또한 같은 문제로 인해 1억이 나오지 않고 5천만이 나왔다.
이 문제는 DataRace가 발생했기 때문에 일어난 문제이다.
Data Race
Data Race란 복수 개의 쓰레드가 하나의 메모리에 동시에 접근하면서 적어도 한 개는 write를 할 때 일어날 수 있는 멀티쓰레드 프로그래밍 문제이다.
풀어서 이야기 하자면, 공유 메모리를 여러 쓰레드에서 읽고 쓰기 때문에 발생한다. 이로 인해서 각 쓰레드가 읽고 쓰는 순서에 따라 실행결과가 달라진다.

어셈블리어를 살펴보면 문제가 왜 발생하는지 알 수 있다. res에 1씩 더하는 문장은 한 번에 이루어지지 않고 총 세 단계로 나눠 이루어진다. 각 코어에서 작업을 실행하며 res에 대한 값을 참조할 때 다른 쓰레드에서 실행 중인 작업을 무시하고 메모리에 저장된 값을 불러와 대부분의 작업들이 말짱 도루묵이 된 것이다.
같은 이유로 이 프로그램을 싱글 코어로 제한해서 실행해도 문제가 발생할 것이다. 하나의 코어에서 시분할로 두 개의 쓰레드가 실행되는데 위의 코드가 실행될 때 컨텍스트 스위칭이 일어나면 Data Race가 발생한다.
그렇다면, 어떻게 이를 해결해야 할까?
정답은 단순하다. 공유 메모리에 대해 동시에 복수개의 쓰레드가 접근할 수 없도록 하면 된다. 즉, 한 번에 한 개의 쓰레드만 메모리에 접근하도록 하면 된다.
Lock
C++11에는 동시에 복수개의 쓰레드가 접근할 수 없도록 하는 Lock 또한 제공한다.
#include <mutex>
std::mutex lock;
<mutex> 를 include해서 전역변수로 mutex 객체를 만들 수 있다.
(mutex는 mutual exclusion의 줄임말이다. 상호배제 라는 뜻을 갖고있다.)
그러면, 이 객체를 아까의 프로그램에 적용시켜 문제를 해결해보자!
#include <iostream>
#include <thread>
#include <mutex>
std::mutex lock;
volatile int res;
void sum()
{
for (int i = 0; i < 50000000; ++i) {
lock.lock();
res += 1;
lock.unlock();
}
}
int main()
{
std::thread t1{ sum };
std::thread t2{ sum };
t1.join();
t2.join();
std::cout << "합계 - " << res << std::endl;
}

와하하! 1억이 나왔다! 멀티쓰레드 프로그래밍 끝! (응아니야)
멀티쓰레드 환경에서 올바른 값이 나왔으니, 실제로 성능이 향상되었는지를 확인해보자!
쓰레드 객체를 생성하지 않고 sum() 함수를 두 번 호출한 프로그램과, 완성한 프로그램을 밀리세컨드 단위로 비교해보자.
바로 밑의 코드가 sum() 함수를 두 번 호출한 프로그램이고, A 코드라고 부르겠다.
#include <iostream>
#include <thread>
#include <chrono>
volatile int res;
void sum()
{
for (int i = 0; i < 50000000; ++i) {
res += 1;
}
}
int main()
{
auto start = std::chrono::high_resolution_clock::now();
sum(); sum();
auto elapsed_time = std::chrono::high_resolution_clock::now() - start;
auto count = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed_time).count();
std::cout << "합계 - " << res << std::endl;
std::cout << "걸린 시간 - " << count << "ms" << std::endl;
}
이 코드가 mutex 객체로 임계 구역을 감싼 코드이다. B 코드라고 부르겠다.
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
std::mutex lock;
volatile int res;
void sum()
{
for (int i = 0; i < 50000000; ++i) {
lock.lock();
res += 1;
lock.unlock();
}
}
int main()
{
auto start = std::chrono::high_resolution_clock::now();
std::thread t1{ sum };
std::thread t2{ sum };
t1.join();
t2.join();
auto elapsed_time = std::chrono::high_resolution_clock::now() - start;
auto count = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed_time).count();
std::cout << "합계 - " << res << std::endl;
std::cout << "걸린 시간 - " << count << "ms" << std::endl;
}
각각 A, B 코드의 실행 결과이다.


성능 향상은 커녕 성능이 약 10배 하락했다.
lock을 걸고 해제하는 작업도 오버헤드가 클 뿐더러, 작업의 큰 부분을 차지하는 부분에서 한 쓰레드만 접근할 수 있게 해놨기 때문에 멀티쓰레드의 이점 또한 가져갈 수 없었기 때문이다.
이제 멀티쓰레드 프로그래밍을 할 때 중요한 사항을 알았다.
첫 번째는 멀티쓰레드를 사용하고도 올바른 결과가 나와야 한다는 점이다.
두 번째는 멀티쓰레드로 인한 성능 향상의 폭이 커야한다는 점이다.
실행 결과에 오류가 있다면 그건 절대로 사용할 수 없는 프로그램이고, 성능 향상이 미미한 정도에 그친다면 그건 헛수고일 뿐이다.
이 두 가지를 유의하면서 프로그래밍 하기는 매우 어렵다.
멀티쓰레드 프로그래밍의 세계에 온 것을 환영한다!
** 오류 지적은 환영입니다^^ **
'컴퓨터 공학 > 멀티쓰레드' 카테고리의 다른 글
멀티쓰레드 프로그래밍 소개 (1) | 2024.10.14 |
---|