結合フレームワークは非同期操作をシリアル化します


8

Combineフレームワークを構成する非同期パイプラインを同期的に(シリアルに)整列させるにはどうすればよいですか?

対応するリソースをダウンロードするURLが50個あるとします。一度に1つずつダウンロードするとします。私はOperation / OperationQueueでそれを行う方法を知っています。たとえば、ダウンロードが完了するまでそれ自体の終了を宣言しないOperationサブクラスを使用します。Combineを使用して同じことをするにはどうすればよいですか?

現時点で私に発生するのは、残りのURLのグローバルリストを保持し、1つをポップし、1つのダウンロード用にその1つのパイプラインを設定し、ダウンロードを実行しsink、パイプラインので繰り返します。これはCombineのようには見えません。

私はURLの配列を作成し、それをパブリッシャーの配列にマッピングしようとしました。パブリッシャーを "作成"して、を使用してパイプラインの下にパブリッシュさせることができることを知っていflatMapます。しかし、それでも私はまだすべてのダウンロードを同時に行っています。制御された方法でアレイをウォークするCombineの方法はありません—またはありますか?

(Futureで何かをすることも想像していましたが、どうしようもなく混乱しました。この考え方には慣れていません。)

回答:


2

私はこれを簡単にテストしましたが、最初のパスでは、各リクエストは前のリクエストが完了するのを待ってから開始するようです。

私はフィードバックを求めてこのソリューションを投稿しています。これが良い解決策ではない場合は、批判的にしてください。

extension Collection where Element: Publisher {

    func serialize() -> AnyPublisher<Element.Output, Element.Failure>? {
        // If the collection is empty, we can't just create an arbititary publisher
        // so we return nil to indicate that we had nothing to serialize.
        if isEmpty { return nil }

        // We know at this point that it's safe to grab the first publisher.
        let first = self.first!

        // If there was only a single publisher then we can just return it.
        if count == 1 { return first.eraseToAnyPublisher() }

        // We're going to build up the output starting with the first publisher.
        var output = first.eraseToAnyPublisher()

        // We iterate over the rest of the publishers (skipping over the first.)
        for publisher in self.dropFirst() {
            // We build up the output by appending the next publisher.
            output = output.append(publisher).eraseToAnyPublisher()
        }

        return output
    }
}

このソリューションのより簡潔なバージョン(@mattにより提供):

extension Collection where Element: Publisher {
    func serialize() -> AnyPublisher<Element.Output, Element.Failure>? {
        guard let start = self.first else { return nil }
        return self.dropFirst().reduce(start.eraseToAnyPublisher()) {
            $0.append($1).eraseToAnyPublisher()
        }
    }
}

ありがとう、ありがとう。appendまさに私が探していたものです。—コードを大幅に強化できます。特に、のcount == 1場合dropFirstは空になるため、ループしないため、時期尚早に戻る必要はありません。またoutput、のreduce代わりに使用できるため、変数を維持する必要はありませんfor...in。より厳密なレンダリングについては、私の回答を参照してください。
マット

3

返されるSubscribers.Demand.max(1)を受け取るカスタムサブスクライバーを作成できます。その場合、サブスクライバーは、受信したときにのみ次の値を要求します。この例はInt.publisherのものですが、マップのランダムな遅延がネットワークトラフィックを模倣しています:-)

import PlaygroundSupport
import SwiftUI
import Combine

class MySubscriber: Subscriber {
  typealias Input = String
  typealias Failure = Never

  func receive(subscription: Subscription) {
    print("Received subscription", Thread.current.isMainThread)
    subscription.request(.max(1))
  }

  func receive(_ input: Input) -> Subscribers.Demand {
    print("Received input: \(input)", Thread.current.isMainThread)
    return .max(1)
  }

  func receive(completion: Subscribers.Completion<Never>) {
    DispatchQueue.main.async {
        print("Received completion: \(completion)", Thread.current.isMainThread)
        PlaygroundPage.current.finishExecution()
    }
  }
}

(110...120)
    .publisher.receive(on: DispatchQueue.global())
    .map {
        print(Thread.current.isMainThread, Thread.current)
        usleep(UInt32.random(in: 10000 ... 1000000))
        return String(format: "%02x", $0)
    }
    .subscribe(on: DispatchQueue.main)
    .subscribe(MySubscriber())

print("Hello")

PlaygroundPage.current.needsIndefiniteExecution = true

遊び場プリント...

Hello
Received subscription true
false <NSThread: 0x600000064780>{number = 5, name = (null)}
Received input: 6e false
false <NSThread: 0x60000007cc80>{number = 9, name = (null)}
Received input: 6f false
false <NSThread: 0x60000007cc80>{number = 9, name = (null)}
Received input: 70 false
false <NSThread: 0x60000007cc80>{number = 9, name = (null)}
Received input: 71 false
false <NSThread: 0x60000007cc80>{number = 9, name = (null)}
Received input: 72 false
false <NSThread: 0x600000064780>{number = 5, name = (null)}
Received input: 73 false
false <NSThread: 0x600000064780>{number = 5, name = (null)}
Received input: 74 false
false <NSThread: 0x60000004dc80>{number = 8, name = (null)}
Received input: 75 false
false <NSThread: 0x60000004dc80>{number = 8, name = (null)}
Received input: 76 false
false <NSThread: 0x60000004dc80>{number = 8, name = (null)}
Received input: 77 false
false <NSThread: 0x600000053400>{number = 3, name = (null)}
Received input: 78 false
Received completion: finished true

UPDATE ついに私はを見つけました.flatMap(maxPublishers: )。それは私にこの興味深いトピックを少し異なるアプローチで更新することを強制します。シリアル化されたストリームの受信が「ランダム」または「ラッキー」な動作ではないことを確認するために、ランダムな遅延だけでなく、スケジュールにグローバルキューを使用していることを確認してください:-)

import PlaygroundSupport
import Combine
import Foundation

PlaygroundPage.current.needsIndefiniteExecution = true

let A = (1 ... 9)
    .publisher
    .flatMap(maxPublishers: .max(1)) { value in
        [value].publisher
            .flatMap { value in
                Just(value)
                    .delay(for: .milliseconds(Int.random(in: 0 ... 100)), scheduler: DispatchQueue.global())
        }
}
.sink { value in
    print(value, "A")
}

let B = (1 ... 9)
    .publisher
    .flatMap { value in
        [value].publisher
            .flatMap { value in
                Just(value)
                    .delay(for: .milliseconds(Int.random(in: 0 ... 100)), scheduler: RunLoop.main)
        }
}
.sink { value in
    print("     ",value, "B")
}

プリント

1 A
      4 B
      5 B
      7 B
      1 B
      2 B
      8 B
      6 B
2 A
      3 B
      9 B
3 A
4 A
5 A
6 A
7 A
8 A
9 A

ここに書かれたことに基づいて

。シリアライズ()?

Clay Ellisによって定義された承認済みの回答は、

.publisher.flatMap(maxPublishers:.max(1)){$ 0}

「シリアル化されていない」バージョンでは

.publisher.flatMap {$ 0}

「実世界の例」

import PlaygroundSupport
import Foundation
import Combine

let path = "postman-echo.com/get"
let urls: [URL] = "... which proves the downloads are happening serially .-)".map(String.init).compactMap { (parameter) in
    var components = URLComponents()
    components.scheme = "https"
    components.path = path
    components.queryItems = [URLQueryItem(name: parameter, value: nil)]
    return components.url
}
//["https://postman-echo.com/get?]
struct Postman: Decodable {
    var args: [String: String]
}


let collection = urls.compactMap { value in
        URLSession.shared.dataTaskPublisher(for: value)
        .tryMap { data, response -> Data in
            return data
        }
        .decode(type: Postman.self, decoder: JSONDecoder())
        .catch {_ in
            Just(Postman(args: [:]))
    }
}

extension Collection where Element: Publisher {
    func serialize() -> AnyPublisher<Element.Output, Element.Failure>? {
        guard let start = self.first else { return nil }
        return self.dropFirst().reduce(start.eraseToAnyPublisher()) {
            return $0.append($1).eraseToAnyPublisher()
        }
    }
}

