article thumbnail image
Published 2022. 10. 8. 01:28
728x90

이전 포스트에서 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라는 사이트이다

 

Compiler Explorer

 

godbolt.org

위 사이트에 우리가 짠 코드를 복사해서 넣어보자.

변환된 코드 어셈블리어 코드 중 우리가 관심있게 볼 부분은 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()을 하지 않으면 어떤 상황이 벌어지는지,

해결 방법은 어떠한 것들이 있는지 알아보도록 하겠다.

 

 

출처 :

http://dry-kiss.blogspot.com/2014/01/in-assembly-level.html

'Development > C++' 카테고리의 다른 글

[C++] Lock(std::lock_guard, std::unique_lock)  (0) 2022.10.14
[C++] Process와 thread  (0) 2022.10.05
복사했습니다!