C++ 락(std::lock, std::unique_lock, std::lock_guard, condition_variable...)

C++ lock(std::lock, std::unique_lock, std::lock_guard, condition_variable...)

Posted by dydtjr1128 on April 05, 2020 · 9 mins read CPP

Intro

std에 존재하는 lock 방법들은 운영체제 단에서 지원하는 크리티컬 섹션을 래핑한 mutex를 기반으로 개발자가 쉽게 락을 걸 수 있도록 도와줍니다. 앞서 배운 lock_guard 또한 mutex를 쉽게 사용할 수 있도록 도와 줍니다.

여기서 중요한 점은 mutex와 lock은 분명히 다른 역할을 하는 것입니다.
mutex는 다른 쓰레드가 공유자원에 접근을 하지 못하게 만드는 동기화 객체입니다. 공유 자원에 접근하기 위해서는 mutex에 대한 잠금을 획득하고, 접근 후에는 잠금을 해제 해야 합니다.

lock은 이러한 mutex를 기반으로 잠글수 있는 기능을 캡슐화 한 객체 입니다. 쉽게 생각하면 자물쇠가 lock 이고, 자물쇠의 열쇠 구멍을 mutex라고 생각 할 수 있습니다. 이러한 객체들은 개발자가 쉽고 간편하게 공유자원의 동시접근을 막을 수 있도록 도와줍니다.

기본적인 lock의 종류

std::mutex

앞서 정리했던 mutex 역시 가장 기본적이고 핵심이 되는 부분입니다. c++의 lock은 이러한 mutex를 베이스로 개발자가 더 쉽고 간편하게 다양한 기능들을 쓸 수 있도록 도와줍니다.

std::lock

C++11에서 추가된 std::lock은 기본적인 lock 클래스입니다.

#include <iostream>
#include <mutex>

std::mutex m1, m2;
int main() {
   std::thread th([&]() {
   std::lock(m1, m2);
   std::cout << "th1" << std::endl;
   m1.unlock();
   m2.unlock();
   });
   std::thread th2([&]() {
   std::lock(m1, m2);
   std::cout << "th2" << std::endl;
   m1.unlock();
   m2.unlock();
   });

   std::cout << "hello" << std::endl;
   th.join();
   th2.join();
}

위의 코드처럼 std::lock은 여러개의 mutex를 한번에 잠글 수 있도록 도와 줍니다.

std::lock_guard

std::lock_guard는 많이 쓰이는 락 종류로써 다음처럼 객체 생성 시에 lock되며 객체가 소멸시에 unlock 되는 특성을 가지고 있습니다.

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

std::mutex m1;
int main() {
   std::thread th([&]() {
   std::lock_guard<std::mutex> lock_guard(m1);
   for (int i = 0; i < 100; i++) {
   std::cout << "th1" << std::endl;
   }
   });
   std::thread th2([&]() {
   std::lock_guard<std::mutex> lock_guard(m1);
   for (int i = 0; i < 100; i++) {
   std::cout << "th2" << std::endl;
   }
   });

   std::cout << "hello" << std::endl;
   th.join();
   th2.join();
}
...
mutex.lock()

if(n == 10)
   return false;

mutex.unlock()
...

또한 위와 같이 중간에 리턴되어 unlock이 되지 않는 문제를 해결 할 수 있습니다.

std::unique_lock

std::unique_lock은 기본적으로 lock_guard와 비슷한 특징을 가지고 있습니다. 둘 다 자신의 생명주기를 가지며 소멸 될 때 unlock 됩니다.
std::unique_lock은 기본적으로 생성과 동시에 lock이 걸리고 소멸시에 unlock되지만 그밖에도 옵션을 통해 생성시 lock을 안 시키기고 따로 특정 시점에 lock을 걸 수도 있습니다.

