Monday, April 29, 2024
HomeIOS DevelopmentActor reentrancy in Swift defined – Donny Wals

Actor reentrancy in Swift defined – Donny Wals


If you begin studying about actors in Swift, you’ll discover that explanations will at all times include one thing alongside the strains of “Actors shield shared mutable state by ensuring the actor solely does one factor at a time”. As a single sentence abstract of actors, that is nice nevertheless it misses an essential nuance. Whereas it’s true that actors do just one factor at a time, they don’t at all times execute operate calls atomically.

On this submit, we’ll discover the next:

  • Exploring what actor reentrancy is
  • Understanding why async features in actors may be problematic

Typically talking, you’ll use actors for objects that should maintain mutable state whereas additionally being protected to move round in duties. In different phrases, objects that maintain mutable state, are handed by reference, and have a should be Sendable are nice candidates for being actors.

Implementing a easy actor

A quite simple instance of an actor is an object that caches information. Right here’s how which may look:

actor DataCache {
  var cache: [UUID: Data] = [:]
}

We will straight entry the cache property on this actor with out worrying about introducing information races. We all know that the actor will be sure that we received’t run into information races once we get and set values in our cache from a number of duties in parallel.

If wanted, we are able to make the cache non-public and write separate learn and write strategies for our cache:

actor DataCache {
  non-public var cache: [UUID: Data] = [:]

  func learn(_ key: UUID) -> Knowledge? {
    return cache[key]
  }

  func write(_ key: UUID, information: Knowledge) {
    cache[key] = information
  }
}

Every part nonetheless works completely nice within the code above. We’ve managed to restrict entry to our caching dictionary and customers of this actor can work together with the cache by a devoted learn and write technique.

Now let’s make issues somewhat extra difficult.

Including a distant cache function to our actor

Let’s think about that our cached values can both exist within the cache dictionary or remotely on a server. If we are able to’t discover a particular key domestically our plan is to ship a request to a server to see if the server has information for the cache key that we’re searching for. Once we get information again we cache it domestically and if we don’t we return nil from our learn operate.

Let’s replace the actor to have a learn operate that’s async and makes an attempt to learn information from a server:

actor DataCache {
  non-public var cache: [UUID: Data] = [:]

  func learn(_ key: UUID) async -> Knowledge? {
    print(" cache learn known as for (key)")
    defer {
      print(" cache learn completed for (key)")
    }

    if let information = cache[key] {
      return information
    }

    do {
      print(" try and learn distant cache for (key)")
      let url = URL(string: "http://localhost:8080/(key)")!
      let (information, response) = attempt await URLSession.shared.information(from: url)

      guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
        print(" distant cache MISS for (key)")
        return nil
      }

      cache[key] = information
      print(" distant cache HIT for (key)")
      return information
    } catch {
      print(" distant cache MISS for (key)")
      return nil
    }
  }

  func write(_ key: UUID, information: Knowledge) {
    cache[key] = information
  }
}

Our operate is lots longer now nevertheless it does precisely what we got down to do; examine if information exists domestically, try and learn it from the server if wanted and cache the consequence.

Should you run and check this code it should most probably work precisely such as you’ve meant, properly completed!

Nevertheless, when you introduce concurrent calls to your learn and write strategies you’ll discover that outcomes can get somewhat unusual…

For this submit, I’m operating a quite simple webserver that I’ve pre-warmed with a few values. After I make a handful of concurrent requests to learn a price that’s cached remotely however not domestically, right here’s what I see within the console:

 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
 distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00

As you possibly can see, executing a number of learn operations leads to having plenty of requests to the server, even when the info exists and also you anticipated to have the info cached after your first name.

Our code is written in a manner that ensures that we at all times write a brand new worth to our native cache after we seize it from the distant so we actually shouldn’t count on to be going to the server this usually.

Moreover, we’ve made our cache an actor so why is it operating a number of calls to our learn operate concurrently? Aren’t actors presupposed to solely do one factor at a time?

The issue with awaiting within an actor

The code that we’re utilizing to seize data from a distant information supply really forces us right into a scenario the place actor reentrancy bites us.

Actors solely do one factor at a time, that’s a reality and we are able to belief that actors shield our mutable state by by no means having concurrent learn and write entry occur on mutable state that it owns.

That stated, actors don’t like to take a seat round and do nothing. Once we name a synchronous operate on an actor that operate will run begin to finish with no interruptions; the actor solely does one factor at a time.

Nevertheless, once we introduce an async operate that has a suspension level the actor won’t sit round and look ahead to the suspension level to renew. As an alternative, the actor will seize the following message in its “mailbox” and begin making progress on that as an alternative. When the factor we had been awaiting returns, the actor will proceed engaged on our authentic operate.

