- Proposal: SE-NNNN
- Author: Christopher Heath: XNUPlay
- Review Manager: TBD
- Status: Active Discussion
- Implementation: Awaiting Implementation
- Decision Notes: TBD
Note: Currently Reviewing the wonderful Async/Await for Swift for how it might minimize the scope of this proposal.
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.
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.
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.
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.
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.
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
/* - */
}
}
Constant
s are already ensured to be thread-safe as per SPL-MemorySafety.
A Constant
is guaranteed to be immutable and therefore able to be read from any thread without concern for unexpected mutation.
Variable
s are always accessedsynchronously
.
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 */
}
Computed Properties
are always accessedsynchronously
.
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 Constant
s, 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
}
}
}
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
.
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.
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 singleasynchronous
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 singlesynchronous
call and inlining access to existing non-modified calls. - If at any point a function or property, accesses an
asynchronous
andsynchronous
call then that function must be run as a singlesynchronous
call.
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
.
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.
This feature is purely additive and should not change ABI.
(Additionally, see Explicit Manglings for Sync/Async below for more on this.)
N/A.
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.
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.
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.
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.
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.
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.