Domanda

Se ho un tipo profondamente immutabile (tutti i membri sono di sola lettura e se sono membri del tipo di riferimento, allora si riferiscono anche a oggetti che sono profondamente immutabili).

Vorrei implementare una proprietà inizializzata pigra sul tipo, in questo modo:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            m_PropName = temp;
        }
        return m_PropName;
    }
}

Da quello che posso dire:

m_PropName = temp; 

... è thread-safe. Non sono troppo preoccupato per due thread che corrono entrambi per inizializzare allo stesso tempo, perché sarà raro, entrambi i risultati sarebbero identici da una prospettiva logica e preferirei non usare un lucchetto se non ho a.

Funzionerà? Quali sono i pro e i contro?

Modifica Grazie per le tue risposte Probabilmente andrò avanti con l'uso di un lucchetto. Tuttavia, sono sorpreso che nessuno abbia sollevato la possibilità del compilatore di rendersi conto che la variabile temp non è necessaria e che si sta assegnando direttamente a m_PropName. In tal caso, un thread di lettura potrebbe eventualmente leggere un oggetto che non ha ancora finito di essere costruito. Il compilatore impedisce una situazione del genere?

(Le risposte sembrano indicare che il runtime non consentirà che ciò accada.)

Modifica Quindi ho deciso di utilizzare un metodo di interscambio CompareExchange ispirato a questo articolo di Joe Duffy .

In sostanza:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            System.Threading.Interlocked(ref m_PropName, temp, null);
        }
        return m_PropName;
    }
}

Questo dovrebbe garantire che tutti i thread che chiamano questo metodo su questa istanza di oggetto otterranno un riferimento allo stesso oggetto, quindi l'operatore == funzionerà. È possibile sprecare lavoro, il che va bene - lo rende solo un algoritmo ottimista.

Come notato in alcuni commenti di seguito, questo dipende dal funzionamento del modello di memoria .NET 2.0. Altrimenti, m_PropName dovrebbe essere dichiarato volatile.

È stato utile?

Soluzione

Funzionerà. La scrittura su riferimenti in C # è garantita come atomica, come descritto nella sezione 5.5 del spec . Questo probabilmente non è ancora un buon modo per farlo, perché il tuo codice sarà più confuso per il debug e la lettura in cambio di un effetto probabilmente minore sulle prestazioni.

Jon Skeet ha un'ottima pagina sull'implementazione dei singoli in C #.

Il consiglio generale su piccole ottimizzazioni come queste non è quello di farle a meno che un profiler non ti dica che questo codice è un hotspot. Inoltre, dovresti stare attento a scrivere codice che non può essere compreso appieno dalla maggior parte dei programmatori senza controllare le specifiche.

EDIT: come notato nei commenti, anche se dici che non ti dispiace se vengono create 2 versioni del tuo oggetto, quella situazione è così contro-intuitiva che questo approccio non dovrebbe mai essere usato.

Altri suggerimenti

Dovresti usare un lucchetto. Altrimenti si rischiano due istanze di m_PropName esistenti e utilizzate da thread diversi. Questo potrebbe non essere un problema in molti casi; tuttavia, se si desidera poter utilizzare == anziché .equals () , questo sarà un problema. Le condizioni di gara rare non sono il miglior insetto da avere. Sono difficili da eseguire il debug e da riprodurre.

Nel tuo codice, se due thread diversi ottengono contemporaneamente la tua proprietà PropName (diciamo, su una CPU multi-core), allora possono ricevere diverse nuove istanze della proprietà che conterranno dati identici ma non essere la stessa istanza dell'oggetto.

Un vantaggio chiave degli oggetti immutabili è che == è equivalente a .equals () , consentendo l'uso del == più performante per confronto. Se non ti sincronizzi nell'inizializzazione lazy, rischi di perdere questo vantaggio.

