C++ Chapter 19.4 : 레이스 컨디션, std::atomic, std::scoped_lock

Date:     Updated:

카테고리:

태그:

인프런에 있는 홍정모 교수님의 홍정모의 따라 하며 배우는 C++ 강의를 듣고 정리한 필기입니다. 😀
🌜 [홍정모의 따라 하며 배우는 C++]강의 들으러 가기!


chapter 19. 모던 C++ 필수 요소들

레이스 컨디션, std::atomic, std::scoped_lock

🔔 레이스 컨디션(Race Condition)이란

Race Condition 👉 동일한 데이터를 서로 다른 여러 스레드들이 접근하는 과정에서 생기는 문제

  • 멀티 스레딩은 여러개의 스레드들이 하나의 메모리를 공유하기 때문에 오류가 발생활 확률이 높다. 👉 레이스 컨디션
    • 접근 하는 순서에 따라 데이터 또는 결과값이 수정되고 그 수정된 데이터에 다른 데이터가 접근하려 잘못된 결과가 발생할 수 있다.

레이스 컨디션이 발생하는 과정

#include <iostream>
#include <mutex>
#include <atomic>
#include <thread>

using namespace std;

int main()
{
	int shared_memory(0);

	auto count_func = [&](){
		for (int i = 0; i < 1000; ++i)
		{
			std::this_thread::sleep_for(std::chrono::microseconds(1));
			shared_memory++;
		}
	};

	thread t1 = thread(count_func);
	thread t2 = thread(count_func);

	t1.join();
	t2.join();
	
	cout << "After" << endl;
	cout << shared_memory << endl;
}
💎출력💎

After
1962

0 초기 값을 가진 shared_memory라는 변수에 열 스레드들이 동시에 접근하도록 해볼 것이다.

  • count_func는 1 밀리세컨즈 대기하고난 후 shared_memory를 1만큼 증가시키는 일을 하는 함수다.
    • 모든 외부 변수를 레퍼런스로 받는 [&] 람다 함수이므로 shared_memory 값도 변하게 된다.
    • sleep_for 함수를 두어 대기 시간을 가진 이유는 대기 시간 없으면 shared_memory++; 연산이 너무 단박에 빨리 끝나버려서 문제가 안보일 수 있어서
  • t1, t2 스레드는 각자 count_func 작업을 수행한다.

📢 레이스 컨디션 발생 !

결과적으로 shared_memory 값은 최종적으로 2000 이 될 것으로 기대가 되지만 실제론 1962 가 나왔다! 다시 실행해보면 1968이 나오는 등 실행 때마다 2000 과는 다른 값이 나오게 된다.

OS 는 CPU가 어떤 스레드를 실행할지에 대한 스케줄링을 마음 대로 결정한다.

  • A 스레드를 작업하는 중간에도 B 스레드를 작업하러 가고 그럴 수 있음
  • 그래야 병렬적으로 처리 되는 것처럼 보이니까!

shared_memory++; 연산시 다음과 같은 3 단계로 진행이 된다.

image

  • 1 단계 👉 메모리에 있는 shared_memory 값을 CPU로 보낸다. CPU가 데이터를 읽어들임!
  • 2 단계 👉 CPU 안에서 값을 +1 더하는 연산을 한다
  • 3 단계 👉 더한 결과값을 다시 shared_memory에 덮어씌운다

image

  1. 현재 shared_memory 값이 624 라고 가정해보자.
  2. t1 스레드가 1, 2 단계를 마치고 3단계로 가기 전 OS 는 sleep_for 대기 시간을 끝낸 t2 스레드의 작업을 처리해준다.
    • 아직 t1의 연산 결과인 625를 shared_memory에 덮어쓰지 못한 상태에서 t2로 넘어감
    • 여전히 shared_memory의 값은 624 인 상태.
  3. t2가 잽싸게 1, 2, 3 단계를 모두 끝냈다고 가정해보자.
    • shared_memoryt1 스레드가 625를 덮어 씌우지 못했어서 여전히 624 이므로 이 값으로 1, 2, 3 단계를 진행하여 shared_memory는 625가 되었다.
  4. 다시 못다한 t1 스레드의 3 단계 작업으로 돌아왔다.
    • 스레드들은 같은 메모리를 공유하지만 각자 사용하는 CPU내의 레지스터는 별개이다. 따라서 t1 스레드의 연산 결과였던 625는 CPU 레지스터 내에 보관되어 있는 상태.
    • 2 단계인 CPU 연산 결과였던 625 를 shared_memory에 덮어 씌운다.
    • 이미 shared_memoryt2 스레드의 작업으로 625 가 된 상태였으므로 그대로 625 다.
  5. 두 스레드의 작업으로 624 👉 626 이 될 것으로 기대했지만 정작 624 👉 625 가 됐다.
    • 덧셈 연산 하나가 무시된 꼴! 그래서 위에서 2000이 나오지 않고 1968 같은 더 적은 값이 나오게 된 것이다.
    • 정리하자면 t1 스레드의 작업 도중에 t2 스레드가 개입 되었기 때문에 이런 문제가 발생한 것이다.
    • 한 스레드가 작업 하는 도중에 다른 스레드가 동일한 메모리에 접근했기 때문

sleep_for 함수가 없다면 2000으로 결과가 올바르게 나오지만 그렇다고 해서 올바르게 동작했다는 것은 아니다. 너무 너무 너무 빠르게 for문을 1000번 다 돌아버려서 t2 스레드가 개입하기도 전에 t1 스레드가 for문 1000번을 다 돌아버리기 때문에 그런 것이다! 따라서 이런 레이스 컨디션 문제를 관측하기 위해 잠깐 잠깐씩 대기 시간을 두었다.


