L'immutabilità elimina completamente la necessità di blocchi nella programmazione multiprocessore?


39

Parte 1

Chiaramente l'immutabilità minimizza la necessità di blocchi nella programmazione multiprocessore, ma elimina tale necessità o ci sono casi in cui l'immutabilità da sola non è sufficiente? Mi sembra che sia possibile rimandare l'elaborazione e incapsulare lo stato così a lungo prima che la maggior parte dei programmi debba effettivamente FARE qualcosa (aggiornare un archivio dati, produrre un rapporto, generare un'eccezione, ecc.). Tali azioni possono sempre essere eseguite senza blocchi? La semplice azione di scartare ogni oggetto e crearne uno nuovo invece di cambiare l'originale (una visione grezza dell'immutabilità) fornisce una protezione assoluta dalla contesa tra processi o ci sono casi angolari che richiedono ancora il blocco?

Conosco molti programmatori e matematici funzionali che amano parlare di "nessun effetto collaterale" ma nel "mondo reale" tutto ha un effetto collaterale, anche se è il tempo necessario per eseguire un'istruzione automatica. Sono interessato sia alla risposta teorica / accademica sia alla risposta pratica / reale.

Se l'immutabilità è sicura, dati determinati limiti o ipotesi, voglio sapere quali sono esattamente i confini della "zona di sicurezza". Alcuni esempi di possibili confini:

  • I / O
  • Eccezioni / errori
  • Interazioni con programmi scritti in altre lingue
  • Interazioni con altre macchine (fisiche, virtuali o teoriche)

Un ringraziamento speciale a @JimmaHoffa per il suo commento che ha dato il via a questa domanda!

Parte 2

La programmazione multiprocessore viene spesso utilizzata come tecnica di ottimizzazione per rendere più veloce l'esecuzione del codice. Quando è più veloce usare i blocchi rispetto agli oggetti immutabili?

Dati i limiti stabiliti dalla Legge di Amdahl , quando è possibile ottenere prestazioni complessivamente migliori (con o senza il garbage collector preso in considerazione) con oggetti immutabili rispetto a quelli mutabili?

Sommario

Sto combinando queste due domande in una per cercare di capire dove si trova il rettangolo di selezione per l'Immutabilità come soluzione ai problemi di threading.


21
but everything has a side effect- Uh, no non lo fa. Una funzione che accetta un valore e restituisce un altro valore e non disturba nulla al di fuori della funzione, non ha effetti collaterali ed è quindi thread-safe. Non importa che il computer usi l'elettricità. Possiamo parlare dei raggi cosmici che colpiscono anche le celle di memoria, se vuoi, ma manteniamo l'argomento pratico. Se vuoi considerare cose come il modo in cui la funzione esegue influenza il consumo di energia, questo è un problema diverso rispetto alla programmazione thread-safe.
Robert Harvey,

5
@RobertHarvey - Forse sto solo usando una diversa definizione di effetto collaterale e avrei dovuto dire invece "effetto collaterale del mondo reale". Sì, i matematici hanno funzioni senza effetti collaterali. Il codice che viene eseguito su una macchina del mondo reale richiede l'esecuzione delle risorse della macchina, indipendentemente dal fatto che muta o meno i dati. La funzione nell'esempio mette il suo valore di ritorno nello stack nella maggior parte delle architetture di macchine.
GlenPeterson,

1
Se riesci davvero a superarlo, penso che la tua domanda vada al cuore di questo famigerato documento research.microsoft.com/en-us/um/people/simonpj/papers/…
Jimmy Hoffa,

6
Ai fini della nostra discussione, presumo che ti riferisca a una macchina completa di Turing che sta eseguendo una sorta di linguaggio di programmazione ben definito, in cui i dettagli di implementazione sono irrilevanti. In altre parole, non dovrebbe importare ciò che sta facendo lo stack, se la funzione che sto scrivendo nel mio linguaggio di programmazione preferito può garantire l'immutabilità all'interno dei confini del linguaggio. Non penso allo stack quando sto programmando in un linguaggio di alto livello, né dovrei farlo.
Robert Harvey,

1
@RobertHarvey spoonerism; Monade heh E puoi raccoglierlo dalle prime pagine di coppia. Ne parlo perché, nel complesso, descrive dettagliatamente una tecnica per gestire gli effetti collaterali in un modo praticamente puro, sono abbastanza sicuro che risponderebbe alla domanda di Glen, quindi l'ho pubblicata come nota a piè di pagina per chiunque trovi questa domanda in il futuro per ulteriori letture.
Jimmy Hoffa,

