如何使通用结构更有效率?


16

“通用构造”是顺序对象的包装器类,可使它线性化(并发对象的强一致性条件)。例如,这是来自Java [1]的一种经过修改的免等待构造,它假定存在一个满足接口的等待空闲队列WFQ(仅需要线程之间的一次性共识)并假定一个Sequential接口:

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

此实现的效果不是很令人满意,因为它确实很慢(您会记住每个调用,并且必须在每次应用时重播它-我们在历史记录大小中具有线性运行时)。有什么方法可以(以合理的方式)扩展WFQand Sequential接口,以使我们能够在应用新调用时节省一些步骤?

我们可以提高效率(在历史记录大小上不是线性运行时,最好是内存使用量也会下降)而又不会丢失wait-free属性吗?

澄清度

我敢肯定,“通用构造”是由[1]组成的,该术语接受线程不安全但线程兼容的对象,该对象由Sequential接口进行了概括。使用免等待队列,第一个构造提供了对象的线程安全,线性化版本,该对象也无需等待(这是确定性和暂停apply操作的前提)。

这是低效率的,因为该方法有效地使每个本地线程从干净的位置开始并对其进行记录的每个操作。无论如何,这是WFQ可行的,因为它通过使用确定所有操作应采用的顺序来有效地实现了同步:每个线程调用apply都将看到相同的本地Sequential对象,并对其应用相同的Invocations 序列。

我的问题是我们是否可以(例如)引入后台清理过程来更新“启动状态”,这样我们就不必从头开始。这并不像拥有带有起始指针的原子指针那样简单-这些方法很容易失去免等待保证。我怀疑其他一些基于队列的方法可能在这里起作用。

行话:

  1. 无需等待-不管线程数或调度程序的决策如何,apply都将以为该线程执行的可证明的有限数量的指令终止。
  2. 无锁-与上述相同,但仅apply在其他线程中完成无限制的操作的情况下,才允许执行时间不受限制。通常,乐观同步方案属于此类。
  3. 阻塞-效率取决于调度程序。

根据要求的可行示例(现在不会过期)

[1] Herlihy和Shavit,《多处理器编程的艺术》


仅当我们知道“有效”对您意味着什么时,问题1才可以回答。
罗伯特·哈维

@RobertHarvey我更正了它-“工作”所需要的只是使包装程序免于等待并且所有操作CopyableSequential都有效-然后,线性化应该基于它的事实Sequential
VF1

这个问题中有很多有意义的词,但是我很难将它们组合在一起以确切地了解您要完成的工作。您能否提供一些说明,以解决您要解决的问题,甚至可以消除一些行话?
JimmyJames '16

@JimmyJames我已经在问题的“扩展注释”中详细说明了。请让我知道是否还有其他术语需要清除。
VF1

在注释的第一段中,您说“线程不安全但线程兼容的对象”和“对象的线性化版本”。您不清楚这是什么意思,因为线程安全和可线性化仅与可执行指令真正相关,但是您正在使用它们来描述作为数据的对象。我假设Invocation(未定义)实际上是方法指针,并且该方法不是线程安全的。我不知道线程兼容是什么意思。
JimmyJames

Answers:


1

这是如何完成此操作的说明和示例。让我知道是否有不清楚的部分。

要点与来源

普遍

初始化:

线程索引以原子增量方式应用。使用AtomicIntegernamed进行管理nextIndex。这些索引通过ThreadLocal实例实例从中获取下一个索引nextIndex并对其进行递增来初始化自身。第一次检索每个线程的索引时会发生这种情况。ThreadLocal创建A 来跟踪该线程创建的最后一个序列。初始化为0。传递并存储顺序工厂对象引用。AtomicReferenceArray创建了两个实例,它们的大小为n。尾部对象已分配给每个引用,并已使用Sequential工厂提供的初始状态进行了初始化。 n是允许的最大线程数。这些数组中的每个元素都“属于”相应的线程索引。

申请方法:

这是完成有趣工作的方法。它执行以下操作:

  • 为此调用创建一个新节点:mine
  • 在公告数组的当前线程索引处设置此新节点

然后,测序循环开始。它将一直持续到当前调用已排序为止:

  1. 使用此线程创建的最后一个节点的序列在announce数组中找到一个节点。稍后再详细介绍。
  2. 如果在步骤2中找到了一个尚未排序的节点,请继续进行操作,否则,只需关注当前调用即可。这只会在每次调用时尝试帮助另一个节点。
  3. 无论在第3步中选择了哪个节点,都请在最后一个已排序节点之后继续尝试对其进行排序(其他线程可能会干扰。)不管是否成功,都应将当前线程的头引用设置为由以下命令返回的序列 decideNext()

