Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

Synthesizing Concurrency

Note: Currently Reviewing the wonderful Async/Await for Swift for how it might minimize the scope of this proposal.

Introduction

Developers have to write large amounts of boilerplate code to support concurrency in complex types. This proposal offers a way for the compiler to automatically synthesize conformance to a high-level Concurrent protocol to reduce concurrent boilerplate code, in a set of scenarios where generating the correct implementation is known to be possible.

Specifically:

  • It aims to provide a high-level Swift protocol that offers opt-in concurrency support for any conformable type
  • It aims to provide a well-defined set of thread-safe, concurrent implementations for types, their properties, and methods.
  • It aims to provide a language/library compatible implementation with deadlock prevention.

Motivation

Building robust types in Swift can involve writing significant boilerplate code to support concurrency. By eliminating the complexity for the users, we make Concurrent types much more appealing to users and allow them to use their own types in optimized concurrent and parallel environments that require thread safety with no added effort on their part (beyond declaring the conformance).

Concurrency is typically not pervasive across many types, and for each one users must implement the concurrent code such that it performs some form of synchronization to prevent unexpected behavior.

Note: Due to it's current status in Swift and use in the Runtime, examples are written in Grand Central Dispatch

// Concurrent Protocol - Dispatch Example
protocol Concurrent {
    // Synthesized Property
    var internalQueue: DispatchQueue { get }
}

What's worse is that if any functions or properties are added, removed, or changed, they must each have their own concurrency code and since it must be manually written, it's possible to get it wrong, either by omission or typographical error (async vs. sync).

Likewise, it becomes necessary when one wishes to modify an existing type that implements concurrency to do so without introducing bottlenecks or different forms of synchronization for some functions and not others, this leads to illegible and inefficient code that may defeat the purpose of implementing concurrency.

Crafting high-performance, readable concurrency code can be difficult and inconvenient to write.

Swift already derives conformance to a number of protocols, automatically synthesizing their inner-workings when possible. Since there is precedent for synthesized conformances in Swift, we propose extending it to concurrency in predictable circumstances.

Proposed solution

In general, we propose that a type synthesize conformance to Concurrent as long as the compiler has reasonable insight into the type. We describe the specific conditions under which these conformances are synthesized below, followed by the details of how the conformance requirements are implemented.

Requesting synthesis is opt-in

Users must opt-in to automatic synthesis by declaring their type as Concurrent without implementing any of its requirements. This conformance must be part of the original type declaration and not on an extension (see Synthesis in extensions below for more on this).

Any type that declares such conformance and satisfies the conditions below will cause the compiler to synthesize an implementation of an internalQueue and async or sync for all properties and methods on that type.

Making the synthesis opt-in—as opposed to automatic derivation without an explicit declaration—provides a number of benefits:

  • The syntax for opting in is natural; there is no clear analogue in Swift today for having a type opt out of a feature.

  • It requires users to make a conscious decision about the public API surfaced by their types. Types cannot accidentally "fall into" conformances that the user does not wish them to; a type that does not initially support Concurrent can be made to at a later date, but the reverse is a potentially breaking change.

  • The conformances supported by a type can be clearly seen by examining its source code; nothing is hidden from the user.

  • We reduce the work done by the compiler and the amount of code generated by not synthesizing conformances that are not desired and not used.

  • As will be discussed later, explicit conformance significantly simplifies the implementation for recursive types.

Overriding synthesized conformances

Any user-provided implementations of an internalQueue and use of async or sync will override the default implementations that would be provided by the compiler.

Defining conditions where synthesis is allowed

For example take the struct below, which contains all-kinds of properties; variable, constant, and computed.

struct Person {
    var name: String // Variable Property
    let birthday: Date // Constant Property
    var age: Int { // Computed Property
        /* - */
    }
}

Synthesized Requirements

Constant Properties

A Constant is guaranteed to be immutable and therefore able to be read from any thread without concern for unexpected mutation.

Synthesized requirements for Variable Properties

  • Variables are always accessed synchronously.

A Variable is mutable and therefore each thread must schedule writes and reads separately out of concern for possible mutation.

The compiler sees this Variable as storage for a value.

// Compiler View - of a Variable Property
struct Person {
    // Variable Property
    var name: String {
        get {
            return underlying_Name_String_Value_Storage
        }
        set (newValue) {
            underlying_Name_String_Value_Storage = newValue
        }
    }
    
    /* Constant Property */
    /* Computed Property */
}

Here the compiler synthesizes synchronous access (read or write) to any Variable on a Concurrent type.

// Compiler View - of a Variable Property on a Concurrent type
struct Person: Concurrent {
    // Variable Property
    var name: String {
        get {
            internalQueue.sync { // Wait to ensure all mutation has finished
                return underlying_Name_String_Value_Storage
            }
        }
        set (newValue) {
            internalQueue.sync { // Schedule this mutation to happen, in order
                underlying_Name_String_Value_Storage = newValue
            }
        }
    }

    /* Constant Property */
    /* Computed Property */
}

Synthesized requirements for Computed Properties

  • Computed Properties are always accessed synchronously.

A Computed Property is essentially a function that gets called to create a value from other values. These other values can be mutable and therefore each thread must schedule writes and reads separately out of concern for possible mutation. (Note: If a computed property only accesses Constants, it should probably be a one-time set Constant; Swift could use a few proposals in this area.)

// Compiler View - of a Variable Property
struct Person {
    /* Variable Property */
    /* Constant Property */

    // Computed Property
    var age: Int {
        // Compute age from birthday; return
    }
}

Here the compiler synthesizes synchronous access (read or write) to any Computed Property on a Concurrent type.

// Compiler View - of a Variable Property on a Concurrent type
struct Person: Concurrent {
    /* Variable Property */
    /* Constant Property */

    // Computed Property
    var age: Int {
        internalQueue.sync { // Wait to ensure all mutation has finished
            // Compute age from birthday; return
        }
    }
}

Considerations for recursive types and abstraction

By making the synthesized conformances opt-in, recursive types have their requirements fall into place with no extra effort. In any cycle belonging to a recursive type, every type in that cycle must declare its conformance explicitly. If a type does so but cannot have its conformance synthesized because it does not satisfy the conditions above, then it is simply an error for that type and not something that must be detected earlier by the compiler in order to reason about all the other types involved in the cycle. (On the other hand, if conformance were implicit, the compiler would have to fully traverse the entire cycle to determine eligibility, which would make implementation much more complex).

With respect to abstraction, the idea that a synchronous function or property can access another synchronous function or property, introduces a problem: Deadlocking.

The Deadlock Problem

Or just Deadlocking, is a problem where a complex program cannot continue execution because one or more threads is waiting on a resource to become available or for another task to complete.

Anytime a synchronous function or property accesses another synchronous function or property; this is defined as a Deadlock, because the first cannot finish without the second being run and the second cannot execute without the first being finished.

Solving the Deadlock Problem

In complex functions where any number of synchronous and asynchronous calls can happen inside a larger scope it is required that the compiler know how to handle compilation of such functions, that may access many concurrent objects through a multitude of calls. Much like Automatic Reference Counting increments and decrements a counter to determine whether an object should be marked for deallocation, we suggest that during compilation a call or set of calls is handled by evaluating their concurrent requirements.

I.e. When a call nests as such:

// Compiler View - of a Complex Function on a Concurrent type

func heavyLift() {
    syncFunction() // 1 Sync
    
    async() // 1 Async
    
    syncSomeFunction() // 2 Sync
    syncSomeOtherFunction() // 3 Sync
    
    asyncSomeFunction() // 2 Async
    asyncSomeOtherFunction() // 3 Async
}

The compiler should implement a non-modified function, exactly as it would today, and wrap each usage in-scope with an asynchronous or synchronous requirement.

Specifically:

  • If a higher-level function accesses only asynchronous functions or properties internally, that function can be executed in-order as a single asynchronous call and inlining access to all non-modified calls.
  • The same is true of synchronous functions or properties. They can be executed in-order as a single synchronous call and inlining access to existing non-modified calls.
  • If at any point a function or property, accesses an asynchronous and synchronous call then that function must be run as a single synchronous call.

Implementation details

Deadlock Prevention is then inherent by synthesis. The following example explains this through a chunk of modified, disassembled Swift code.

// Disassembly View - of an Integer Assignment without Thread-Safety
int __T07Project14ConcurrentTypeC21functionWithoutSafetyySi5value_tF(int arg0) { // Standard
    _swift_beginAccess(__T07Project6objectSiv, &var_30, 0x1, 0x0);
    *__T07Project6objectSiv = arg0;
    rax = _swift_endAccess(&var_30);
    return rax;
}

// Disassembly View - of an Integer Assignment with Asynchronous Access
int __T07Project14ConcurrentTypeC17functionWithAsyncySi5value_tF(int arg0) { // AsyncCall
    _swift_beginAccess(r13 + 0x10, &var_30, 0x0, 0x0);
    _swift_endAccess(&var_30, &var_30, 0x0, 0x0);
    rax = _swift_rt_swift_allocObject();
    
    // Call the Standard Function
    __T07Project14ConcurrentTypeC21functionWithoutSafetyySi5value_tF(int arg0)
    
    var_60 = __NSConcreteStackBlock;
    var_98 = _Block_copy(&var_60);
    var_A0 = __T0So13DispatchQueueC0A0E5asyncySo0A5GroupCSg5group_AC0A3QoSV3qosAC0A13WorkItemFlagsV5flagsyyXB7executetFfA_(&var_60, 0x18, __NSConcreteStackBlock);
    var_A1 = __T0So13DispatchQueueC0A0E5asyncySo0A5GroupCSg5group_AC0A3QoSV3qosAC0A13WorkItemFlagsV5flagsyyXB7executetFfA0_();
    __T0So13DispatchQueueC0A0E5asyncySo0A5GroupCSg5group_AC0A3QoSV3qosAC0A13WorkItemFlagsV5flagsyyXB7executetF(var_A0, var_A1 & 0xff, __NSConcreteStackBlock, __T0So13DispatchQueueC0A0E5asyncySo0A5GroupCSg5group_AC0A3QoSV3qosAC0A13WorkItemFlagsV5flagsyyXB7executetFfA1_(&var_60, 0x18), var_98);
    rax = _swift_rt_swift_release(rax, var_A1 & 0xff);
    return rax;
}

// Disassembly View - of an Integer Assignment with Synchronous Access
int __T07Project14ConcurrentTypeC16functionWithSyncySi5value_tF(int arg0) { // SyncCall
    _swift_beginAccess(r13 + 0x10, &var_28, 0x0, 0x0);
    _swift_endAccess(&var_28, &var_28, 0x0, &var_28);
    rax = _swift_rt_swift_allocObject();
    
    // Call the Standard Function
    __T07Project14ConcurrentTypeC21functionWithoutSafetyySi5value_tF(int arg0)
    
    var_58 = __NSConcreteStackBlock;
    var_90 = _Block_copy(&var_58);
    _swift_rt_swift_release(rax, 0x18);
    dispatch_sync(*(r13 + 0x10), var_90);
    rax = _Block_release(var_90);
    return rax;
}

Here are 3 functions, one which assigns a value to an integer without any safety, like a non-concurrent type. As well as two more which call that function using asynchronous and synchronous access respectively.

The compiler determines which access should be used in a given scope, and places that scope inside a synchronous or asynchronous call.

Take a function which assigns this integer twice asynchronously:

    // Standard Call
    +---------------------------------------+
    | TwoAsyncs                             |
    |    +-------------+   +-------------+  |
    |    |  AsyncCall  |   |  AsyncCall  |  |
    |    +-------------+   +-------------+  |
    +---------------------------------------+

One might reason that this function would fire-and-forget those calls, but instead the compiler is rectifying them as a single async.

    // Single Asynchronous Call
    +---------------------------------------+
    | TwoAsyncs (Actually)                  |
    |    +-------------+   +-------------+  |
    |    |  Standard   |   |   Standard  |  |
    |    +-------------+   +-------------+  |
    +---------------------------------------+

This behavior is the same for synchronous-only functions; however, instead of rehashing lets look at the more interesting complex case. We start with this:

    // Standard Call
    +---------------------------------------+
    | AnyAsync/SyncCombination              |
    |    +-------------+   +-------------+  |
    |    |  AsyncCall  |   |   SyncCall  |  |
    |    +-------------+   +-------------+  |
    +---------------------------------------+

But in actuality the compiler has composed a single synchronous call, since there is a sync call at any point in the function.

    // Single Synchronous Call
    +---------------------------------------+
    | AnyAsync/SyncCombination (Actually)   |
    |    +-------------+   +-------------+  |
    |    |  Standard   |   |   Standard  |  |
    |    +-------------+   +-------------+  |
    +---------------------------------------+

Let's do one more for added clarity.

    +-------------------------------------------------------------------------------------+
    | Combination                                                                         |
    | +---------------------------------------+ +---------------------------------------+ |
    | | TwoAsyncs                             | | AnyAsync/SyncCombination              | |
    | |    +-------------+   +-------------+  | |    +-------------+   +-------------+  | |
    | |    |  AsyncCall  |   |  AsyncCall  |  | |    |  AsyncCall  |   |   SyncCall  |  | |
    | |    +-------------+   +-------------+  | |    +-------------+   +-------------+  | |
    | +---------------------------------------+ +---------------------------------------+ |
    +-------------------------------------------------------------------------------------+

Becomes:

    +-------------------------------------------------------------------------------------+
    | Combination //Sync                                                                  |
    | +---------------------------------------+ +---------------------------------------+ |
    | | TwoAsyncs //Async                     | | AnyAsync/SyncCombination //Sync       | |
    | |    +-------------+   +-------------+  | |    +-------------+   +-------------+  | |
    | |    |  Standard   |   |   Standard  |  | |    |  Standard   |   |   Standard  |  | |
    | |    +-------------+   +-------------+  | |    +-------------+   +-------------+  | |
    | +---------------------------------------+ +---------------------------------------+ |
    +-------------------------------------------------------------------------------------+
We've already made great decisions about thread-safety by implementing SE-0035 for Value Types

SE-0035 Limiting Inout Capture actually provides us with a proof-of-concept as to why Value Types should not be asynchronously mutated inside a closure.

Source compatibility

By making the conformance opt-in, this is a purely additive change that should not affect existing code and should be easily applicable to stdlib types.

Some current types using Grand Central Dispatch should be audited for recursive-implementation, if a user wishes to replace their own implementation with this synthesized one.

Effect on ABI stability

This feature is purely additive and should not change ABI.

(Additionally, see Explicit Manglings for Sync/Async below for more on this.)

Effect on API resilience

N/A.

Alternatives considered

In order to realistically scope this proposal, we considered but ultimately deferred the following items, some of which could be proposed additively in the future.

Synthesis in extensions

Requirements will be synthesized only for protocol conformances that are part of the type declaration itself; conformances added in extensions will not be synthesized.

However, to align with Codable in the context of SR-4920, we will also currently forbid synthesized requirements in extensions in the same file; this specific case can be revisited later for all derived conformances.

Explicit Manglings

Because accesses are compiled and their async or sync wrappers are deterministic from use case, it may be useful to create specific Manglings; this is optional.

Embedding or Building Dispatch

Dispatch already provides us with a very powerful and exacting standard for concurrency in Swift, it would be even more useful if embedded directly in the runtime with replacements like that currently in the Runtime.

I feel as if most of the reason this would be frowned upon is a Swift desire for style, code cleanliness and some hope of a 'better' (whatever that means to you) solution.

Yet, we could add the existing library to the Runtime or even rewrite Grand Central Dispatch as a Swift project and embed it in the Standard Library.

Ideally, this would be deferred to a separate Swift Evolution Proposal.

Keyword Overrides

It is worth mentioning a Keyword could be used for overriding a function and defining explicit behavior as sync or async. However, this opens the door to misuse and incorrect code that can only be debugged at runtime with TSAN. And while we love TSAN:

	TSAN how I love thee. Let me non atomically count the ways…
								- Philippe Hausler

This is not a good idea.

Acknowledgments

Thanks to everyone in the Swift Community working to make it an even more vibrant place. And especially to those who worked on SE-0166 and SE-0185. Whom might notice large parts of a shared ideal, that made this proposal much easier to write.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment