Presto-MultilevelSplitQueue讨论

1,609 阅读9分钟

执行调度的通用讨论

调度问题的场景

作为一个查询引擎来说一个重要的问题是如何解决计算任务的调度问题,我们会遇到一些什么样的问题呢?

  1. 举一个简单的例子,比如我现在有10个请求,有编号为【0】的任务需要10s,而编号为【1】-【9】的任务需要1s,如果按顺序执行所有任务的lantency会不会很高?
    • 简单方案就是先执行【1】-【9】的任务,最后执行【0】的任务。
  2. 但是对于一个在线查询的系统来说,假如在执行的过程中还在不停的进入。执行时间都是1s的任务,那是不是意味着编号【0】的任务是不是永远都无法执行了呢?
    • 所以我们需要一个相对公平的系统,不能出现部分任务永远饥饿的场景。
  3. 任务之间或许是相互关联的,比如【0】-【5】的任务需要需要执行完才能执行【6】的任务。
    • 任务之间是有一定关联性的,或许需要统一的调度策略。
  4. 分布式系统中调度结果或许是不可控的?比如对依赖服务的请求被block了。实际耗时和预期耗时差异很大如何适配?
    • 考虑不可控的分布式环境和极端场景。

一般性原理与策略

此类问题的建模其实很多地方的是类似的,presto的思路大概是这样的:

  1. 给执行任务分配一个时间分片,将时间分片轮流分给任务,以最小化lantency以及避免饥饿的情况。
  2. presto内置了一个多层级的优先队列,通过累计任务运行时间来平衡不同时长之间竞争关系,通过累计运行时间的方式让队列不断升级,让同样代价的任务尽可能的只跟同级别的任务进行竞争。
  3. 参照presto-concept任务之间的依赖关系通过构建DAG的方式解决,这里在不讨论,在同一个task内,多个split往往是有一定关联系的,presto使用一个TaskPriorityTracker来统一搜集类型运行时间和管理优先级调度策略。

presto的模型抽象

presto构建了如下几个类来完成这个调度问题

  1. TaskExecutor:单个实例内的只有一个,接受外接的任务调度请求和任务状态管理,持有工作线程资源,以及维护一些必要的统计信息。内部会维持一个MultilevelSplitQueue,该队列维持了一个等待调度的split。
  2. MultilevelSplitQueue:内置了一个分层的优先队列,按照level维护了调度的时间和优先级分数等待统计
  3. PrioritizedSplitRunner:为了实现类似操作系统的分片调度的能力,presto抽象出来一个SplitRunner的接口,在进行调度的时候每次只会调度这个接口的processor一个时间分片,然后重新寻找一个合适的task用于下一个分片的执行。而PrioritizedSplitRunner和SplitRunner的主要区别在于内置了一个Priority的对象,Priority则内置了当前的level,以及在level内的分数。
  4. TaskHandler:这类的意义在于标记一组的split,这一类split可能同属于一个Task,他们有着相同的处理逻辑,同时他们在进行调度的时候会有一些外部的限制,比如他们的累计时间需要一起调度;split的并发度可能有一定要求。

整体的执行流程大概如下所示,是一个按照时间分片不断循环执行直到任务结束的模式

image.png

多LEVEL的优先队列的实现

成员变量

首先详细描述下MultilevelSplitQueue的内部实现,从核心的成员变量开始

private final List<PriorityQueue<PrioritizedSplitRunner>> levelWaitingSplits;
private final AtomicLong[] levelScheduledTime = new AtomicLong[LEVEL_THRESHOLD_SECONDS.length];
private final AtomicLong[] levelMinPriority;
private final List<CounterStat> selectedLevelCounters;
  • levelWaitingSplits : 是一个size为5的优先队列的2维数组。
  • levelSchedueTime:是一个size为5的调度时间累加器,表示当前level的已用掉的调度时间。PrioritizedSplitRunner在执行完任务的process之后会增加当前level的调度时间。这部分的数据主要用于在poll数据的时候计算具体从哪个level的队列里面获取。
  • levelTimeMultiplier:这个字段用于设置不同level之间的cpu时间分配。
  • levelMinPriority是一个size为5的优先级分数,这个参数也是唯一可以指定的构造器参数。表名当前level的最小的优先级分数,每次从队里里面成功take出数据之后会更新这个当前level的优先级分数,具体什么时候用后面讨论到PriorityTracker如何管理一组split的优先级的时候再讨论这部分。

队列功能实现

对于优先队列来说2个最基本的方法就是offer和take

  1. 对于offer的场景,比较简单,MultilevelSplitQueue会接受一个带有Priority的Runner,将队列放入指定的level的队列即可。
  2. 对于take的场景,会稍微复杂一些,首先我们有5个队列,如何确定当前哪个level的队列最合适进行调度呢?

