0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【物語で学ぶ Swift Concurrency】アクターとTaskで競合をなくす秘訣

Posted at

登場人物

  • ミライ:高校生。プログラミング初心者だが興味津々。
  • アキラ:高校生。少しだけSwiftをかじったことがある。
  • 先生:プログラミング部の顧問。Swift Concurrencyに詳しい。

ミライ「先生、Swiftでネットから画像をダウンロードする機能を作ろうとしてたんですけど、同じURLの画像を何度もリクエストしちゃいそうで……あと、並行処理が絡むとどうやってバグを防げばいいのか頭がこんがらがってます。」

先生「そうなんだね。たとえばクラス(class)の代わりにアクター(actor)という仕組みを使うと、並行処理でも安全にデータを扱えるようになるよ。特に“同じURLで何度も画像を取得しに行ってしまう”問題を解決するには、アクターとキャッシュの使い方を工夫する必要があるんだ。
 それと、日本の高校の購買部では、焼きそばパンとメロンパンが大人気なんだってね。せっかくだから、そのパンの画像が来るケースを例に取り入れようか。」

アキラ「アクターって、一度にひとつのタスクしかコードを実行しないように守ってくれる、並行処理でも安全に状態を扱える仕組みなんですよね?」

先生「そのとおり。ただし、アクターのメソッドの中でawaitしている箇所があると、そのタイミングで処理が一時停止する。その間に他のタスクが同じアクターにメッセージを送ると、処理が“割り込まれる(再入される)”可能性がある。だから変数の更新を途中で邪魔されないよう注意が必要だよ。」

ミライ「再入(reentrancy)ってやつですよね。途中で他の処理が割り込んできて、自分が操作していた変数が予期せず変わってしまうかもしれない、って聞きました。」

先生「そう。ここではアクター内部でキャッシュ用の辞書を使って、すでにダウンロード済みのURLなら新たにダウンロードしないようにしたり、逆に途中で他タスクが割り込んでキャッシュを更新するかもしれない状況にうまく対応しないと、同じURLなのに違う画像が返ってきたり、無駄なダウンロードを何回もしてしまったりするんだ。
 じゃあ早速、具体的なアクターを見てみようか。」


1. シンプルなactor ImageDownloaderと問題点

先生「まずは、こんな風に書くとするね。」

actor ImageDownloader {
    private var cached: [String: UIImage] = [:]
    
    func image(from url: String) async -> UIImage {
        // ① まずキャッシュがあるかチェック
        if cached.keys.contains(url) {
            return cached[url]!
        }
        // ② なければダウンロード
        let image = await downloadImage(from: url)
        // ③ キャッシュに保存
        cached[url] = image
        return cached[url]!
    }
    
    // サーバーに画像をリクエストしたと仮定するメソッド
    // 2秒待ってから、URLが"bread"なら焼きそばパンかメロンパンをランダムで返す
    // それ以外は UIImage()(空画像)を返す
    func downloadImage(from url: String) async -> UIImage {
        try? await Task.sleep(nanoseconds: 2_000_000_000)  // 2秒待つ
        switch url {
        case "bread":
            // 「焼きそばパン」か「メロンパン」をランダムで返す
            let imageName = Bool.random() ? "yakisoba-pan" : "melon-pan"
            return UIImage(named: imageName) ?? UIImage()
        default:
            // それ以外は UIImage() を返す
            return UIImage()
        }
    }
}

アキラ「キャッシュに“URL文字列→画像”のマップを保存して、もしキャッシュ済みならそれを返す、なければdownloadImageでダウンロードしてキャッシュに入れる……定番の流れですね。でも、どんな問題があるんでしょう?」

先生「パッと見は良さそうだけど、実行してみると、同じURLで呼び出したはずのimage(from:)メソッドが、呼ぶたびに別々の画像を返すことがあるんだよ。今回“bread”ってURLだと、焼きそばパンとメロンパンがランダムで返ってくるでしょ? そのはずが、同じURLを2回呼んでも画像が揃わないことが起きるわけだね。」


2. 「ダウンロード後にもう一度キャッシュをチェック」する修正