🔔 레이스 컨디션 해결 방법

std::atomic

#include <atomic>

  • 아래 3단계를 꼭 한번에 다 실행되게끔 묶어준다. 쪼개질 수 없는 원자처럼!
    • 1 단계 👉 메모리에 있는 shared_memory 값을 CPU로 보낸다. CPU가 데이터를 읽어들임!
    • 2 단계 👉 CPU 안에서 값을 +1 더하는 연산을 한다
    • 3 단계 👉 더한 결과값을 다시 shared_memory에 덮어씌운다
  • 사용 방법
    • 메모리가 공유되는 변수를 atomic 타입으로 설정하면 된다.
      atomic<int> shared_memory(0);
      
    • atomic에 증감 연산자 같은 여러 연산자들이 오버로딩 되어있기 때문에 shared_memory++ 할 수 있다.
      • 혹은 shared_memory.fetch_add(1)
  • 단점
    • 느리다! atomic의 연산은 당연 일반 증감연산보다 느리다. 남용하면 엄청 느리다.
#include <iostream>
#include <atomic>
#include <thread>

using namespace std;

int main()
{
	atomic<int> shared_memory(0);
	
	auto count_func = [&]()
	{
		for (int i = 0; i < 1000; ++i)
		{
			std::this_thread::sleep_for(std::chrono::microseconds(1));
			shared_memory++;		// 혹은 shared_memory.fetch_add(1);	
		}
	};

	std::thread t1 = std::thread(count_func);
	std::thread t2 = std::thread(count_func);

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

    cout << "After" << endl;
	cout << shared_memory << '\n';
}
💎출력💎

After
2000


mutex 의 lock, unlock

#include <mutex>

  • shared_memroy++을 A 스레드가 이미 작업 중이거나 작업을 하다가 말았으면 다른 스레드들이 못 건드리게 잠궈둔다.
    mutex mtx;
    
    mtx.lock();
    shared_memory++;		
    mtx.unlock();
    
  • 단점
    • unlock을 반드시 해줘야 다른 스레드가 작업을 수행할 수 있는데 프로그래머가 명시하는 것을 깜빡할 수도 있다.
    • 예외가 발생하거나 했을때 unlock을 건너뛸 수도 있다.
#include <iostream>
#include <mutex>
#include <atomic>
#include <thread>

using namespace std;

int main()
{
	int shared_memory(0);
	mutex mtx;

	auto count_func = [&]()
	{
		for (int i = 0; i < 1000; ++i)
		{
			mtx.lock();
			shared_memory++;		
			mtx.unlock();
		}
	};

	std::thread t1 = std::thread(count_func);
	std::thread t2 = std::thread(count_func);

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

    cout << "After" << endl;
	cout << shared_memory << '\n';
}
💎출력💎

After
2000


std::lock_guard

#include <mutex>

mutex mtx;

std::lock_guard<mutex> lock(mtx);		// unlock을 해주지 않아도 자동으로 해줌!
shared_memory++;
  • shared_memroy++을 A 스레드가 이미 작업 중이거나 작업을 하다가 말았으면 다른 스레드들이 못 건드리게 잠궈둔다. mutex의 lock 함수, unlock 함수와 기능은 동일하다.
  • lock 함수, unlock 함수과 다른 점
    • 👉 스마트 포인터처럼 자신의 영역을 벗어나면 자동으로 unlock()을 호출해준다.
      • lock_guard 타입의 변수 lock은 for문 scope를 벗어나 메모리가 해제될 때 자동으로 unlock 된다.
#include <iostream>
#include <mutex>
#include <atomic>
#include <thread>

using namespace std;

int main()
{
	int shared_memory(0);

	mutex mtx;
	auto count_func = [&]()
	{
		for (int i = 0; i < 1000; ++i)
		{
			std::lock_guard<mutex> lock(mtx);		// unlock을 해주지 않아도 자동으로 해줌!
			shared_memory++;
		}
	};

	std::thread t1 = std::thread(count_func);
	std::thread t2 = std::thread(count_func);

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

    cout << "After" << endl;
	cout << shared_memory << '\n';
}
💎출력💎

After
2000


std::scoped_lock

#include <mutex>

mutex mtx;

std::scoped_lock<mutex> lock(mtx);		// unlock을 해주지 않아도 자동으로 해줌!
shared_memory++;
  • shared_memroy++을 A 스레드가 이미 작업 중이거나 작업을 하다가 말았으면 다른 스레드들이 못 건드리게 잠궈둔다. mutex의 lock 함수, unlock 함수와 기능은 동일하다.
  • std::lock_guard 함수와 동일하다. 스마트 포인터처럼 자신의 영역을 벗어나면 자동으로 unlock()을 호출해준다.
  • 단, C++ 17에서만 사용 가능하며 std::lock_guard 함수보다 권장된다.
#include <iostream>
#include <mutex>
#include <atomic>
#include <thread>

using namespace std;

int main()
{
	int shared_memory(0);

	mutex mtx;
	auto count_func = [&]()
	{
		for (int i = 0; i < 1000; ++i)
		{
			std::scoped_lock<mutex> lock(mtx);		// unlock을 해주지 않아도 자동으로 해줌!
			shared_memory++;
		}
	};

	std::thread t1 = std::thread(count_func);
	std::thread t2 = std::thread(count_func);

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

    cout << "After" << endl;
	cout << shared_memory << '\n';
}
💎출력💎

After
2000


🌜 개인 공부 기록용 블로그입니다. 오류나 틀린 부분이 있을 경우 
언제든지 댓글 혹은 메일로 지적해주시면 감사하겠습니다! 😄

맨 위로 이동하기

댓글남기기