presto是这样做的,level的数量是固定的5: 首先假设了在不同level的CPU时间的预期分布是确定的,具体实现上选择使用levelMinPriority的幂次方来确定分配比例,比如当levelMinPriority=2的时候,0-5的level的时间分布比例为1:2:4:8:16:32.

之前提到过我们维护了一个levelSchedueTime的数组,标识了已经调度的时间,那一个简单的方案就是比较一下不同level的已经调度的时间分布比例是不是如预期的,然后找到最小的一个比例对应的level即为当前需要take需要操作的level队列。

public PrioritizedSplitRunner take()
        throws InterruptedException
{
    while (true) {
        lock.lockInterruptibly();
        try {
            PrioritizedSplitRunner result;
            while ((result = pollSplit()) == null) {
                notEmpty.await();
            }

            if (result.updateLevelPriority()) {
                offer(result);
                continue;
            }

            int selectedLevel = result.getPriority().getLevel();
            levelMinPriority[selectedLevel].set(result.getPriority().getLevelPriority());
            selectedLevelCounters.get(selectedLevel).update(1);

            return result;
        }
        finally {
            lock.unlock();
        }
    }
}

@GuardedBy("lock")
private PrioritizedSplitRunner pollSplit()
{
    long targetScheduledTime = getLevel0TargetTime();
    double worstRatio = 1;
    int selectedLevel = -1;
    for (int level = 0; level < LEVEL_THRESHOLD_SECONDS.length; level++) {
        if (!levelWaitingSplits.get(level).isEmpty()) {
            long levelTime = levelScheduledTime[level].get();
            double ratio = levelTime == 0 ? 0 : targetScheduledTime / (1.0 * levelTime);
            if (selectedLevel == -1 || ratio > worstRatio) {
                worstRatio = ratio;
                selectedLevel = level;
            }
        }

        targetScheduledTime /= levelTimeMultiplier;
    }

    if (selectedLevel == -1) {
        return null;
    }

    PrioritizedSplitRunner result = levelWaitingSplits.get(selectedLevel).poll();
    checkState(result != null, "pollSplit cannot return null");

    return result;
}

public void offer(PrioritizedSplitRunner split)
{
    checkArgument(split != null, "split is null");

    split.setReady();
    int level = split.getPriority().getLevel();
    lock.lock();
    try {
        if (levelWaitingSplits.get(level).isEmpty()) {
            // Accesses to levelScheduledTime are not synchronized, so we have a data race
            // here - our level time math will be off. However, the staleness is bounded by
            // the fact that only running splits that complete during this computation
            // can update the level time. Therefore, this is benign.
            long level0Time = getLevel0TargetTime();
            long levelExpectedTime = (long) (level0Time / Math.pow(levelTimeMultiplier, level));
            long delta = levelExpectedTime - levelScheduledTime[level].get();
            levelScheduledTime[level].addAndGet(delta);
        }

        levelWaitingSplits.get(level).offer(split);
        notEmpty.signal();
    }
    finally {
        lock.unlock();
    }
}

关于基准时间的level0TagetTime的讨论

这里有一点比较有意思的是,实现上如何处理计算比例的,实现上并不是直接用了一个常数来进行计算,而是通过计算一个target0的基准时间,然后用这个时间bastime在每次take的时候进行计算,同时在新的level被offer的时候也会被设置一个根据当前计算出来的basetime乘以之前预设的系数设置假定值,假装好像有任务已经执行过一样。

至于为啥要这么做?我其实没有想得非常清楚,一个可能的原因是为了避免同一个level【比如level0】加入了太多请求之后,然后给其他level加了一些split过来,如果按照正常的逻辑可能在相当长的时间内里面level0的split都没办法运行了。因为之前占用的调度时间太长。但这么做也是有代价的,就是每次take的时候都需要计算一遍。

@GuardedBy("lock")
private long getLevel0TargetTime()
{
    long level0TargetTime = levelScheduledTime[0].get();
    double currentMultiplier = levelTimeMultiplier;

    for (int level = 0; level < LEVEL_THRESHOLD_SECONDS.length; level++) {
        currentMultiplier /= levelTimeMultiplier;
        long levelTime = levelScheduledTime[level].get();
        level0TargetTime = Math.max(level0TargetTime, (long) (levelTime / currentMultiplier));
    }

    return level0TargetTime;
}

如何调整一组Split的优先级。