上述嵌套循环的关键是 decideNext()方法。要了解这一点,我们需要查看Node类。

节点类

此类在双向链接列表中指定节点。在这堂课中没有很多动作。大多数方法是简单的检索方法,应该相当不言自明。

尾法

这将返回一个序列为0的特殊节点实例。它仅充当占位符,直到调用将其替换。

属性和初始化

  • seq:序列号,初始化为-1(表示无序列)
  • invocation:调用的价值 apply()。开始施工。
  • nextAtomicReference用于前向链接。一旦分配,将永远不会改变
  • previousAtomicReference用于排序后分配并由清除的反向链接truncate()

决定下一个

这种方法只是Node中具有非平凡逻辑的一种。简而言之,提供一个节点作为候选对象,以成为链表中的下一个节点。的compareAndSet()方法将检查其引用是否为null,如果是,则将引用设置为候选。如果已经设置了引用,则不执行任何操作。此操作是原子操作,因此如果同时提供两个候选者,则只会选择一个。这样可以确保仅选择一个节点作为下一个节点。如果选择了候选节点,则将其顺序设置为下一个值,并将其上一个链接设置为该节点。

跳回Universal类apply方法...

decideNext()使用我们的节点或来自的节点调用了最后一个排序的节点(选中时)announce数组中的,有两种可能的情况:1.该节点已成功排序。2.一些其他线程抢占了该线程。

下一步是检查是否为此调用创建了节点。之所以会发生这种情况,是因为该线程成功地对其进行了排序,或者其他线程从announce数组中将其拾取并为我们进行了排序。如果尚未排序,则重复该过程。否则,调用将通过清除该线程索引处的announce数组并返回调用的结果值来结束。清除了公告数组,以确保不存在对剩余节点的引用,这将防止该节点被垃圾回收,因此从该点开始将链接列表中的所有节点保留在堆上。

评估方法

现在,调用的节点已成功排序,需要评估调用。为此,第一步是确保已评估了该调用之前的调用。如果他们还没有,该线程将不会等待,但会立即执行该工作。

确保优先方法

ensurePrior()方法通过检查链接列表中的上一个节点来完成此工作。如果未设置其状态,则将评估前一个节点。这是递归的节点。如果尚未评估在先节点之前的节点,它将调用对该节点的评估,依此类推。

现在已知前一个节点具有状态,我们可以评估该节点。检索最后一个节点并将其分配给局部变量。如果该引用为null,则意味着其他某个线程抢占了该线程并已经对该节点进行了评估;设置它的状态。否则,先前节点的状态Sequential将与该节点的调用一起传递给对象的apply方法。返回的状态在节点上设置,然后truncate()调用该方法,从节点清除反向链接,因为不再需要该反向链接。

MoveForward方法

如果所有头参考尚未指向进一步的前进,则前进方法将尝试将所有头参考移至该节点。这是为了确保如果线程停止调用,它的头部将不会保留对不再需要的节点的引用。该compareAndSet()方法将确保仅在检索到节点后其他线程未更改节点的情况下才更新节点。

宣布阵列和帮助

使这种方法无需等待而不是简单地获得无锁的关键在于,我们不能假定线程调度程序会在需要时为每个线程赋予优先级。如果每个线程只是简单地尝试对自己的节点进行排序,则有可能在负载下连续抢占一个线程。为了解决这种可能性,每个线程将首先尝试“帮助”可能无法排序的其他线程。

基本思想是,随着每个线程成功创建节点,分配的序列会单调增加。如果一个或多个线程不断抢占另一个线程,则用于查找announce数组中未排序节点的索引将向前移动。即使当前试图对给定节点进行排序的每个线程都被另一个线程连续抢占,最终所有线程仍将尝试对该节点进行排序。为了说明,我们将构建一个包含三个线程的示例。

在开始时,所有三个线程的head和announce元素都指向该tail节点。在lastSequence每个线程是0。

此时,线程1将通过调用执行。它检查通告数组的最后一个序列(零),该序列是当前计划索引的节点。它对节点进行排序并将其lastSequence设置为1。

线程2现在通过调用执行,它在最后一个序列(零)处检查announce数组,并发现它不需要帮助,因此尝试对调用序列进行排序。成功,现在lastSequence设置为2。

现在执行线程3,并且还可以看到处的节点announce[0]已经被排序,并且对其自身的调用进行排序。它lastSequence现在设置为3。

