I’m currently looking at converting a number of old libraries to Swift 6. For various reasons both technical and non-technical, I am trying to minimize the blast radius of the changes, from both an API standpoint and a performance one; some of these libraries are accessed very often. I am aware that there are much better patterns than the one I am about to describe, but they all require changes to the implementations that aren’t really feasible under the current circumstances.
These libraries all have something in common;
- They’re synchronously, globally accessible (and should be synchronously, globally accessible, in the same way that say, the file system is synchronously, globally accessible)
- They’re responsible for returning values (think localized strings or
UserDefaults), they don’t just listen to events - Their implementations were intended to be thread safe, and after we migrate to Swift 6 this should be basically a guarantee
- They need dependencies that can’t be gotten globally
- …so they are implemented as a global var
- That global var is only set once, in a “startup sequence.” There’s no compile time safeguard for this, but in practice this is as close to guaranteed as you can get, let’s just take it as a given for the purposes of this discussion
Here’s an example:
// global scope
var myThing: MyThingProtocol? // don't @ me about the existential, it was 2016
protocol MyThingProtocol: Sendable { // they don't currently have this conformance, but this was the original intention for all of them
}
// startup sequence:
func startup(someDependency: SomeDependency) {
let theThing = MyThing(someDependency)
myThing = theThing
}
Swift 6, naturally, complains that this is unsafe. And an obvious solution would be to simply swap this out for something like:
static let myThing: OSAllocatedUnfairLock<MyProtocol?> = .init(initialValue: nil)
You could also very reasonable argue that if the lock is pulled out to here, you might be able to remove locks from other places inside the library to “pay off” the performance deficit we’d be incurring with a second lock.
In practice, however, we’ve never seen any crashes that can be directly attributed to this pattern, and if I write a simple program like the following:
class C: P {
var a: [Int] = [99]
}
protocol P: AnyObject {
var a: [Int] { get set }
}
var p: P? = nil
// in main.swift
p = C()
await withTaskGroup { group in
for i in 0..<1000 {
group.addTask {
print(p?.a.first as Any)
}
}
}
TSAN has no complaints.
Of course, my real use case might look more like
// main.swift
await withTaskGroup { group in
for i in 0..<1000 {
group.addTask {
if i == 50 { // picking a random time to actually set this, don't worry about accesses that missed the opportunity to read a real value, they'll be fine
p = C()
}
print(p?.a.first as Any)
}
}
}
…since I have no control over when people try to access these. And of course, this does trigger TSAN, but it’s not clear to me that it will ever crash.
It occurred to me that perhaps I could use some of the machinery that’s used in global lets, since those are only set once and presumably are very fast to read. I’m also aware of some concurrency primitives like memory barriers that might help, though I’ve never used them in practice and based on what I’ve read of the implementation of the original dispatch_once this seems difficult for someone who hasn’t got experience with them to implement.
So in summary, I’m looking for a solution that lets me keep the fast reads, or some hard evidence that even with the access pattern I’m describing, this can crash that I can bring to the powers-that-be to convince them that they always needed a lock. I’m okay with slowing down setting the global var.