이 글은 개인의 학습을 목적으로 정리한 글입니다. 이점 참고하고 읽어주세요 ;)
멀티 쓰레드는 하나의 메모리 공간을 공유하기 때문에 병렬 처리에 있어서 굉장한 편리함과 효율성을 제공합니다. 하지만 같은 메모리 공간을 공유한다는 건, 여러 쓰레드들이 동시다발적인 일의 처리로 인해 메모리 내에서 오류를 발생시킬 수 있습니다. 이러한 문제를 race condition이라고 함. 이번 포스팅에서는 바로 이 race condition을 처리하는 방법에 대해 알아보겠습니다.
#include <iostream>
#include <thread>
#include <atomic>
#include <mutex>
#include <chrono>
using namespace std;
int main()
{
int shared_memory(0); // 여러 쓰레드들이 이 변수에 동시에 접근하도록
auto count_func = [&] {
for (int i = 0; i < 1000; i++)
{
this_thread::sleep_for(chrono::milliseconds(1)); // sleep이 없으면 너무 빨리 처리해서 문제가 보이지 않음
shared_memory++;
}
};
thread t1 = thread(count_func); // shared_memory에 접근해서 일을 처리
t1.join(); // t1이 끝나기 전까지는 main을 끝내지 않고 대기
cout << "After" << endl;
cout << shared_memory << endl;
}
하나의 쓰레드에서 shared_memory에 접근해서 일을 처리할 때에는 정상 작동합니다.
thread t1 = thread(count_func); // shared_memory에 접근해서 일을 처리
thread t2 = thread(count_func); // t2도 동시에 shared_memory에 접근해서 일을 처리
t1.join();
t2.join();
이번에는 t2를 추가하여 두 개의 쓰레드로 작업 실행해보면
2000이 출력되지 않고 결과값 역시 프로그램을 실행할 때마다 달라지는 걸 확인할 수 있습니다.
그 이유는 멀티 쓰레드의 작동 방식 때문입니다.
위의 예제에서 shared_memory의 값을 1씩 증가시키는 방법은 총 3단계로 이뤄집니다.
1. 쓰레드에서 메모리 영역에 저장된 shared_memory의 값을 CPU 영역으로 가져온다.
2. CPU 영역에서 shared_memory의 값을 1 증가시킨다.
3. CPU 영역에서 증가시킨 shared_memory의 값을 메모리 영역에 저장된 shared_memory에 저장한다.
위의 3단계로 작업이 이뤄지는데, 만약 t1에서 shared_memory에 저장된 10이라는 값을 가져와 1,2단계를 진행하는 동안 t2가 재빠르게 1,2,3 단계를 모두 진행하여 shared_memory의 값을 11로 덮어씌워버리면, t1에서 진행한 연산의 결과 역시 11이기 때문에 t1과 t2가 진행한 두 개의 덧셈 연산 중 하나는 묻혀버립니다. 이 때문에 위의 프로그램을 실행했을 때 2000이 출력되지 않고 각기 다른 값이 출력됩니다.
이러한 race condition 문제를 해결하는 방법 중 하나로 <atomic>을 사용하는 방법이 있습니다. atom은 원자라는 뜻이 있는데, 원자의 성질 중 '절대 쪼갤 수 없다'라는 성질에서 이 atomic의 이름을 기인한 것 같습니다.
#include <atomic> // 쪼갤 수 없다 -> 연산의 3단계를 쪼갤 수 없도록 만든다
.
.
int main()
{
atomic<int> shared_memory(0);
.
.
사용법은 위와 같습니다. 기존 예제에 <atomic>헤더를 추가해주고, main 함수에서는 shared_memory 변수를 선언할 때 일반 자료형이 아닌 atomic <int>와 같이 atomic으로 묶어준 변수를 선언합니다. atomic 자료형을 선언함으로써 shared_memory에서 발생하는 ++ 연산은 일반 int 자료형의 덧셈 연산이 아닌 atomic <int> 자료형의 연산이 되어 race condition 문제를 방지할 수 있습니다.
실제로 프로그램을 실행해보면 위와 같이 2000이 제대로 연산되어 출력됨을 볼 수 있습니다.
하지만 일반 자료형이 아니기 때문에 일반 자료형의 연산보다는 낮은 연산 속도를 보여주는 단점을 가지고 있습니다.
race condition을 해결하는 또다른 방법 중 하나는 <mutex>를 이용하는 방법입니다. mutex에 대해서는 지난 포스팅에서 설명했으므로 이번 포스팅에서는 그 사용법과 보완점에 대해서만 다루겠습니다.
#include <mutex>
int main()
{
mutex mtx;
int shared_memory(0); // 일반 int 자료형을 사용
auto count_func = [&] {
for (int i = 0; i < 1000; i++)
{
this_thread::sleep_for(chrono::milliseconds(1));
mtx.lock();
shared_memory++;
mtx.unlock();
}
};
.
.
.
사용법은 위와 같습니다.
헤더에 mutex를 추가하고, shared_memory는 일반 int 자료형 변수로 선언을 합니다.
그리고 count_func를 정의할 때, shared_memory에 접근을 하는 shared_memory++; 코드 앞뒤로 다른 쓰레드의 작업이 shared_memory에 접근하지 못하게 mtx.lock()과 mtx.unlock()으로 묶어줍니다.
그리고 프로그램을 실행시켜보면 역시 정상적으로 2000이 연산되어 출력됨을 알 수 있습니다.
mutex는 lock을 설정했으면 반드시 unlock을 설정해야합니다. unlock을 설정하지 않으면 다른 쓰레드들이 lock에서 막혀 작업을 하지 못하게 됩니다. 하지만 unlock을 설정했음에도 예외처리로 인해 unlock이 실행되기 전에 컴파일러가 catch문으로 넘어가게 되는 등의 문제가 발생할 수도 있습니다. 이러한 mutex의 문제를 보완해주는 것이 바로 lock_guard입니다.
int main()
{
mutex mtx;
int shared_memory(0);
auto count_func = [&] {
for (int i = 0; i < 1000; i++)
{
this_thread::sleep_for(chrono::milliseconds(1));
std::scoped_lock lock(mtx); // 해당 scope 안에서 lock_guard 실행. scope를 벗어나면 자동으로 lock이 해제됨
shared_memory++;
}
};
mutex 자료형의 변수를 선언하는 것은 동일합니다.
대신 mutex가 필요한 스코프 내에서 직접 lock과 unlock을 설정하지 않고, std::scope_lock 자료형 변수를 사전에 정의한 mutex 변수를 할당함으로써 선언해줍니다. 이렇게 설정하면 mutex가 필요한 스코프 내에서 자동으로 lock이 실행되고 해당 스코프를 벗어나면 알아서 unlock이 실행됩니다. lock_guard와 scoped_guard는 C++ 17 환경에서만 사용할 수 있으니 이점 유의하시기 바랍니다.
auto count_func = [&] {
for (int i = 0; i < 1000; i++)
{
//this_thread::sleep_for(chrono::milliseconds(1)); // 쉬는 시간을 제거
shared_memory++;
}
};
흥미로운 점이 하나 있는데, 사실 맨 처음의 예제에서 위와 같이 sleep_for을 통해 쓰레드들의 작업 사이에 주는 쉬는 시간을 없애면
정상적으로 2000이 출력되는 걸 볼 수 있습니다. 이게 어떻게 된 일일까요? 처음 예제에서 멀티 쓰레딩 간에 작업이 제대로 실행되지 않은 것이 sleep_for를 통한 쉬는 시간 때문이었을까요?
그렇지 않습니다. 여기서 2000이 제대로 출력되지 않은 건, 오히려 멀티쓰레딩이 제대로 실현되지 않았기 때문입니다. 예제에서의 덧셈 연산은 굉장히 간단한 연산이기 때문에 굳이 두 개의 쓰레드가 동시에 작업을 할 필요도 없이 t1 쓰레드가 1000번의 덧셈 연산을 실행하고, 곧이어 t2가 1000번의 연산을 실행합니다. 이는 멀티쓰레딩이 아니라 그냥 t1과 t2가 각각의 순서에 맞춰 덧셈 연산을 실행한 결과와 다를 바 없습니다. 이러한 문제들을 발견하는 게 쉽지 않아 멀티쓰레딩이 굉장히 효율적인 방법이면서 동시에 사용하기 어려운 테크닉인 것 같습니다.
'Information Technology > C++' 카테고리의 다른 글
[C++] 벡터 내적과 멀티 쓰레딩 (0) | 2020.01.03 |
---|---|
[C++] 작업 기반 비동기 프로그래밍 (0) | 2020.01.03 |
[C++] 멀티 쓰레딩 기초 (0) | 2020.01.01 |
[C++] C++17에서 여러 개의 리턴값 반환 (0) | 2020.01.01 |
[C++] 람다 함수와 std::function (0) | 2019.12.28 |