Как перемежать потоки (с противодавлением)

Предположим, что у меня есть два возможно бесконечных потока:

s1 = a..b..c..d..e...
s2 = 1.2.3.4.5.6.7...

Я хочу объединить потоки, а затем сопоставить объединенный поток с медленным асинхронным действием (например, в Bacon с fromPromise и flatMapConcat).

Я могу объединить их с merge:

me = a12b3.c45d6.7e...

И затем карта

s1 = a..b..c..d..e...
s2 = 1.2.3.4.5.6.7...
me = a12b3.c45d6.7e...
mm = a..1..2..b..3..c..4..5..

Как вы видите, greedier s2 потоки получат преимущество в долгосрочной перспективе. Это нежелательное поведение.


Поведение слияния не в порядке, так как я хочу иметь какое-то противодавление, чтобы иметь более чередующееся, "справедливое", "круговое" слияние. Несколько примеров поведения желаемого:

s1 = a.....b..............c...
s2 = ..1.2.3..................
mm = a...1...b...2...3....c...

s1 = a.........b..........c...
s2 = ..1.2.3..................
mm = a...1...2...b...3....c...

Один из способов подумать, что s1 и s2 отправляют задачи работнику, который может обрабатывать только одну задачу в то время. С merge и flatMapConcat я получу жадный диспетчер задач, но я хочу более справедливый.


Я бы хотел найти простое и элегантное решение. Было бы неплохо, если бы он был легко обобщен для произвольного количества потоков:

// roundRobinPromiseMap(streams: [Stream a], f: a -> Promise b): Stream b
var mm = roundRobinPromiseMap([s1, s2], slowAsyncFunc);

Решение с использованием RxJS или другой библиотеки Rx тоже отлично.


Разъяснения

Не zipAsArray

Я не хочу:

function roundRobinPromiseMap(streams, f) {
  return Bacon.zipAsArray.apply(null, streams)
    .flatMap(Bacon.fromArray)
    .flatMapConcat(function (x) {
      return Bacon.fromPromise(f(x));
    });
}

Сравните примерную мраморную диаграмму:

s1  = a.....b..............c.......
s2  = ..1.2.3......................
mm  = a...1...b...2...3....c....... // wanted
zip = a...1...b...2........c...3... // zipAsArray based

Да Я столкнулся с проблемами буферизации

... но так же я буду с несправедливым несправедливым:

function greedyPromiseMap(streams, f) {
  Bacon.mergeAll(streams).flatMapConcat(function (x) {
    return Bacon.fromPromise(f(x));
  });
}

Мраморная диаграмма

s1    = a.........b..........c...
s2    = ..1.2.3..................
mm    = a...1...2...b...3....c...
merge = a...1...2...3...b....c...

Ответ 1

Основная проблема здесь заключалась в том, чтобы понять, как формализовать справедливость. В вопросе я уже упомянул рабочую аналогию. Выяснилось, что очевидными критериями справедливости является выбор потока, который генерировал меньше событий, чем другие, или принимал еще больше: кто генерировал потоки, ожидал меньше времени.

После этого было довольно тривиально формализовать желаемый результат с использованием денотационной семантики: код находится в GitHub

У меня не было времени для разработки денотационных комбинаторов, чтобы включить withStateMachine из Bacon.js, поэтому следующим шагом было его переопределение в JavaScript с помощью Bacon.js. Полное управляемое решение доступно как сущность.

Идея состоит в том, чтобы создать конечный автомат с

  • затраты на поток и очереди как состояние
  • потоки и дополнительный поток обратной связи в качестве входных данных

Когда вывод всей системы будет возвращен, мы можем удалить событие следующего события, когда закончится предыдущий потоковый поток.

Для этого мне пришлось сделать немного уродливый rec combinator

function rec(f) {
  var bus = new Bacon.Bus();
  var result = f(bus);
  bus.plug(result);
  return result;
}

Тип (EventStream a -> EventStream a) -> EventStream a - тип напоминает другие комбинаторы рекурсии, например. fix.

Это можно сделать с улучшенным общесистемным поведением, так как Bus нарушает распространение подписки. Мы должны работать над этим.

Вторая вспомогательная функция stateMachine, которая берет массив потоков и превращает их в единый конечный автомат. По существу это .withStateMachine ∘ mergeAll ∘ zipWithIndex.

function stateMachine(inputs, initState, f) {
  var mapped = inputs.map(function (input, i) {
    return input.map(function (x) {
      return [i, x];
    })
  });
  return Bacon.mergeAll(mapped).withStateMachine(initState, function (state, p) {
    if (p.hasValue()) {
      p = p.value();
      return f(state, p[0], p[1]);
    } else {
      return [state, p];
    }
  });
}

Используя эти два помощника, мы можем написать не столь сложный честный планировщик:

