Static Thread Safety

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 DispatchQueues, 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
    }
}
23 Likes

I really like this idea. The ability to annotate a function that explicitly states that "This function is only called on this thread" alone would be a huge improvement in DX.

I often find myself writing

/**
  Lorem ipsum...
  NOTE: must call this on the main thread
*/
func foo() {
  guard isMainThread() else { 
    throw Error.mustCallOnMainThread
  }
...

The ability to add the constraint to the function signature would not only mean that a check like this wouldn't be needed, it would also make the constraint clear and discoverable at the API level instead of being hidden inside a doc comment. And the compile-time checks would be hugely beneficial in preventing issues and saving time.

Would it be possible to make language changes to enable a property-wrapper solution but with compile-time checks? I feel that may be a more popular solution here.

Hypothetically, this might also have performance benefits, because if the compiler can prove that an instance is only accessed on a single thread, then all ARC traffic could become non-atomic.

2 Likes

I've given a property wrapper implementation at the bottom of the pitch which allows variables to be checked for access on a given queue, however that doesn't deal with the frustration of the hidden assumptions in many frameworks for what queue a callback is called on. Being able to make that clear in the function declaration would be a tremendous improvement to Swift.

1 Like

This is a +1 from me

Yes! I really love this idea.

It will be interesting to see what the balance is in terms of explicitly or implicitly marking function’s queue precondition. I really like seeing that contract made explicit. Similar to try/catch. On the otherhand, you can imagine many UIKit apps primarily being written against the main thread and having on(.main) on basically every definition. But I think you’re proposal provides a good balance here by allowing for functions to inheirt their type’s queue. :+1:

A subclass will inherit the execution-context-modifier of its superclass.

I wanted to clary if this would be the case? Assuming UIViewController is annotated as on(.main)

// implicit `on(.main)`
class ViewController: UIViewController {
  // implicit `on(.main)`
  func bar() {}
}

Another case worth considering is extensions.

// in file A or module A
on(.main)
struct Foo {}

// in file B or module B
extension Foo {
  // I think as proposed, this would be an implicit `on(.main)` ?
  func bar() {}
}

If we wanted to keep things a tad more explicit, the execution-context-modifier could be something that only applies to the lexical scope. I think this might align a little closer with access control modifiers.

Thanks for putting time into the initial proposal!

This is a great proposal and something I would start using immediately. That said, I am not sure about “on(.any)” being clear; perhaps “thread(.main)” would be more readable? Also, what about the protocols? For instance, would it be possible to annotate a delegate protocol to guarantee (and document) that all calls happen on the main thread? If so, that would be fantastic.

2 Likes

Yes, the idea is that subclasses inherit the queue modifier, so most of the majority of existing UI code will not need any modification, but would be checked for thread safety by the compiler.

There are three essential parts which would make this possible without major changes to most third party code:

  1. Defining built in frameworks variable and function call queue assumptions
  2. Defining built in frameworks callback queue assumptions
  3. Ensuring point 2 works correctly with the async() call of DispatchQueue

I mention extensions in the proposal, in the Inherited conformance section, and am thinking that an extension without a queue modifier would apply the specification of the original class/struct, but one could add a different modifier on the extension, so a class with functions used on a separate queue could be separated out into an extension for cleaner code.

1 Like

"on(.any)" would mean the developer is marking the code or class or variable as thread safe, it could be called from any queue.

"on(.main)" means it would have to be accessed or executed on the main queue.

I thought about using 'queue' or 'thread' as the keyword, but I suggest 'on' to be independent of the concurrency implementation. Some people use GCD DispatchQueues, others use Threads, there may be other mechanisms in Swifts future. Other alternatives might be "executionContext(.main)", "restrictTo(.main)" or "contextLabel(.main)", but I liked the brevity and simplicity of "on(.main)"

I mention protocols briefly in the Inherited conformance section, yes I propose the execution-context-modifier could be added to a protocol or variables and functions in the protocol.

1 Like

I wrote Advanced type system for preventing data races. a while ago, you may find some inspiration there.

To benefit from that I have to slap on(.main) or on(.any) on every single function in my program? That sounds like a giant chore. It would be much more useful if on(.any) was assumed for the untagged functions, but source compatibility. :/ I think running with thread sanitizer gives me more bang for my buck than this tagging.

That would be a breaking change, for example

import AppKit
func f(_ x: @escaping () -> Void) {
    x()
}
func g() {
    DispatchQueue.main.async {
        f {
            NSView().layout()
        }
    }
}

if x is on(.any), then you cannot call layout from inside of it, even though currently this code is perfectly okay

Is this a pun on the word Queue?

1 Like

According to the pitch, DispatchQueue.main.async will be tagged, so calling NSView().layout() from within that block will be OK. In fact, the point of the pitch is to ensure that the developer does so.

But I'm not calling layout directly from that block. I'm calling it from inside x which would be tagged differently

If x is currently called from within a legal context, this pitch should not change anything. If x can truly be invoked from anywhere, and NSView().layout() is called from within that same context, the program is already buggy. This pitch aims to help developers find those bugs.

Okay, let's backtrack a bit. Is there a difference between these three functions?

func a() { NSView().layout() }

on(.main) 
func b() { NSView().layout() }

on(.any)
func c() { NSView().layout() }

I was under the impression that the compiler would flag c as an error at compile time, even if it's never called, am I right?

1 Like

That's my understanding as well, but I am not the author of the pitch.

1 Like

Okay. So compiler should prevent calls to main-thread-apis inside functions tagged on(.any)

x is an @escaping function parameter
because of that, compiler will assume on(.any)
x calls NSView().layout()
layout is main-thread-api
compiler would prevent that if this pitch is implemented
my code snippet with functions f and g is 100% fine today

If all the sentences above are true, then this will be a breaking change

My understanding is that this is supposed to be a breaking change. You have code the compiler cannot guarantee is called from within a legal context, so it's going to complain.

I think the best solution would be to remove the exception for the @escaping function parameters, and make them untagged when they are not tagged

I think the key phrase there is bug free code.

Terms of Service

Privacy Policy

Cookie Policy