Risposte:


35

Questa è una domanda stranamente formulata che è molto, molto ampia se ha una risposta completa. Mi concentrerò sul chiarire alcune delle specifiche di cui stai chiedendo.

L'immutabilità è un compromesso di progettazione. Rende alcune operazioni più difficili (modifica rapida dello stato di oggetti di grandi dimensioni, costruzione di oggetti frammentari, mantenimento di uno stato in esecuzione, ecc.) A favore di altri (debug più facile, ragionamento più semplice sul comportamento del programma, non doversi preoccupare delle cose che cambiano sotto di te durante il lavoro contemporaneamente, ecc.). È quest'ultimo a cui teniamo con questa domanda, ma voglio sottolineare che si tratta di uno strumento. Un buon strumento che spesso risolve più problemi di quanti ne causi (nella maggior parte dei programmi moderni ), ma non un proiettile d'argento ... Non qualcosa che cambia il comportamento intrinseco dei programmi.

Ora, cosa ti prende? L'immutabilità ti dà una cosa: puoi leggere l'oggetto immutabile liberamente, senza preoccuparti del suo stato che cambia sotto di te (supponendo che sia veramente profondamente immutabile ... Avere un oggetto immutabile con membri mutabili di solito è un affare). Questo è tutto. Ti libera dalla necessità di gestire la concorrenza (tramite blocchi, istantanee, partizionamento dei dati o altri meccanismi; l'attenzione della domanda originale sui blocchi è ... Errata vista la portata della domanda).

Si scopre però che molte cose leggono oggetti. IO funziona, ma IO stesso tende a non gestire bene l'uso simultaneo. Quasi tutte le elaborazioni lo fanno, ma altri oggetti possono essere mutabili o l'elaborazione stessa potrebbe utilizzare uno stato non favorevole alla concorrenza. La copia di un oggetto è un grosso problema nascosto in alcune lingue poiché una copia completa non è (quasi) mai un'operazione atomica. Qui è dove gli oggetti immutabili ti aiutano.

Per quanto riguarda le prestazioni, dipende dalla tua app. Le serrature sono (di solito) pesanti. Altri meccanismi di gestione della concorrenza sono più veloci ma hanno un forte impatto sulla progettazione. In generale , un design altamente concorrente che utilizza oggetti immutabili (ed evita i loro punti deboli) funzionerà meglio di un design altamente concorrenziale che blocca gli oggetti mutabili. Se il tuo programma è leggermente concorrente, dipende e / o non importa.

Ma le prestazioni non dovrebbero essere la tua più grande preoccupazione. Scrivere programmi simultanei è difficile . Il debug di programmi simultanei è difficile . Gli oggetti immutabili aiutano a migliorare la qualità del programma eliminando le opportunità di errore nell'implementazione manuale della gestione della concorrenza. Semplificano il debug perché non stai cercando di tracciare lo stato in un programma simultaneo. Semplificano il tuo design e quindi rimuovono i bug lì.

Quindi, per riassumere: l'immutabilità aiuta ma non eliminerà le sfide necessarie per gestire correttamente la concorrenza. Questo aiuto tende ad essere pervasivo, ma i maggiori guadagni provengono da una prospettiva di qualità piuttosto che dalle prestazioni. E no, l'immutabilità non ti scusa magicamente dalla gestione della concorrenza nella tua app, mi dispiace.


+1 Questo ha senso, ma potresti dare un esempio di dove in un linguaggio profondamente immutabile devi ancora preoccuparti di gestire la concorrenza correttamente? Affermi di farlo, ma uno scenario del genere non mi è chiaro
Jimmy Hoffa,

@JimmyHoffa In una lingua immutabile hai ancora bisogno in qualche modo di aggiornare lo stato tra i thread. Le due lingue più immutabili che conosco (Clojure e Haskell) forniscono un tipo di riferimento (atomi e Mvar) che fornisce un modo per inviare lo stato modificato tra i thread. La semantica dei loro tipi di riferimento impedisce alcuni tipi di errori di concorrenza, ma altri sono ancora possibili.
stonemetal

@stonemetal interessante, nei miei 4 mesi con Haskell non avevo ancora sentito parlare di Mvars, ho sempre sentito di usare STM per la comunicazione dello stato di concorrenza che si comporta più come il passaggio del messaggio di Erlang, pensavo. Sebbene l'esempio perfetto di immutabilità che non risolva i problemi simultanei a cui riesco a pensare sia l'aggiornamento di un'interfaccia utente, se hai 2 thread che tentano di aggiornare un'interfaccia utente con diverse versioni di dati, uno potrebbe essere più recente e quindi è necessario ottenere il secondo aggiornamento in modo da avere un condizioni di gara in cui è necessario garantire il sequenziamento in qualche modo .. Pensiero interessante .. Grazie per i dettagli
Jimmy Hoffa,

1
@jimmyhoffa - L'esempio più comune è IO. Anche se la lingua è immutabile, il tuo database / sito web / file non lo è. Un'altra è la tua tipica mappa / riduzione. Immutabilità significa che l'aggregazione della mappa è più infallibile, ma è comunque necessario gestire il coordinamento "una volta eseguita tutta la mappa in parallelo, ridurre".
Telastyn,

1
@JimmyHoffa: MVars è una primitiva di concorrenza mutabile di basso livello (tecnicamente, un riferimento immutabile a una posizione di archiviazione mutabile), non troppo diversa da quella che vedresti in altre lingue; deadlock e condizioni di gara sono molto possibili. STM è un'astrazione di concorrenza di alto livello per la memoria condivisa mutabile senza lock (molto diversa dalla trasmissione di messaggi) che consente transazioni componibili senza possibilità di deadlock o condizioni di competizione. I dati immutabili sono solo thread-safe, nient'altro da dire al riguardo.
CA McCann,

13

Una funzione che accetta un valore e restituisce un altro valore e non disturba nulla al di fuori della funzione, non ha effetti collaterali ed è quindi thread-safe. Se vuoi considerare cose come il modo in cui la funzione esegue influenza il consumo di energia, questo è un problema diverso.

Suppongo che ti riferisci a una macchina completa di Turing che sta eseguendo una sorta di linguaggio di programmazione ben definito, in cui i dettagli di implementazione sono irrilevanti. In altre parole, non dovrebbe importare ciò che sta facendo lo stack, se la funzione che sto scrivendo nel mio linguaggio di programmazione preferito può garantire l'immutabilità all'interno dei confini del linguaggio. Non penso allo stack quando sto programmando in un linguaggio di alto livello, né dovrei farlo.

Per illustrare come funziona, ho intenzione di offrire alcuni semplici esempi in C #. Affinché questi esempi siano veri, dobbiamo fare un paio di ipotesi. Innanzitutto, il compilatore segue senza errori le specifiche C # e, in secondo luogo, produce programmi corretti.

Diciamo che voglio una semplice funzione che accetta una raccolta di stringhe e restituisce una stringa che è una concatenazione di tutte le stringhe nella raccolta separate da virgole. Un'implementazione semplice e ingenua in C # potrebbe apparire così:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    string result = string.Empty;
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result += s;
        else
            result += ", " + s;
    }
    return result;
} 

