Let me start by saying our goal should always be to try to use Sendable and @Sendable if possible. However, there is another solution if you know a type is thread-safe but impossible to make compatible with either of those two sendable definitions.
What is @unchecked Sendable?
It’s in the name: unchecked Sendable. In other words, we can use it to tell the compiler a type is Sendable but we ask the compiler to not actually check for it.
This could be a solution in case you have synchronized access manually using a lock. Imagine the following cache for article titles:
final class ArticleTitlesCache {
private let cacheMutatingLock = DispatchQueue(label: "cache.lock.queue")
/// A private mutable member which is only accessed inside this cache via the serial lock queue.
private var articleTitles: Set<String> = []
func addArticleTitle(_ title: String) {
cacheMutatingLock.sync {
_ = articleTitles.insert(title)
}
}
func cachedArticleTitles() -> Set<String> {
return cacheMutatingLock.sync {
return articleTitles
}
}
}
As soon as we want to make this type Sendable, we will run into the following error:
For the compiler, it’s impossible to check whether the lock queue is properly used and whether the cache is actually thread-safe. In those cases, we can decide to take ownership of checking for data races by using @unchecked Sendable:
final class ArticleTitlesCache: @unchecked Sendable {
private let cacheMutatingLock = DispatchQueue(label: "cache.lock.queue")
/// A private mutable member which is only accessed inside this cache via the serial lock queue.
private var articleTitles: Set<String> = []
func addArticleTitle(_ title: String) {
cacheMutatingLock.sync {
_ = articleTitles.insert(title)
}
}
func cachedArticleTitles() -> Set<String> {
return cacheMutatingLock.sync {
return articleTitles
}
}
}
Why you should be careful using @unchecked Sendable
The above code compiles fine using @unchecked Sendable, but it’s important to realize the impact of this change. For example, imagine if we would access articleTitles without using the lock queue:
/// Returns the total number of cached article titles.
var count: Int {
articleTitles.count
}
Nothing is stopping us from writing this code, and using this property inside concurrency domains will be allowed since the compiler assumes our type is Sendable. However, as we didn’t use the cacheMutatingLock, we suddenly introduced a potential data race!
Manual locking mechanisms are challenging to keep correct, and there’s no compile-time safety or guarantee of safety. Determining the need for synchronization can be challenging and worst of all: unsafe code does not guarantee failure at runtime. Potential runtime issues are hard to reproduce, and you’ll likely find out about them in a crash report. Good luck reproducing!
Therefore, you should try to avoid using @unchecked Sendable as much as possible. If the code has proven to be thread-safe for years, it’s a good in-between solution while migrating a codebase. However, I recommend opening a ticket like “Migrate ArticleTitlesCache to an actor” so you don’t forget about it.
To finalize this section, here’s a proper way of creating such cache:
private actor ArticleTitlesCacheActor {
private var articleTitles: Set<String> = []
/// Returns the total number of cached article titles.
var count: Int {
articleTitles.count
}
/// Adds a new article title to the cache.
func addArticleTitle(_ title: String) {
articleTitles.insert(title)
}
/// Returns the cached article titles.
func cachedArticleTitles() -> Set<String> {
return articleTitles
}
}
Summary
In this lesson, we’ve learned that we could use @unchecked Sendable as a temporary solution when we’re confident enough to take responsibility of a thread-safe type. The compiler will stop performing Sendable checks and assumes we know what we’re doing. However, manually managing locks is complex and challenging, so it’s recommended to stay away from unchecked sendable types as much as possible.
In the next lesson, we’re going to dive deeper into compilation checks by looking at region-based isolation.