Concurrency-safe global variables help you prevent data races and allow you to solve strict-concurrency-related warnings. Since you can access global variables from any context, ensuring access is safe by removing mutability or conforming to Sendable is essential.
As a developer, you must prevent data races since they can make your apps crash unexpectedly. When preparing for Swift 6, you’ll enable the strict concurrency build setting and likely run into warnings about accessing a shared mutable state. Let’s dive into what this means and how you can solve these warnings.
What are global variables?
A global variable has a global scope, meaning it’s accessible and visible (hence accessible) from anywhere in your code. You’ve most likely used a global variable when working with a singleton:
struct APIProvider {
static let shared = APIProvider()
}
By using the static
keyword, you can now access the shared
variable from anywhere in your code:
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, world!")
}
.padding()
.onAppear {
/// We can access the shared instance
/// using the `shared` variable.
APIProvider.shared.request()
}
}
}
The usage of global variables, particularly singletons, is often opinionated by developers in the community. Whether or not you decide to use them, it’s essential to think about concurrency-safe global variables since they’re accessible from anywhere in your code. This means it is accessible from different threads, as well as from async contexts. Therefore, it’s crucial to prevent data races. Make sure to read Race condition vs. Data Race: the differences explained to get a good sense of what we’re discussing.
Creating concurrency-safe global variables
When preparing your project for Swift 6, you’re likely running into the following warning:
Reference to class property ‘shared’ is not concurrency-safe because it involves shared mutable state
The following code is an example of where this warning would show up:
class ImageCache {
/// When you've enabled the strict concurrency build setting:
/// Warning: Reference to class property 'shared' is not concurrency-safe
/// because it involves shared mutable state.
static var shared = ImageCache()
}
Compared to the earlier shared code example of an API provider, we’re now dealing with a class called ImageCache
and a mutable variable shared
. The strict concurrency build setting will indicate unsafe access and show a warning accordingly:
There are several ways of solving this warning. You could isolate the image cache using a global actor:
/// Isolated by the @MainActor, making is concurrency-safe.
@MainActor
class ImageCache {
static var shared = ImageCache()
func clearCache() {
/// ...
}
}
You can now only access the image cache from the main actor, making its access serialized and concurrency-safe. However, actor isolation might not always work since it complicates access from non-concurrency contexts. Another solution would be to make image cache both immutable and conform to Sendable
:
/// The `ImageCache` is `final` and conforms to `Sendable`, making it thread-safe.
final class ImageCache: Sendable {
/// The global variable is no longer a `var`, making it immutable.
static let shared = ImageCache()
func clearCache() {
/// ...
}
}
Note that we’ve had to do a few things to make our image cache concurrency-safe:
- We’ve marked the class as
final
, making introducing mutable states through inheritance impossible. This is required to make a class conform toSendable
. - The
shared
variable is no longer mutable since we’ve defined it as astatic let
.
While either actor isolation or conforming to Sendable
works in most cases, you might have global instances with a custom locking mechanism. There’s a way to opt out of concurrency checking for these cases.
Marking a global variable as nonisolated unsafe
You could be running into a scenario where you know your global variable is concurrency-safe, but you’re still running into strict concurrency-related warnings. An example could be a force unwrapped shared property which you initialize through a configuration method:
struct APIProvider: Sendable {
static var shared: APIProvider!
let apiURL: URL
init(apiURL: URL) {
self.apiURL = apiURL
}
static func configure(apiURL: URL) {
/// Warning:
/// Reference to static property 'shared' is not concurrency-safe because it involves shared mutable state.
shared = APIProvider(apiURL: apiURL)
}
}
It’s better to rewrite this code and eliminate the forced unwrap global variable, but that’s not always possible when dealing with a large codebase. Secondly, you’re likely sure the configuration method is called directly at the app launch, not risking data races. In those cases, you can make use of a new nonisolated(unsafe)
keyword that was introduced in SE-412:
struct APIProvider: Sendable {
/// We've now indicated to the compiler we're taking responsibility ourselves
/// regarding thread-safety access of this global variable.
nonisolated(unsafe) static var shared: APIProvider!
}
Note: at the moment of writing this article, the proposal just got accepted and isn’t released yet. It’s expected to be available in Swift 5.10. However, since many of you are preparing for Swift 6 already, I find it important to share best practices in advance to prevent you from doing unnecessary work.
It’s essential to realize this is not making your code thread-safe. You’re taking responsibility yourself to ensure you’re only calling the configure(apiURL: )
method in a way that does not result in any data races.
Conclusion
Global variables allow you to access shared instances from anywhere in your codebase. With strict concurrency, we must ensure access to the global state becomes concurrency-safe by actor isolation or Sendab
le conformance. In exceptional cases, we can opt out by marking a global variable as nonisolated unsafe.
If you like to learn more tips on concurrency, check out the Concurrency category page. Feel free to contact me or tweet me on Twitter if you have any additional suggestions or feedback.
Thanks!