Questo esempio è immutabile, prima facie. Come lo so? Perché l' stringoggetto è immutabile. Tuttavia, l'implementazione non è l'ideale. Poiché resultè immutabile, un nuovo oggetto stringa deve essere creato ogni volta attraverso il ciclo, sostituendo l'oggetto originale a cui resultpunta. Ciò può influire negativamente sulla velocità e fare pressione sul cestino, poiché deve ripulire tutte quelle stringhe extra.

Ora, diciamo che faccio questo:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    var result = new StringBuilder();
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result.Append(s);
        else
            result.Append(", " + s);
    }
    return result.ToString();
} 

Si noti che ho sostituito string resultcon un oggetto mutabile, StringBuilder. Questo è molto più veloce del primo esempio, perché una nuova stringa non viene creata ogni volta attraverso il ciclo. Invece, l'oggetto StringBuilder aggiunge semplicemente i caratteri di ciascuna stringa a una raccolta di caratteri e restituisce il tutto alla fine.

Questa funzione è immutabile, anche se StringBuilder è mutabile?

Sì. Perché? Perché ogni volta che viene chiamata questa funzione, viene creato un nuovo StringBuilder, solo per quella chiamata. Quindi ora abbiamo una funzione pura che è thread-safe, ma contiene componenti mutabili.

