ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • C++ 멀티스레드, lock(mutex)
    C++/C++ 멀티스레드 2022. 10. 6. 21:07
    728x90

    저번 포스팅에서는 공유데이터에 대한 동시접근으로 인해 발생하는 문제를 해결하기 위한 방법으로

    atomic을 사용해봤다.

     

    오늘은 mutex라는 lock을 사용해 보겠다.

     

    먼저 문제가 되는 상황을 살펴보면

    이번에는 C++의 컨테이너 자료형 Vector가 멀티스레드 환경에서 동시에 접근되는 상황이다.

     

    들어가기에 앞서 vector자료형에 대해서 간단히 살펴보면

    1. 가변배열

    2. 벡터의 size가 capacity만큼 커지면,

    2-1. 새로운 메모리공간에 기존의 1.5~2배에 해당하는 capacity를 할당받고,

    2-2. 새로운 메모리공간에 기존의 메모리에 있던 값들을 복사한다.

    2-3. 기존의 메모리에 있던 값들을 지운다.

     

    #include <iostream>
    #include <thread>
    #include <atomic>
    #include <mutex>
    
    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;
    }

    이렇게 실행하면, v.size()가 이상한 값이 나오겠지라고 생각할 수 있지만,

    아예 실행조차 안된다.

     

    이유는 이렇다.

    1. push_back을 하던 도중, capacity를 확장해야 할 때, 두 스레드 모두 capa를 늘리려고 시도할 것이다. 이 때

    t1스레드가 먼저 새로운 공간에 기존의 값을 복사하고 삭제를 했는데 

    t2스레드가 기존의 공간에 있던 값을 복사하려고 하면, 이미 삭제된 값을 참조하는 상황이 된다.

    double free문제가 발생하는 것이다.

     

    2. t2스레드가 기존의 값이 삭제되기 전에 기존의 값을 무사히 복사했다고 하자.

    하지만 t1스레드와 t2스레드 모두 새로운 공간의 메모리를 할당받으려고 하니까

    동일한 값이 두 곳에 복사가 되는 꼴이 된다.

     

    그러면, 벡터가 capacity를 확장할 일이 없도록

    미리 예상되는 사용량을 예약하고 사용하면 어떨까?

    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;
    }

    위의 문제는 스레드가 확장할 때 생겼던 문제니까 괜찮아 보이지만

    위 코드를 실행해보면 실행은 잘 되지만, 20000은 나오지 않고, 1999X정도의 값이 나온다.

     

    아마도, 두 개의 스레드가 동시에 push_back을 하며, 같은 메모리공간에 값을 써서 발생한 문제인 것으로 보인다.

     

    그러면 어떻게 해야할까?

    이전 포스팅에서 배운 atomic을 쓰면 좋을 듯 싶은데,

    atomic을 사용하면 push_back()같은..vector의 기능을 활용하지 못한다.

     

    이 때 필요한 것이 자물쇠(lock)이다.

    Lock

    lock의 개념을 간단하게 생각하면

    1인용 화장실이라고 생각하면 될 것 같다.

     

    누군가가 사용하려고 들어가서 잠그면, 다른 사람은 기다려야하는 것처럼

    멀티스레드에서 어떤 스레드가 공유자원에 대해서 lock을 걸고 사용하면,

    다른 스레드는 lock이 풀릴 때 까지 대기해야한다.

    이렇게 함으로써, 공유자원에 동시접근 했을 때 발생하는 문제를 해결할 수 있다.

     

    vector<int> v;
    mutex m;
    
    void Push()
    {
    	for (int i = 0; i < 10000; i++)
    	{
    		m.lock();
    		v.push_back(i);
    		m.unlock();
    	}
    }
    
    int main()
    {
    
    	std::thread t1(Push);
    	std::thread t2(Push);
    
    	t1.join();
    	t2.join();
    
    	cout << v.size() << endl;
    }

     

    C++에서는 mutex를 사용할 수 있다.

    가장 기본적인 lock이다.

     

    코드에서 lock과 unlock사이에는 항상 한 개의 스레드만 작업할 수 있는 것이 보장된다.

    이러한 특징을 상호배제(mutual exclusive)라고 한다.

     

    하지만 위와 같이 lock을 해줬을 때 발생하는 문제가 하나 있다.

     

    unlock을 이악물고(?) 해줘야 한다는 것이다.

    사실 코드가 짧고, lock의 수가 적다면 문제가 되지 않지만,

    코드가 길~어지고, lock을 이곳저곳에서 하다보면 unlock을 했는지, 하지 않았는지 확실하지 않을 때가 있다.

     

    만약 어떤 스레드가 lock을 걸고, unlock하는 것을 깜빡하고 함수를 종료해버리면

    다른 스레드는 unlock을 하염없이 기다리게 될 것이다.

     

    1인용 화장실에 누가 들어갔는데, 어떤 이유에서인지, 화장실 창문으로 나가버린 것이다.

    그러면, 다른 사람들은 열리지않을 화장실문 앞에서 똥줄타는거다.

     

    void Push()
    {
    	for (int i = 0; i < 10000; i++)
    	{
    		m.lock();
    		v.push_back(i);
    
    		if (i == 5500)
    			break;
    
    		m.unlock();
    	}
    }

     

    이런 상황이 있을 수 있다.

     

    for문을 돌다가 break를 통해 빠져나가는 코드는 꽤나 흔하다.

    하지만 이런 코드가 많을수록 unlock을 깜빡할 위험성이 커지게 되는 것이다.

     

    이 때 사용하면 좋은 것이 있다.

    lock_guard, unique_lock

     

    C++ 에서 표준으로 제공하는 함수인데, 프로그래머가 unlock을 하지 않아도 자체적으로 unlock을 해준다.

    따라서 직접 lock을 하는 것 보다, 이런 함수를 사용하는 것이 안전하다.

     

    vector<int> v;
    mutex m;
    
    void Push()
    {
    	for (int i = 0; i < 10000; i++)
    	{
    		std::lock_guard<std::mutex> lockGuard(m);
    		v.push_back(i);
    	}
    }
    
    int main()
    {
    
    	std::thread t1(Push);
    	std::thread t2(Push);
    
    	t1.join();
    	t2.join();
    
    	cout << v.size() << endl;
    }

    이렇게 코드를 작성해보면

    결과도 올바르게 실행되고, unlock을 아차차 깜빡하고 하지 않았지만, 에러가 발생하지 않는다.

     

    unique_lock

    unique_lock은 lock_guard랑 거의 동일한데, lock_guard와 차이점이 있다면, lock 하는 시점을 사용자가 정할 수 있다는 것이다.

     

    lock_guard는 생성하는 즉시 lock이 걸리지만, unique_lock은 생성해놓고, 나중에 lock을 걸 수 있다.

     

    void Push()
    {
    	for (int i = 0; i < 10000; i++)
    	{
    		std::unique_lock<std::mutex> UniqueLock(m, std::defer_lock);
    		//something to do...
    		UniqueLock.lock();
    		v.push_back(i);
    	}
    }
    
    int main()
    {
    	std::thread t1(Push);
    	std::thread t2(Push);
    
    	t1.join();
    	t2.join();
    
    	cout << v.size() << endl;
    }

     

    unique_lock을 생성할 때 std::defer_lock을 인자로 전달해서, lock을 나중에 걸겠다는 의사를 알려줘야 한다.

     

    lock_guard는 어떻게 동작하는 걸까

    업계에서는 유명하다는데

    나는 처음듣는 디자인패턴이 있다고 한다

    RAII(Resource Acquisition Is Initialization)

    이게 먼뜻이고?

     

    여튼... lock_guard를 어떻게 구현하냐 하면

    lock을 갖는 객체를 이용하는 것이다.

     

    lock을 멤버변수로 갖는 클래스를 선언을 하는데

    1. 생성자에서는 인자로 받은 mutex에 lock을 걸어준다.

    2. 소멸자에서는 인자로 받은 mutex의 lock을 해제한다.

     

    이렇게 동작한다.

     

    따라서 lock_guard를 생성할 때 자동으로 lock이 걸리고

    객체가 중괄호 block을 나가거나, 함수가 종료되거나 할 때 소멸되면서 자동으로 unlock이 되게 하는 원리다.

     

    vector<int> v;
    mutex m;
    
    template<typename T>
    class myLockGuard
    {
    public:
    	myLockGuard(T& m)
    	{
    		_mutex = &m;
    		m.lock();
    	}
    	~myLockGuard()
    	{
    		m.unlock();
    	}
    
    private:
    	T* _mutex;
    };
    
    void Push()
    {
    	for (int i = 0; i < 10000; i++)
    	{
    		myLockGuard<std::mutex> lockGuard(m);
    		v.push_back(i);
    	}
    }
    
    int main()
    {
    	std::thread t1(Push);
    	std::thread t2(Push);
    
    	t1.join();
    	t2.join();
    
    	cout << v.size() << endl;
    }

    대충 이런 원리로 구현이 된다.

     

     

    Lock의 범위

    lock을 걸 때는 그 범위도 중요하다.

    "이론적으로"

    lock을 거는 범위가 너무 크면 성능에 좋지 않다. 하지만, 프로그래머가 신경써야하는 부분이 적어진다.

    - 사실상 싱글스레드처럼 동작하게 된다.

    - 멀티스레드를 사용하는 목적은 병렬성을 통한 성능향상인데, 싱글스레드와 마찬가지면,,, 안돼!!

     

    lock을 거는 범위가 너무 작으면 병렬성이 높아져서 성능에는 좋지만, 프로그래머가 신경써야 하는 부분이 많아진다.

    void Push()
    {
    	lock_guard<std::mutex> LockGuard(m);
    	for (int i = 0; i < 10000; i++)
    	{		
    		v.push_back(i);
    	}
    }

     

    이렇게 할 때랑

     

    void Push()
    {
    	for (int i = 0; i < 10000; i++)
    	{		
    		lock_guard<std::mutex> LockGuard(m);
    		v.push_back(i);
    	}
    }

    이렇게 할 때 lock의 범위가 다른데

     

    첫 번째 방식은 스레드 하나가 먼저 Push를 시작해서 lock을 잡으면

    for문을 10000번 모두 수행할 때까지 다른 스레드는 계속 대기해야한다.

     

    두 번째 방식은 스레드가 각 iteration마다 lock을 잡았다 풀었다 하는 방식이기 때문에

    2개의 스레드가 번갈아가면서 push_back을 수행한다.

     

    아직 잘 와닿진 않는다.

     

    728x90

    'C++ > C++ 멀티스레드' 카테고리의 다른 글

    C++ SpinLock구현  (0) 2022.10.09
    멀티스레드 Lock구현  (0) 2022.10.09
    C++, 멀티스레드 교착상태(DeadLock)  (0) 2022.10.08
    C++ 멀티스레드, Atomic  (0) 2022.10.05
    C++ 스레드 생성  (0) 2022.10.05

    댓글

Designed by Tistory.