Copia costruttore con argomento non const suggerito dalle regole di sicurezza thread?


9

Ho un wrapper per qualche codice legacy.

class A{
   L* impl_; // the legacy object has to be in the heap, could be also unique_ptr
   A(A const&) = delete;
   L* duplicate(){L* ret; legacy_duplicate(impl_, &L); return ret;}
   ... // proper resource management here
};

In questo codice legacy, la funzione che "duplica" un oggetto non è thread-safe (quando si chiama lo stesso primo argomento), quindi non è contrassegnata constnel wrapper. Immagino che seguano le regole moderne: https://herbsutter.com/2013/01/01/video-you-dont-know-const-and-mutable/

Questo duplicatesembra un buon modo per implementare un costruttore di copie, ad eccezione del dettaglio che non lo è const. Pertanto non posso farlo direttamente:

class A{
   L* impl_; // the legacy object has to be in the heap
   A(A const& other) : L{other.duplicate()}{} // error calling a non-const function
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

Qual è la via d'uscita da questa situazione paradossale?

(Diciamo anche che legacy_duplicatenon è thread-safe ma so che lascia l'oggetto nello stato originale quando esce. Essendo una funzione C il comportamento è solo documentato ma non ha un concetto di costanza.)

Posso pensare a molti possibili scenari:

(1) Una possibilità è che non vi sia alcun modo di implementare un costruttore di copie con la solita semantica. (Sì, posso spostare l'oggetto e non è quello di cui ho bisogno.)

(2) D'altra parte, la copia di un oggetto è intrinsecamente non thread-safe, nel senso che la copia di un tipo semplice può trovare l'origine in uno stato semi-modificato, quindi posso semplicemente andare avanti e farlo forse,

class A{
   L* impl_;
   A(A const& other) : L{const_cast<A&>(other).duplicate()}{} // error calling a non-const function
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

(3) o addirittura dichiarare duplicateconst e mentire sulla sicurezza dei thread in tutti i contesti. (Dopo tutto la funzione legacy non si preoccupa, constquindi il compilatore non si lamenterà nemmeno.)

class A{
   L* impl_;
   A(A const& other) : L{other.duplicate()}{}
   L* duplicate() const{L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

(4) Infine, posso seguire la logica e creare un costruttore di copie che accetta un argomento non const .

class A{
   L* impl_;
   A(A const&) = delete;
   A(A& other) : L{other.duplicate()}{}
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

Si scopre che questo funziona in molti contesti, perché questi oggetti di solito non lo sono const.

La domanda è: si tratta di un percorso valido o comune?

Non posso nominarli, ma intuitivamente mi aspetto molti problemi lungo la strada di avere un costruttore di copie non const. Probabilmente non si qualificherà come un tipo di valore a causa di questa sottigliezza.

(5) Infine, anche se questo sembra essere eccessivo e potrebbe avere un costo di runtime elevato, potrei aggiungere un mutex:

class A{
   L* impl_;
   A(A const& other) : L{other.duplicate_locked()}{}
   L* duplicate(){
      L* ret; legacy_duplicate(impl_, &ret); return ret;
   }
   L* duplicate_locked() const{
      std::lock_guard<std::mutex> lk(mut);
      L* ret; legacy_duplicate(impl_, &ret); return ret;
   }
   mutable std::mutex mut;
};

Ma essere costretti a farlo sembra una pessimizzazione e rende la classe più grande. Non sono sicuro. Attualmente mi sposto verso (4) , o (5) o una combinazione di entrambi.

—— EDIT

Un'altra opzione:

(6) Dimentica tutto il non-senso della funzione membro duplicato e chiama semplicemente legacy_duplicatedal costruttore e dichiara che il costruttore della copia non è thread-safe. (E se necessario crea un'altra versione thread-safe del tipo, A_mt)

class A{
   L* impl_;
   A(A const& other){legacy_duplicate(other.impl_, &impl_);}
};

MODIFICA 2

Questo potrebbe essere un buon modello per ciò che fa la funzione legacy. Si noti che toccando l'input la chiamata non è thread-safe rispetto al valore rappresentato dal primo argomento.

void legacy_duplicate(L* in, L** out){
   *out = new L{};
   char tmp = in[0];
   in[0] = tmp; 
   std::memcpy(*out, in, sizeof *in); return; 
}

1
" In questo codice legacy la funzione che duplica un oggetto non è thread-safe (quando si chiama lo stesso primo argomento) " Ne sei sicuro? Esiste uno stato non contenuto all'interno del Lquale viene modificato creando una nuova Listanza? In caso contrario, perché ritieni che questa operazione non sia thread-safe?
Nicol Bolas,

Sì, questa è la situazione. Sembra che lo stato interno del primo argomento sia stato modificato durante l'exection. Per qualche ragione (qualche "ottimizzazione" o cattiva progettazione o semplicemente per specifica) la funzione legacy_duplicatenon può essere chiamata con lo stesso primo argomento da due thread diversi.
alfC

@TedLyngmo ok l'ho fatto. Sebbene tecnicamente in c ++ pre 11 const abbia un significato più sfocato in presenza di thread.
alfC

@TedLyngmo sì, è un video abbastanza buono. è un peccato che il video si occupi solo dei membri giusti e non tocchi il problema della costruzione (anche se la costanza è sull'oggetto "altro"). In prospettiva, potrebbe non esserci un modo intrinseco di rendere sicuro questo thread wrapper durante la copia senza aggiungere un altro strato di astrazione (e un mutex concreto).
alfC

Sì, beh, questo mi ha confuso e probabilmente sono una di quelle persone che non sanno cosa constsignifichi davvero. :-) Non ci penserei due volte a prendere un const&nel mio ctor copia finché non modifico other. Penso sempre alla sicurezza dei thread come qualcosa che si aggiunge a tutto ciò che è necessario accedere da più thread, tramite incapsulamento, e non vedo davvero l'ora di avere le risposte.
Ted Lyngmo,

Risposte:


0

Includerei solo entrambe le opzioni (4) e (5), ma esplicitamente optare per un comportamento non sicuro quando si ritiene che sia necessario per le prestazioni.

Ecco un esempio completo.

#include <cstdlib>
#include <thread>

struct L {
  int val;
};

void legacy_duplicate(const L* in, L** out) {
  *out = new L{};
  std::memcpy(*out, in, sizeof *in);
  return;
}

class A {
 public:
  A(L* l) : impl_{l} {}
  A(A const& other) : impl_{other.duplicate_locked()} {}

  A copy_unsafe_for_multithreading() { return {duplicate()}; }

  L* impl_;

  L* duplicate() {
    printf("in duplicate\n");
    L* ret;
    legacy_duplicate(impl_, &ret);
    return ret;
  }
  L* duplicate_locked() const {
    std::lock_guard<std::mutex> lk(mut);
    printf("in duplicate_locked\n");
    L* ret;
    legacy_duplicate(impl_, &ret);
    return ret;
  }
  mutable std::mutex mut;
};

int main() {
  A a(new L{1});
  const A b(new L{2});

  A c = a;
  A d = b;

  A e = a.copy_unsafe_for_multithreading();
  A f = const_cast<A&>(b).copy_unsafe_for_multithreading();

  printf("\npointers:\na=%p\nb=%p\nc=%p\nc=%p\nd=%p\nf=%p\n\n", a.impl_,
     b.impl_, c.impl_, d.impl_, e.impl_, f.impl_);

  printf("vals:\na=%d\nb=%d\nc=%d\nc=%d\nd=%d\nf=%d\n", a.impl_->val,
     b.impl_->val, c.impl_->val, d.impl_->val, e.impl_->val, f.impl_->val);
}

Produzione:

in duplicate_locked
in duplicate_locked
in duplicate
in duplicate

pointers:
a=0x7f85e8c01840
b=0x7f85e8c01850
c=0x7f85e8c01860
c=0x7f85e8c01870
d=0x7f85e8c01880
f=0x7f85e8c01890

vals:
a=1
b=2
c=1
c=2
d=1
f=2

Questo segue la guida di stile di Google in cui constcomunica la sicurezza dei thread, ma il codice che chiama l'API può annullare la propria iscrizione tramiteconst_cast


Grazie per la risposta, penso che non cambi la tua risposta e non sono sicuro, ma un modello migliore legacy_duplicatepotrebbe essere void legacy_duplicate(L* in, L** out) { *out = new L{}; char tmp = in[0]; /*some weird call here*/; in[0] = tmp; std::memcpy(*out, in, sizeof *in); return; }(cioè non const in)
alfC

La tua risposta è molto interessante perché può essere combinata con l'opzione (4) e una versione esplicita dell'opzione (2). Cioè, A a2(a1)potrebbe provare a essere thread-safe (o essere eliminato) e A a2(const_cast<A&>(a1))non cercherebbe affatto di essere thread-safe.
Alf

2
Sì, se si prevede di utilizzare Ain contesti thread-safe e thread-unssafe, è necessario estrarre il const_castcodice chiamante in modo che sia chiaro dove è noto che la sicurezza thread è stata violata. Va bene aggiungere ulteriore sicurezza dietro l'API (mutex) ma non va bene nascondere la non sicurezza (const_cast).
Michael Graczyk,

0

TLDR: correggi l'implementazione della tua funzione di duplicazione o introduci un mutex (o qualche dispositivo di blocco più appropriato, forse uno spinlock, o assicurati che il tuo mutex sia configurato per girare prima di fare qualcosa di più pesante) per ora , quindi correggi l'implementazione della duplicazione e rimuovere il blocco quando il blocco diventa effettivamente un problema.

Penso che un punto chiave da notare sia che stai aggiungendo una funzione che prima non esisteva: la possibilità di duplicare un oggetto da più thread contemporaneamente.

Ovviamente, nelle condizioni che hai descritto, sarebbe stato un bug - una condizione di competizione, se lo avessi fatto prima, senza usare una sorta di sincronizzazione esterna.

Pertanto, qualsiasi utilizzo di questa nuova funzionalità sarà qualcosa che aggiungi al tuo codice, non ereditato come funzionalità esistente. Dovresti essere tu a sapere se l'aggiunta del blocco extra sarà effettivamente costosa, a seconda della frequenza con cui utilizzerai questa nuova funzionalità.

Inoltre, in base alla complessità percepita dell'oggetto - dal trattamento speciale che gli stai dando, suppongo che la procedura di duplicazione non sia banale, quindi, già piuttosto costosa in termini di prestazioni.

Sulla base di quanto sopra, hai due percorsi che puoi seguire:

A) Sai che copiare questo oggetto da più thread non accadrà abbastanza spesso perché l'overhead del blocco aggiuntivo sia costoso - forse banalmente economico, almeno dato che la procedura di duplicazione esistente è abbastanza costosa da sola, se usi un mutex spinlock / pre-spin e non c'è contesa su di esso.

B) Sospetti che la copia da più thread avvenga abbastanza spesso da causare un ulteriore blocco. Quindi hai davvero una sola opzione: correggere il codice di duplicazione. Se non lo risolvi, dovrai comunque bloccarlo, sia a questo livello di astrazione o altrove, ma ne avrai bisogno se non vuoi bug - e come abbiamo stabilito, in questo percorso, supponi tale blocco sarà troppo costoso, pertanto l'unica opzione è correggere il codice di duplicazione.

Ho il sospetto che tu sia davvero nella situazione A, e solo l'aggiunta di un mutex spinlock / spinning che non ha quasi nessuna penalità prestazionale quando non contestato, funzionerà bene (ricordati di confrontarlo, però).

C'è, in teoria, un'altra situazione:

C) Contrariamente all'apparente complessità della funzione di duplicazione, in realtà è banale, ma non può essere risolto per qualche motivo; è così banale che persino uno spinlock non contestato introduce un inaccettabile degrado delle prestazioni nella duplicazione; la duplicazione su thread paralleli viene usata raramente; la duplicazione su un singolo thread viene utilizzata continuamente, rendendo assolutamente inaccettabile il degrado delle prestazioni.

In questo caso, suggerisco quanto segue: dichiarare eliminati i costruttori / operatori di copia predefiniti, per impedire a chiunque di utilizzarli accidentalmente. Crea due metodi di duplicazione esplicitamente richiamabili, uno thread-safe e uno non sicuro; fai in modo che i tuoi utenti li chiamino esplicitamente, a seconda del contesto. Ancora una volta, non c'è altro modo per ottenere prestazioni accettabili a thread singolo e multi threading sicuro, se ci si trova davvero in questa situazione e non si riesce a correggere l'implementazione della duplicazione esistente. Ma penso che sia altamente improbabile che tu lo sia davvero.

Basta aggiungere quel mutex / spinlock e benchmark.


Puoi indicarmi il materiale sul mutex spinlock / pre-spin in C ++? È qualcosa di più complicato di ciò che è fornito da std::mutex? La funzione duplicata non è un segreto, non l'ho menzionato per mantenere il problema ad alto livello e non ricevere risposte su MPI. Ma dato che sei andato così in profondità, posso darti maggiori dettagli. La funzione legacy è MPI_Comm_dupe l'effettiva sicurezza senza thread è descritta qui (l'ho confermato) github.com/pmodels/mpich/issues/3234 . Questo è il motivo per cui non riesco a correggere i duplicati. (Inoltre, se aggiungo un mutex, sarò tentato di rendere tutte le chiamate MPI thread-safe.)
alfC

Purtroppo non conosco molto std :: mutex, ma immagino che giri un po 'prima di lasciare il processo in pausa. Un noto dispositivo di sincronizzazione in cui puoi controllarlo manualmente è: docs.microsoft.com/en-us/windows/win32/api/synchapi/… Non ho confrontato le prestazioni, ma sembra che std :: mutex sia ora superiore: stackoverflow.com/questions/9997473/… e implementato utilizzando: docs.microsoft.com/en-us/windows/win32/sync/…
DeducibleSteak

Sembra che questa è una buona descrizione delle considerazioni generali di prendere in considerazione: stackoverflow.com/questions/5869825/...
DeducibleSteak

Grazie ancora, sono su Linux se questo è importante.
alfC

Ecco un confronto delle prestazioni in qualche modo dettagliato (per una lingua diversa, ma immagino che sia informativo e indicativo di cosa aspettarsi): matklad.github.io/2020/01/04/… Il TLDR è: gli spinlock vincono con un valore estremamente ridotto margine quando non c'è contesa, può perdere molto quando c'è contesa.
DeducibleSteak
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.