Actors and per-resource isolation

I wonder what would be the best practice to achieve per-resource isolation using actors. Let's assume the following actor-isolated func:

func image(from url: URL) async throws -> UIImage

Now, if you're using the same actor instance and call this concurrently, you won't get the best possible outcome since downloads will be serially queued, always called on the same executor. But that kind of behaviour would only make sense (or be acceptable) for the same URL (i.e. serially queuing requests to the same URL)

The only way I can think of solving this is by keeping a [URL: Task] dict and returning the task instead:

func image(from url: URL) -> Task<UIImage, Error>.

Although, I'm not sure how this would work when awaiting for the task's value in multiple places (2 entities racing for the same resource). Also, it breaks the nice async/await API + adds an extra cancelling/cleaning up responsibility.

Is there currently a better/smarter/more advanced way to do this?

Assuming your async actor method has suspension points (awaits) the calls to the method aren't actually serially queued. Well, the calls are (assuming they're made from the same task), but possibly a new caller from a different Task will be allowed on to the actor as soon as the previous caller is suspended via a suspension point. More info in this thread.

Right, that's correct. No matter how many times I've seen it discussed here, I keep forgetting actor isolated func don't offer any guarantees beyond partial operations proper order (between suspension points).

But that makes it even worse, it means that in the example above you can easily end up downloading the image twice at the same time. So, the question remains, without the ability to use a semaphore, a queue or anything that blocks, how would one solve this?

Your proposal of storing a [URL: Task] in an actor seems just fine to achieve this goal.

I suppose in that case, access to the [URL: Task] should be synchronized via a DispatchQueue, NSLock, etc since actors are reentrant. Is my understanding correct?

No, that would already break the promise of not using blocking means inside actors. The access would be synchronized since this would be a field in the actor, thus isolated and mutable only from within the actor.

I don't think the re-entrancy will matter in this case. The first entrant into the actor finds the map empty for its key, spawns a task to do the work, stores the task in the map, and awaits the task. Other entrants now find the task and simply await it. No ordering issues here.

3 Likes