现在,再次调用线程1。它检查索引1处的announce数组,并发现它已被排序。同时,线程2被调用。它检查索引2处的公告数组,并发现它已被排序。这两个线程1线程2现在试图测序自己的节点。 线程2获胜,并按顺序对其进行调用。它lastSequence设置为4。同时,线程三已被调用。它检查它的索引lastSequence(mod 3)并发现处的节点announce[0]尚未排序。 在第二次尝试线程1的同时,再次调用线程2线程1查找未排序的调用,announce[1]该调用是线程2刚刚创建的节点。它尝试对线程2的调用进行排序并成功。 线程2找到了它自己的节点,announce[1]并且已经对其进行了排序。它设置lastSequence为5。 然后调用线程3,并发现放置线程1的那个节点announce[0]仍未排序,并尝试这样做。同时,线程2也已被调用并抢占了线程3。它对节点进行排序并将其设置lastSequence为6。

线程1。即使线程3试图对其进行排序,调度程序仍不断地挫败两个线程。但是在这一点上。线程2现在也指向announce[0](6 mod 3)。将所有三个线程设置为尝试对同一调用进行排序。无论哪个线程成功,下一个要排序的节点都是线程1的等待调用,即所引用的节点announce[0]

这是不可避免的。为了使线程被抢占,其他线程必须对节点进行排序,并且这样做时,它们将不断lastSequence前进。如果给定线程的节点连续未排序,则最终所有线程都将指向announce数组中的索引。在尝试对要帮助的节点进行排序之前,没有线程会做其他任何事情,最坏的情况是所有线程都指向同一个未排序的节点。因此,对任何调用进行排序所需的时间是线程数量的函数,而不是输入大小的函数。


您介意将一些代码摘录放到pastebin上吗?许多事情(例如无锁链表)可以这样简单地陈述吗?当细节太多时,很难整体上理解您的答案。无论如何,这看起来很有希望,我当然想深入研究它提供的保证。
VF1

这肯定看起来像是一个有效的无锁实现,但是却缺少我担心的基本问题。线性化的要求需要提供“有效历史记录”,在链表实现的情况下,需要“ previous和” next指针有效。以免等待的方式维护和创建有效的历史记录似乎很困难。
VF1

@ VF1我不确定什么问题没有解决。据我所知,您在其余评论中提到的所有内容都在我给出的示例中得到了解决。
JimmyJames

您已经放弃了免等待属性。
VF1

@ VF1你怎么看?
JimmyJames

0

我之前的回答并没有真正正确回答问题,但是由于OP认为它很有用,所以我将其保留。根据问题链接中的代码,这是我的尝试。我仅对此进行了真正的基础测试,但似乎可以正确计算平均值。欢迎反馈有关此操作是否正确的等待时间。

注意:我删除了通用接口,并使其成为一个类。让Universal由Sequentials以及由Sequentials组成似乎是不必要的复杂性,但我可能会遗漏一些东西。在普通班上,我将状态变量标记为volatile。这不是使代码正常工作所必需的。保守一点(使用线程是个好主意),并防止每个线程(一次)执行所有计算。

顺序工厂

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

普遍

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

平均

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

演示代码

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

我在此处发布代码时对其进行了一些编辑。可以,但是如果您有任何疑问,请告诉我。


您不必为我保留其他答案(我之前已经更新了我的问题,可以从中得出任何相关结论)。不幸的是,此答​​案也无法回答问题,因为它实际上并没有释放中的任何内存wfq,因此您仍然必须遍历整个历史记录-运行时间没有得到改善,只是增加了一个恒定因素。
VF1'1

@ Vf1与执行每次计算相比,遍历整个列表以检查其是否已计算所需的时间将是微不足道的。因为不需要先前的状态,所以应该可以删除初始状态。测试很困难,可能需要使用自定义的集合,但是我添加了一个小的更改。
JimmyJames

@ VF1已更新为似乎可以与基本的粗略测试一起使用的实现。我不确定它是否安全,但是如果我知道通用的线程正在使用该线程,那么它可以跟踪每个线程并在所有线程安全通过它们之后删除元素。
JimmyJames

@ VF1查看ConcurrentLinkedQueue的代码,offer方法具有一个循环,就像您声称使另一个应答非等待的循环。寻找注释“失去CAS竞争另一个线程;再阅读下一篇”
JimmyJames

“应该有可能删除初始状态”-完全正确。它应该是,但是很容易巧妙地引入失去等待自由的代码。线程跟踪方案可能会起作用。最后,我无权访问CLQ源,您可以链接吗?
VF1'1
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.