¿Cómo puedo hacer que una construcción universal sea más eficiente?

16

Una "construcción universal" es una clase de contenedor para un objeto secuencial que permite que se linealice (una condición de consistencia fuerte para objetos concurrentes). Por ejemplo, aquí hay una construcción adaptada sin esperas, en Java, de [1], que presume la existencia de una cola sin esperas que satisface la interfaz WFQ(que solo requiere un consenso entre hilos) y asume una Sequentialinterfaz:

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

Esta implementación no es muy satisfactoria, ya que es realmente lenta (recuerda cada invocación y tiene que reproducirla en cada solicitud; tenemos un tiempo de ejecución lineal en el tamaño del historial). ¿Hay alguna forma de ampliar las interfaces WFQy Sequential(de manera razonable) para permitirnos guardar algunos pasos al aplicar una nueva invocación?

¿Podemos hacer esto más eficiente (no el tiempo de ejecución lineal en el tamaño del historial, preferiblemente el uso de memoria también disminuye) sin perder la propiedad sin esperas?

Aclaración

Una "construcción universal" es un término que estoy seguro de que fue creado por [1] que acepta un objeto inseguro pero compatible con hilos, que es generalizado por la Sequentialinterfaz. Usando una cola sin esperas, la primera construcción ofrece una versión linealizable del objeto que también es libre de esperas (esto supone applyoperaciones de determinismo y detención ).

Esto es ineficiente, ya que el método consiste en hacer que cada subproceso local comience desde una pizarra limpia y aplique todas las operaciones que se hayan registrado. En cualquier caso, esto funciona porque logra una sincronización efectiva mediante el uso de WFQpara determinar el orden en el que se deben aplicar todas las operaciones: cada llamada de subproceso applyverá el mismo Sequentialobjeto local , con la misma secuencia de Invocations aplicada.

Mi pregunta es si podemos (por ejemplo) introducir un proceso de limpieza en segundo plano que actualice el "estado inicial" para que no tengamos que reiniciar desde cero. Esto no es tan simple como tener un puntero atómico con un puntero de inicio: este tipo de enfoques pierden fácilmente la garantía sin esperas. Mi sospecha es que algún otro enfoque basado en colas podría funcionar aquí.

Jerga:

  1. sin esperas: independientemente de la cantidad de subprocesos o de la toma de decisiones del planificador, applyterminará en un número de instrucciones probadamente acotadas ejecutadas para ese subproceso.
  2. sin bloqueo: igual que el anterior, pero admite la posibilidad de un tiempo de ejecución ilimitado, solo en el caso de que applyse realice un número ilimitado de operaciones en otros subprocesos. Por lo general, los esquemas de sincronización optimistas entran en esta categoría.
  3. bloqueo - eficiencia a merced del planificador.

Un ejemplo de trabajo, según lo solicitado (ahora en una página que no caducará)

[1] Herlihy y Shavit, El arte de la programación multiprocesador .

VF1
fuente
La pregunta 1 solo se puede responder si sabemos lo que "funciona" significa para usted.
Robert Harvey
@RobertHarvey Lo corregí: todo lo que necesita para "trabajar" es que el contenedor esté libre de esperas y que todas las operaciones CopyableSequentialsean válidas; la linealización debería seguir al hecho de que es así Sequential.
VF1
Hay muchas palabras significativas en esta pregunta, pero estoy luchando por unirlas para comprender exactamente lo que está tratando de lograr. ¿Puede dar una explicación de qué problema está tratando de resolver y tal vez diluir un poco la jerga?
JimmyJames
@JimmyJames He elaborado en un "comentario extendido" dentro de la pregunta. Avíseme si hay alguna otra jerga que aclarar.
VF1
en el primer párrafo del comentario, dice "objeto inseguro para subprocesos pero compatible con subprocesos" y "versión linealizable del objeto". No está claro qué quiere decir con eso porque la seguridad de subprocesos y la linealización solo son verdaderamente relevantes para las instrucciones ejecutables, pero las está utilizando para describir objetos, que son datos. Supongo que Invocación (que no está definida) es efectivamente un puntero de método y es ese método el que no es seguro para subprocesos. No sé qué significa compatible con hilos .
JimmyJames

Respuestas:

1

Aquí hay una explicación y un ejemplo de cómo se logra esto. Avíseme si hay partes que no están claras.

Gist con fuente

Universal

Inicializacion:

Los índices de subprocesos se aplican de manera atómicamente incrementada. Esto se gestiona utilizando un AtomicIntegernombre nextIndex. Estos índices se asignan a subprocesos a través de una ThreadLocalinstancia que se inicializa obteniendo el siguiente índice nextIndexe incrementándolo. Esto sucede la primera vez que se recupera el índice de cada subproceso la primera vez. A ThreadLocalse crea para rastrear la última secuencia que creó este hilo. Se inicializa 0. La referencia de objeto de fábrica secuencial se pasa y se almacena. Se AtomicReferenceArraycrean dos instancias de tamaño n. El objeto de cola se asigna a cada referencia, habiéndose inicializado con el estado inicial proporcionado por la Sequentialfábrica. nes el número máximo de hilos permitido. Cada elemento en estas matrices 'pertenece' al índice de subprocesos correspondiente.

Aplicar método:

Este es el método que hace el trabajo interesante. Hace lo siguiente:

  • Cree un nuevo nodo para esta invocación: mine
  • Establezca este nuevo nodo en la matriz de anuncios en el índice del hilo actual

Entonces comienza el ciclo de secuenciación. Continuará hasta que la invocación actual se haya secuenciado:

  1. encuentre un nodo en la matriz de anuncios utilizando la secuencia del último nodo creado por este hilo. Más sobre esto más tarde.
  2. si se encuentra un nodo en el paso 2, aún no está secuenciado, continúe con él, de lo contrario, solo concéntrese en la invocación actual. Esto solo intentará ayudar a otro nodo por invocación.
  3. Cualquiera que sea el nodo seleccionado en el paso 3, siga intentando secuenciarlo después del último nodo secuenciado (pueden interferir otros subprocesos). Independientemente del éxito, establezca la referencia del encabezado de subprocesos actual a la secuencia devuelta por decideNext()

La clave del bucle anidado descrito anteriormente es el decideNext()método. Para entender eso, necesitamos mirar la clase Node.

Clase de nodo

Esta clase especifica nodos en una lista doblemente vinculada. No hay mucha acción en esta clase. La mayoría de los métodos son métodos de recuperación simples que deberían explicarse por sí mismos.

método de cola

esto devuelve una instancia de nodo especial con una secuencia de 0. Simplemente actúa como un marcador de posición hasta que una invocación lo reemplaza.

Propiedades e inicialización

  • seq: el número de secuencia, inicializado a -1 (es decir, sin secuencia)
  • invocation: el valor de la invocación de apply(). En construcción.
  • next: AtomicReferencepara el enlace directo. una vez asignado, esto nunca será cambiado
  • previous: AtomicReferencepara el enlace hacia atrás asignado al secuenciar y borrado portruncate()

Decidir a continuación

Este método es solo uno en Nodo con lógica no trivial. En pocas palabras, se ofrece un nodo como candidato para ser el siguiente nodo en la lista vinculada. El compareAndSet()método verificará si su referencia es nula y, de ser así, establecerá la referencia al candidato. Si la referencia ya está establecida, no hace nada. Esta operación es atómica, por lo que si se ofrecen dos candidatos en el mismo momento, solo se seleccionará uno. Esto garantiza que solo se seleccionará un nodo como el siguiente. Si se selecciona el nodo candidato, su secuencia se establece en el siguiente valor, y su enlace anterior se establece en este nodo.

Volver al método de aplicación de la clase Universal ...

Habiendo llamado decideNext()al último nodo secuenciado (cuando está marcado) con nuestro nodo o un nodo de la announcematriz, hay dos posibles ocurrencias: 1. El nodo fue secuenciado exitosamente 2. Algún otro hilo se adelantó a este hilo.

El siguiente paso es verificar si el nodo creado para esta invocación. Esto podría suceder porque este hilo lo ha secuenciado con éxito o algún otro hilo lo recogió de la announcematriz y lo ha secuenciado para nosotros. Si no se ha secuenciado, el proceso se repite. De lo contrario, la llamada finaliza al borrar la matriz de anuncio en el índice de este hilo y devolver el valor del resultado de la invocación. La matriz de anuncio se borra para garantizar que no queden referencias al nodo que evite que el nodo se recolecte basura y, por lo tanto, mantenga todos los nodos en la lista vinculada desde ese punto en vivo en el montón.

Evaluar método

Ahora que el nodo de la invocación se ha secuenciado correctamente, la invocación debe evaluarse. Para hacer eso, el primer paso es asegurar que las invocaciones anteriores a esta hayan sido evaluadas. Si no tienen este hilo, no esperará pero hará ese trabajo de inmediato.

Garantizar método anterior

El ensurePrior()método hace esto comprobando el nodo anterior en la lista vinculada. Si su estado no está establecido, se evaluará el nodo anterior. Nodo de que esto es recursivo. Si el nodo anterior al nodo anterior no ha sido evaluado, llamará a la evaluación para ese nodo y así sucesivamente.