Actors don’t like to take a seat round and do nothing once they have messages of their mailbox. They are going to choose up the following process to carry out each time an lively process is suspended.

The truth that actors can do that is known as actor reentrancy and it may possibly trigger attention-grabbing bugs and challenges for us.

Fixing actor reentrancy is usually a tough downside. In our case, we are able to resolve the reentrancy difficulty by creating and retaining duties for every community name that we’re about to make. That manner, reentrant calls to learn can see that we have already got an in progress process that we’re awaiting and people calls may even await the identical process’s consequence. This ensures we solely make a single community name. The code under exhibits the complete DataCache implementation. Discover how we’ve modified the cache dictionary in order that it may possibly both maintain a fetch process or our Knowledge object:

actor DataCache {
  enum LoadingTask {
    case inProgress(Activity<Knowledge?, Error>)
    case loaded(Knowledge)
  }

  non-public var cache: [UUID: LoadingTask] = [:]
  non-public let remoteCache: RemoteCache

  init(remoteCache: RemoteCache) {
    self.remoteCache = remoteCache
  }

  func learn(_ key: UUID) async -> Knowledge? {
    print(" cache learn known as for (key)")
    defer {
      print(" cache learn completed for (key)")
    }

    // we've got the info, no must go to the community
    if case let .loaded(information) = cache[key] {
      return information
    }

    // a earlier name began loading the info
    if case let .inProgress(process) = cache[key] {
      return attempt? await process.worth
    }

    // we do not have the info and we're not already loading it
    do {
      let process: Activity<Knowledge?, Error> = Activity {
        guard let information = attempt await remoteCache.learn(key) else {
          return nil
        }

        return information
      }

      cache[key] = .inProgress(process)
      if let information = attempt await process.worth {
        cache[key] = .loaded(information)
        return information
      } else {
        cache[key] = nil
        return nil
      }
    } catch {
      return nil
    }
  }

  func write(_ key: UUID, information: Knowledge) async {
    print(" cache write known as for (key)")
    defer {
      print(" cache write completed for (key)")
    }

    do {
      attempt await remoteCache.write(key, information: information)
    } catch {
      // didn't retailer the info on the distant cache
    }
    cache[key] = .loaded(information)
  }
}

I clarify this strategy extra deeply in my submit on constructing a token refresh stream with actors in addition to my submit on constructing a customized async picture loader so I received’t go into an excessive amount of element right here.

Once we run the identical check that we ran earlier than, the consequence seems to be like this:

 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn known as for DDFA2377-C10F-4324-BBA3-68126B49EB00
 try and learn distant cache for DDFA2377-C10F-4324-BBA3-68126B49EB00
 distant cache HIT for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00
 cache learn completed for DDFA2377-C10F-4324-BBA3-68126B49EB00

We begin a number of cache reads, that is actor reentrancy in motion. However as a result of we’ve retained the loading process so it may be reused, we solely make a single community name. As soon as that decision completes, all of our reentrant cache learn actions will obtain the identical output from the duty we created within the first name.

The purpose is that we are able to depend on actors doing one factor at a time to replace some mutable state earlier than we hit our await. This state will then inform reentrant calls that we’re already engaged on a given process and that we don’t must make one other (on this case) community name.

Issues turn out to be trickier once you try to make your actor right into a serial queue that runs async duties. In a future submit I’d prefer to dig into why that’s so tough and discover attainable options.

In Abstract

Actor reentrancy is a function of actors that may result in delicate bugs and surprising outcomes. On account of actor reentrancy we should be very cautious once we’re including async strategies to an actor, and we have to be sure that we take into consideration what can and may occur when we’ve got a number of, reentrant, calls to a selected operate on an actor.

Typically that is utterly nice, different occasions it’s wasteful however received’t trigger issues. Different occasions, you’ll run into issues that come up because of sure state in your actor being modified whereas your operate was suspended. Each time you await one thing within an actor it’s essential that you just ask your self whether or not you’ve made any state associated assumptions earlier than your await that it’s worthwhile to reverify after your await.

The first step to avoiding reentrancy associated points is to know what it’s, and have a way of how one can resolve issues once they come up. Sadly there’s no single answer that fixes each reentrancy associated difficulty. On this submit you noticed that holding on to a process that encapsulates work can stop a number of community calls from being made.

Have you ever ever run right into a reentrancy associated downside your self? And in that case, did you handle to unravel it? I’d love to listen to from you on Twitter or Mastodon!



RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments