본문 바로가기
프로그래밍/시스템 프로그래밍

락 기초

by 오늘의논리 2024. 3. 4.
728x90
#include <iostream>
#include <thread>
#include <vector>

vector<int> v;

void push()
{
	for (int i = 0; i < 10000; ++i)
	{
		v.push_back(i);
	}
}

int main()
{
    std::thread t1(push);
    std::thread t2(push);

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

    cout << v.size() << endl;
    
    return 0;
}

 

이런 코드가 있다고 가정하면 v 사이즈는 어떻게 출력될까? 10000 넣는 쓰레드가 두개가 있으니 2만이 출력될까? 실행해 보면 알겠지만 크래쉬가 나고 문제가 생긴다. 이는 동적배열은 사이즈가 차면 데이터를 지우고 이사를 하는데 t1 쓰레드가 벡터가 다꽉찼다고 인지하여 데이터를 지우고 이사를 하는데 t2 쓰레드가 이미 지워진 데이터를 지우거나 해서 문제가 발생했다고 예측할 있다.

 

그렇다면 reserve 통해 미리 사이즈를 늘려논다면 어떨까?

#include <iostream>
#include <thread>
#include <vector>

vector<int> v;

void push()
{
    for (int i = 0; i < 10000; ++i)
    {
    	v.push_back(i);
    }
}

int main()
{
    v.reserve(20000);

    std::thread t1(push);
    std::thread t2(push);

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

    cout << v.size() << endl;

    return 0;
}

 

결과:

이제는 크래쉬는 나지 않지만 20000 아닌 조금 부족한 값이 뜬다는 것을 확인 있다. 이는 멀티쓰레드 환경에서 동적배열에 데이터를 넣을때 인덱스가 겹치거나 해서 발생하는 문제이다.

이때 vector 같은 복잡한 데이터 타입의 연산은 예를들어 push_back 연산만 봐도 메모리 할당, 객체복사 , 포인터 업데이트 여러 단계로 이루어 지고 이러한 연산중 하나가 실행되는 도중에 쓰레드가 동일한 vector 접근하면 예상치 못한 결과가 발생할 수도 있기 때문에 atomic atomic연산을 사용하는것은 불가능하다.

 

그렇기때문에 Lock 이라는것을 이용해야하는데 원래는 CriticalSection 등을 이용했지만 VS11 이후부터는 <mutex> 추가하여 이용가능하다. 선언할 때는 mutex m; 이렇게 선언 하면 된다.

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>

vector<int> v;
mutex m;

void push()
{
    for (int i = 0; i < 10000; ++i)
    {
        //잠그기
        m.lock();

        v.push_back(i);

        //풀기
        m.unlock();
    }
}

int main()
{
    v.reserve(20000);
 
    std::thread t1(push);
    std::thread t2(push);

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

    cout << v.size() << endl;

    return 0;

}​

 

결과:

코드와 같이 lock 걸어주고 unlock 으로 해제하며 사용하는데 lock 건상태에서 다른쓰레드가 unlock 으로 해제해 주기 전까진 접근하지 못한다

 

하지만 이런 Lock 방식을 사용하면 좋은것은 아니다. 왜냐하면 서로 경합하며 lock 걸기때문에 일반적인 방식보다는 느리게 동작할 밖에 없다. 이러한 lock mutual exclusive(상호베타적)이라는 특징을 가지고 있다.

그렇다면 이러한 lock 재귀적으로   있을까? 코드에서 push 함수에

void push()
{
    for (int i = 0; i < 10000; ++i)
    {
        //잠그기
        m.lock();
        m.lock();

        v.push_back(i);

        //풀기
        m.unlock();
        m.unlock();
    }
}​

 