var streamA = ""
let A = collection
    .publisher.flatMap{$0}

    .sink(receiveCompletion: { (c) in
        print(streamA, "     ", c, "    .publisher.flatMap{$0}")
    }, receiveValue: { (postman) in
        print(postman.args.keys.joined(), terminator: "", to: &streamA)
    })


var streamC = ""
let C = collection
    .serialize()?

    .sink(receiveCompletion: { (c) in
        print(streamC, "     ", c, "    .serialize()?")
    }, receiveValue: { (postman) in
        print(postman.args.keys.joined(), terminator: "", to: &streamC)
    })

var streamD = ""
let D = collection
    .publisher.flatMap(maxPublishers: .max(1)){$0}

    .sink(receiveCompletion: { (c) in
        print(streamD, "     ", c, "    .publisher.flatMap(maxPublishers: .max(1)){$0}")
    }, receiveValue: { (postman) in
        print(postman.args.keys.joined(), terminator: "", to: &streamD)
    })

PlaygroundPage.current.needsIndefiniteExecution = true

プリント

.w.h i.c hporves ht edownloadsa erh appeninsg eriall y.-)       finished     .publisher.flatMap{$0}
... which proves the downloads are happening serially .-)       finished     .publisher.flatMap(maxPublishers: .max(1)){$0}
... which proves the downloads are happening serially .-)       finished     .serialize()?

他のシナリオでも非常に役立つようです。次のスニペットでmaxPublishersのデフォルト値を使用して、結果を比較してみてください:-)

import Combine

let sequencePublisher = Publishers.Sequence<Range<Int>, Never>(sequence: 0..<Int.max)
let subject = PassthroughSubject<String, Never>()

let handle = subject
    .zip(sequencePublisher.print())
    //.publish
    .flatMap(maxPublishers: .max(1), { (pair)  in
        Just(pair)
    })
    .print()
    .sink { letters, digits in
        print(letters, digits)
    }

"Hello World!".map(String.init).forEach { (s) in
    subject.send(s)
}
subject.send(completion: .finished)

@mattシンクは、戻りの受信時のみ、Subsribers.Demand.unlimitedとは動作しません...シリアルキューのような適切な計測器を使用している可能性があります。Data.init?(contentsOf url:URL)は、シナリオに最適なオプションです。 。2つのIntの合計を作成する必要がある場合は、[lhs:Int、rhs:Int] .reduce .... ??? MySerialDownloaderSubscriberのreceive(_ input :)内でData.init?(contentsOf url:URL)を使用します。
user3441734

@マットは、更新された回答を参照してください。結合はエキサイティングですが、(少なくとも私にとって)理解するのは非常に困難です...
user3441734

分かりました!ではmaxPublishers、パラメータ、我々は背圧を加えることを得ます。これは、私の質問で述べたとおりです。「パブリッシャーを "作成"して、それをflatMapを使用してパイプラインでパブリッシュさせることができますが、すべてのダウンロードを同時に実行しています。」まあ、maxPublishersパラメータを使用すると、それらは同時ではありません
マット

@mattはい、Subscribers.Demand.unlimitedを使用したシンクコールパブリッシャー独自のサブスクライブ、flatMapは、使用例.max(1)で、異なる値を持つパブリッシャー独自のサブスクライバーを設定するのと同じ効果があります。私はそれがとても使いやすい別のシナリオで別の例を追加します。
user3441734

2

他のすべてのReactiveフレームワークでは、これは非常に簡単です。concat1つのステップで結果を連結およびフラット化するために使用するだけでreduce、結果を最終的な配列にできます。Apple Publisher.Concatenateは、一連のパブリッシャーを受け入れるオーバーロードがないため、これを難しくしています。と同様の奇妙さがありPublisher.Mergeます。これは、rx Observableのような単一のジェネリック型を返すのではなく、ネストされたジェネリックパブリッシャーを返すという事実と関係があると感じています。Concatenateを呼び出すだけでいいと思いますループして、連結された結果を単一の配列に減らしますが、次のリリースでこの問題に対処できることを本当に望んでいます。確かに、2つ以上のパブリッシャーを連結し、4つ以上のパブリッシャーをマージする必要があります(これら2つのオペレーターのオーバーロードは一貫性がなく、奇妙です)。

編集:

私はこれに戻って、実際にパブリッシャーの任意の配列を連結でき、それらが順番に発行することを発見しました。なぜConcatenateManyこれを行うような関数がないのか私にはわかりませんが、型消去されたパブリッシャーを使用するつもりであれば、自分で書くのはそれほど難しくありません。この例は、mergeが時間順に発生し、concatが組み合わせの順に発生することを示しています。

import PlaygroundSupport
import SwiftUI
import Combine

let p = Just<Int>(1).append(2).append(3).delay(for: .seconds(0.25), scheduler: RunLoop.main).eraseToAnyPublisher()
let q = Just<Int>(4).append(5).append(6).eraseToAnyPublisher()
let r = Just<Int>(7).append(8).append(9).delay(for: .seconds(0.5), scheduler: RunLoop.main).eraseToAnyPublisher()
let concatenated: AnyPublisher<Int, Never> = [q,r].reduce(p) { total, next in
  total.append(next).eraseToAnyPublisher()
}

var subscriptions = Set<AnyCancellable>()

concatenated
  .sink(receiveValue: { v in
    print("concatenated: \(v)")
  }).store(in: &subscriptions)

Publishers
  .MergeMany([p,q,r])
  .sink(receiveValue: { v in
    print("merge: \(v)")
  }).store(in: &subscriptions)

はい、おそらく私が意図的に50のような大きな数を選択したと思います。
マット

MergeManyがあります。ConcatenateManyがない理由がわかりません。Rx swiftにはObservable.concatがあり、Reactive SwiftにはflatMap(.concat)があるため、これは奇妙です。多分私は何かが欠けています。developer.apple.com/documentation/combine/publishers/mergemany
Josh Homann

でしょうconcat(他の反応性の枠組みに)シリアライズ?
マット

はい。Sequence of Sequenceの場合、フラット化の方法は1つだけです。つまり、Sequence.flatMapのように、1つの内部シーケンスの要素を次々に配置します。非同期シーケンスがある場合は、フラット化時に時間ディメンションを考慮する必要があります。したがって、すべての内部シーケンスから要素を時間順に放出する(マージする)か、各内部シーケンスから要素をシーケンスの順序で放出する(連結)ことができます。大理石の図をご覧ください:rxmarbles.com/#concat vs rxmarbles.com/#merge
Josh Homann

.append作成する演算子ですPublisher.Concatenate
rob mayoff

2

元の質問から:

私はURLの配列を作成し、それをパブリッシャーの配列にマッピングしようとしました。パブリッシャーを "作成"して、を使用してパイプラインの下にパブリッシュさせることができることを知っていflatMapます。しかし、それでも私はまだすべてのダウンロードを同時に行っています。制御された方法でアレイをウォークするCombineの方法はありません—またはありますか?


これが本当の問題を代弁するおもちゃの例です。

let collection = (1 ... 10).map {
    Just($0).delay(
        for: .seconds(Double.random(in:1...5)),
        scheduler: DispatchQueue.main)
        .eraseToAnyPublisher()
}
collection.publisher
    .flatMap() {$0}
    .sink {print($0)}.store(in:&self.storage)

これは、ランダムな時間に到着するランダムな順序で1から10までの整数を放出します。目標はcollection、1から10までの整数を順番に出力することを実行することです。


ここで、1つだけ変更します。

.flatMap {$0}

maxPublishersパラメータを追加します。

let collection = (1 ... 10).map {
    Just($0).delay(
        for: .seconds(Double.random(in:1...5)),
        scheduler: DispatchQueue.main)
        .eraseToAnyPublisher()
}
collection.publisher
    .flatMap(maxPublishers:.max(1)) {$0}
    .sink {print($0)}.store(in:&self.storage)

プレストは、私たちは今やるそれらの間のランダムな間隔で、順番に、1から10までEMITに整数を。


これを元の問題に適用してみましょう。実例を示すために、かなり低速のインターネット接続と、ダウンロードするのにかなり大きなリソースが必要です。最初に、私は普通にそれを行います.flatMap