E se lo facessi?

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;

    public string ConcatenateWithCommas(ImmutableList<string> list)
    {
        foreach (string s in list)
        {
            if (isFirst)
                result.Append(s);
            else
                result.Append(", " + s);
        }
        return result.ToString();
    } 
}

Questo metodo è thread-safe? No, non lo è. Perché? Perché la classe ora mantiene lo stato da cui dipende il mio metodo. Nel metodo è ora presente una condizione di competizione: un thread può essere modificato IsFirst, ma un altro thread può eseguire il primo Append(), nel qual caso ora ho una virgola all'inizio della mia stringa che non dovrebbe essere lì.

Perché dovrei voler farlo in questo modo? Bene, potrei volere che i thread accumulino le stringhe nel mio resultsenza riguardo all'ordine, o nell'ordine in cui i thread entrano. Forse è un logger, chi lo sa?

Ad ogni modo, per sistemarlo, ho messo una lockdichiarazione intorno alle viscere del metodo.

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;
    private static object locker = new object();

    public string AppendWithCommas(ImmutableList<string> list)
    {
        lock (locker)
        {
            foreach (string s in list)
            {
                if (isFirst)
                    result.Append(s);
                else
                    result.Append(", " + s);
            }
            return result.ToString();
        }
    } 
}

Ora è di nuovo sicuro per i thread.

L'unico modo in cui i miei metodi immutabili potrebbero non riuscire a essere thread-safe è se il metodo perde in qualche modo parte della sua implementazione. Questo potrebbe succedere? Non se il compilatore è corretto e il programma è corretto. Avrò mai bisogno di blocchi su tali metodi? No.

Per un esempio di come potrebbe essere trapelata l'implementazione in uno scenario di concorrenza, vedere qui .


2
A meno che non mi sbagli, perché a Listè mutabile, nella prima funzione che hai dichiarato "puro", un altro thread potrebbe rimuovere tutti gli elementi dall'elenco o aggiungere un mucchio di più mentre è nel ciclo foreach. Non sono sicuro di come giocherebbe con l' IEnumeratoressere while(iter.MoveNext())ed, ma a meno che ciò non IEnumeratorsia immutabile (dubbio), allora ciò minaccerebbe di interrompere il ciclo foreach.
Jimmy Hoffa,

È vero, devi presumere che la raccolta non sia mai scritta mentre i thread ne leggono. Sarebbe un presupposto valido se ogni thread che chiama il metodo crea il proprio elenco.
Robert Harvey,

Non penso che tu possa chiamarlo "puro" quando contiene quell'oggetto mutevole che sta usando come riferimento. Se ha ricevuto un IEnumerable potresti essere in grado di avanzare tale reclamo perché non puoi aggiungere o rimuovere elementi da un IEnumerable, ma potrebbe trattarsi di una matrice o di un elenco consegnato come IEnumerable in modo che il contratto IEnumerable non garantisca alcun modulo di purezza. La vera tecnica per rendere pura quella funzione sarebbe l'immutabilità con il pass-by-copy, C # non lo fa, quindi dovresti copiare la Lista proprio quando la funzione la riceve; ma l'unico modo per farlo è con una foreach su di esso ...
Jimmy Hoffa,

1
@JimmyHoffa: Dannazione, mi hai fatto ossessionare da questo problema di pollo e uova! Se vedi una soluzione ovunque, per favore fatemelo sapere.
Robert Harvey,

1
Ho appena trovato questa risposta ora ed è una delle migliori spiegazioni sull'argomento che ho incontrato, gli esempi sono super concisi e rendono davvero facile parlare. Grazie!
Stephen Byrne,

4

Non sono sicuro di aver compreso le tue domande.

IMHO la risposta è sì. Se tutti i tuoi oggetti sono immutabili, non hai bisogno di alcun lucchetto. Ma se è necessario preservare uno stato (ad esempio, si implementa un database o è necessario aggregare i risultati da più thread), è necessario utilizzare la mutabilità e quindi anche i blocchi. L'immutabilità elimina la necessità di blocchi, ma di solito non puoi permetterti di avere applicazioni completamente immutabili.

Risposta alla parte 2: i blocchi dovrebbero essere sempre più lenti rispetto ai blocchi.


3
La seconda parte chiede "Qual è il compromesso prestazionale tra serrature e strutture immutabili?" Probabilmente merita una sua domanda, se è persino responsabile.
Robert Harvey,

4

