I have been thinking about simple solutions to improve thread safety of typical Swift applications, primarily providing enough basic information to the compiler to catch common errors at compile time. I've searched the forum for something similar, but not found anything with the same approach. Apologies if this has already been discussed at length and thrown out already, or if I have overlooked a simple fundamental flaw which means this could simply not work.
I've prepared this draft proposal below, and look forward to any thoughts.
Static thread safety
- Proposal: SE-NNNN
- Authors: Guy Brooker
- Review Manager: TBD
- Status: Pitch
Introduction
One of the principal goals of Swift is “to make writing and maintaining correct programs easier for the developer”. Swift has gone a long way to eliminate many common errors found in other programming languages through static type checking, ensuring safe memory access. The free for all in C where anything and everything was possible by casting pointers to different data types is thankfully over.
Most modern non-trivial Swift applications require some form of concurrency, asynchronously retrieving information over a network, firing timers , updating UI or handling notifications, however Swift does not provide concurrency primitives today. Accessing variables concurrently in Swift is inherently dangerous, and is a very easy mistake to make. The Swift language provides no safety for a developer of concurrent code, leaving them in a similar position as the C programmer of yesteryear, permitting frequent programming errors to go un-flagged.
This proposal sets out some small language changes which would allow the compiler to spot basic concurrent programming errors. To be clear, the title of this proposal “Static thread safety” is intended to mean compile time thread safety checks. It does not add concurrency primitives, nor provide any automatic concurrency or thread safety. It simply allows developers to write safer code.
Motivation
Writing correct asynchronous code is difficult to code, and easy to make mistakes. Consider the following example:
class Model {
var count: Int = 0
func updateFromNetwork() {
//...
let request = URLRequest(url: URL(string: "http://swift.org/json")!)
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
//...
self.count = self.count + 1 // This is unsafe
}
task.resume()
}
}
let m = Model()
m.updateFromNetwork()
m.updateFromNetwork()
print(m.count) // 0, 1 or 2 ?
The variable count
should either be read and written on a single thread, or access needs locks or synchronisation to ensure safe access.
In the above example, the dataTask
function of URLSession
will execute the supplied closure on a default OperationQueue
. Calling updateFromNetwork
twice, then reading count
,could return either 0, 1 or 2 depending on the latency of the network operation and the platforms thread scheduler.
This failure pattern is easy to write, and difficult to debug. The compiler is unable to detect the issue, as it is unaware of the developers assumptions on what execution queue the count
variable may be accessed from, or what execution queue the closure provided to URLSession
will execute on.
Furthermore, most interaction with AppKit
or UIKit
requires function calls and data access on the main
thread / queue. This requirement remains in SwiftUI
. Many errors in UI code come from accessing the UI frameworks from another execution context.
Runtime checks are possible using Xcode’s ThreadSanitizer tool to help detect multiple accesses to a variable. GCD provides the DispatchQueue.dispatchPrecondition(condition: )
function, which allows a runtime check on which GCD queue code is running under, however failure of the condition causes the program to crash, hence programmers use it sparingly.
Most Swift developers use the Grand Central Dispatch (GCD) framework for developing asynchronous code with DispatchQueues, though other thread and operation libraries exist and may be platform specific. Proposals have been made for async/await primitives, however the principles set out here could apply to any implementation. For the purpose of this discussion the term execution context is used to refer to a thread, dispatch queue or other concurrent execution mechanism.
Proposed solution
Variables and functions
Providing the compiler with hints about the assumptions the programmer is making on variable access and code blocks, enables the compiler to catch common mistakes.
An execution-context-modifier on
is introduced for variable and function definitions with an argument which identifies the execution context the variable may be accessed on, or the code block (a function or closure) may be executed on. The modifier is used in the declaration of variables or functions as follows:
class Model {
on(.main) var count: Int = 0
on(timer) func badCode() {
self.count = self.count + 1 // Error
}
on(.main) func goodCode() {
self.count = self.count + 1 // Perfect
}
}
The developer tells the compiler that variable count
is intended to be accessed on the main
queue. A function badCode()
is provided that the developer notes is intended to be called from a timer thread. With this metadata, the compiler can flag that access to the variable self.count
in the badCode()
function is not permitted. Function goodCode()
can access count
without a compiler error, as it is only called on the main
execution context.
Constants
Constants by definition can not be modified, and are therefore inherently thread safe. The on
modifier cannot be used for a let
constant declaration.
Closures
Swift Closures are self-contained blocks of code which may be passed around and called from different points in a program. Functions are actually closures with names attached.
When declaring a function which takes an escaping closure as an argument, the closure argument declaration can contain an execution-context-modifier.
func doWork(callback: @escaping on(.main) () -> Void) -> Void {
// ...
DispatchQueue.main.async {
callback()
}
}
doWork() {
self.count = 1
}
When parsing doWork
the compiler can verify that the call to callback
is called from the main
execution context. When parsing a call to doWork
the compiler can infer what execution context the closure will be called on, and hence verify access to the variable count
.
The definition of functions such as async
in DispatchQueue
will need to re-defined to permit the compiler to infer the execution context for closures which are passed to it. For instance a simplistic definition, ignoring the intricacies of DispatchWorkItem
could be:
func async(block: @escaping on(self) ()->Void)
As a side note, it may be possible to drop the @escaping
attribute on parameters with an execution-context-modifier as the compiler may assume all functions with that modifier are escaping.
Inherited conformance
An execution-context-modifier used on a class, struct, protocol or enum will apply to all var
or func
members defined within the body. That inheritance may be overridden by an explicit on()
modifier.
A subclass will inherit the execution-context-modifier of its superclass. An implementation of a protocol with an execution-context-modifier will inherit the protocol’s modifier. A subclass or protocol implementation may include its own execution-context-modifier, however it must match the modifier of its superclass or protocol.
An overridden variable or function or implementation of a protocol may include its own execution-context-modifier, however it must match the modifier of its superclass or protocol definition if specified.
An execution-context-modifier used on an extension will apply to all func
members defined within the body. That inheritance may be overridden by an explicit on(context:)
modifier.
When an execution-context-modifier is used on aclass or struct, init()
functions should be untagged by default. Object initialisation can normally be executed in any context.
Thread Safe Variables and Functions
Any class which has been designed with thread safety in mind, and may be used or called from any execution context is identified with on(.any)
.
Concurrent Queues
Code blocks executed on concurrent queues like DispatchQueue.global()
shall be identified with on(.any)
, and therefore the compiler will ensure that no variables or functions are accessed within that block which are restricted to a specific context such as main
.
Default Behaviour
Without any execution-context-modifier the compiler makes no assumptions about execution context for a variable or function call. Data structures such as an array can be safely used on any single thread, and need no execution-context-modifier.
The compiler can ensure that a data structure is used safely from the execution context in its definition.
class Model {
public on(.main) intArray = [Int]()
}
Any calls to functions or variables on intArray
must be called from the main
execution context.
For any @escaping
function parameter without an execution-context-modifier, the compiler will assume on(.any)
.
User defined execution context
It is typical for an application to have a few single thread execution contexts other than the main thread / queue. Within the scope of the definition of an instance of one of those contexts, the compiler can restrict access to that specific context.
on(myQueue) var count: Int = 0
on(myQueue) func incrementCount() {
count = count + 1
}
For classes such as URLSession
which take an execution context (an OperationQueue) as an argument when created, could specify that context for functions with callbacks
func dataTask(
with: URLRequest,
completionHandler: on(self.delegateQueue.Q) (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
The above syntax may be difficult to implement in the Swift compiler. One potential fallback solution may be that all execution contexts which could be verified by the compiler should be defined in the global static context:
static myQueue ...
class MyClass {
func dataTask(completionHandler: on(myQueue) () -> Void) -> Void
}
A simplistic, worse case alternative could be simply that on(.any)
is assumed for any public callback API, which doesn’t guarantee that the function is called in the main
context.
Built in frameworks
Substantial modifications will be required to built-in frameworks, to indicate to the compiler the assumptions made in the API design.
One of the most significant assumptions in Apple platform frameworks is that all UI code is executed on the main thread. All AppKit, UIKit and SwiftUI classes and structures would be marked with on(.main)
, with any specific modifications applied to any variables or functions as necessary. This will allow the compiler to spot thread safety issues for any user interface code, one of the biggest source of software issues fr swift developers today. For many classes, it may be as simple as on(.main) class UIView { … }
, with the inheritance rules taking care of applying that modifier to variables and functions within the class and subclasses of UIView
.
Swift program entry points @main
would implicitly add an on(.main)
modifier.
Thread safe classes, such as UserDefaults
would be annotated with on(.any)
.
The Swift API to GCD and other thread libraries will need to include execution-context-modifiers to indicate to the compiler where execution context changes, typically through code blocks passed as parameters to functions.
Any functions in built in frameworks which include escaping callback arguments, particularly networking and UI code, will need execution-context-modifiers for those callback parameters.
No modifications are required to standard data structure definitions through the default behaviour which assumes a class or structure may be used in any execution context, but only on one thread.
Complex concurrency
This proposal aims to address the most common concurrency issues in everyday swift programs. There will be complex multi-threaded application architectures which cannot be described or verified with simple execution-context-modifiers. In such cases, the developer will not tag any variables functions or blocks, and take full responsibility for the concurrency. Occasional use of on(.any)
may be required when interoperating with execution context tagged APIs.
Runtime implications
None. This proposal will have no impact at all at runtime. execution-context-modifiers are only relevant at compile time. A function or block with an on(.main)
modifier is not automatically executed on the main execution context, but the compiler will ensure that any call to that function is called from that context, either from a block already tagged as on(.main)
or from a call such as DispatchQueue.main.async
.
Detailed design
This is the weakest part of this draft of the proposal, and I need help to fill in the gaps. Here are the issues I currently foresee.
Argument type of on()
The argument type for the execution-context-modifier has purposefully not been specified in the above description.
If the implementation was limited to GCD, it might be a DispatchQueue
. However libdispatch is not available on all platforms, and there are other thread and execution queue implementations. The prospect of native concurrency operators async
and await
may bring new patterns for concurrency.
It is therefore recommended that this feature be implemented in a manner which could be used with all of the above concurrency architectures. This proposal suggests a simple class Q
is used to identify execution contexts.
If Q
is only used by the compiler, then implementation could be a very simple string identifier.
struct Q: Equatable {
let name: String
static let main = Q(name: DispatchQueue.main.label)
static let any = Q(name: "com.apple.AnyExecutionContext")
}
For a user defined context the developer would provide a Q
instance in the scope which can be checked by the compiler.
extension Q {
func createDispatchQueue() -> DispatchQueue {
return DispatchQueue(label: name)
}
}
let backgroundQ = Q(name: "com.my.backgroundQueue")
class Test {
static let background = backgroundQ.createDispatchQueue()
on(backgroundQ) var count: Int = 0
on(.main) func doSomething() {
Test.background.asyncAfter(deadline: .now() + 1) {
self.count = self.count + 1
}
}
}
A more sophisticated implementation might tighten integration between the dispatch queue, thread or operation and the Q type.
For instance, the Q type might retain a reference to the queue, thread or other execution context. An async
function could be added to Q
which would call the referenced context appropriately. It may also be preferable to maintain a global set of Q
instances to ensure the same identifier is not defined twice.
Compiler implementation
The compilers job is simply to test the equality of the Q attribute when a variable is accessed or block is executed with the Q attribute of that block, making a special case for Q.any. For the sake of this description we’ll use the verb untagged to mean not having an execution-context-modifier either explicitly defined or inherited from its outer block or superclass.
on(X) {}
may access an on(X) var
or an on(.any) var
on(.any) {}
may only access an on(.any) var
outside the block
untagged {}
may access an on(.any) var
or an untagged var
Likewise
on(X) {}
may call an on(X) {}
or an on(.any) {}
on(.any) {}
may call an on(.any) {}
or an untagged {}
untagged {}
may call an on(.any) var
or an untagged var
An untagged block or function called from an on(X) {}
will inherit the callers execution context. for the purpose of a compiler check.
How this is implemented in the compiler needs further study.
Metadata format and ABI compatibility
This proposal needs inputs for this section, subject to implementation specifics.
Source compatibility
With tthe built in libraries and frameworks tagged with execution-context-modifiers as described above, developers bug free code should be source compatible. As this proposal changes only compiler meta data, and not functionality, there should be no change whatsoever to program execution.
Code with detected access violations will not compile. That is the point of this proposal.
Effect on ABI stability
This proposal requires a change to meta data stored by the compiler for variables and functions, to include the execution-context modifiers. At the time of this draft, I do not know if there is a way of achieving that without impacting ABI stability.
Compiled Frameworks or libraries should include that meta data, so that the compiler would be able to perform the same checks as if the full source was available.
Effect on API resilience
An execution-context modifier adds more information to an API for the compiler to verify concurrent access safety. It does not break or change public API’s.
This proposal should meet resilience requirements, if the metadata may be added to a library in a manner which would be ignored by an older compiler.
Alternatives considered
Without compiler support, static thread safety cannot be achieved.
A simple run-time mechanism can be created for use when debugging an application with DispatchQueue
s, using dispatchPrecondition
and a property wrapper.
class MyClass {
@AccessOnlyOn(queue: .main)
var count: Int = 0
func doSomething() {
DispatchQueue.accessOnlyOn(queue:.main)
// ...
}
}
The above code will crash at runtime if count
is accessed from any queue other than main
or if doSomething()
is called from any queue other than main. Liberal use of this property wrapper and function can help remind the developer in which context a given function or variable is intended to be accessed from. Unfortunately the compiler will not highlight any concurrency errors.
To avoid crashes (but not ensuring thread safe operation) for production code, the property wrapper and access test can be written to only have an effect while running in debug mode:
extension DispatchQueue {
static public func accessOnlyOn(queue dispatchQueue: DispatchQueue) {
#if DEBUG
dispatchPrecondition(condition: .onQueue(dispatchQueue))
#endif
}
static public func dontAccessOn(queue dispatchQueue: DispatchQueue) {
#if DEBUG
dispatchPrecondition(condition: .notOnQueue(dispatchQueue))
#endif
}
}
@propertyWrapper public struct AccessOnlyOn<Value> {
let queue: DispatchQueue
private (set) var value: Value
public var wrappedValue: Value {
get {
DispatchQueue.accessOnlyOn(queue:queue)
return value }
set {
DispatchQueue.accessOnlyOn(queue:queue)
value = newValue }
}
public init(wrappedValue: Value, queue: DispatchQueue) {
self.value = wrappedValue
self.queue = queue
}
}