C ++에서 이동 가능한 유형의 뮤텍스를 어떻게 처리해야합니까?


85

설계 상 std::mutex이동하거나 복사 할 수 없습니다. 이것은 A뮤텍스를 보유 하는 클래스 가 default-move-constructor를받지 않음을 의미합니다 .

A스레드로부터 안전한 방식 으로이 유형을 이동 가능 하게 만드는 방법은 무엇입니까?


4
질문에는 특이한 점이 있습니다. 이동 작업 자체도 스레드로부터 안전해야합니까, 아니면 개체에 대한 다른 액세스가 스레드로부터 안전하다면 충분합니까?
Jonas Schäfer

2
@paulm 정말 디자인에 따라 다릅니다. 클래스에 뮤텍스 멤버 변수 std::lock_guard가 있고 is 메서드 범위 만있는 것을 자주 보았습니다 .
Cory Kramer

2
@Jonas Wielicki : 처음에는 이동하는 것도 스레드로부터 안전해야한다고 생각했습니다. 그러나 다시 생각하는 것은 아닙니다. 객체를 이동 구성하면 일반적으로 이전 객체의 상태가 무효화되기 때문에 이것은별로 의미가 없습니다. 따라서 다른 스레드 이전 개체에 액세스 할 수 없어야합니다 . 그렇지 않으면 곧 유효하지 않은 개체에 액세스 할 수 있습니다. 내가 맞아?
Jack Sabbath


1
@Dieter Lücking : 네, 이것이 아이디어입니다. 뮤텍스 M은 클래스 B를 보호합니다. 그러나 스레드로부터 안전하고 액세스 가능한 객체를 갖기 위해 둘 다 어디에 저장합니까? M과 B는 모두 A .. 클래스로 갈 수 있으며이 경우 클래스 A는 클래스 범위에서 Mutex를 갖습니다.
Jack Sabbath

답변:


104

약간의 코드로 시작해 보겠습니다.

class A
{
    using MutexType = std::mutex;
    using ReadLock = std::unique_lock<MutexType>;
    using WriteLock = std::unique_lock<MutexType>;

    mutable MutexType mut_;

    std::string field1_;
    std::string field2_;

public:
    ...

C ++ 11에서는 실제로 활용하지는 않지만 C ++ 14에서는 훨씬 더 유용하게 될 다소 암시적인 형식 별칭을 거기에 넣었습니다. 인내심을 가지십시오.

귀하의 질문은 다음과 같이 요약됩니다.

이 클래스에 대한 이동 생성자와 이동 할당 연산자를 어떻게 작성합니까?

이동 생성자부터 시작하겠습니다.

생성자 이동

회원 mutex이 만들어졌습니다 mutable. 엄밀히 말하면 이동 멤버에게는 필요하지 않지만 복사 멤버도 원한다고 가정합니다. 그렇지 않은 경우 mutex를 만들 필요가 없습니다 mutable.

시공 할 때 A잠글 필요가 없습니다 this->mut_. 그러나 mut_생성중인 객체 (이동 또는 복사) 를 잠글 필요가 있습니다. 다음과 같이 할 수 있습니다.

    A(A&& a)
    {
        WriteLock rhs_lk(a.mut_);
        field1_ = std::move(a.field1_);
        field2_ = std::move(a.field2_);
    }

thisfirst 의 멤버를 기본으로 구성한 다음 a.mut_잠금이 설정된 후에 만 ​​값을 할당해야합니다 .

이동 할당

이동 할당 연산자는 다른 스레드가 할당 표현식의 lhs 또는 rhs에 액세스하고 있는지 알 수 없기 때문에 훨씬 더 복잡합니다. 그리고 일반적으로 다음과 같은 시나리오를 경계해야합니다.

// Thread 1
x = std::move(y);

// Thread 2
y = std::move(x);

위의 시나리오를 올바르게 보호하는 이동 할당 연산자는 다음과 같습니다.