先生「まずはシンプルな修正として、ダウンロードが終わった後にもう一度キャッシュを確認する、という手を使うんだ。」

actor ImageDownloader {
    private var cached: [String: UIImage] = [:]

    func image(from url: String) async -> UIImage {
        // ダウンロード前にキャッシュチェック
        if cached.keys.contains(url) {
            return cached[url]!
        }
        // ダウンロード
        let image = await downloadImage(from: url)
        // ダウンロード後に再度キャッシュをチェック
        if cached.keys.contains(url) {
            // すでに他タスクがキャッシュを埋めていたら、そちらを使う
            return cached[url]!
        }
        // 誰もキャッシュしていなかったら自分が保存して返す
        cached[url] = image
        return cached[url]!
    }

    func downloadImage(from url: String) async -> UIImage {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        switch url {
        case "bread":
            let imageName = Bool.random() ? "yakisoba-pan" : "melon-pan"
            return UIImage(named: imageName) ?? UIImage()
        default:
            return UIImage()
        }
    }
}

ミライ「ダウンロードの結果を得た後も、“他が先にキャッシュを埋めたかもしれないからもう一度見る”わけですね。それなら途中で別のタスクが割り込んでも、最終的には同じURLに対して同じ画像を返せますね!」


3. キャッシュの競合を「Task」で防ぐ方法

先生「でも、まだ問題が残る。ダウンロードそのものは同時に走る可能性があるから、無駄な通信が走ったり、最終的な上書きがどちらのパンになるかタイミング次第になったりする。
 もう一歩踏み込むなら、同じURLをリクエストした場合は“一つのダウンロードTaskだけ走らせて、他はそれが終わるのを待つ”という仕組みにするといいよ。」

アキラ「どう書くんでしょう?」

先生「こんな感じで、キャッシュ用の辞書を[String: CacheEntry]にして、ダウンロード中を表すinProgress(Task<UIImage, Never>)と、準備完了を表すready(UIImage)に分けるんだ。」

actor ImageDownloader {
    private enum CacheEntry {
        case inProgress(Task<UIImage, Never>)
        case ready(UIImage)
    }

    private var cache: [String: CacheEntry] = [:]

    func image(from url: String) async -> UIImage? {
        // キャッシュをチェック
        if let cached = cache[url] {
            switch cached {
            case .ready(let image):
                return image
            case .inProgress(let task):
                // すでにダウンロード中のTaskがあれば、その結果を待つ
                return await task.value
            }
        }
        // まだダウンロードしていない場合: 新しくTaskを作って辞書に登録
        let task = Task {
            await downloadImage(from: url)
        }
        cache[url] = .inProgress(task)
        // Taskの結果を待ってからキャッシュを更新
        let image = await task.value
        cache[url] = .ready(image)
        return image
    }

    func downloadImage(from url: String) async -> UIImage {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        switch url {
        case "bread":
            let imageName = Bool.random() ? "yakisoba-pan" : "melon-pan"
            return UIImage(named: imageName) ?? UIImage()
        default:
            return UIImage()
        }
    }
}

4. まとめ

ミライ「なるほど、アクターでキャッシュを守るだけじゃなく、再入(タスク割り込み)を考慮して、ダウンロード後や途中に他タスクがキャッシュを先に更新するかもしれないっていうのを見越さないといけないんですね。日本の高校の購買部の焼きそばパンやメロンパンみたいに、何度も注文が来るものは特に。」

先生「そういうことだよ。アクターの基本は“一度に一つのタスクしか実行されない”だけど、awaitのところでタスクが止まっている間は別のタスクが入り込めるからね。だから“awaitが終わったあとの状態がどうなっているか”を再確認したり、“同じURLのダウンロードタスクが既にあればそのTaskを待つ”みたいな形にすると、無駄も減らせるし結果もブレにくくなる。」

ミライ「ありがとうございました! yakisoba-panmelon-pan どっちになるかはランダムだけど、ちゃんと同じURLで呼ばれたら同じものが返るように作ってみます!」

先生「日本の高校生たちが喜ぶアプリになりそうだね。がんばって!」

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?