function fairScheduler(streams, fn) {
  var streamsCount = streams.length;
  return rec(function (res) {
    return stateMachine(append(streams, res), initialFairState(streamsCount), function (state, i, x) {
      // console.log("FAIR: " + JSON.stringify(state), i, x);

      // END event
      if (i == streamsCount && x.end) {
        var additionalCost = new Date().getTime() - x.started;

        // add cost to input stream cost center
        var updatedState = _.extend({}, state, {
          costs: updateArray(
            state.costs,
            x.idx, function (cost) { return cost + additionalCost; }),
        });

        if (state.queues.every(function (q) { return q.length === 0; })) {
          // if queues are empty, set running: false and don't emit any events
          return [_.extend({}, updatedState, { running: false }), []];
        } else {
          // otherwise pick a stream with
          // - non-empty queue
          // - minimal cost
          var minQueueIdx = _.chain(state.queues)
            .map(function (q, i) {
              return [q, i];
            })
            .filter(function (p) {
              return p[0].length !== 0;
            })
            .sortBy(function (p) {
              return state.costs[p[1]];
            })
            .value()[0][1];

          // emit an event from that stream
          return [
            _.extend({}, updatedState, {
              queues: updateArray(state.queues, minQueueIdx, function (q) { return q.slice(1); }),
              running: true,
            }),
            [new Bacon.Next({
              value: state.queues[minQueueIdx][0],
              idx: minQueueIdx,
            })],
          ];
        }
      } else if (i < streamsCount) {
        // event from input stream
        if (state.running) {
          // if worker is running, just enquee the event
          return [
            _.extend({}, state, {
              queues: updateArray(state.queues, i, function (q) { return q .concat([x]); }),
            }),
            [],
          ];
        } else {
          // if worker isn't running, start it right away
          return [
            _.extend({}, state, {
              running: true,
            }),
            [new Bacon.Next({ value: x, idx: i})],
          ]
        }
      } else {
        return [state, []];
      }

    })
    .flatMapConcat(function (x) {
      // map passed thru events,
      // and append special "end" event
      return fn(x).concat(Bacon.once({
        end: true,
        idx: x.idx,
        started: new Date().getTime(),
      }));
    });
  })
  .filter(function (x) {
    // filter out END events
    return !x.end;
  })
  .map(".value"); // and return only value field
}

Остальная часть кода в сущности довольно прямолинейна.

Ответ 2

Вот сумасшедший кусок кода, который может помочь.

Он превращает входящие потоки в один поток событий "значение", а затем объединяет их с событиями "Отправить" (и "конец" для ведения бухгалтерии). Затем, используя конечный автомат, он создает очереди из событий "значение" и отправляет значения в событиях "Отправить".

Первоначально я написал roundRobinThrottle, но я переместил его в gist.

Вот круговойRobinPromiseMap, который очень похож. Код в сущности проверен, но это не так.

# roundRobinPromiseMap :: (a -> Promise b) -> [EventStream] -> EventStream
roundRobinPromiseMap = (promiser, streams) ->
    # A bus to trigger new sends based on promise fulfillment
    promiseFulfilled = new Bacon.Bus()

    # Merge the input streams into a single, keyed stream
    theStream = Bacon.mergeAll(streams.map((s, idx) ->
        s.map((val) -> {
            type: 'value'
            index: idx
            value: val
        })
    ))
    # Merge in 'end' events
    .merge(Bacon.mergeAll(streams.map((s) ->
        s.mapEnd(-> {
            type: 'end'
        })
    )))
    # Merge in 'send' events that fire when the promise is fulfilled.
    .merge(promiseFulfilled.map({ type: 'send' }))
    # Feed into a state machine that keeps queues and only creates
    # output events on 'send' input events.
    .withStateMachine(
        {
            queues: streams.map(-> [])
            toPush: 0
            ended: 0
        }
        handleState

    )
    # Feed this output to the promiser
    theStream.onValue((value) ->
        Bacon.fromPromise(promiser(value)).onValue(->
            promiseFulfilled.push()
    ))

handleState = (state, baconEvent) ->
    outEvents = []

    if baconEvent.hasValue()
        # Handle a round robin event of 'value', 'send', or 'end'
        outEvents = handleRoundRobinEvent(state, baconEvent.value())
    else
        outEvents = [baconEvent]

    [state, outEvents]

handleRoundRobinEvent = (state, rrEvent) ->
    outEvents = []

    # 'value' : push onto queue
    if rrEvent.type == 'value'
        state.queues[rrEvent.index].push(rrEvent.value)
    # 'send' : send the next value by round-robin selection
    else if rrEvent.type == 'send'
        # Here a sentinel for empty queues
        noValue = {}
        nextValue = noValue
        triedQueues = 0

        while nextValue == noValue && triedQueues < state.queues.length
            if state.queues[state.toPush].length > 0
                nextValue = state.queues[state.toPush].shift()
            state.toPush = (state.toPush + 1) % state.queues.length
            triedQueues++
        if nextValue != noValue
            outEvents.push(new Bacon.Next(nextValue))
    # 'end': Keep track of ended streams
    else if rrEvent.type == 'end'
        state.ended++

    # End the round-robin stream if all inputs have ended
    if roundRobinEnded(state)
        outEvents.push(new Bacon.End())

    outEvents

roundRobinEnded = (state) ->
    emptyQueues = allEmpty(state.queues)
    emptyQueues && state.ended == state.queues.length

allEmpty = (arrays) ->
    for a in arrays
        return false if a.length > 0
    return true