-
Notifications
You must be signed in to change notification settings - Fork 5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remove "async" from ImageCaching #386
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -39,7 +39,7 @@ public struct ImageDownloadService: ImageDownloader, Sendable { | |
} | ||
|
||
private func cachedImage(for url: URL) async throws -> UIImage? { | ||
guard let entry = await imageCache.getEntry(with: url.absoluteString) else { return nil } | ||
guard let entry = imageCache.getEntry(with: url.absoluteString) else { return nil } | ||
switch entry { | ||
case .inProgress(let task): | ||
let image = try await task.value | ||
|
@@ -50,15 +50,15 @@ public struct ImageDownloadService: ImageDownloader, Sendable { | |
} | ||
|
||
private func awaitAndCacheImage(from task: Task<UIImage, Error>, cacheKey key: String) async throws -> UIImage { | ||
await imageCache.setEntry(.inProgress(task), for: key) | ||
imageCache.setEntry(.inProgress(task), for: key) | ||
let image: UIImage | ||
do { | ||
image = try await task.value | ||
} catch { | ||
await imageCache.setEntry(nil, for: key) | ||
imageCache.setEntry(nil, for: key) | ||
throw error | ||
} | ||
await imageCache.setEntry(.ready(image), for: key) | ||
imageCache.setEntry(.ready(image), for: key) | ||
return image | ||
} | ||
|
||
|
@@ -78,13 +78,13 @@ public struct ImageDownloadService: ImageDownloader, Sendable { | |
} | ||
} | ||
|
||
public func cancelTask(for url: URL) async { | ||
if let entry = await imageCache.getEntry(with: url.absoluteString) { | ||
public func cancelTask(for url: URL) { | ||
if let entry = imageCache.getEntry(with: url.absoluteString) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (nit) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right. 👍 |
||
switch entry { | ||
case .inProgress(let task): | ||
if !task.isCancelled { | ||
task.cancel() | ||
await imageCache.setEntry(nil, for: url.absoluteString) | ||
imageCache.setEntry(nil, for: url.absoluteString) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest addressing it separately, but I believe there is a race condition in this code. I would suggest synchronizing most of the Gravatar types on a custom global actor. It will ensure that this code runs in the background (good) and is synchronized with no reentrancy (great). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Like what? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As an async function, There are multiple potential race conditions:
It's hard to reason about it because it's a race condition as any line can be executed in any order in parallel to each other, which can lead to unpredictable results. While both The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure the results would be "unpredictable" in the sense that it would be in a real race condition though. If you call these 2 methods in a parallel manner, one of them will get to access the cache first it's true, and we won't know which one. But it doesn't mean things will go wrong. For example, both scenarios are fine:
No race condition here. This is just 2 tasks running in parallel. If the caller party wants to really make sure one executes before the other, they should just call them in a serial way. The important bit is, each method should be able to access to the "correct" state of the cache. We ensure it by using a thread safe cache here (NSCache) so all the accesses to the cache are "synchronized". If anything, with this change we are making the code more prone to race conditions. Because now if 3rd parties want to inject their own cache to the SDK, they are fully responsible for synchronizing the accesses to it. If they don't use any thread safe cache implementation like NSCache, then there will indeed be race conditions.
Good point. I was thinking about introducing the ability of using a separate cache key than the URL itself. It could help with creating different tasks for the same URL if you need to. We need to discuss this one further inside the team. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There is no way to do that since Here's another simple example, even without cancellation:
As a result, two tasks are running instead of one and there is no way to cancel the first one.
That was also the case with the previous design where the cache methods were
That's not exactly what I was alluding to. A better option would to either remove Btw, it's be cool to support cancellation via tasks: let task = Task {
let image = ImageDownloader.shared.fetchImage(with: url)
}
// At a later time
task.cancel() I think the bigger issue is that the system doesn't automatically refresh avatars. I would suggest prioritizing it to ensure that if I change avatar on one device, it will eventually be updated on the other device without having to restart the app. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It's a bit of a different topic, but is kind of related. Gravatar seems to serve images with cache control headers to enable validation:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes of course, there was no doubt about that... My objection was to "There is no way to do that". "That" being "running 2 async functions in a serial way if desired". There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Gotcha. Yeah I guess we can just allow other parties to by-pass the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ah, got you. Yeah I was a bit confused. It's a bit impractical to do it this way though. In reality, the scenario will be something like you open an iPad app with three columns where the same avatar is displayed in two or three different places, they all create separate async contexts/functions, and this code will likely run in parallel since
You do need a memory cache, but yeah, it's a bit tricky to get right. I think a good default would be to synchronously display an image from a memory cache, even if it is stale, and automatically refresh it in the background. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
default: | ||
break | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW, I am not sure about removing
async
fromsetEntry(...)
. I can't think of a similar problem happening related to this. Any thoughts?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It has to be synchronous or it ends up with race conditions due to reentrancy. This seems like a good post on it, but I only briefly looked into it. You generally want at little
await
calls as possible, especially inside actors (ImageDownloadService
should really be one or be synchronized on one).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That post explains the solution we already applied here. We too have the
.inProgress(...)
state and stuff.What else could we be causing by making setEntry async? Any examples?