Ahora que se sabe que el nodo anterior tiene un estado, podemos evaluar este nodo. El último nodo se recupera y se asigna a una variable local. Si esta referencia es nula, significa que algún otro subproceso se ha adelantado a este y ya ha evaluado este nodo; estableciendo su estado. De lo contrario, el estado del nodo anterior se pasa al Sequentialmétodo de aplicación del objeto junto con la invocación de este nodo. El estado devuelto se establece en el nodo y truncate()se llama al método, borrando el enlace hacia atrás del nodo ya que ya no es necesario.

Método MoveForward

El método de avance intentará mover todas las referencias de cabeza a este nodo si aún no apuntan a algo más adelante. Esto es para garantizar que si un subproceso deja de llamar, su cabecera no retendrá una referencia a un nodo que ya no sea necesario. El compareAndSet()método se asegurará de que solo actualicemos el nodo si algún otro hilo no lo ha cambiado desde que se recuperó.

Anunciar matriz y ayudar

La clave para hacer que este enfoque sea libre de espera en lugar de simplemente libre de bloqueo es que no podemos suponer que el planificador de subprocesos dará prioridad a cada subproceso cuando lo necesite. Si cada subproceso simplemente intentó secuenciar sus propios nodos, es posible que un subproceso se pueda adelantar continuamente bajo carga. Para tener en cuenta esta posibilidad, cada subproceso primero intentará 'ayudar' a otros subprocesos que no puedan secuenciarse.

La idea básica es que a medida que cada subproceso crea con éxito nodos, las secuencias asignadas aumentan monotónicamente. Si un subproceso o subprocesos se adelantan continuamente a otro subproceso, el índice del uso para encontrar nodos no secuenciados en la announcematriz avanzará. Incluso si cada subproceso que actualmente está tratando de secuenciar un nodo dado se ve continuamente reemplazado por otro subproceso, eventualmente todos los subprocesos intentarán secuenciar ese nodo. Para ilustrar, construiremos un ejemplo con tres hilos.

En el punto de partida, los elementos de cabeza y anuncio de los tres hilos apuntan al tailnodo. El lastSequencepara cada hilo es 0.

En este punto, el hilo 1 se ejecuta con una invocación. Comprueba la matriz de anuncio para su última secuencia (cero), que es el nodo que está programado para indexar actualmente. Secuencia el nodo y se lastSequenceestablece en 1.

El subproceso 2 ahora se ejecuta con una invocación, comprueba la matriz de anuncios en su última secuencia (cero) y ve que no necesita ayuda, por lo que intenta secuenciar su invocación. Tiene éxito y ahora lastSequenceestá configurado en 2.

El subproceso 3 ahora se ejecuta y también ve que el nodo en announce[0]ya está secuenciado y secuencia su propia invocación. Ahora lastSequenceestá configurado en 3.

Ahora se vuelve a invocar el hilo 1 . Comprueba la matriz de anuncios en el índice 1 y descubre que ya está secuenciada. Al mismo tiempo, se invoca el hilo 2 . Comprueba la matriz de anuncios en el índice 2 y descubre que ya está secuenciada. Tanto el subproceso 1 como el subproceso 2 ahora intentan secuenciar sus propios nodos. El hilo 2 gana y secuencia su invocación. Está lastSequenceconfigurado en 4. Mientras tanto, se ha invocado el hilo tres. Comprueba el índice lastSequence(mod 3) y descubre que el nodo en announce[0]no ha sido secuenciado. El hilo 2 se invoca nuevamente al mismo tiempo que el hilo 1 está en su segundo intento. Hilo 1encuentra una invocación no secuenciada en la announce[1]que se encuentra el nodo recién creado por Thread 2 . Intenta secuenciar la invocación de Thread 2 y tiene éxito. El hilo 2 encuentra su propio nodo announce[1]y ha sido secuenciado. Establece que es lastSequence5. El subproceso 3 se invoca y encuentra que el nodo en el que se colocó el subproceso 1 announce[0]todavía no está secuenciado e intenta hacerlo. Mientras tanto, el hilo 2 también se ha invocado y se adelanta al hilo 3. Secuencia su nodo y lo establece lastSequenceen 6.

Hilo pobre 1 . A pesar de que Thread 3 está tratando de secuenciarlo, ambos hilos han sido continuamente frustrados por el programador. Pero en este punto. El hilo 2 ahora también apunta a announce[0](6 mod 3). Los tres hilos están configurados para intentar secuenciar la misma invocación. No importa qué subproceso tenga éxito, el siguiente nodo que se secuenciará será la invocación en espera del Subproceso 1, es decir, el nodo al que hace referencia announce[0].