생성자의 인자로 mutex만 넘겨 준다면 생성 시에 lock이 걸리게 됩니다. 생성자의 인자로 mutex와 함께 std::defer_lock, std::try_to_lock, std::adopt_lock을 넘겨 줄 수 있습니다.
세가지 모두 컴파일 타임 상수 입니다.

  • std::defer_lock : 기본적으로 lock이 걸리지 않으며 잠금 구조만 생성됩니다. lock() 함수를 호출 될 때 잠금이 됩니다. 둘 이상의 뮤텍스를 사용하는 상황에서 데드락이 발생 할 수 있습니다.(std::lock을 사용한다면 해결 가능합니다.)
  • std::try_to_lock : 기본적으로 lock이 걸리지 않으며 잠금 구조만 생성됩니다. 내부적으로 try_lock()을 호출해 소유권을 가져오며 실패하더라도 바로 false를 반환 합니다. lock.owns_lock() 등의 코드로 자신이 락을 걸 수 있는 지 확인이 필요합니다.
  • std::adopt_lock : 기본적으로 lock이 걸리지 않으며 잠금 구조만 생성됩니다. 현재 호출 된 쓰레드가 뮤텍스의 소유권을 가지고 있다고 가정합니다. 즉, 사용하려는 mutex 객체가 이미 lock 되어 있는 상태여야 합니다.(이미 lock 된 후 unlock을 하지않더라도 unique_lock 으로 생성 해 unlock해줄수 있습니다.)
#include <mutex>
#include <thread>
#include <chrono>

struct Box {
   explicit Box(int num) : num_things{num} {}

   int num_things;
   std::mutex m;
};

void transfer(Box &from, Box &to, int num) {
   std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);
   std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);

   // 2개의 뮤텍스를 동시에 lock
   std::lock(lock1, lock2);

   from.num_things -= num;
   to.num_things += num;
}

int main() {
   Box acc1(100);
   Box acc2(50);

   std::thread t1(transfer, std::ref(acc1), std::ref(acc2), 10);
   std::thread t2(transfer, std::ref(acc2), std::ref(acc1), 5);

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

위의 예제는 unique_lock의 사용 예제 입니다. 각 transfer 함수를 쓰레드로 실행하며 그때 인자로 mutex를 가진 Box 객체를 넘겨줍니다.
각 쓰레드에서는 lock을 걸고 계좌의 돈을 옮기는 로직을 수행합니다.

하지만 이와 같은 상황에서는 주석 친 부분과 같이 unique_lock을 2줄에 걸쳐 잠그는 코드는 사용해서는 안됩니다. 이러한 코드는 문제를 발생 시 킬 수 있는 여지가 존재합니다.
만약에 2줄에 걸쳐 unique_lock을 이용해 락을 건다면 이때는 양쪽 다 락이 걸리는 데드락이 발생할 수 있기 때문입니다. 여기서는 std::defer_lock을 이용해 생성자에서 잠그는 것이 아니라 잠금 구조만 생성을 하고 후에 잠그도록 하였습니다.

하지만 transfer 함수가 다음과 같은 코드로 되어 있다면 어떻게 될까요?

void transfer(Box &from, Box &to, int num) {
   //객체 생성과 동시에 lock
   std::unique_lock<std::mutex> lock1(from.m);
   std::unique_lock<std::mutex> lock2(to.m);

   from.num_things -= num;
   to.num_things += num;
}

위와 같은 상황이라면 쓰레드 1(from = acc1, to = acc2)from.m(acc1)을 락 걸고 그 다음줄인 to.m(acc2)를 수행하기 전에 쓰레드 2(from = acc2, to = acc1)가 첫줄에서 from.m(acc2)를 건 상황이 될 수 있습니다.
이때 두 쓰레드가 그 다음줄을 수행하려고 하지만 둘다 락이 걸려있는 상태이므로 대기를 하게 됩니다. 하지만 둘다 락을 풀어 주지 않기 때문에 데드락 현상이 발생하게 됩니다.
그래서 이런상환에서는 std:lock을 이용해 동시에 락을 걸어 주어야 타이밍 이슈로 데드락이 발생하지 않습니다.

Reference

https://en.cppreference.com/w/cpp/thread/mutex
https://en.cppreference.com/w/cpp/thread/unique_lock/unique_lock
https://docs.microsoft.com/ko-kr/cpp/standard-library/unique-lock-class?view=vs-2019