이렇게 lock unlock 두번씩 사용해서 실행하면 바로 크래쉬가 난다. 이는 허용되지 않았고 재귀적으로 lock 되는 다른 버전의 lock 있다. 쨋든 lock 걸면 unlock 해주어야 한다는것은 알겠는데 만약 조건처리 등에 의해 unlock 해주지 못한다면 프로그램은 영영 lock 걸려서 종료되지 않을 것이다. 이를 데드락 혹은 리소스 데드락 이라고 하는데 쨋든! 작은 함수나 소규모 프로그램이라면 수동으로 lock unlock 해주는것이 어렵지 않지만 게임이나 프로그램의 규모가 커지면 수동으로 잠그고 해제 하는 것은 찾기도 쉽지 않을 뿐더러 좋지 않은 습관이라고 있을 이다.

 

그렇다면 이를 해결해기위해 c++패턴중 RAII(Resource Acquisition Is Initialization)이라는것이 있는데 어떤 래핑 혹은 래퍼 클래스를 만들어 생성자에서 잠그고 소멸자에서 풀어주는 행동을 하는 것이다. 물론 Lock 뿐만 아니라 DB 연동등서도 사용되는 패턴이다.

 

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>

vector<int> v;
mutex m;

template<typename T>
class LockGuard
{
public:
    LockGuard(T& m)
    {
        _mutex = &m;
        _mutex->lock();
    }
    ~LockGuard()
    {
    	_mutex->unlock();
    }

    private:
    T* _mutex = &m;

};

void push()
{
	for (int i = 0; i < 10000; ++i)
    {
        //잠그기
        LockGuard<std::mutex> lockGuard(m);
        v.push_back(i);

        if (i == 5000) // 종료되어도 객체가 소멸하면서 소멸자에서 해제를 해준다.
        	break;
	}

}

int main()
{
    v.reserve(20000);

    std::thread t1(push);
    std::thread t2(push);

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

    cout << v.size() << endl;

    return 0;
}

 

 

코드에서는 LockGuard 라는 래퍼 클래스를 만들어서 생성시 lock 을걸어주고 소멸시 해제시켜서 실행하면 정상적으로 프로그램이 서로 5001개씩만 데이터를 넣어주고 종료가 잘된다.

 

하지만 매번 이런 래퍼 클래스를 만들어야 하는건 아니고 이미 표준에도 들어가 있다.

void push()
{
    for (int i = 0; i < 10000; ++i)
    {
        //잠그기
        std::lock_guard<std::mutex> lockGuard(m);
        v.push_back(i);

        if (i == 5000) // 종료되어도 객체가 소멸하면서 소멸자에서 해제를 해준다.
        	break;
    }
}

 

코드와 동일하게 동작한다. 또는

std::unique_lock<std::mutex> uniqueLock(m); 이렇게 유니크 이라는 방식도 있는데 완전히 동일하지는 않고 추가적 세부기능으로는

 

void push()
{
    for (int i = 0; i < 10000; ++i)
    {
        //잠그기
        std::unique_lock<std::mutex> uniqueLock(m, std::defer_lock);//락 예약
        uniqueLock.lock();//락 실행

        v.push_back(i);

        if (i == 5000) // 종료되어도 객체가 소멸하면서 소멸자에서 해제를 해준다.
       		break;
    }
}

이렇게 Lock 예약을 걸어놨다가 내가 lock 걸고싶을때 걸수 있다. 하지만 당연히 일반적인 lockguard 보다는 조금 용량을 차지 하기 때문에 간단한 경우라면 그냥 lockguard 사용하는 것이 낫고 lock 거는 시점을 뒤로 미루거나 해야할 경우라면 유니크락을 사용하는것이 낫다.

 

기초에 대해 알아봤는데 락을 거는 시점도 유의깊게 봐야 한다. 코드같이 반복문 안에 lock 되어있을경우 반복문마다 lock 걸었다가 풀었다가 하고 함수 초입부분에 있었다면 함수 실행시에 걸고 끝나면 해제가 될것이다. 이런 부분을 주의깊게 생각해서 코드를 짜야 것이다.

728x90

'프로그래밍 > 시스템 프로그래밍' 카테고리의 다른 글

SpinLock  (1) 2024.03.05
DeadLock  (0) 2024.03.04
Atomic  (0) 2024.03.03
쓰레드 생성  (1) 2024.03.03
멀티 쓰레드  (0) 2024.03.03

댓글