There are APIs that cannot be used with the Swift concurrency model, or should be used with extreme caution. APIs that involve thread-local storage will not behave correctly, and holding onto locks across suspension points can lead to deadlocks and other weird behavior. As a result, developers should be able to specify API that is not available for use from within asynchronous contexts.
I've started thinking about how such an attribute could work, but have come to a few cross-roads.
The high-level idea can be summarized with the snippet below:
@available(*: unavailableFromAsync)
func pthread_shenanigans() {
// Cute concurrency model you have there.
// It would be a shame if something were to happen to it...
}
func asyncFunc() async {
pthread_shenanigans() // error, `pthread_shenanigans()` is not available from an async context
}
My questions arise when we start talking about how this attribute should propagate.
I see three possible roads, though I am open to additional thoughts:
- Implicitly Inherited Unavailability
- Explicit Unavailability
- Thin Unavailability Checking
Each of these has different pros and cons that I discuss below.
Propagation
The attribute needs to propagate through some mechanism in order to be effective. We don't want folks to be able to circumvent this mechanism with a layer of indirection.
func myWrapperFunc() {
pthread_shenanigans()
}
func asyncFunc2() async {
myWrapperFunc() // This should still emit an error
}
Implicitly Inherit Unavailability
Pros:
- Low developer overhead
- Correct
Cons:
- Compile-time cost
Being able to implicitly inherit the unavailability attribute results in a walk of each declaration and expression in the function. This is expensive, but is alleviated by two factors. First, we can memoize the results with the requester. Second, we only need to run this check starting at asynchronous functions and public functions.
Consider the following code:
func a() { }
func b() { a() }
func c() { b() }
func d() {
a()
b()
}
func e() async {
d()
c()
}
The steps the checker will take are as follows:
- e: expand
- d: expand
- a: expand
- b: expand
- a: memoized
- c: expand
- b: memoized
- d: expand
There are no additional async functions in the file to check, so we are done.
There are 5 expansions and 2 memoized results returned.
The developer of a framework only needs to annotate the specific API or types that are unavailable and the compiler propagates the requirement to every function that calls it. With this implementation, myWrapperfunc
implicitly inherits the unavailability from an asynchronous context from the call to pthread_shenanigans
.
Module Boundaries
The availability from asynchronous contexts would need to be computed and stored in the swift module for every public function and public type to ensure that bad API can't leak across module boundaries.
This can, of course, be done by the compiler automatically, so most developer probably won't notice.
C/Objective-C
While types and functions can explicitly be attributed with an attribute like __attribute__((swift_attr("unavailableFromAsync")))
(the exact spelling is up for discussion), ultimately, like any other bridging to C, there is a level of trust that the library isn't doing anything nefarious, or that it has been appropriately annotated if it does.
We can check that annotated types and API's are not being used directly. If they are used directly in a Swift function, then the unavailability is propagated implicitly. If the unavailable API is used within another, unannotated API, it is not possible for Swift to look inside the C/Objective-C function for verification.
Availability Behavior
Pros:
- Correct
Medium:
- Compile-time cost (Cheaper for unavailability checking)
Cons:
- Lots of developer overhead
In this model, developers need to explicitly annotate API with the unavailableFromAsync
in order to use any other unavailable APIs or types. This may be fairly noisy as we go through and annotate things like pthread_mutex_t
, but may not be too bad with sufficient fix-it mashing.
Checking whether a given expression is available in an asynchronous context is trivial with this model. For each function called in a given asynchronous context, check that the unavailableFromAsync
attribute is not present.
Ensuring that each function is properly annotated is where the cost comes in.
The explicit-annotation checker needs to ensure that any usage of unavailable API is only used from an unavailable context and emit a fix-it and diagnostic in cases where that does not hold.
Given the code example from above, the explicit annotation checker follows:
- a: expand
- b: expand
- a: memoized
- c: expand
- b: memoized
- d: expand
- a: memoized
- b: memoized
- e: expand
- d: memoized
- c: memoized
There are 5 function expansions and 6 memoized results returned. The example had no functions that were untouched by the asynchronous function e
, so the number of expanded functions is the same as in the implicit case. If additional synchronous functions were declared in the file, the number of expanded functions would increase for the explicit annotation checking where it wouldn't for the implicit availability checking. That all said, the checking for this already exists in the compiler today for ensuring that declarations have the necessary OS availability attributes (in places where fixAvailabilityForDecl
gets called). I'm still wrapping my head around how these mechanisms work, so my understanding of the cost model is fuzzy.
Module Boundaries
Like with the implicit version, public API would need to be annotated at module boundaries.
This is not a factor because the explicit annotation is a requirement for functions if the are unavailable.
C/Objective-C
Like with the implicit checking, we can ensure that annotated C/Objective-C types are not being used directly from asynchronous contexts, or indirectly through a chain of synchronous calls in Swift through the use of an unavailableFromAsync
swift attribute. The same limitations exist, we are unable to verify that the unavailable API are not used within C/Objective-C functions.
Thin Checking
Pros:
- Compile-time cost (It's cheap!)
- Low developer overhead
Const:
- It's easy to mess up
This is the cheapest option with the least level of protection. The checking only verifies that an asynchronous function isn't directly calling or using an unavailable declaration. This is a simple walk of the expressions and types in the body of the asynchronous function verifying that nothing has the unavailableFromAsync
attribute.
This would allow developers to circumvent the protection with a layer of indirection, either by wrapping the unavailable type in a struct of their own, or the function in another synchronous function.
Future
In the future, I would like to propose a mechanism for a weaker form of checking. It is technically possible to use semaphores and locks in the straight-line code between suspension points safely and a model for that would be beneficial. That is not part of this discussion.