Come posso rendere più efficiente una costruzione universale?


16

Una "costruzione universale" è una classe wrapper per un oggetto sequenziale che ne consente la linearizzazione (una condizione di coerenza forte per oggetti concorrenti). Ad esempio, ecco una costruzione senza attesa adattata, in Java, da [1], che presume l'esistenza di una coda senza attesa che soddisfa l'interfaccia WFQ(che richiede solo un consenso una tantum tra i thread) e assume Sequentialun'interfaccia:

public interface WFQ<T> // "FIFO" iteration
{
    int enqueue(T t); // returns the sequence number of t
    Iterable<T> iterateUntil(int max); // iterates until sequence max
}
public interface Sequential
{
    // Apply an invocation (method + arguments)
    // and get a response (return value + state)
    Response apply(Invocation i); 
}
public interface Factory<T> { T generate(); } // generate new default object
public interface Universal extends Sequential {}

public class SlowUniversal implements Universal
{
    Factory<? extends Sequential> generator;
    WFQ<Invocation> wfq = new WFQ<Invocation>();
    Universal(Factory<? extends Sequential> g) { generator = g; } 
    public Response apply(Invocation i)
    {
        int max = wfq.enqueue(i);
        Sequential s = generator.generate();
        for(Invocation invoc : wfq.iterateUntil(max))
            s.apply(invoc);
        return s.apply(i);
    }
}

Questa implementazione non è molto soddisfacente poiché è molto lenta (ti ricordi di ogni invocazione e devi riprodurla ad ogni applicazione - abbiamo un runtime lineare nella dimensione della cronologia). Esiste un modo per estendere le interfacce WFQe Sequential(in modo ragionevole) per consentirci di salvare alcuni passaggi quando si applica una nuova chiamata?

