如何防止actor重入导致重复请求?

How to prevent actor reentrancy resulting in duplicative requests?

在 WWDC 2021 视频中,Protect mutable state with Swift actors,他们提供了以下代码片段:

actor ImageDownloader {
    private var cache: [URL: Image] = [:]

    func image(from url: URL) async throws -> Image? {
        if let cached = cache[url] {
            return cached
        }

        let image = try await downloadImage(from: url)

        cache[url] = cache[url, default: image]

        return cache[url]
    }

    func downloadImage(from url: URL) async throws -> Image { ... }
}

问题在于 actor 提供可重入性,因此 cache[url, default: image] 引用有效地确保即使您由于某些竞争执行了重复请求,您至少在继续后检查 actor 的缓存,确保您为重复请求获取相同的图像。

在那个视频中,他们 say:

A better solution would be to avoid redundant downloads entirely. We’ve put that solution in the code associated with this video.

但是网站上没有与该视频关联的代码。那么,更好的解决方案是什么?

我了解 actor 重入的好处(如 SE-0306 中所述)。例如,如果下载四张图片,一张不想禁止重入,失去下载的并发性。实际上,我们希望等待对特定图像的重复先验请求的结果(如果有),如果没有,则开始新的 downloadImage.

关键是保留对 Task 的引用,如果找到,awaitvalue

也许:

actor ImageDownloader {
    private var cache: [URL: Image] = [:]
    private var tasks: [URL: Task<Image, Error>] = [:]

    func image(from url: URL) async throws -> Image {
        if let image = try await tasks[url]?.value {
            print("found request")
            return image
        }

        if let cached = cache[url] {
            print("found cached")
            return cached
        }

        let task = Task {
            try await download(from: url)
        }

        tasks[url] = task
        defer { tasks[url] = nil }

        let image = try await task.value
        cache[url] = image

        return image
    }

    private func download(from url: URL) async throws -> Image {
        let (data, response) = try await URLSession.shared.data(from: url)
        guard
            let response = response as? HTTPURLResponse,
            200 ..< 300 ~= response.statusCode,
            let image = Image(data: data)
        else {
            throw URLError(.badServerResponse)
        }
        return image
    }
}

在我想出, I stumbled across Andy Ibanez’s write-up, Understanding Actors in the New Concurrency Model后,他没有提供Apple的代码,而是提供了受其启发的东西。这个想法非常相似,但他使用枚举来跟踪缓存和待处理的响应:

actor ImageDownloader {
    private enum ImageStatus {
        case downloading(_ task: Task<UIImage, Error>)
        case downloaded(_ image: UIImage)
    }
    
    private var cache: [URL: ImageStatus] = [:]
    
    func image(from url: URL) async throws -> UIImage {
        if let imageStatus = cache[url] {
            switch imageStatus {
            case .downloading(let task):
                return try await task.value
            case .downloaded(let image):
                return image
            }
        }
        
        let task = Task {
            try await downloadImage(url: url)
        }
        
        cache[url] = .downloading(task)
        
        do {
            let image = try await task.value
            cache[url] = .downloaded(image)
            return image
        } catch {
            // If an error occurs, we will evict the URL from the cache
            // and rethrow the original error.
            cache.removeValue(forKey: url)
            throw error
        }
    }
    
    private func downloadImage(url: URL) async throws -> UIImage {
        let imageRequest = URLRequest(url: url)
        let (data, imageResponse) = try await URLSession.shared.data(for: imageRequest)
        guard let image = UIImage(data: data), (imageResponse as? HTTPURLResponse)?.statusCode == 200 else {
            throw ImageDownloadError.badImage
        }
        return image
    }
}

您可以在 the Developer app 中找到“更好的解决方案”代码。在 Developer 应用程序中打开会话,select 代码选项卡,然后滚动到“11:59 - 在等待后检查您的假设:更好的解决方案”。

屏幕截图来自我的 iPad,但 Developer 应用程序也可在 iPhone、Mac 和 Apple TV 上使用。 (我不知道Apple TV版本是否提供了查看和复制代码的方法,但是......)

据我所知,代码在 developer.apple.com 网站上不可用,无论是在 the WWDC session's page 上还是作为示例项目的一部分。

为了后代,这里是 Apple 的代码。它与 Andy Ibanez 极其相似:

actor ImageDownloader {

    private enum CacheEntry {
        case inProgress(Task<Image, Error>)
        case ready(Image)
    }

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

    func image(from url: URL) async throws -> Image? {
        if let cached = cache[url] {
            switch cached {
            case .ready(let image):
                return image
            case .inProgress(let task):
                return try await task.value
            }
        }

        let task = Task {
            try await downloadImage(from: url)
        }

        cache[url] = .inProgress(task)

        do {
            let image = try await task.value
            cache[url] = .ready(image)
            return image
        } catch {
            cache[url] = nil
            throw error
        }
    }
}