Incapsulare un insieme di stati correlati in un singolo riferimento mutabile a un oggetto immutabile può consentire l'esecuzione di molti tipi di modifica dello stato senza blocco utilizzando il modello:

do
{
   oldState = someObject.State;
   newState = oldState.WithSomeChanges();
} while (Interlocked.CompareExchange(ref someObject.State, newState, oldState) != oldState;

Se due thread tentano entrambi di aggiornarsi someObject.statecontemporaneamente, entrambi gli oggetti leggeranno il vecchio stato e determineranno quale sarebbe il nuovo stato senza le reciproche modifiche. Il primo thread per eseguire CompareExchange memorizzerà ciò che pensa dovrebbe essere lo stato successivo. Il secondo thread scoprirà che lo stato non corrisponde più a quello che aveva letto in precedenza e quindi ricalcolerà il successivo stato del sistema con le modifiche del primo thread rese effettive.

Questo modello ha il vantaggio che un thread che si fa strada non può bloccare l'avanzamento di altri thread. Ha l'ulteriore vantaggio che anche in presenza di forti contese, alcuni thread faranno sempre progressi. Ha lo svantaggio, tuttavia, che in presenza di contesa molti thread potrebbero impiegare molto tempo a fare un lavoro che finiranno per scartare. Ad esempio, se 30 thread su CPU separate provano tutti a cambiare contemporaneamente un oggetto, quello riuscirà al primo tentativo, uno al secondo, uno al terzo, ecc. In modo che ogni thread finisca in media facendo circa 15 tentativi per aggiornare i suoi dati. L'uso di un blocco "consultivo" può migliorare significativamente le cose: prima che un thread tenti di aggiornare, dovrebbe verificare se è impostato un indicatore "contesa". Se è così, dovrebbe acquisire un blocco prima di eseguire l'aggiornamento. Se un thread effettua alcuni tentativi falliti di aggiornamento, dovrebbe impostare il flag di contesa. Se un thread che tenta di acquisire il blocco rileva che non c'era nessun altro in attesa, dovrebbe cancellare il flag di contesa. Si noti che il blocco qui non è necessario per "correttezza"; il codice funzionerebbe correttamente anche senza di esso. Lo scopo del blocco è di ridurre al minimo la quantità di tempo che il codice impiega per operazioni che non hanno esito positivo.


4

Inizi con

Chiaramente l'immutabilità riduce al minimo la necessità di blocchi nella programmazione multiprocessore

Sbagliato. Devi leggere attentamente la documentazione per ogni classe che usi. Ad esempio, const std :: string in C ++ non è thread-safe. Gli oggetti immutabili possono avere uno stato interno che cambia quando vi si accede.

Ma lo stai guardando da un punto di vista totalmente sbagliato. Non importa se un oggetto è immutabile o meno, ciò che conta è se lo cambi. Quello che stai dicendo è come dire "se non fai mai un test di guida, non puoi mai perdere la patente di guida per guida ubriaca". Vero, ma piuttosto manca il punto.

Ora nel codice di esempio qualcuno ha scritto con una funzione chiamata "ConcatenateWithCommas": se l'input fosse mutabile e tu avessi usato un lucchetto, cosa guadagneresti? Se qualcun altro tenta di modificare l'elenco mentre si tenta di concatenare le stringhe, un blocco può impedire l'arresto anomalo. Ma non sai ancora se concatenare le stringhe prima o dopo che l'altro thread le ha modificate. Quindi il tuo risultato è piuttosto inutile. Hai un problema che non è correlato al blocco e non può essere risolto con il blocco. Ma se usi oggetti immutabili e l'altro thread sostituisce l'intero oggetto con uno nuovo, stai usando l'oggetto vecchio e non il nuovo oggetto, quindi il tuo risultato è inutile. Devi pensare a questi problemi a un livello funzionale reale.


2
const std::stringè un cattivo esempio e un po 'di aringa rossa. Le stringhe C ++ sono mutabili e constnon possono comunque garantire l'immutabilità. Tutto ciò che dice è che constpossono essere chiamate solo funzioni. Tuttavia, tali funzioni possono ancora modificare lo stato interno e constpossono essere eliminate. Infine, c'è lo stesso problema di qualsiasi altra lingua: solo perché il mio riferimento constnon significa che lo sia anche il tuo riferimento. No, dovrebbe essere utilizzata una struttura di dati veramente immutabile.
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.