이전 포스트에서 std::thread를 이용하여 멀티 스레드를 사용해보았다.
포스트의 마지막에 말했던 멀티 스레드 프로그래밍을 하면서 주의해야 할 점에 대해 알아보려고 한다.
thread를 처음 사용시 예상하지 못하는 문제점
우선, thread는 process와 다르게 스택 메모리를 제외한 나머지 힙, 코드, 데이터 영역을 공유한다.
아래와 같은 코드를 작성하고 출력되는 결과를 한번 예상해보자.
#include <iostream>
#include <thread>
#include <vector>
using namespace std;
int num;
void addNum()
{
num++;
}
int main()
{
vector<thread> vThread;
for(int i = 0; i < 5; i++)
{
vThread.push_back(thread([]()
{
for(int i = 0; i < 10000; i++)
{
addNum();
}
}
)
);
}
for(int i = 0; i < 5; i++)
{
vThread[i].join();
}
cout << num << endl;
}
addNum()이라는 함수에서 전역변수 num 을 1씩 증가시켜준다.
thread들은 프로세스 내의 데이터 영역을 공유하기때문에 전역변수 num을 공유한다.
addNum함수를 10000번 반복하는 동작을 총 5개의 스레드에서 수행하게 된다.
우리가 쉽게 생각했을때 1을 더하는것을 5만번 수행하니 50000이라는 값이 출력되어야 할 것 같다.
아래는 위 코드를 빌드하여 실제로 5번 실행했을때 나오는 결과 값이다.
3번째 출력처럼 50000만이 나와야 될 것 같은데 그렇지 않다.
한번 위 예제 코드를 가져다가 실행해보면 실행할때마다 다른 값이 나오는것을 확인 할 수 있다.
왜 이런 현상이 일어나는지 알아보자.
어셈블리어를 통해 알아보는 CPU 연산 과정
num++이라는 연산을 CPU에서 수행하기 위해서는 CPU의 레지스터에 데이터를 기록 한 다음 연산을 수행해야 한다.
즉, 메모리에 저장된 num이라는 값을 레지스터에 복사한 후에 연산을 하고, 다시 메모리에 복사하는 방식으로 연산이 처리된다.
우리가 짠 코드를 어셈블리어로 변환해주는 사이트가 있다.
Complier Explorer라는 사이트이다
위 사이트에 우리가 짠 코드를 복사해서 넣어보자.
변환된 코드 어셈블리어 코드 중 우리가 관심있게 볼 부분은 addNum() 함수에서도 아래 부분이다.
addNum():
mov eax, DWORD PTR num[rip]
add eax, 1
mov DWORD PTR num[rip], eax
위 코드를 간단히 설명하자면,
rip는 레지스터이다. 프로세서가 읽고 있는 현재 명령의 위치를 가키리는 명령 포인터이다.
num[rip]는 rip가 가리키는 주소로부터 DWORD만큼을 읽는다는 것이다. 즉, 전역변수 num의 값을 가져온다.
mov 명령어는 move와 비슷하게 생긴것 처럼 num의 값을 가져와서 eax 레지스터에 복사한다.
add 명령어는 eax 레지스터의 값에 오른쪽 값 1을 더해주는 명령어이다.
세번째 줄을 보면 add 명령어를 통해 1 증가된 eax값을 num에 다시 복사해주는 것을 볼 수 있다.
위 어셈블리어를 통해 num++이 실제로 어떤식으로 동작되는지 알아보았다.
만약 스레드 2개가 아래와 같이 동작한다면 어떻게 될까 ?
분명 2개의 스레드가 addNum()함수를 실행했는데 num 값이 1이 되었다.
스레드 1이 num의 값을 레지스터에 값을 복사 했을때
스레드 2가 그 순간에 num 값을 1증가 시켰을 경우, 스레드 1에서 값이 변경되었다는 것을 알 수 없다.
따라서 이미 num의 값은 1이 되었지만, 스레드 1의 남은 연산 과정에 의해서 num에 1이 다시 들어가게 된다.
위와 같이 스레드들이 동시에 접근하여 문제가 발생할 수 있는 지점을 크리티컬 섹션이라고 한다.
그리고 스레드들이 공유 자원을 동시에 접근하여 발생하는 문제를 race condition이라고 부른다.
멀티 스레드 사용시 공유 자원을 안전하게 사용할 수 있는 mutex
위와 같은 문제를 해결하기 위한 방법으로 mutex라는 것이 있다.
사용 방법은 아래와 같다.
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // mutex 헤더 추가
using namespace std;
mutex m; //전역으로 mutex 변수 선언
int num;
void addNum()
{
m.lock(); // lock()함수를 통해 다른 스레드가 접근할 수 없도록 문을 잠금
num++;
m.unlock(); // 내 연산이 모두 끝났을때 unlock()함수를 통해 문을 열어 다른 스레드가 접근 가능하도록 변경
}
int main()
{
vector<thread> vThread;
for(int i = 0; i < 5; i++)
{
vThread.push_back(thread([]()
{
for(int i = 0; i < 10000; i++)
{
addNum();
}
}
)
);
}
for(int i = 0; i < 5; i++)
{
vThread[i].join();
}
cout << num << endl;
}
#include <mutex> 헤더를 추가해준다.
thread들이 mutex 변수를 공유할 수 있도록 전역으로 mutex 변수를 선언해준다.
크리티컬 섹션, 즉 공유하는 자원 num의 값이 변경되는 부분 addNum() 함수 안에서 n++의 위에서
lock() 함수를 통해 다른 스레드가 해당 자원에 접근할수 없도록 막는다.
공유하는 자원에 대한 연산이 모두 끝났다면, unlock() 함수를 통해 다른 스레드가 자원에 접근할수 있도록 해준다.
위 동작은 우리가 공용화장실에 들어가서 다른 사람이 못들어오게 문을 잠그고 일을 본 후에 문을 열고 나가는 것과 비슷하다고 볼 수 있고, 실제로 많은 사람들이 mutex를 설명할때 많이 비유하기도 한다.
위 코드의 실행 결과를 동일하게 5번한 후의 결과를 보겠다.
모두 정확하게 50000이 출력되는것을 확인 할 수 있다.
오늘은 여기까지 알아보도록 하겠다.
다음 포스트에서는 mutex lock()을 걸어주었는데 unlock()을 하지 않으면 어떤 상황이 벌어지는지,
해결 방법은 어떠한 것들이 있는지 알아보도록 하겠다.
출처 :
'Development > C++' 카테고리의 다른 글
[C++] Lock(std::lock_guard, std::unique_lock) (0) | 2022.10.14 |
---|---|
[C++] Process와 thread (0) | 2022.10.05 |