Perdi anche l'immutabilità. Il tuo oggetto verrà inizializzato due volte con oggetti diversi (che contengono gli stessi valori), quindi un thread che ha già ottenuto il valore della tua proprietà, ma che lo ottiene di nuovo, potrebbe ricevere un oggetto diverso la seconda volta.

Sarei interessato a ricevere altre risposte a questo, ma non vedo alcun problema. La copia duplicata verrà abbandonata e verrà GCed.

Devi rendere il campo volatile però.

A riguardo:

  

Tuttavia, sono sorpreso che nessuno abbia portato   la possibilità del compilatore   rendendosi conto che la variabile temp è   inutile e solo assegnando   direttamente a m_PropName. Se così fosse   il caso, quindi un thread di lettura potrebbe   possibilmente leggere un oggetto che non ha   finito di essere costruito. Fa il   compilatore prevenire una situazione del genere?

Ho pensato di menzionarlo ma non fa differenza. Il nuovo operatore non restituisce un riferimento (e quindi l'assegnazione al campo non avviene) fino al completamento del costruttore - questo è garantito dal runtime, non dal compilatore.

Tuttavia, il linguaggio / runtime NON garantisce realmente che altri thread non possano vedere un oggetto parzialmente costruito - dipende da cosa fa il costruttore .

Aggiornamento:

L'OP si chiede anche se questa pagina ha un'idea utile . Il loro frammento di codice finale è un'istanza di Doppio blocco controllato che è il classico esempio di un'idea che migliaia di persone si consigliano l'un l'altro senza avere idea di come farlo nel modo giusto. Il problema è che le macchine SMP sono composte da più CPU con le proprie cache di memoria. Se dovessero sincronizzare le loro cache ogni volta che si verificava un aggiornamento della memoria, ciò annullerebbe i vantaggi di avere diverse CPU. Quindi si sincronizzano solo con una "barriera di memoria", che si verifica quando viene rimosso un blocco, o si verifica un'operazione interbloccata o si accede a una variabile volatile .

Il solito ordine degli eventi è:

  • Il coder scopre il blocco con doppio controllo
  • Il coder scopre le barriere di memoria

Tra questi due eventi, rilasciano molti software non funzionanti.

Inoltre, molte persone credono (come fa quel tizio) che puoi "eliminare il blocco". usando le operazioni interbloccate. Ma in fase di esecuzione sono una barriera di memoria e quindi causano l'arresto di tutte le CPU e la sincronizzazione delle loro cache. Hanno un vantaggio rispetto ai blocchi in quanto non hanno bisogno di effettuare una chiamata nel kernel del sistema operativo (sono solo "codice utente"), ma possono uccidere le prestazioni tanto quanto qualsiasi tecnica di sincronizzazione .

Riepilogo: il threading code sembra circa 1000 volte più facile da scrivere di quanto non sia.

Sono tutto per lazy init quando i dati potrebbero non essere sempre accessibili e possono essere necessarie una buona quantità di risorse per recuperare o archiviare i dati.

Penso che qui ci sia un concetto chiave dimenticato: Come per i concetti di progettazione C #, non dovresti rendere i tuoi membri di istanza thread-safe di default. Solo i membri statici dovrebbero essere thread-safe di default. A meno che non si acceda ad alcuni dati statici / globali, non è necessario aggiungere blocchi aggiuntivi nel codice.

Da ciò che mostra il tuo codice, l'iniz pigro è tutto all'interno di una proprietà di istanza, quindi non aggiungerei blocchi ad esso. Se, in base alla progettazione, è previsto l'accesso simultaneo da più thread, quindi andare avanti e aggiungere il blocco.

A proposito, potrebbe non ridurre di molto il codice, ma sono un fan dell'operatore null-coalesce. Il corpo del tuo getter potrebbe invece diventare questo:
m_PropName = m_PropName ?? nuovo ... ();
return m_PropName;

Elimina il " if (m_PropName == null) ... " e secondo me lo rende più conciso e leggibile.

Non sono un esperto di C #, ma per quanto ne so, ciò rappresenta un problema solo se si richiede che venga creata solo un'istanza di ReadOnlyCollection. Dici che l'oggetto creato sarà sempre lo stesso e non importa se due (o più) thread creano una nuova istanza, quindi direi che va bene farlo senza un lucchetto.

Una cosa che potrebbe diventare un bizzarro bug in seguito sarebbe se si comparasse per l'uguaglianza delle istanze, che a volte non sarebbe la stessa. Ma se lo tieni a mente (o semplicemente non lo fai) non vedo altri problemi.

Sfortunatamente, hai bisogno di un lucchetto. Ci sono molti bug piuttosto sottili quando non si blocca correttamente. Per un esempio scoraggiante, guarda questa answer .

Si può tranquillamente usare l'inizializzazione lenta senza un blocco se il campo verrà scritto solo se è vuoto o contiene già il valore da scrivere o, in alcuni casi, un equivalente . Si noti che non esistono due oggetti mutabili equivalenti; un campo che contiene un riferimento a un oggetto mutabile può essere scritto solo con un riferimento a lo stesso oggetto (il che significa che la scrittura non avrebbe alcun effetto).

Esistono tre schemi generali che è possibile utilizzare per l'inizializzazione lenta, a seconda delle circostanze:

  1. Usa un lucchetto se calcolare il valore da scrivere sarebbe costoso, e si desidera evitare di spendere inutilmente tale sforzo. Il modello di blocco ricontrollato è buono su sistemi il cui modello di memoria lo supporta.
  2. Se si sta memorizzando un valore immutabile, calcolarlo se sembra necessario e archiviarlo. Altri thread che non vedono il negozio possono eseguire un calcolo ridondante, ma cercheranno semplicemente di scrivere il campo con il valore che è già lì.
  3. Se si sta memorizzando un riferimento a un oggetto di classe mutabile economico da produrre, creare un nuovo oggetto se sembra necessario, quindi utilizzare `Interlocked.CompareExchange` per memorizzarlo se il campo è ancora vuoto.

Nota che se si può evitare di bloccare qualsiasi accesso diverso dal primo all'interno di un thread, rendere il lettore pigro sicuro per i thread non dovrebbe comportare costi di prestazioni significativi. Mentre è comune che le classi mutabili non siano thread-safe, tutte le classi che dichiarano di essere immutabili dovrebbero essere thread-safe al 100% per qualsiasi combinazione di azioni del lettore. Qualsiasi classe che non può soddisfare un tale requisito di sicurezza del thread non deve pretendere di essere immutabile.

Questo è sicuramente un problema.

Considera questo scenario: Discussione " A " accede alla proprietà e la raccolta viene inizializzata. Prima di assegnare l'istanza locale al campo " m_PropName " ;, Thread " B " accede alla proprietà, tranne per il completamento. Discussione " B " ora ha un riferimento a quell'istanza, che è attualmente memorizzata in " m_PropName " ... fino a quando Thread " A " continua, a quel punto " m_PropName " viene sovrascritto dall'istanza locale in quel thread.

Ora ci sono un paio di problemi. In primo luogo, Thread " B " non ha più l'istanza corretta, dal momento che l'oggetto proprietario pensa che " m_PropName " è l'unica istanza, ma trapelò un'istanza inizializzata quando Thread "B" completato prima della discussione "A". Un altro è se la raccolta è cambiata tra quando "Discussione A" e " e Discussione "B" ottenuto i loro esempi. Quindi hai dati errati. Potrebbe anche essere peggio se stessi osservando o modificando internamente la raccolta di sola lettura (che, ovviamente, non puoi con ReadOnlyCollection, ma potresti sostituirla con qualche altra implementazione che potresti osservare tramite eventi o modificare internamente ma non esternamente).

Autorizzato sotto: CC-BY-SA insieme a attribuzione
Non affiliato a StackOverflow
scroll top