let eph = URLSessionConfiguration.ephemeral
let session = URLSession(configuration: eph)
let url = "https://photojournal.jpl.nasa.gov/tiff/PIA23172.tif"
let collection = [url, url, url]
    .map {URL(string:$0)!}
    .map {session.dataTaskPublisher(for: $0)
        .eraseToAnyPublisher()
}
collection.publisher.setFailureType(to: URLError.self)
    .handleEvents(receiveOutput: {_ in print("start")})
    .flatMap() {$0}
    .map {$0.data}
    .sink(receiveCompletion: {comp in
        switch comp {
        case .failure(let err): print("error", err)
        case .finished: print("finished")
        }
    }, receiveValue: {_ in print("done")})
    .store(in:&self.storage)

結果は

start
start
start
done
done
done
finished

これは、3つのダウンロードを同時に実行していることを示しています。さて、今変更します

    .flatMap() {$0}

    .flatMap(maxPublishers:.max(1) {$0}

結果は次のとおりです。

start
done
start
done
start
done
finished

そのため、現在は順次ダウンロードしていますが、これは最初に解決する必要がある問題です。


追加する

TIMTOWTDIの原則に従って、代わりにパブリッシャーをチェーンしappendてシリアル化できます。

let collection = (1 ... 10).map {
    Just($0).delay(
        for: .seconds(Double.random(in:1...5)),
        scheduler: DispatchQueue.main)
        .eraseToAnyPublisher()
}
let pub = collection.dropFirst().reduce(collection.first!) {
    return $0.append($1).eraseToAnyPublisher()
}

その結果、元のコレクションで遅延した発行元をシリアル化する発行元になります。購読して証明しましょう:

pub.sink {print($0)}.store(in:&self.storage)

案の定、整数は順番に到着します(ランダムな間隔で)。


pubClay Ellisによって提案されているように、コレクションの拡張機能を使用して、パブリッシャーのコレクションからのの作成をカプセル化できます。

extension Collection where Element: Publisher {
    func serialize() -> AnyPublisher<Element.Output, Element.Failure>? {
        guard let start = self.first else { return nil }
        return self.dropFirst().reduce(start.eraseToAnyPublisher()) {
            return $0.append($1).eraseToAnyPublisher()
        }
    }
}

1

以下は、可能なアプローチを示す1ページのプレイグラウンドコードです。主なアイデアは、非同期API呼び出しを一連のFutureパブリッシャーに変換し、シリアルパイプラインを作成することです。

入力:1〜10のintの範囲。バックグラウンドキューで非同期に文字列に変換されます。

非同期APIへの直接呼び出しのデモ:

let group = DispatchGroup()
inputValues.map {
    group.enter()
    asyncCall(input: $0) { (output, _) in
        print(">> \(output), in \(Thread.current)")
        group.leave()
    }
}
group.wait()

出力:

>> 1, in <NSThread: 0x7fe76264fff0>{number = 4, name = (null)}
>> 3, in <NSThread: 0x7fe762446b90>{number = 3, name = (null)}
>> 5, in <NSThread: 0x7fe7624461f0>{number = 5, name = (null)}
>> 6, in <NSThread: 0x7fe762461ce0>{number = 6, name = (null)}
>> 10, in <NSThread: 0x7fe76246a7b0>{number = 7, name = (null)}
>> 4, in <NSThread: 0x7fe764c37d30>{number = 8, name = (null)}
>> 7, in <NSThread: 0x7fe764c37cb0>{number = 9, name = (null)}
>> 8, in <NSThread: 0x7fe76246b540>{number = 10, name = (null)}
>> 9, in <NSThread: 0x7fe7625164b0>{number = 11, name = (null)}
>> 2, in <NSThread: 0x7fe764c37f50>{number = 12, name = (null)}

パイプラインの組み合わせのデモ:

出力:

>> got 1
>> got 2
>> got 3
>> got 4
>> got 5
>> got 6
>> got 7
>> got 8
>> got 9
>> got 10
>>>> finished with true

コード:

import Cocoa
import Combine
import PlaygroundSupport

// Assuming there is some Asynchronous API with
// (eg. process Int input value during some time and generates String result)
func asyncCall(input: Int, completion: @escaping (String, Error?) -> Void) {
    DispatchQueue.global(qos: .background).async {
            sleep(.random(in: 1...5)) // wait for random Async API output
            completion("\(input)", nil)
        }
}

// There are some input values to be processed serially
let inputValues = Array(1...10)

// Prepare one pipeline item based on Future, which trasform Async -> Sync
func makeFuture(input: Int) -> AnyPublisher<Bool, Error> {
    Future<String, Error> { promise in
        asyncCall(input: input) { (value, error) in
            if let error = error {
                promise(.failure(error))
            } else {
                promise(.success(value))
            }
        }
    }
    .receive(on: DispatchQueue.main)
    .map {
        print(">> got \($0)") // << sideeffect of pipeline item
        return true
    }
    .eraseToAnyPublisher()
}

// Create pipeline trasnforming input values into chain of Future publishers
var subscribers = Set<AnyCancellable>()
let pipeline =
    inputValues
    .reduce(nil as AnyPublisher<Bool, Error>?) { (chain, value) in
        if let chain = chain {
            return chain.flatMap { _ in
                makeFuture(input: value)
            }.eraseToAnyPublisher()
        } else {
            return makeFuture(input: value)
        }
    }

// Execute pipeline
pipeline?
    .sink(receiveCompletion: { _ in
        // << do something on completion if needed
    }) { output in
        print(">>>> finished with \(output)")
    }
    .store(in: &subscribers)

PlaygroundPage.current.needsIndefiniteExecution = true

0

flatMap(maxPublishers:transform:)と一緒.max(1)に使用してください。

func imagesPublisher(for urls: [URL]) -> AnyPublisher<UIImage, URLError> {
    Publishers.Sequence(sequence: urls.map { self.imagePublisher(for: $0) })
        .flatMap(maxPublishers: .max(1)) { $0 }
        .eraseToAnyPublisher()
}

どこ

func imagePublisher(for url: URL) -> AnyPublisher<UIImage, URLError> {
    URLSession.shared.dataTaskPublisher(for: url)
        .compactMap { UIImage(data: $0.data) }
        .receive(on: RunLoop.main)
        .eraseToAnyPublisher()
}

そして

var imageRequests: AnyCancellable?

func fetchImages() {
    imageRequests = imagesPublisher(for: urls).sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("done")
        case .failure(let error):
            print("failed", error)
        }
    }, receiveValue: { image in
        // do whatever you want with the images as they come in
    })
}