上述的take代码中有这样的一段,这里似乎说明take出来的task可能会被重新退回队列?这又是为何呢?


            if (result.updateLevelPriority()) {
                offer(result);
                continue;
            }

再回到我们最初讨论的场景3,对于一组task下的split其实是有一定相关性的,比如我们希望用一组task占用的cpu来累加而非单个split的累加。

  • 首先,我们需要一个Tracker,这个Tracker需要和一组split关联在一起,因为只有一个公共的对象才能把各个所有关联task的对象关联到一起。这样每次更新信息的时候才能保持更新。
  • 其次,仅仅有一个reference是不够的的,一个问题在于堆里面管理的是split,虽然实现了比较的功能,但是只有在入堆和出堆的时候才能触发计算,所以presto给每个split维护了一个Priority的对象,然后给一组handle的对象也维护一个Priority。TaskHandler的Priority实际上可以看成是一种Cache。

更新的逻辑大概是这样的思路: 比如一组TaskHandler有2个split对象(A、B),当A任务的时间分片完成的时候,更新A的Priority和TaskHandler的Priority,这是B的并没有更新,只有当B从堆中take出来的时候发现B的Priority和TaskHandler的level不同则需要将当前的B重新入堆。

public boolean updateLevelPriority()
{
    Priority newPriority = taskHandle.getPriority();
    Priority oldPriority = priority.getAndSet(newPriority);
    return newPriority.getLevel() != oldPriority.getLevel();
}

这里有个疑问是为啥只有level变更才需要重新入堆?我自己的理解大概是这样的。理论上说最严格的逻辑是同一个TaskHandler的所有split都重新计算更新Cache。当然这个是不可行的,因为成本太高。而level为啥不一样?关键的原因是需要保证level级别内的统计是准确的,因为这个部分数据是需要进行后续的决策的,一旦混淆则调度的准确性差异会非常大。

如何实现累计时间和split的Priority调整

首先是实现决策的逻辑是简单的,其实也是写死的代码,presto给每个level指定了一个阈值,凡是累计的时间超过这个阈值则进入下一个level。

问题在于何时做这个决策?之前已经讨论了如何维护Priority。

  • 首先对于第一次入堆的split是根据指定的Priority进行决策level的,
  • 对于已经在队列中的对象,在执行完时间分片之后,会更新Priority,这时的Tracker是包含了当前TaskHandler的所有split的累计时间的,这个时候会根据累计时间计算level,重新入堆
public Priority updatePriority(Priority oldPriority, long quantaNanos, long scheduledNanos)
{
    int oldLevel = oldPriority.getLevel();
    int newLevel = computeLevel(scheduledNanos);

    long levelContribution = Math.min(quantaNanos, LEVEL_CONTRIBUTION_CAP);

    if (oldLevel == newLevel) {
        addLevelTime(oldLevel, levelContribution);
        return new Priority(oldLevel, oldPriority.getLevelPriority() + quantaNanos);
    }

    long remainingLevelContribution = levelContribution;
    long remainingTaskTime = quantaNanos;

    // a task normally slowly accrues scheduled time in a level and then moves to the next, but
    // if the split had a particularly long quanta, accrue time to each level as if it had run
    // in that level up to the level limit.
    for (int currentLevel = oldLevel; currentLevel < newLevel; currentLevel++) {
        long timeAccruedToLevel = Math.min(SECONDS.toNanos(LEVEL_THRESHOLD_SECONDS[currentLevel + 1] - LEVEL_THRESHOLD_SECONDS[currentLevel]), remainingLevelContribution);
        addLevelTime(currentLevel, timeAccruedToLevel);
        remainingLevelContribution -= timeAccruedToLevel;
        remainingTaskTime -= timeAccruedToLevel;
    }

    addLevelTime(newLevel, remainingLevelContribution);
    long newLevelMinPriority = getLevelMinPriority(newLevel, scheduledNanos);
    return new Priority(newLevel, newLevelMinPriority + remainingTaskTime);
}

这段代码里面还讨论一个非常有意思的问题,虽然presto对split划分了时间片,但是并不代表这个时间片是严格有效的,比如说有些场景下可能由于IO之类的问题导致整个split调度的时间远不止一个时间分片,比如数分钟,这时如果没有做一些额外的处理可能会导致饥饿的问题:

  • 一方面presto做了一个调度时间的上限LEVEL_CONTRIBUTION_CAP(30s)也就是一次process最多只能当成30s。
  • 另一方面,当new-level和old-level之间差异很大的时候将并不是直接增加new-level的CPU时间,而是将间隔内的level的所有CPU时间都按照阈值的设置填满,这样可以尽可能的避免new-level的其他split由于单个任务的原因导致饥饿的情况。