Possiamo renderlo più efficiente (non un runtime lineare nella dimensione della cronologia, preferibilmente anche l'uso della memoria diminuisce) senza perdere la proprietà senza attesa?

Una precisazione

Una "costruzione universale" è un termine che sono abbastanza sicuro che è stato inventato da [1] che accetta un oggetto thread-non sicuro ma compatibile con thread, che è generalizzato Sequentialdall'interfaccia. Usando una coda senza attesa, la prima costruzione offre una versione dell'oggetto thread-safe e linearizzabile che è anche senza attesa (questo presuppone applyoperazioni deterministiche e di arresto ).

Questo è inefficiente, poiché il metodo è efficace per far iniziare ogni thread locale da una lavagna pulita e applicare ogni operazione mai registrata su di esso. In ogni caso, questo funziona perché raggiunge la sincronizzazione in modo efficace usando il WFQper determinare l'ordine in cui tutte le operazioni dovrebbero essere applicate: ogni chiamata di thread applyvedrà lo stesso Sequentialoggetto locale , con la stessa sequenza di Invocations applicate ad esso.

La mia domanda è se possiamo (ad esempio) introdurre un processo di pulizia in background che aggiorna lo "stato iniziale" in modo da non dover ricominciare da capo. Questo non è semplice come avere un puntatore atomico con un puntatore iniziale: questi tipi di approcci perdono facilmente la garanzia senza attesa. Il mio sospetto è che qualche altro approccio basato sulla coda potrebbe funzionare qui.

Gergo:

  1. wait-free - indipendentemente dal numero di thread o dal processo decisionale dello scheduler, applyterminerà con un numero limitato di istruzioni eseguite per quel thread.
  2. senza blocco - come sopra, ma ammette la possibilità di un tempo di esecuzione illimitato, solo nel caso in cui un numero illimitato di applyoperazioni venga eseguito in altri thread. In genere, gli schemi di sincronizzazione ottimistica rientrano in questa categoria.
  3. blocco: efficienza in balia dello schedulatore.

Un esempio funzionante, come richiesto (ora su una pagina che non scadrà)

[1] Herlihy and Shavit, The Art of Multiprocessor Programming .


La domanda 1 risponde solo se sappiamo cosa significa "funziona" per te.
Robert Harvey,

@RobertHarvey L'ho corretto - tutto ciò che serve per "lavorare" è che il wrapper sia privo di attesa e che tutte le operazioni CopyableSequentialsiano valide - la linearità dovrebbe quindi seguire dal fatto che lo è Sequential.
VF1

Ci sono molte parole significative in questa domanda, ma sto lottando per metterle insieme per capire esattamente cosa stai cercando di realizzare. Puoi fornire una spiegazione di quale problema stai cercando di risolvere e forse ridurre il gergo un po '?
JimmyJames,

@JimmyJames Ho elaborato un "commento esteso" all'interno della domanda. Per favore fatemi sapere se c'è qualche altro gergo da chiarire.
VF1,

nel primo paragrafo del commento si dice "oggetto non sicuro ma compatibile con thread" e "versione linearizzabile dell'oggetto". Non è chiaro cosa intendi per questo, perché thread-safe e linearizzabili sono veramente rilevanti solo per le istruzioni eseguibili ma le stai usando per descrivere oggetti, che sono dati. Presumo che Invocation (che non è definito) sia effettivamente un puntatore a metodo ed è quel metodo che non è thread-safe. Non so cosa significhi compatibilità con thread .
JimmyJames,

Risposte:


1

Ecco una spiegazione ed un esempio di come questo è realizzato. Fammi sapere se ci sono parti che non sono chiare.

Gist con fonte

universale

Inizializzazione:

Gli indici dei thread vengono applicati in modo atomicamente incrementato. Questo è gestito usando un AtomicIntegernome nextIndex. Questi indici sono assegnati ai thread attraverso ThreadLocalun'istanza che si inizializza ottenendo l'indice successivo nextIndexe incrementandolo. Ciò accade la prima volta che viene recuperato l'indice di ogni thread la prima volta. A ThreadLocalviene creato per tenere traccia dell'ultima sequenza creata da questo thread. È inizializzato 0. Il riferimento sequenziale dell'oggetto factory viene passato e memorizzato. AtomicReferenceArrayVengono create due istanze di dimensioni n. L'oggetto tail viene assegnato a ciascun riferimento, essendo stato inizializzato con lo stato iniziale fornito dalla Sequentialfabbrica. nè il numero massimo di thread consentiti. Ogni elemento in queste matrici 'appartiene' all'indice del thread corrispondente.

Applicare metodo:

Questo è il metodo che fa il lavoro interessante. Fa quanto segue:

  • Crea un nuovo nodo per questa chiamata: il mio
  • Imposta questo nuovo nodo nella matrice di annuncio nell'indice del thread corrente

Quindi inizia il loop di sequenziamento. Continuerà fino a quando l'attuale chiamata non sarà stata sequenziata:

  1. trova un nodo nella matrice di annuncio usando la sequenza dell'ultimo nodo creato da questo thread. Più su questo più tardi.
  2. se viene trovato un nodo nel passaggio 2 non è ancora in sequenza, continuare con esso, altrimenti concentrarsi solo sull'invocazione corrente. Questo tenterà di aiutare solo un altro nodo per invocazione.
  3. Qualunque nodo sia stato selezionato nel passaggio 3, continua a provare a sequenziarlo dopo l'ultimo nodo sequenziato (altri thread potrebbero interferire.) Indipendentemente dal successo, imposta il riferimento head dei thread correnti sulla sequenza restituita da decideNext()

La chiave per il ciclo nidificato sopra descritto è il decideNext()metodo. Per capirlo, dobbiamo guardare alla classe Node.

Classe di nodo

Questa classe specifica i nodi in un elenco doppiamente collegato. Non c'è molta azione in questa classe. La maggior parte dei metodi sono semplici metodi di recupero che dovrebbero essere abbastanza autoesplicativi.

metodo di coda

questo restituisce un'istanza di nodo speciale con una sequenza di 0. Funziona semplicemente come segnaposto fino a quando non viene sostituita da una chiamata.

Proprietà e inizializzazione

  • seq: il numero progressivo, inizializzato su -1 (che significa non seguito)
  • invocation: il valore dell'invocazione di apply(). Impostato su costruzione.
  • next: AtomicReferenceper il collegamento in avanti. una volta assegnato, questo non verrà mai modificato
  • previous: AtomicReferenceper il collegamento a ritroso assegnato al sequenziamento e cancellato datruncate()

Decidi il prossimo

Questo metodo è solo uno in Nodo con logica non banale. In poche parole, un nodo viene offerto come candidato per essere il nodo successivo nell'elenco collegato. Il compareAndSet()metodo verificherà se il riferimento è nullo e, in tal caso, imposta il riferimento sul candidato. Se il riferimento è già impostato, non fa nulla. Questa operazione è atomica, quindi se due candidati vengono offerti nello stesso momento, ne verrà selezionato solo uno. Questo garantisce che un solo nodo sarà mai selezionato come il prossimo. Se viene selezionato il nodo candidato, la sequenza viene impostata sul valore successivo e il collegamento precedente viene impostato su questo nodo.

Tornare alla classe universale applica metodo ...

Avendo richiamato decideNext()l'ultimo nodo in sequenza (se selezionato) con il nostro nodo o un nodo announcedall'array, ci sono due possibili occorrenze: 1. Il nodo è stato sequenziato con successo 2. Qualche altro thread ha preceduto questo thread.

Il prossimo passo è verificare se il nodo creato per questa chiamata. Questo potrebbe accadere perché questo thread lo ha sequenziato correttamente o qualche altro thread lo ha raccolto announcedall'array e lo ha sequenziato per noi. Se non è stato sequenziato, il processo viene ripetuto. In caso contrario, la chiamata termina cancellando l'array di annuncio nell'indice di questo thread e restituendo il valore del risultato dell'invocazione. L'array di annuncio viene cancellato per garantire che non vi siano riferimenti al nodo lasciato in giro che impedirebbero la raccolta di dati inutili del nodo e, pertanto, manterrebbero vivi sull'heap tutti i nodi dell'elenco collegato da quel punto in poi.

Valuta il metodo

Ora che il nodo dell'invocazione è stato sequenziato correttamente, l'invocazione deve essere valutata. Per fare ciò, il primo passo è garantire che le invocazioni precedenti a questa siano state valutate. Se non hanno questo thread non aspetteranno ma funzioneranno immediatamente.

Metodo SecurePrior

Il ensurePrior()metodo funziona in questo modo controllando il nodo precedente nell'elenco collegato. Se il suo stato non è impostato, verrà valutato il nodo precedente. Nodo che questo è ricorsivo. Se il nodo precedente al nodo precedente non è stato valutato, chiamerà valutare per quel nodo e così via.

Ora che è noto che il nodo precedente ha uno stato, possiamo valutare questo nodo. L'ultimo nodo viene recuperato e assegnato a una variabile locale. Se questo riferimento è nullo, significa che qualche altro thread ha preceduto questo thread e ha già valutato questo nodo; impostando lo stato. Altrimenti, lo stato del nodo precedente viene passato al Sequentialmetodo di applicazione dell'oggetto insieme all'invocazione di questo nodo. Lo stato restituito viene impostato sul nodo e truncate()viene chiamato il metodo, cancellando il collegamento all'indietro dal nodo in quanto non è più necessario.

Metodo MoveForward

Il metodo di spostamento in avanti tenterà di spostare tutti i riferimenti principali a questo nodo se non stanno già indicando qualcosa di più lungo. Questo per garantire che se un thread smette di chiamare, il suo head non manterrà un riferimento a un nodo che non è più necessario. Il compareAndSet()metodo assicurerà di aggiornare il nodo solo se qualche altro thread non lo ha modificato da quando è stato recuperato.

Annunciare array e aiutare

La chiave per rendere questo approccio senza attesa invece che semplicemente senza lock è che non possiamo presumere che lo scheduler dei thread darà a ogni thread la priorità quando ne ha bisogno. Se ogni thread ha semplicemente tentato di mettere in sequenza i propri nodi, è possibile che un thread possa essere continuamente prevenuto sotto carico. Per tenere conto di questa possibilità, ogni thread tenterà innanzitutto di "aiutare" altri thread che potrebbero non essere in grado di essere sequenziati.

L'idea di base è che mentre ogni thread crea correttamente nodi, le sequenze assegnate stanno aumentando monotonicamente. Se un thread o thread continuano a precludere un altro thread, l'indice utilizzato per trovare nodi non seguiti nella announcematrice si sposterà in avanti. Anche se ogni thread che sta attualmente tentando di sequenziare un determinato nodo viene continuamente prevenuto da un altro thread, alla fine tutti i thread cercheranno di sequenziare quel nodo. Per illustrare, costruiremo un esempio con tre thread.

Al punto di partenza, la testa di tutti e tre i thread e gli elementi di annuncio sono puntati sul tailnodo. Il lastSequenceper ogni thread è 0.

A questo punto, il thread 1 viene eseguito con una chiamata. Controlla la matrice di annuncio per l'ultima sequenza (zero) che è il nodo che è attualmente pianificato per indicizzare. Segue il nodo ed lastSequenceè impostato su 1.

Il thread 2 viene ora eseguito con un richiamo, controlla l'array di annuncio nell'ultima sequenza (zero) e vede che non ha bisogno di aiuto e quindi tenta di sequenziare il suo richiamo. Ci riesce e ora lastSequenceè impostato su 2.

Il thread 3 è ora eseguito e vede anche che il nodo in announce[0]è già in sequenza e sequenze è la propria chiamata. Ora lastSequenceè impostato su 3.

Ora il thread 1 viene nuovamente richiamato. Controlla la matrice di annuncio all'indice 1 e trova che è già in sequenza. Allo stesso tempo, viene invocato il thread 2 . Controlla la matrice di annuncio all'indice 2 e trova che è già in sequenza. Sia Thread 1 che Thread 2 ora tentano di sequenziare i propri nodi. Il thread 2 vince e sequenzia la sua invocazione. È lastSequenceimpostato su 4. Nel frattempo, è stato richiamato il thread tre. Controlla l'indice lastSequence(mod 3) e trova che il nodo in announce[0]non è stato sequenziato. Il thread 2 viene nuovamente invocato nello stesso momento in cui il thread 1 è al secondo tentativo. Discussione 1trova un'invocazione senza conseguenze in announce[1]cui si trova il nodo appena creato da Thread 2 . Tenta di mettere in sequenza l' invocazione di Thread 2 e ha esito positivo. Il thread 2 trova il proprio nodo su announce[1]ed è stato sequenziato. È impostato lastSequencesu 5. Il thread 3 viene quindi richiamato e trova quel nodo in cui si trova il thread 1 announce[0]non è ancora in sequenza e tenta di farlo. Nel frattempo anche il thread 2 è stato invocato e impedisce il thread 3. Mette in sequenza il suo nodo e lo imposta lastSequencesu 6.

Discussione scadente 1 . Anche se Thread 3 sta cercando di sequenziarlo, entrambi i thread sono stati continuamente contrastati dallo scheduler. Ma a questo punto. Il thread 2 ora punta anche a announce[0](6 mod 3). Tutti e tre i thread sono impostati per tentare di sequenziare la stessa chiamata. Indipendentemente dal thread con esito positivo, il nodo successivo da sequenziare sarà l'invocazione in attesa del thread 1, ovvero il nodo a cui fa riferimento announce[0].

Questo è inevitabile Affinché i thread possano essere prevenuti, altri thread devono sequenziare i nodi e, mentre lo fanno, si sposteranno continuamente in lastSequenceavanti. Se il nodo di un determinato thread non viene continuamente sequenziato, alla fine tutti i thread indicheranno il suo indice nella matrice di annuncio. Nessun thread farà altro fino a quando il nodo che sta cercando di aiutare non è stato sequenziato, lo scenario peggiore è che tutti i thread puntano allo stesso nodo non seguito. Pertanto, il tempo necessario per sequenziare qualsiasi invocazione dipende dal numero di thread e non dalla dimensione dell'input.


Ti dispiacerebbe mettere alcuni degli estratti di codice su pastebin? Molte cose (come l'elenco dei link senza lock) possono essere semplicemente dichiarate come tali? È un po 'difficile capire la tua risposta nel suo insieme quando ci sono così tanti dettagli. In ogni caso, questo sembra promettente, mi piacerebbe sicuramente scavare in ciò che garantisce.
VF1,

Sembra certamente un'implementazione valida e senza lock, ma manca il problema fondamentale di cui mi preoccupo. Il requisito della linearizzabilità richiede la presenza di una "cronologia valida", che, nel caso dell'implementazione dell'elenco collegato, necessita di un puntatore previouse nextdi essere valida. Mantenere e creare una cronologia valida in modo senza attesa sembra difficile.
VF1

@ VF1 Non sono sicuro di quale problema non sia stato risolto. Tutto ciò che menzioni nel resto del commento è affrontato nell'esempio che ho dato, da quello che posso dire.
JimmyJames,

Hai rinunciato alla proprietà senza attesa .
VF1,

@ VF1 Come immagini?
JimmyJames,

0

La mia risposta precedente in realtà non risponde correttamente alla domanda, ma poiché l'OP la considera utile, la lascerò così com'è. Sulla base del codice nel link nella domanda, ecco il mio tentativo. Ho fatto solo test di base su questo, ma sembra calcolare correttamente le medie. Feedback accolto con favore se questo è correttamente senza attesa.

NOTA : ho rimosso l'interfaccia Universal e l'ho resa una classe. Avere Universal composto da sequenziali oltre ad essere uno sembra una complicazione inutile ma potrei mancare qualcosa. Nella classe media, ho contrassegnato la variabile di stato come volatile. Questo non è necessario per far funzionare il codice. Essere prudenti (una buona idea con il threading) ed evitare che ogni thread esegua tutti i calcoli (una volta).

Sequenziale e fabbrica

public interface Sequential<E, S, R>
{ 
  R apply(S priorState);

  S state();

  default boolean isApplied()
  {
    return state() != null;
  }
}

public interface Factory<E, S, R>
{
   S initial();

   Sequential<E, S, R> generate(E input);
}

universale

import java.util.concurrent.ConcurrentLinkedQueue;

public class Universal<I, S, R> 
{
  private final Factory<I, S, R> generator;
  private final ConcurrentLinkedQueue<Sequential<I, S, R>> wfq = new ConcurrentLinkedQueue<>();
  private final ThreadLocal<Sequential<I, S, R>> last = new ThreadLocal<>();

  public Universal(Factory<I, S, R> g)
  { 
    generator = g;
  }

  public R apply(I invocation)
  {
    Sequential<I, S, R> newSequential = generator.generate(invocation);
    wfq.add(newSequential);

    Sequential<I, S, R> last = null;
    S prior = generator.initial(); 

    for (Sequential<I, S, R> i : wfq) {
      if (!i.isApplied() || newSequential == i) {
        R r = i.apply(prior);

        if (i == newSequential) {
          wfq.remove(last.get());
          last.set(newSequential);

          return r;
        }
      }

      prior = i.state();
    }

    throw new IllegalStateException("Houston, we have a problem");
  }
}

Media

public class Average implements Sequential<Integer, Average.State, Double>
{
  private final Integer invocation;
  private volatile State state;

  private Average(Integer invocation)
  {
    this.invocation = invocation;
  }

  @Override
  public Double apply(State prior)
  {
    System.out.println(Thread.currentThread() + " " + invocation + " prior " + prior);

    state = prior.add(invocation);

    return ((double) state.sum)/ state.count;
  }

  @Override
  public State state()
  {
    return state;
  }

  public static class AverageFactory implements Factory<Integer, State, Double> 
  {
    @Override
    public State initial()
    {
      return new State(0, 0);
    }

    @Override
    public Average generate(Integer i)
    {
      return new Average(i);
    }
  }

  public static class State
  {
    private final int sum;
    private final int count;

    private State(int sum, int count)
    {
      this.sum = sum;
      this.count = count;
    }

    State add(int value)
    {
      return new State(sum + value, count + 1);
    }

    @Override
    public String toString()
    {
      return sum + " / " + count;
    }
  }
}

Codice demo

private static final int THREADS = 10;
private static final int SIZE = 50;

public static void main(String... args)
{
  Average.AverageFactory factory = new Average.AverageFactory();

  Universal<Integer, Average.State, Double> universal = new Universal<>(factory);

  for (int i = 0; i < THREADS; i++)
  {
    new Thread(new Test(i * SIZE, universal)).start();
  }
}

static class Test implements Runnable
{
  final int start;
  final Universal<Integer, Average.State, Double> universal;

  Test(int start, Universal<Integer, Average.State, Double> universal)
  {
    this.start = start;
    this.universal = universal;
  }

  @Override
  public void run()
  {
    for (int i = start; i < start + SIZE; i++)
    {
      System.out.println(Thread.currentThread() + " " + i);

      System.out.println(System.nanoTime() + " " + Thread.currentThread() + " " + i + " result " + universal.apply(i));
    }
  }
}

Ho apportato alcune modifiche al codice mentre lo pubblicavo qui. Dovrebbe essere OK, ma fammi sapere se hai problemi con esso.


Non devi mantenere l'altra tua risposta per me (ho precedentemente aggiornato la mia domanda per avere delle conclusioni pertinenti da trarre da essa). Sfortunatamente, questa risposta non risponde neanche alla domanda, dal momento che in realtà non libera alcuna memoria nella wfq, quindi devi ancora attraversare tutta la storia - il runtime non è migliorato se non per un fattore costante.
VF1

@ Vf1 Il tempo necessario per attraversare l'intero elenco per verificare se è stato calcolato sarà minuscolo rispetto a ogni calcolo. Poiché gli stati precedenti non sono richiesti, dovrebbe essere possibile rimuovere gli stati iniziali. Il test è difficile e potrebbe richiedere l'utilizzo di una raccolta personalizzata ma ho aggiunto una piccola modifica.
JimmyJames,

@ VF1 Aggiornato a un'implementazione che sembra funzionare con i test di base. Non sono sicuro che sia sicuro, ma dalla parte superiore della mia testa, se l'universale fosse a conoscenza dei thread che stanno lavorando con esso, potrebbe tenere traccia di ogni thread e rimuovere gli elementi una volta che tutti i thread li hanno superati.
JimmyJames,

@ VF1 Guardando il codice per ConcurrentLinkedQueue, il metodo di offerta ha un ciclo molto simile a quello che hai affermato ha reso l'altra risposta non-wait-free. Cerca il commento "Hai perso la corsa CAS a un altro thread;
rileggi il

"Dovrebbe essere possibile rimuovere gli stati iniziali" - esattamente. Esso dovrebbe essere , ma la sua facile introdurre sottilmente codice che perde attesa libertà. Uno schema di tracciamento dei thread potrebbe funzionare. Infine, non ho accesso alla fonte CLQ, ti dispiacerebbe collegare?
VF1
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.