その結果:

シリアル

しかし、私たちはあなたがそのように連続してそれらを実行することで大きなパフォーマンスの打撃を受けることを認識すべきです。たとえば、一度に6つまで上げると、2倍以上速くなります。

同時

個人的には、絶対に必要な場合のみ連続してダウンロードすることをお勧めします(一連の画像/ファイルをダウンロードする場合は、ほとんどの場合そうではありません)。はい、リクエストを同時に実行すると、リクエストが特定の順序で終了しない可能性がありますが、順序に依存しない構造体(たとえば、単純な配列ではなくディクショナリ)を使用するだけですが、パフォーマンスの向上は非常に大きいため、通常は価値があります。

ただし、それらを順番にダウンロードする場合は、maxPublishersパラメーターでそれを実現できます。


うん、それは私の答えはすでに言っていることだ:stackoverflow.com/a/59889993/341994だけでなく、答えは私が報奨金を授与stackoverflow.com/a/59889174/341994
マット


ちなみに、シーケンシャルといえば、私は別のタスクのためにシーケンシャル非同期オペレーションを活用してきました。それを書いてくれてありがとう
マット

@マット-笑 私はあなたがそのmaxPublishersオプションを見つけたとは知らなかったと告白します。そして、私がそれがあなたであることに気づいたのなら、私は「シリアルを行わないでください」については言いませんでした(シリアルと同時の賛否両論を完全に理解しているので)。私は文字通り「一度に1つのファイルをダウンロードしたい」だけを見て、最近maxPublishers私がしている別のオプションを見つけました(つまり、この質問に対する最新のソリューションを提供しています)。私は結合ソリューションを共有したいと思いました思いついた。私はそんなに派生的であるつもりはありませんでした。
ロブ

1
ええ、私が以前話していたのは、stackoverflow.com / a / 48104095/1271826で参照されているソリューションでした。それはとても役に立ちました。
マット
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.