-
Notifications
You must be signed in to change notification settings - Fork 2.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a prospective vision for improving the approachability of data-race safety. #2621
base: main
Are you sure you want to change the base?
Conversation
data-race safety.
|
||
The current execution semantics of async functions also impede programmer’s understanding of the concurrency model because there is a significant difference in what `nonisolated` means on synchronous and asynchronous functions. Nonisolated synchronous functions always run in the isolation domain of the caller, while nonisolated async functions always switch off of the caller's actor (if there is one). It's confusing that `nonisolated` does not have a consistent meaning when applied to functions, and the current behavior conflates the concept of actor isolation with the ability for a function to suspend. | ||
|
||
Changing the default execution semantics of nonisolated async functions to run wherever they are called default better facilitates progressive disclosure of concurrency. This default allows functions to leverage suspension without forcing callers to cross an isolation boundary and imposing data-race safety checks on arguments and results. A lot of basic asynchronous code can be written correctly and efficiently with only the ability to suspend. When an async function needs to always run off of an actor, the API author can still specify that with an annotation on the function. This provides a better default for most cases while still maintaining the ease of specifying that an async function switch off of an actor to run. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
Changing the default execution semantics of nonisolated async functions to run wherever they are called
defaultbetter facilitates progressive disclosure of concurrency.
When an async function needs to always run off of an actor, the API author can still specify that with an annotation on the function.
to what annotation(s) is this referring?
|
||
Changing the default execution semantics of nonisolated async functions to run wherever they are called default better facilitates progressive disclosure of concurrency. This default allows functions to leverage suspension without forcing callers to cross an isolation boundary and imposing data-race safety checks on arguments and results. A lot of basic asynchronous code can be written correctly and efficiently with only the ability to suspend. When an async function needs to always run off of an actor, the API author can still specify that with an annotation on the function. This provides a better default for most cases while still maintaining the ease of specifying that an async function switch off of an actor to run. | ||
|
||
Many programmers have internalized the SE-0338 semantics, and making this change several years after SE-0338 was accepted creates an unforuntate intermediate state where it's difficult to understand the semantics of nonisolated async function without understanding the build settings of the module you're writing code in. We can alleviate some of these consequences with a careful migration design. There are more details about migration in the [automatic migration](#automatic-migration) section of this document, and in the [source compatibility](https://github.com/hborla/swift-evolution/blob/async-function-isolation/proposals/NNNN-async-function-isolation.md#source-compatibility) section of the proposal for this change. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
it's difficult to understand the semantics of nonisolated async function
should perhaps be 'semantics of a...' or '...functions'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While reading through the proposal, I found some typos I don't want to keep secret. You find them as suggestions below that can easily be applied.
|
||
Now, “If nothing in the program uses concurrency, suppress all the concurrency diagnostics” requires what compiler writers call a *whole-program analysis*, and rules like that tend not to work out well on multiple levels. For one, it would require the compiler to look at all of the code in the program all at once; this might be okay for small scripts, but it would scale poorly as the program got more complex. More importantly, it would make the first adoption of concurrency extremely painful: programmers would be hit by a tidal wave of errors in code they haven’t changed. And, of course, many libraries do use concurrency behind the scenes; importing even a single library like that would force concurrency-safety diagnostics everywhere. | ||
|
||
A better approach is to locally state our assumption that that the sequential parts of the program are “single-threaded”. Rather than having to assume the possibility of concurrency, Swift would know that these parts of the code will all run sequentially, which it can use to prove that there aren’t any data races. There can still be concurrent parts of the program elsewhere, but Swift would stop them from accessing the single-threaded bits. Fortunately, this is something that Swift can already model quite well! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A better approach is to locally state our assumption that that the sequential parts of the program are “single-threaded”. Rather than having to assume the possibility of concurrency, Swift would know that these parts of the code will all run sequentially, which it can use to prove that there aren’t any data races. There can still be concurrent parts of the program elsewhere, but Swift would stop them from accessing the single-threaded bits. Fortunately, this is something that Swift can already model quite well! | |
A better approach is to locally state our assumption that the sequential parts of the program are “single-threaded”. Rather than having to assume the possibility of concurrency, Swift would know that these parts of the code will all run sequentially, which it can use to prove that there aren’t any data races. There can still be concurrent parts of the program elsewhere, but Swift would stop them from accessing the single-threaded bits. Fortunately, this is something that Swift can already model quite well! |
|
||
### Single-threaded code and its challenges under Swift 6 | ||
|
||
The easiest and best way to model single-threaded code is with a global actor. Everything on a global actor runs sequentially, and code that isn’t isolated to that actor can’t access the data that is. All programs start running on the global actor `MainActor`, and if everything in the program is isolated to the main actor, there shouldn’t be any concurrency errors. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The easiest and best way to model single-threaded code is with a global actor. Everything on a global actor runs sequentially, and code that isn’t isolated to that actor can’t access the data that is. All programs start running on the global actor `MainActor`, and if everything in the program is isolated to the main actor, there shouldn’t be any concurrency errors. | |
The easiest and best way to model single-threaded code is with a global actor. Everything on a global actor runs sequentially, and code that isn’t isolated to that actor can’t access its data. All programs start running on the global actor `MainActor`, and if everything in the program is isolated to the main actor, there shouldn’t be any concurrency errors. |
} | ||
``` | ||
|
||
Analogous problems arise with all the other kinds of false positive listed above. For example, when using values from generic code, the value’s type usually must conform to one or more protocols. However, actor-isolated types cannot easily conform to protocols that aren’t aware of that isolation: they can declare the conformance, but it’s often impossible to write a useful implementation because the value’s properties will not be available. This is exactly the same kind of conflict as with global variables, where we have generally single-threaded code but a presumption of concurrency from the protocol, except that *this* conflict usually can’t be solved with annotations at all — the only fixes are to change the protocol, avoid all the isolated storage, or dangerously assert (with `assumeIsolated`) that the method is only used dynamically from the right actor. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Analogous problems arise with all the other kinds of false positive listed above. For example, when using values from generic code, the value’s type usually must conform to one or more protocols. However, actor-isolated types cannot easily conform to protocols that aren’t aware of that isolation: they can declare the conformance, but it’s often impossible to write a useful implementation because the value’s properties will not be available. This is exactly the same kind of conflict as with global variables, where we have generally single-threaded code but a presumption of concurrency from the protocol, except that *this* conflict usually can’t be solved with annotations at all — the only fixes are to change the protocol, avoid all the isolated storage, or dangerously assert (with `assumeIsolated`) that the method is only used dynamically from the right actor. | |
Analogous problems arise with all the other kinds of false positives listed above. For example, when using values from generic code, the value’s type usually must conform to one or more protocols. However, actor-isolated types cannot easily conform to protocols that aren’t aware of that isolation: they can declare the conformance, but it’s often impossible to write a useful implementation because the value’s properties will not be available. This is exactly the same kind of conflict as with global variables, where we have generally single-threaded code but a presumption of concurrency from the protocol, except that *this* conflict usually can’t be solved with annotations at all — the only fixes are to change the protocol, avoid all the isolated storage, or dangerously assert (with `assumeIsolated`) that the method is only used dynamically from the right actor. |
} | ||
``` | ||
|
||
And this in turn means that functions that call those functions must also be `@MainActor`, and so on until the `@MainActor` annotation has been laboriously propagated throughout the entire transitive tree of callers. Because main actor isolation is so common, many programmers have resorted to reflexively writing `@MainActor` everywhere, an onerous annotation burden that goes against Swift’s goals of making the simplest things easy. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And this in turn means that functions that call those functions must also be `@MainActor`, and so on until the `@MainActor` annotation has been laboriously propagated throughout the entire transitive tree of callers. Because main actor isolation is so common, many programmers have resorted to reflexively writing `@MainActor` everywhere, an onerous annotation burden that goes against Swift’s goals of making the simplest things easy. | |
And this in turn means that functions that call those functions must also be `@MainActor`, and so on until the `@MainActor` annotation has been laboriously propagated throughout the entire transitive tree of callers. Because main actor isolation is so common, many programmers have resorted to reflexively writing `@MainActor` everywhere, an onerous annotation burden that goes against Swift’s goals of making the obvious things the default. |
|
||
### Allowing modules to default to being “single-threaded” | ||
|
||
We believe that the right solution to these problems is to allow code to opt in to being “single-threaded” by default, on a module-by-module basis. This would change the default isolation rule for unannotated code in the module: rather than being non-isolated, and therefore having to deal with the presumption of concurrency, the code would instead be implicitly isolated to `@MainActor`. Code imported from other modules would be unaffected by the current module’s choice of default. When the programmer really wants concurrency, they can request it explicitly by marking a function or type as `nonisolated` (which can used on any declaration as of [SE-0449](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0449-nonisolated-for-global-actor-cutoff.md)), or they can define it in a module that doesn’t default to main-actor isolation. This doesn’t fundamentally change anything about Swift’s isolation model; it just flips the default, effectively creating a model in which code is single-threaded except where it explicitly requests concurrency. Modules that don’t want this could of course continue to use the current rules. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We believe that the right solution to these problems is to allow code to opt in to being “single-threaded” by default, on a module-by-module basis. This would change the default isolation rule for unannotated code in the module: rather than being non-isolated, and therefore having to deal with the presumption of concurrency, the code would instead be implicitly isolated to `@MainActor`. Code imported from other modules would be unaffected by the current module’s choice of default. When the programmer really wants concurrency, they can request it explicitly by marking a function or type as `nonisolated` (which can used on any declaration as of [SE-0449](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0449-nonisolated-for-global-actor-cutoff.md)), or they can define it in a module that doesn’t default to main-actor isolation. This doesn’t fundamentally change anything about Swift’s isolation model; it just flips the default, effectively creating a model in which code is single-threaded except where it explicitly requests concurrency. Modules that don’t want this could of course continue to use the current rules. | |
We believe that the right solution to these problems is to allow code to opt in to being “single-threaded” by default, on a module-by-module basis. This would change the default isolation rule for unannotated code in the module: rather than being non-isolated, and therefore having to deal with the presumption of concurrency, the code would instead be implicitly isolated to `@MainActor`. Code imported from other modules would be unaffected by the current module’s choice of default. When the programmer really wants concurrency, they can request it explicitly by marking a function or type as `nonisolated` (which can be used on any declaration as of [SE-0449](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0449-nonisolated-for-global-actor-cutoff.md)), or they can define it in a module that doesn’t default to main-actor isolation. This doesn’t fundamentally change anything about Swift’s isolation model; it just flips the default, effectively creating a model in which code is single-threaded except where it explicitly requests concurrency. Modules that don’t want this could of course continue to use the current rules. |
|
||
## Isolated conformances | ||
|
||
When checking a conformance to a protocol, Swift 6 often requires implementations to be nonisolated when the requirement is, including when the requirement is synchronous or the parameters are not Sendable. This makes it difficult to implement nonisolated protocols with any kind of isolated type: global-actor-isolated types, certainly, but also actors themselves. This restriction is very important when writing concurrent code, but it's a common source of false positives in single-threaded programs. Even worse, there's often no good solution to the problem: a correct implementation of the protocol for an isolated types usually requires access to the isolated data, and the only way to get that is to assert that the calling context is actually isolated, which completely subverts the static isolation safety that Swift 6 tries to provide. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When checking a conformance to a protocol, Swift 6 often requires implementations to be nonisolated when the requirement is, including when the requirement is synchronous or the parameters are not Sendable. This makes it difficult to implement nonisolated protocols with any kind of isolated type: global-actor-isolated types, certainly, but also actors themselves. This restriction is very important when writing concurrent code, but it's a common source of false positives in single-threaded programs. Even worse, there's often no good solution to the problem: a correct implementation of the protocol for an isolated types usually requires access to the isolated data, and the only way to get that is to assert that the calling context is actually isolated, which completely subverts the static isolation safety that Swift 6 tries to provide. | |
When checking a conformance to a protocol, Swift 6 often requires implementations to be nonisolated when the requirement is, including when the requirement is synchronous or the parameters are not Sendable. This makes it difficult to implement nonisolated protocols with any kind of isolated type: global-actor-isolated types, certainly, but also actors themselves. This restriction is very important when writing concurrent code, but it's a common source of false positives in single-threaded programs. Even worse, there's often no good solution to the problem: a correct implementation of the protocol for aisolated types usually requires access to the isolated data, and the only way to get that is to assert that the calling context is actually isolated, which completely subverts the static isolation safety that Swift 6 tries to provide. |
|
||
The current execution semantics of async functions also impede programmer’s understanding of the concurrency model because there is a significant difference in what `nonisolated` means on synchronous and asynchronous functions. Nonisolated synchronous functions always run in the isolation domain of the caller, while nonisolated async functions always switch off of the caller's actor (if there is one). It's confusing that `nonisolated` does not have a consistent meaning when applied to functions, and the current behavior conflates the concept of actor isolation with the ability for a function to suspend. | ||
|
||
Changing the default execution semantics of nonisolated async functions to run wherever they are called default better facilitates progressive disclosure of concurrency. This default allows functions to leverage suspension without forcing callers to cross an isolation boundary and imposing data-race safety checks on arguments and results. A lot of basic asynchronous code can be written correctly and efficiently with only the ability to suspend. When an async function needs to always run off of an actor, the API author can still specify that with an annotation on the function. This provides a better default for most cases while still maintaining the ease of specifying that an async function switch off of an actor to run. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changing the default execution semantics of nonisolated async functions to run wherever they are called default better facilitates progressive disclosure of concurrency. This default allows functions to leverage suspension without forcing callers to cross an isolation boundary and imposing data-race safety checks on arguments and results. A lot of basic asynchronous code can be written correctly and efficiently with only the ability to suspend. When an async function needs to always run off of an actor, the API author can still specify that with an annotation on the function. This provides a better default for most cases while still maintaining the ease of specifying that an async function switch off of an actor to run. | |
Changing the default execution semantics of nonisolated async functions to run wherever they are called better facilitates progressive disclosure of concurrency. This default allows functions to leverage suspension without forcing callers to cross an isolation boundary and imposing data-race safety checks on arguments and results. A lot of basic asynchronous code can be written correctly and efficiently with only the ability to suspend. When an async function needs to always run off of an actor, the API author can still specify that with an annotation on the function. This provides a better default for most cases while still maintaining the ease of specifying that an async function switches off of an actor to run. |
|
||
Changing the default execution semantics of nonisolated async functions to run wherever they are called default better facilitates progressive disclosure of concurrency. This default allows functions to leverage suspension without forcing callers to cross an isolation boundary and imposing data-race safety checks on arguments and results. A lot of basic asynchronous code can be written correctly and efficiently with only the ability to suspend. When an async function needs to always run off of an actor, the API author can still specify that with an annotation on the function. This provides a better default for most cases while still maintaining the ease of specifying that an async function switch off of an actor to run. | ||
|
||
Many programmers have internalized the SE-0338 semantics, and making this change several years after SE-0338 was accepted creates an unforuntate intermediate state where it's difficult to understand the semantics of nonisolated async function without understanding the build settings of the module you're writing code in. We can alleviate some of these consequences with a careful migration design. There are more details about migration in the [automatic migration](#automatic-migration) section of this document, and in the [source compatibility](https://github.com/hborla/swift-evolution/blob/async-function-isolation/proposals/NNNN-async-function-isolation.md#source-compatibility) section of the proposal for this change. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Many programmers have internalized the SE-0338 semantics, and making this change several years after SE-0338 was accepted creates an unforuntate intermediate state where it's difficult to understand the semantics of nonisolated async function without understanding the build settings of the module you're writing code in. We can alleviate some of these consequences with a careful migration design. There are more details about migration in the [automatic migration](#automatic-migration) section of this document, and in the [source compatibility](https://github.com/hborla/swift-evolution/blob/async-function-isolation/proposals/NNNN-async-function-isolation.md#source-compatibility) section of the proposal for this change. | |
Many programmers have internalized the SE-0338 semantics, and making this change several years after SE-0338 was accepted creates an unfortunate intermediate state where it's difficult to understand the semantics of nonisolated async functions without understanding the build settings of the module you're writing code in. We can alleviate some of these consequences with a careful migration design. There are more details about migration in the [automatic migration](#automatic-migration) section of this document, and in the [source compatibility](https://github.com/hborla/swift-evolution/blob/async-function-isolation/proposals/NNNN-async-function-isolation.md#source-compatibility) section of the proposal for this change. |
Pitched on the forums at https://forums.swift.org/t/prospective-vision-improving-the-approachability-of-data-race-safety/76183. Please leave design feedback there, and editorial feedback here!