Modern Concurrency: Beyond the Basics

Oct 20 2022 · Swift 5.5, iOS 15, Xcode 13.4

Part 2: Concurrent Code

17. Creating a GlobalActor

Episode complete

Play next episode

About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 16. GlobalActor Next episode: 18. Using a GlobalActor

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Heads up... You've reached locked video content where the transcript will be shown as obfuscated text.

The image loader actor implements an in memory cache. It manages a dictionary of completed, failed, and in progress downloads so the server doesn't get duplicate requests. But the image cache doesn't persist on the device. When you quit the app and run it again it has to fetch all the images from the server all over again. In this episode, you'll create a custom global actor to provide a persistent on disk image cache, that allows easy and safe access to shared resources from anywhere in your app. Continue with your project from episode 15 or open the starter project. In the model group, create a new swift file named ImageDatabase.swift. Replace the import statement. You declare a new actor called ImageDatabase, and annotate it with @globalActor. This makes the type conform to the global actor protocol, which you satisfy by adding the shared property right away. Now you can access your new actor type from anywhere by referring to the shared instance ImageDatabase.shared. You can also move methods of other types to the ImageDatabase serial executor by annotating them with @ImageDatabase. Add an imageLoader property. Your new actor will use this to automatically fetch images that aren't already fetched from the server. Add a storage property. Jump to the definition of DiskStorage. It's in the database group and contains simple file operation methods to write read and remove files. Jump back to ImageDatabase, and add an index property. You'll keep an index of the persisted files on disk in StoredImagesIndex. This lets you avoid checking the file system every time you send a request to ImageDatabase. You've only added a few properties, but you should check that you haven't accidentally introduced some concurrency issues. You've introduced two dependencies into your code ImageLoader and DiskStorage. Image loader is an actor so it definitely doesn't introduce any concurrency issues. But what about DiskStorage? Could this type lead to concurrency issues in your global actor? You could argue that storage belongs to ImageDatabase, which is an actor, therefore, storage is code executes serially and the code in DiskStorage cannot introduce data bases. Click forward to DiskStorage. There's nothing to stop other threads, actors, or functions, from creating their own instances of DiskStorage. In that case, the code could be unreliable. One way to fix this is to convert DiskStorage to an actor as well. However, since you mostly expect ImageDatabase to work with DiskStorage, making it an actor will introduce some redundant switching between actors, so undo this change. In this app what you really need is to guarantee that the code in DiskStorage always runs on ImageDatabase's serial executor. This will eliminate concurrency issues and avoid excessive actor hopping. So, annotate DiskStorage. You move the whole DiskStorage type to the ImageDatabase serial executor. This way, ImageDatabase and DiskStorage can never step on each other's toes. Click back to ImageDatabase. Moving DiskStorage to ImageDatabase produces this error. You cannot create DiskStorage which runs on ImageDatabase's serial executor before you've created ImageDatabase itself. You'll fix that by deferring the storage initialization to a new method called setUp, along with a few other things you need to take care of when you initialize your database. Change the storage declaration. Now storage is an optional, and add a setUp method. setUp initializes DiskStorage and reads all the files persisted on disk into the storedImagesIndex lookup index. Anytime you save new files to disk, you'll also update the index, you'll need to ensure you call it before any other method in ImageDatabase because you'll initialize your storage there. Don't worry about this for now, though, you'll take care of it soon. The new cache will need to write images to disk. When you fetch an image, you'll export it to PNG format and save it. Add another method to ImageDatabase. First, you get the images PNG data, then save the data in a file. Finally, add the asset to the lookup index. Here's a familiar compiler error, it's complaining about the file name call, so jump to its definition. Look closely, this is a pure function that uses no state at all, so you can safely make it non-isolated like load images and download image in emoji art model. Click back to ImageDatabase.swift, to see this fixed the error. Now you need a helper method to fetch image from the database. If the file is already stored on disk you'll fetch it from there. Otherwise, you'll use image loader to make a request to the server. Add a method. This method takes a path to an asset and either returns an image or throws an error. Fetch the keys in the cache. Before trying the disk or the network, check your local copy of keys for the image. If it's in memory, fetch it directly from the cache. Because your caching strategy is getting more complex, You also add a new log message that lets you know you've successfully retrieved an in memory image. You might think you could have just directly called contains on the cache keys, instead of first fetching a local copy, but this could corrupt memory in release builds when there are concurrent updates. If there's no cached asset in memory check the on disk index. You get the asset file name from DiskStorage.fileName for, and check the database index for a match. If the key doesn't exist, you throw an error that transfers the execution to the catch statement. You'll try fetching the asset from the server there, but first, if there is a match, try to read the file from disk and initialize a UI image with its contents. So, if either of these steps fails, throw an error. You'll try to get the image from the server in the catch block. If you do retrieve a cache image stored in memory then return it. Storing it in memory will save you a trip to the file system the next time you need it. Finally, fill in the catch closure to fetch the asset from the server. If all the other attempts fail, you call imageLoader.image to fetch the image stored on disk, then return it. Your persistence layer is almost ready, to complete it you'll add one final method for debugging purposes. Add a clear method to ImageDatabase. You iterate over all the index files in storedImagesIndex, and try to delete the matching files on disk. Finally, you remove all values from the index as well. This method lets you easily test your caching logic. Your cache is ready, in the next episode, you'll put it to work in emoji art.