Como devo lidar com mutexes em tipos móveis em C ++?

86

Por design, std::mutexnão é móvel nem copiável. Isso significa que uma classe que Acontém um mutex não receberá um construtor de movimento padrão.

Como eu tornaria esse tipo Amóvel de maneira segura para thread?

Jack Sabbath
fonte
4
A questão tem uma peculiaridade: a operação de movimentação em si também deve ser thread-safe ou é suficiente se outros acessos ao objeto forem thread-safe?
Jonas Schäfer
2
@paulm Isso realmente depende do design. Tenho visto frequentemente uma classe ter uma variável de membro mutex, então apenas o std::lock_guardmétodo tem escopo.
Cory Kramer
2
@Jonas Wielicki: No início, pensei que movê-lo também deveria ser thread-safe. No entanto, não que eu pense nisso novamente, isso não faz muito sentido, uma vez que mover-construir um objeto geralmente invalida o estado do objeto antigo. Portanto, outros threads não devem ser capazes de acessar o objeto antigo, se ele for movido .. do contrário, eles podem acessar em breve um objeto inválido. Estou certo?
Jack Sabbath
2
por favor siga este link pode usar completo para isso justsoftwaresolutions.co.uk/threading/…
Ravi Chauhan
1
@Dieter Lücking: sim, essa é a ideia .. mutex M protege a classe B. No entanto, onde devo armazenar ambos para ter um objeto acessível e seguro para thread? Ambos M e B podem ir para a classe A .. e, neste caso, a classe A teria um Mutex no escopo da classe.
Jack Sabbath

Respostas:

105

Vamos começar com um pouco de código:

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:
    ...

Coloquei alguns aliases de tipo bastante sugestivos lá, dos quais realmente não tiraremos proveito no C ++ 11, mas se tornarão muito mais úteis no C ++ 14. Seja paciente, vamos chegar lá.

Sua pergunta se resume a:

Como escrevo o construtor de movimento e o operador de atribuição de movimento para esta classe?

Começaremos com o construtor de movimento.

Construtor de movimento

Observe que o membro mutexfoi feito mutable. Estritamente falando, isso não é necessário para os membros de movimento, mas estou assumindo que você também deseja membros de cópia. Se não for esse o caso, não há necessidade de fazer o mutex mutable.

Ao construir A, você não precisa travar this->mut_. Mas você precisa bloquear o mut_do objeto a partir do qual está construindo (mover ou copiar). Isso pode ser feito assim:

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

Observe que tivemos que construir os membros de por padrão thisprimeiro e, em seguida, atribuir-lhes valores somente depois de a.mut_ser bloqueado.

Mover Atribuição

O operador de atribuição de movimentação é substancialmente mais complicado porque você não sabe se algum outro encadeamento está acessando o lhs ou o rhs da expressão de atribuição. E, em geral, você precisa se proteger contra o seguinte cenário:

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

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

Aqui está o operador de atribuição de movimentação que protege corretamente o cenário acima:

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

Observe que deve-se usar std::lock(m1, m2)para bloquear os dois mutexes, em vez de apenas bloqueá-los um após o outro. Se você bloqueá-los um após o outro, então quando dois threads atribuem dois objetos na ordem oposta como mostrado acima, você pode obter um deadlock. O objetivo std::locké evitar esse impasse.

Copiar construtor

Você não perguntou sobre os membros copiadores, mas podemos também falar sobre eles agora (se não for você, alguém vai precisar deles).

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

O construtor de cópia se parece muito com o construtor de movimento, exceto que o ReadLockalias é usado em vez do WriteLock. Atualmente esses dois apelidos std::unique_lock<std::mutex>e, portanto, realmente não faz nenhuma diferença.

Mas em C ++ 14, você terá a opção de dizer o seguinte:

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

Isso pode ser uma otimização, mas não definitivamente. Você terá que medir para determinar se é. Mas com essa mudança, pode-se copiar a construção do mesmo rhs em vários threads simultaneamente. A solução C ++ 11 força você a tornar tais threads sequenciais, mesmo que o rhs não esteja sendo modificado.

Copiar Atribuição

Para completar, aqui está o operador de atribuição de cópia, que deve ser bastante autoexplicativo depois de ler sobre todo o resto:

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

E etc.

Quaisquer outros membros ou funções livres que acessem Ao estado também precisarão ser protegidos se você espera que vários threads sejam capazes de chamá-los de uma vez. Por exemplo, aqui está 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_);
        }
    }

Observe que se você depende apenas de std::swapfazer o trabalho, o bloqueio estará na granularidade errada, bloqueando e desbloqueando entre os três movimentos que std::swapexecutariam internamente.

Na verdade, pensar sobre swappode fornecer uma visão sobre a API que você pode precisar fornecer para uma API "thread-safe" A, que em geral será diferente de uma API "não thread-safe", devido ao problema de "granularidade de bloqueio".

Observe também a necessidade de proteção contra "troca automática". "self-swap" deve ser um ambiente autônomo. Sem a autoverificação, bloquearíamos recursivamente o mesmo mutex. Isso também pode ser resolvido sem a verificação automática usando std::recursive_mutexfor MutexType.

Atualizar

Nos comentários abaixo, Yakk está muito infeliz por ter que construir coisas por padrão nos construtores de copiar e mover (e ele tem razão). Se você tiver uma opinião forte o suficiente sobre esse problema, a ponto de estar disposto a gastar memória com ele, você pode evitá-lo desta forma:

  • Adicione quaisquer tipos de bloqueio de que você precisa como membros de dados. Esses membros devem vir antes dos dados que estão sendo protegidos:

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • E então nos construtores (por exemplo, o construtor de cópia) faça isso:

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

Opa, Yakk apagou seu comentário antes que eu tivesse a chance de completar esta atualização. Mas ele merece crédito por empurrar esse problema e encontrar uma solução para essa resposta.

Atualização 2

E dyp veio com esta boa sugestão:

    A(const A& a)
        : A(a, ReadLock(a.mut_))
    {}
private:
    A(const A& a, ReadLock rhs_lk)
        : field1_(a.field1_)
        , field2_(a.field2_)
    {}
Howard Hinnant
fonte
2
Seu construtor de cópia atribui campos, ele não os copia. Isso significa que eles precisam ser construídos por padrão, o que é uma restrição infeliz.
Yakk - Adam Nevraumont
@Yakk: Sim, colocar mutexesem tipos de classe não é o "único caminho verdadeiro". É uma ferramenta da caixa de ferramentas e se você quiser usar, é assim.
Howard Hinnant
@Yakk: Pesquise minha resposta pela string "C ++ 14".
Howard Hinnant
ah, desculpe, eu perdi aquele bit C ++ 14.
Yakk - Adam Nevraumont
2
ótima explicação @HowardHinnant! em C ++ 17 você também pode usar std :: scoped_lock lock (x.mut_, y_mut_); Dessa forma, você confia na implementação para bloquear vários mutexes em uma ordem adequada
fen
7

Dado que não parece ser uma maneira agradável, limpa e fácil de responder isso - a solução de Anton eu acho que é correta, mas é definitivamente discutível, a menos que uma resposta melhor apareça, eu recomendaria colocar essa classe na pilha e cuidar dela via um std::unique_ptr:

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

Agora é um tipo totalmente móvel e qualquer um que tenha um bloqueio no mutex interno enquanto um movimento ocorre ainda está seguro, mesmo que seja questionável se isso é uma boa coisa a fazer

Se você precisar copiar semântica, use

auto a2 = std::make_shared<A>();
Mike Vine
fonte
5

Esta é uma resposta invertida. Em vez de incorporar "esses objetos precisam ser sincronizados" como uma base do tipo, injete-o sob qualquer tipo.

Você lida com um objeto sincronizado de maneira muito diferente. Um grande problema é que você precisa se preocupar com deadlocks (bloqueio de vários objetos). Basicamente, também nunca deve ser sua "versão padrão de um objeto": objetos sincronizados são para objetos que estarão em contenção e seu objetivo deve ser minimizar a contenção entre threads, não varrê-la para baixo do tapete.

Mas sincronizar objetos ainda é útil. Em vez de herdar de um sincronizador, podemos escrever uma classe que envolve um tipo arbitrário na sincronização. Os usuários precisam saltar alguns obstáculos para fazer operações no objeto agora que ele está sincronizado, mas não estão limitados a algum conjunto limitado de operações codificadas manualmente no objeto. Eles podem compor várias operações no objeto em uma ou ter uma operação em vários objetos.

Aqui está um wrapper sincronizado em torno de um tipo arbitrário 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)};
}

Recursos do C ++ 14 e C ++ 1z incluídos.

isso pressupõe que as constoperações são seguras para múltiplos leitores (que é o que os stdcontêineres assumem).

O uso se parece com:

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

para um intacesso sincronizado.

Eu desaconselho ter synchronized(synchronized const&). Raramente é necessário.

Se você precisar synchronized(synchronized const&), ficaria tentado a substituir T t;por std::aligned_storage, permitindo a construção de posicionamento manual e fazer a destruição manual. Isso permite o gerenciamento adequado da vida útil.

Exceto isso, poderíamos copiar a fonte e T, em seguida, ler a partir dela:

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

para atribuiçã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);
}

as versões de posicionamento e armazenamento alinhado são um pouco mais confusas. A maior parte do acesso a tseria substituída por uma função de membro T&t()e T const&t()const, exceto na construção, onde você teria que pular alguns obstáculos.

Ao fazer synchronizedum invólucro em vez de parte da classe, tudo o que temos que garantir é que a classe seja respeitada internamente constcomo sendo de múltiplos leitores e a escreva em uma única thread.

Nos raros casos em que precisamos de uma instância sincronizada, saltamos por obstáculos como o acima.

Pedimos desculpas por quaisquer erros de digitação acima. Provavelmente existem alguns.

Um benefício colateral do acima é que operações arbitrárias n-árias em synchronizedobjetos (do mesmo tipo) funcionam juntas, sem ter que codificá-las previamente. Adicione uma declaração de amigo e synchronizedobjetos n-ários de vários tipos podem funcionar juntos. Talvez eu tenha que deixar accessde ser um amigo inline para lidar com conflitos de sobrecarga nesse caso.

exemplo vivo

Yakk - Adam Nevraumont
fonte
4

Usar mutexes e semântica de movimentação C ++ é uma maneira excelente de transferir dados entre threads de maneira segura e eficiente.

Imagine um encadeamento 'produtor' que faz lotes de strings e os fornece a (um ou mais) consumidores. Esses lotes podem ser representados por um objeto contendo objetos (potencialmente grandes) std::vector<std::string>. Nós absolutamente queremos 'mover' o estado interno desses vetores para seus consumidores sem duplicação desnecessária.

Você simplesmente reconhece o mutex como parte do objeto, não parte do estado do objeto. Ou seja, você não deseja mover o mutex.

O bloqueio de que você precisa depende do seu algoritmo ou de quão generalizados são seus objetos e da gama de usos que você permite.

Se você apenas mover de um objeto 'produtor' de estado compartilhado para um objeto de 'consumo' local de segmento, você pode estar OK para bloquear apenas o objeto movido de .

Se for um design mais geral, você precisará bloquear ambos. Nesse caso, você precisa considerar o bloqueio morto.

Se isso for um problema potencial, use std::lock()para adquirir bloqueios em ambos os mutexes de uma maneira livre de deadlock.

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

Como uma nota final, você precisa ter certeza de entender a semântica do movimento. Lembre-se de que o objeto movido é deixado em um estado válido, mas desconhecido. É inteiramente possível que um encadeamento que não está realizando a movimentação tenha um motivo válido para tentar acessar o objeto movido de quando ele pode encontrar esse estado válido, mas desconhecido.

De novo, meu produtor está apenas mexendo nas cordas e o consumidor levando embora toda a carga. Nesse caso, toda vez que o produtor tenta adicionar ao vetor, ele pode encontrar o vetor não vazio ou vazio.

Em suma, se o potencial acesso simultâneo ao objeto movido for uma gravação, é provável que esteja tudo bem. Se for uma leitura, pense por que não há problema em ler um estado arbitrário.

Persixty
fonte
3

Em primeiro lugar, deve haver algo errado com o seu design se você quiser mover um objeto que contém um mutex.

Mas se você decidir fazê-lo de qualquer maneira, terá que criar um novo mutex no construtor de movimento, que é, por exemplo:

// movable
struct B{};

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

Isso é seguro para thread, porque o construtor de movimento pode assumir com segurança que seu argumento não é usado em nenhum outro lugar, portanto, o bloqueio do argumento não é necessário.

Anton Savin
fonte
2
Isso não é thread-safe. E se o a.mutexestiver bloqueado: você perde esse estado. -1
2
@ DieterLücking Contanto que o argumento seja a única referência ao objeto movido, não há razão sã para que seu mutex seja bloqueado. E mesmo que seja, não há razão para bloquear um mutex de um objeto recém-criado. E se houver, este é um argumento para o mau design geral de objetos móveis com mutexes.
Anton Savin
1
@ DieterLücking Isso não é verdade. Você pode fornecer um código que ilustre o problema? E não na forma A a; A a2(std::move(a)); do some stuff with a.
Anton Savin
2
No entanto, se essa fosse a melhor maneira, eu recomendaria mesmo assim - newativar a instância e colocá-la em um std::unique_ptr- isso parece mais limpo e provavelmente não causará confusão. Boa pergunta.
Mike Vine
1
@MikeVine Acho que você deveria adicionar isso como uma resposta.
Anton Savin