Esto es inevitable Para que los subprocesos se vacíen, otros subprocesos deben ser nodos de secuencia y, a medida que lo hacen, continuamente avanzarán lastSequence. Si el nodo de un subproceso dado no se secuencia continuamente, eventualmente todos los subprocesos apuntarán a su índice en la matriz de anuncios. Ningún subproceso hará otra cosa hasta que el nodo al que intenta ayudar se haya secuenciado, el peor de los casos es que todos los subprocesos estén apuntando al mismo nodo no secuenciado. Por lo tanto, el tiempo requerido para secuenciar cualquier invocación es una función del número de subprocesos y no del tamaño de la entrada.

JimmyJames
fuente
¿Te importaría poner algunos de los extractos de código en pastebin? ¿Muchas cosas (como la lista enlazada sin bloqueo) se pueden expresar simplemente como tales? Es un poco difícil entender su respuesta en su conjunto cuando hay tantos detalles. En cualquier caso, esto parece prometedor, ciertamente me gustaría profundizar en las garantías que ofrece.
VF1
Ciertamente, parece una implementación válida sin bloqueo, pero falta el problema fundamental que me preocupa. El requisito de linealización requiere que exista un "historial válido" que, en el caso de la implementación de la lista vinculada, necesita un puntero previousy nextpara ser válido. Mantener y crear un historial válido sin esperas parece difícil.
VF1
@ VF1 No estoy seguro de qué problema no se aborda. Todo lo que mencionas en el resto del comentario se aborda en el ejemplo que di, por lo que puedo decir.
JimmyJames
Has renunciado a la propiedad sin esperas .
VF1
@ VF1 ¿Cómo te imaginas?
JimmyJames
0

Mi respuesta anterior realmente no responde la pregunta correctamente, pero como el OP lo ve como útil, lo dejaré como está. Según el código en el enlace de la pregunta, aquí está mi intento. Solo he realizado pruebas realmente básicas sobre esto, pero parece que calcula los promedios correctamente. Se agradecen los comentarios sobre si esto es adecuadamente sin esperas.

NOTA : Eliminé la interfaz Universal y la convertí en una clase. Tener Universal compuesto de secuencias y ser uno parece una complicación innecesaria, pero podría estar perdiendo algo. En la clase promedio, he marcado la variable de estado para ser volatile. Esto no es necesario para que el código funcione. Para ser conservador (una buena idea con subprocesos) y evitar que cada subproceso haga todos los cálculos (una vez).

Secuencial y Fábrica

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

Universal

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

Promedio

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

Código de demostración

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

Hice algunas ediciones al código mientras lo publicaba aquí. Debería estar bien, pero avíseme si tiene problemas con él.

JimmyJames
fuente
No tiene que mantener su otra respuesta por mí (previamente actualicé mi pregunta para sacar conclusiones relevantes de ella). Desafortunadamente, esta respuesta tampoco responde a la pregunta, ya que en realidad no libera nada de la memoria en el wfq, por lo que aún tiene que recorrer toda la historia: el tiempo de ejecución no ha mejorado, excepto por un factor constante.
VF1
@ Vf1 El tiempo que se tarda en recorrer la lista completa para verificar si se ha calculado será minúsculo en comparación con cada cálculo. Debido a que los estados anteriores no son obligatorios, debería ser posible eliminar los estados iniciales. La prueba es difícil y puede requerir el uso de una colección personalizada, pero he agregado un pequeño cambio.
JimmyJames
@ VF1 Actualizado a una implementación que parece funcionar con pruebas básicas básicas. No estoy seguro de que sea seguro, pero fuera de mi cabeza, si el universal estaba al tanto de los hilos que están trabajando con él, podría realizar un seguimiento de cada hilo y eliminar elementos una vez que todos los hilos hayan pasado de manera segura.
JimmyJames
@ VF1 Mirando el código para ConcurrentLinkedQueue, el método de oferta tiene un ciclo muy similar al que usted afirmó que hizo que la otra respuesta no fuera sin esperas. Busque el comentario "Carrera de CAS perdida a otro hilo; vuelva a leer a continuación"
JimmyJames
"Debería ser posible eliminar los estados iniciales" - exactamente. Que debería ser , pero es fácil de introducir sutilmente código que pierde la libertad de espera. Un esquema de seguimiento de subprocesos podría funcionar. Finalmente, no tengo acceso a la fuente CLQ, ¿te importaría vincular?
VF1