    A& operator=(A&& a)
    {
        if (this != &a)
        {
            WriteLock lhs_lk(mut_, std::defer_lock);
            WriteLock rhs_lk(a.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            field1_ = std::move(a.field1_);
            field2_ = std::move(a.field2_);
        }
        return *this;
    }

std::lock(m1, m2)두 뮤텍스를 하나씩 잠그는 대신 두 개의 뮤텍스를 잠그는 데 사용해야 합니다. 하나씩 잠그면 두 스레드가 위와 같이 반대 순서로 두 개체를 할당하면 교착 상태가 발생할 수 있습니다. 요점은 std::lock교착 상태를 피하는 것입니다.

생성자 복사

사본 멤버에 대해 묻지 않았지만 지금 얘기하는 것이 좋습니다 (당신이 아니라면 누군가가 필요합니다).

    A(const A& a)
    {
        ReadLock  rhs_lk(a.mut_);
        field1_ = a.field1_;
        field2_ = a.field2_;
    }

복사 생성자는 이동 생성자와 비슷 ReadLock하지만 WriteLock. 현재 둘 다 별칭 std::unique_lock<std::mutex>이므로 실제로 차이가 없습니다.

그러나 C ++ 14에서는 다음과 같이 말할 수있는 옵션이 있습니다.

    using MutexType = std::shared_timed_mutex;
    using ReadLock  = std::shared_lock<MutexType>;
    using WriteLock = std::unique_lock<MutexType>;

있지만 확실히, 최적화합니다. 그것이 맞는지 결정하기 위해 측정해야 할 것입니다. 그러나이 변경 으로 여러 스레드에서 동일한 rhs의 구문 동시에 복사 할 수 있습니다 . C ++ 11 솔루션은 rhs가 수정되지 않더라도 이러한 스레드를 순차적으로 만들도록 강제합니다.

할당 복사

완전성을 위해 여기에 복사 할당 연산자가 있습니다.이 연산자는 다른 모든 내용을 읽은 후에 상당히 자명해야합니다.

    A& operator=(const A& a)
    {
        if (this != &a)
        {
            WriteLock lhs_lk(mut_, std::defer_lock);
            ReadLock  rhs_lk(a.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            field1_ = a.field1_;
            field2_ = a.field2_;
        }
        return *this;
    }

기타 등등

A여러 스레드가 한 번에 호출 할 수있을 것으로 예상되는 경우의 상태에 액세스하는 다른 모든 멤버 또는 자유 함수 도 보호해야합니다. 예를 들면 다음과 같습니다 swap.

    friend void swap(A& x, A& y)
    {
        if (&x != &y)
        {
            WriteLock lhs_lk(x.mut_, std::defer_lock);
            WriteLock rhs_lk(y.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            using std::swap;
            swap(x.field1_, y.field1_);
            swap(x.field2_, y.field2_);
        }
    }

std::swap작업 수행 에만 의존하는 경우 std::swap내부적으로 수행 되는 세 가지 동작 사이의 잠금 및 잠금 해제 세분화가 잘못되어 잠금이 설정됩니다 .

실제로 생각해 swap보면 "스레드 안전"을 제공해야 할 수있는 API에 대한 통찰력을 얻을 수 있습니다 A. 일반적으로 "잠금 세분성"문제로 인해 "스레드 안전하지 않은"API와는 다릅니다.

또한 "셀프 스왑"으로부터 보호해야 할 필요성에 유의하십시오. "셀프 스왑"은 작동하지 않아야합니다. 자체 검사가 없으면 동일한 뮤텍스를 재귀 적으로 잠급니다. 이 문제는 std::recursive_mutexfor 를 사용하여 자체 검사없이 해결할 수도 있습니다 MutexType.

최신 정보

아래의 주석에서 Yakk는 복사 및 이동 생성자에서 기본 구성을해야하는 것에 대해 매우 불만족 스럽습니다. 이 문제에 대해 충분히 강하게 느끼고 그것에 대해 기꺼이 기억할 수 있다면 다음과 같이 피할 수 있습니다.

  • 데이터 멤버로 필요한 잠금 유형을 추가하십시오. 이러한 구성원은 보호되는 데이터 앞에 와야합니다.

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • 그런 다음 생성자 (예 : 복사 생성자)에서 다음을 수행합니다.

    A(const A& a)
        : read_lock_(a.mut_)
        , field1_(a.field1_)
        , field2_(a.field2_)
    {
        read_lock_.unlock();
    }
    

죄송합니다. Yakk가이 업데이트를 완료하기 전에 댓글을 삭제했습니다. 그러나 그는이 문제를 추진하고이 답변에 대한 해결책을 얻은 것에 대한 공로를 인정받을 만합니다.

업데이트 2

그리고 dyp는이 좋은 제안을 내놓았습니다.

    A(const A& a)
        : A(a, ReadLock(a.mut_))
    {}
private:
    A(const A& a, ReadLock rhs_lk)
        : field1_(a.field1_)
        , field2_(a.field2_)
    {}

2
복사 생성자는 필드를 할당하지만 복사하지는 않습니다. 즉, 기본값으로 구성 할 수 있어야하며 이는 불행한 제한입니다.
Yakk-Adam Nevraumont

@Yakk : 예, mutexes클래스 유형에 넣는 것은 "진정한 방법"이 아닙니다. 도구 상자의 도구이며 사용하려는 경우 이것이 방법입니다.
Howard Hinnant

@Yakk : "C ++ 14"문자열에 대한 내 대답을 검색합니다.
Howard Hinnant

아, 죄송합니다. C ++ 14 비트를 놓쳤습니다.
Yakk-Adam Nevraumont

2
좋은 설명 @HowardHinnant! C ++ 17에서는 std :: scoped_lock lock (x.mut_, y_mut_);을 사용할 수도 있습니다. 이렇게하면 여러 뮤텍스를 적절한 순서로 잠그기 위해 구현에 의존합니다
fen

7

이 질문에 답할 수있는 멋지고 깨끗하고 쉬운 방법이없는 것 같습니다. Anton의 솔루션 은 옳다고 생각 하지만 더 나은 답변이 나오지 않는 한 확실히 논쟁의 여지가 있습니다. 그런 클래스를 힙에 넣고 돌보는 것이 좋습니다. 통해 std::unique_ptr:

auto a = std::make_unique<A>();

이제 완전히 움직일 수있는 유형이며 이동이 발생하는 동안 내부 뮤텍스에 대한 잠금을 가진 사람은 이것이 좋은 일인지에 대한 논쟁의 여지가 있더라도 여전히 안전합니다.

복사 의미론이 필요한 경우

auto a2 = std::make_shared<A>();

5

이것은 거꾸로 된 대답입니다. "이 객체는 동기화되어야 함"을 유형의 기본으로 포함하는 대신 모든 유형 아래에 삽입하십시오 .

동기화 된 개체를 매우 다르게 처리합니다. 한 가지 큰 문제는 교착 상태 (여러 객체 잠금)에 대해 걱정해야한다는 것입니다. 또한 기본적으로 "객체의 기본 버전"이되어서는 안됩니다. 동기화 된 객체는 경합 상태가 될 객체를위한 것이며, 목표는 스레드 사이의 경합을 최소화하는 것이어야합니다.

그러나 개체 동기화는 여전히 유용합니다. 동기화 기에서 상속하는 대신 동기화에서 임의의 유형을 래핑하는 클래스를 작성할 수 있습니다. 개체가 동기화되었으므로 사용자는 개체에 대한 작업을 수행하기 위해 몇 가지 작업을 수행해야하지만 개체에 대해 손으로 코딩 된 제한된 작업 집합에 제한되지 않습니다. 개체에 대한 여러 작업을 하나로 구성하거나 여러 개체에 대해 작업을 수행 할 수 있습니다.

다음은 임의 유형에 대한 동기화 된 래퍼입니다 T.

template<class T>
struct synchronized {
  template<class F>
  auto read(F&& f) const&->std::result_of_t<F(T const&)> {
    return access(std::forward<F>(f), *this);
  }
  template<class F>
  auto read(F&& f) &&->std::result_of_t<F(T&&)> {
    return access(std::forward<F>(f), std::move(*this));
  }
  template<class F>
  auto write(F&& f)->std::result_of_t<F(T&)> {
    return access(std::forward<F>(f), *this);
  }
  // uses `const` ness of Syncs to determine access:
  template<class F, class... Syncs>
  friend auto access( F&& f, Syncs&&... syncs )->
  std::result_of_t< F(decltype(std::forward<Syncs>(syncs).t)...) >
  {
    return access2( std::index_sequence_for<Syncs...>{}, std::forward<F>(f), std::forward<Syncs>(syncs)... );
  };
  synchronized(synchronized const& o):t(o.read([](T const&o){return o;})){}
  synchronized(synchronized && o):t(std::move(o).read([](T&&o){return std::move(o);})){}  
  // special member functions:
  synchronized( T & o ):t(o) {}
  synchronized( T const& o ):t(o) {}
  synchronized( T && o ):t(std::move(o)) {}
  synchronized( T const&& o ):t(std::move(o)) {}
  synchronized& operator=(T const& o) {
    write([&](T& t){
      t=o;
    });
    return *this;
  }
  synchronized& operator=(T && o) {
    write([&](T& t){
      t=std::move(o);
    });
    return *this;
  }
private:
  template<class X, class S>
  static auto smart_lock(S const& s) {
    return std::shared_lock< std::shared_timed_mutex >(s.m, X{});
  }
  template<class X, class S>
  static auto smart_lock(S& s) {
    return std::unique_lock< std::shared_timed_mutex >(s.m, X{});
  }
  template<class L>
  static void lock(L& lockable) {
      lockable.lock();
  }
  template<class...Ls>
  static void lock(Ls&... lockable) {
      std::lock( lockable... );
  }
  template<size_t...Is, class F, class...Syncs>
  friend auto access2( std::index_sequence<Is...>, F&&f, Syncs&&...syncs)->
  std::result_of_t< F(decltype(std::forward<Syncs>(syncs).t)...) >
  {
    auto locks = std::make_tuple( smart_lock<std::defer_lock_t>(syncs)... );
    lock( std::get<Is>(locks)... );
    return std::forward<F>(f)(std::forward<Syncs>(syncs).t ...);
  }

  mutable std::shared_timed_mutex m;
  T t;
};
template<class T>
synchronized< T > sync( T&& t ) {
  return {std::forward<T>(t)};
}

C ++ 14 및 C ++ 1z 기능이 포함되어 있습니다.

이는 const작업이 다중 판독기 안전 std하다고 가정 합니다 ( 컨테이너가 가정하는 것임).

사용은 다음과 같습니다.

synchronized<int> x = 7;
x.read([&](auto&& v){
  std::cout << v << '\n';
});

에 대한 int동기화 된 접근.

나는 synchronized(synchronized const&). 거의 필요하지 않습니다.

당신이 필요로하는 경우에 synchronized(synchronized const&), 나는 대체 유혹 할 것 T t;으로 std::aligned_storage수동으로 배치하는 건설을 허용, 수동 파괴 해. 이를 통해 적절한 평생 관리가 가능합니다.

이를 제외하고 소스를 복사 한 T다음 읽을 수 있습니다.

synchronized(synchronized const& o):
  t(o.read(
    [](T const&o){return o;})
  )
{}
synchronized(synchronized && o):
  t(std::move(o).read(
    [](T&&o){return std::move(o);})
  )
{}

할당 :

synchronized& operator=(synchronized const& o) {
  access([](T& lhs, T const& rhs){
    lhs = rhs;
  }, *this, o);
  return *this;
}
synchronized& operator=(synchronized && o) {
  access([](T& lhs, T&& rhs){
    lhs = std::move(rhs);
  }, *this, std::move(o));
  return *this;
}
friend void swap(synchronized& lhs, synchronized& rhs) {
  access([](T& lhs, T& rhs){
    using std::swap;
    swap(lhs, rhs);
  }, *this, o);
}

배치 및 정렬 된 스토리지 버전은 조금 더 지저분합니다. 에 대한 대부분의 액세스 t는 일부 후프를 통과해야하는 구성을 제외하고 멤버 함수 T&t()및 로 대체됩니다 T const&t()const.

synchronized클래스의 일부 대신 래퍼 를 만들면 클래스가 내부적 const으로 다중 판독기 인 것으로 간주하고 단일 스레드 방식으로 작성하는 것입니다.

에서 드문 우리가 동기화 된 인스턴스를해야 할 경우, 우리는 위와 같이 농구를 통해 이동합니다.

위의 오타에 대해 사과드립니다. 아마 약간있을 것입니다.

위의 부수적 인 이점은 synchronized(동일한 유형의) 객체 에 대한 n 항 임의 연산 이 미리 하드 코딩하지 않고도 함께 작동한다는 것입니다. 친구 선언에 추가하면 synchronized여러 유형의 n 항 개체가 함께 작동 할 수 있습니다. access이 경우 과부하 확신을 다루기 위해 인라인 친구에서 벗어나야 할 수도 있습니다 .

라이브 예


4

뮤텍스와 C ++ 이동 의미 체계를 사용하는 것은 스레드간에 데이터를 안전하고 효율적으로 전송하는 훌륭한 방법입니다.

일련의 문자열을 만들어 (한 명 이상의) 소비자에게 제공하는 '생산자'스레드를 상상해보십시오. 이러한 배치는 (잠재적으로 큰) std::vector<std::string>개체를 포함하는 개체로 표현 될 수 있습니다 . 우리는 불필요한 복제없이 이러한 벡터의 내부 상태를 소비자로 '이동'하고 싶습니다.

뮤텍스는 객체 상태의 일부가 아닌 객체의 일부로 인식 할뿐입니다. 즉, 뮤텍스를 이동하고 싶지 않습니다.

필요한 잠금은 알고리즘 또는 객체의 일반화 방법 및 허용하는 사용 범위에 따라 다릅니다.

공유 상태 '생산자'개체에서 스레드 로컬 '소비'개체로만 이동 하는 경우 이동 개체 만 잠그는 것이 좋습니다.

좀 더 일반적인 디자인이라면 둘 다 잠 가야합니다. 이러한 경우 교착 상태를 고려해야합니다.

이것이 잠재적 인 문제라면을 사용 std::lock()하여 교착 상태가없는 방식으로 두 뮤텍스에 대한 잠금을 획득하십시오.

http://en.cppreference.com/w/cpp/thread/lock

마지막으로 이동 의미를 이해하고 있는지 확인해야합니다. 이동 된 개체가 유효하지만 알 수없는 상태로 남아 있음을 상기하십시오. 이동을 수행하지 않는 스레드가 유효하지만 알 수없는 상태를 발견 할 때 이동 된 개체에 액세스를 시도 할 유효한 이유가있을 수 있습니다.

다시 내 생산자는 문자열을 쾅하고 소비자는 전체 부하를 제거하고 있습니다. 이 경우 생산자가 벡터에 추가하려고 할 때마다 벡터가 비어 있지 않거나 비어있을 수 있습니다.

간단히 말해서, 이동 된 개체에 대한 잠재적 동시 액세스가 쓰기에 해당한다면 괜찮을 것입니다. 읽기에 해당하는 경우 임의의 상태를 읽는 것이 왜 괜찮은지 생각해보십시오.


3

우선 뮤텍스가 포함 된 객체를 이동하려면 디자인에 문제가 있어야합니다.

그러나 어쨌든 그것을하기로 결정했다면, 당신은 이동 생성자에서 새로운 뮤텍스를 생성해야합니다. 예 :

// movable
struct B{};

class A {
    B b;
    std::mutex m;
public:
    A(A&& a)
        : b(std::move(a.b))
        // m is default-initialized.
    {
    }
};

이동 생성자가 인수가 다른 곳에서는 사용되지 않는다고 안전하게 가정 할 수 있으므로 스레드로부터 안전하므로 인수 잠금이 필요하지 않습니다.


2
스레드로부터 안전하지 않습니다. a.mutex잠긴 경우 : 그 상태를 잃어 버립니다. -1

2
@ DieterLücking 인수가 이동 된 개체에 대한 유일한 참조 인 한 뮤텍스를 잠글 정상적인 이유는 없습니다. 그리고 그럴지라도 새로 생성 된 객체의 뮤텍스를 잠글 이유가 없습니다. 그리고 만약 있다면 이것은 뮤텍스가있는 이동 가능한 객체의 전반적인 잘못된 디자인에 대한 논쟁입니다.
Anton Savin

1
@ DieterLücking 이것은 사실이 아닙니다. 문제를 설명하는 코드를 제공 할 수 있습니까? 그리고 형태가 아닙니다 A a; A a2(std::move(a)); do some stuff with a.
Anton Savin

2
그러나 이것이 최선의 방법이라면 어쨌든 new인스턴스를 올려서 배치 std::unique_ptr하는 것이 더 깨끗해 보이고 혼란의 문제로 이어지지 않을 것입니다. 좋은 질문.
Mike Vine

1
@MikeVine 대답으로 추가해야한다고 생각합니다.
Anton Savin
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.