Skip to content

[Executors] Move assert/assume APIs onto actors; assumeIsolated() #64812

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

Merged
merged 2 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
410 changes: 208 additions & 202 deletions stdlib/public/Concurrency/ExecutorAssertions.swift

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions stdlib/public/Concurrency/MainActor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,46 @@ extension MainActor {
return try await body()
}
}

@available(SwiftStdlib 5.9, *)
extension MainActor {
/// A safe way to synchronously assume that the current execution context belongs to the MainActor.
///
/// This API should only be used as last resort, when it is not possible to express the current
/// execution context definitely belongs to the main actor in other ways. E.g. one may need to use
/// this in a delegate style API, where a synchronous method is guaranteed to be called by the
/// main actor, however it is not possible to annotate this legacy API with `@MainActor`.
///
/// - Warning: If the current executor is *not* the MainActor's serial executor, this function will crash.
///
/// Note that this check is performed against the MainActor's serial executor, meaning that
/// if another actor uses the same serial executor--by using ``MainActor/sharedUnownedExecutor``
/// as its own ``Actor/unownedExecutor``--this check will succeed, as from a concurrency safety
/// perspective, the serial executor guarantees mutual exclusion of those two actors.
@available(SwiftStdlib 5.9, *)
@_unavailableFromAsync(message: "await the call to the @MainActor closure directly")
public static func assumeIsolated<T>(
_ operation: @MainActor () throws -> T,
file: StaticString = #fileID, line: UInt = #line
) rethrows -> T {

typealias YesActor = @MainActor () throws -> T
typealias NoActor = () throws -> T

/// This is guaranteed to be fatal if the check fails,
/// as this is our "safe" version of this API.
let executor: Builtin.Executor = Self.shared.unownedExecutor.executor
guard _taskIsCurrentExecutor(executor) else {
// TODO: offer information which executor we actually got
fatalError("Incorrect actor executor assumption; Expected same executor as \(self).", file: file, line: line)
}

// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) {
(_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn()
}
}
}
#endif
243 changes: 133 additions & 110 deletions stdlib/public/Distributed/DistributedAssertions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,98 +16,97 @@ import _Concurrency
// ==== -----------------------------------------------------------------------
// MARK: Precondition APIs

/// Unconditionally if the current task is executing on the serial executor of the passed in `actor`,
/// and if not crash the program offering information about the executor mismatch.
///
/// This function's effect varies depending on the build flag used:
///
/// * In playgrounds and `-Onone` builds (the default for Xcode's Debug
/// configuration), stops program execution in a debuggable state after
/// printing `message`.
///
/// * In `-O` builds (the default for Xcode's Release configuration), stops
/// program execution.
///
/// * In `-Ounchecked` builds, the optimizer may assume that this function is
/// never called. Failure to satisfy that assumption is a serious
/// programming error.
///
/// - Parameter actor: the actor whose serial executor we expect to be the current executor
@available(SwiftStdlib 5.9, *)
public func preconditionOnExecutor(
of actor: some DistributedActor,
_ message: @autoclosure () -> String = String(),
file: StaticString = #fileID, line: UInt = #line
) {
guard _isDebugAssertConfiguration() || _isReleaseAssertConfiguration() else {
return
}

guard __isLocalActor(actor) else {
return
}
extension DistributedActor {
/// Unconditionally if the current task is executing on the serial executor of the passed in `actor`,
/// and if not crash the program offering information about the executor mismatch.
///
/// This function's effect varies depending on the build flag used:
///
/// * In playgrounds and `-Onone` builds (the default for Xcode's Debug
/// configuration), stops program execution in a debuggable state after
/// printing `message`.
///
/// * In `-O` builds (the default for Xcode's Release configuration), stops
/// program execution.
///
/// * In `-Ounchecked` builds, the optimizer may assume that this function is
/// never called. Failure to satisfy that assumption is a serious
/// programming error.
@available(SwiftStdlib 5.9, *)
public nonisolated func preconditionIsolated(
_ message: @autoclosure () -> String = String(),
file: StaticString = #fileID, line: UInt = #line
) {
guard _isDebugAssertConfiguration() || _isReleaseAssertConfiguration() else {
return
}

guard __isLocalActor(self) else {
return
}

guard let unownedExecutor = self.localUnownedExecutor else {
preconditionFailure(
"Incorrect actor executor assumption; Distributed actor \(self) is 'local' but has no executor!",
file: file, line: line)
}

let expectationCheck = _taskIsCurrentExecutor(unownedExecutor._executor)

guard let unownedExecutor = actor.localUnownedExecutor else {
preconditionFailure(
"Incorrect actor executor assumption; Distributed actor \(actor) is 'local' but has no executor!",
// TODO: offer information which executor we actually got
precondition(expectationCheck,
// TODO: figure out a way to get the typed repr out of the unowned executor
"Incorrect actor executor assumption; Expected '\(unownedExecutor)' executor. \(message())",
file: file, line: line)
}

let expectationCheck = _taskIsCurrentExecutor(unownedExecutor._executor)

// TODO: offer information which executor we actually got
precondition(expectationCheck,
// TODO: figure out a way to get the typed repr out of the unowned executor
"Incorrect actor executor assumption; Expected '\(unownedExecutor)' executor. \(message())",
file: file, line: line)
}

// ==== -----------------------------------------------------------------------
// MARK: Assert APIs

/// Performs an executor check in debug builds.
///
/// * In playgrounds and `-Onone` builds (the default for Xcode's Debug
/// configuration): If `condition` evaluates to `false`, stop program
/// execution in a debuggable state after printing `message`.
///
/// * In `-O` builds (the default for Xcode's Release configuration),
/// `condition` is not evaluated, and there are no effects.
///
/// * In `-Ounchecked` builds, `condition` is not evaluated, but the optimizer
/// may assume that it *always* evaluates to `true`. Failure to satisfy that
/// assumption is a serious programming error.
///
///
/// - Parameter actor: the actor whose serial executor we expect to be the current executor
@available(SwiftStdlib 5.9, *)
@_transparent
public func assertOnExecutor(
of actor: some DistributedActor,
_ message: @autoclosure () -> String = String(),
file: StaticString = #fileID, line: UInt = #line
) {
guard _isDebugAssertConfiguration() else {
return
}

guard __isLocalActor(actor) else {
return
}

guard let unownedExecutor = actor.localUnownedExecutor else {
preconditionFailure(
"Incorrect actor executor assumption; Distributed actor \(actor) is 'local' but has no executor!",
file: file, line: line)
}

guard _taskIsCurrentExecutor(unownedExecutor._executor) else {
// TODO: offer information which executor we actually got
// TODO: figure out a way to get the typed repr out of the unowned executor
let msg = "Incorrect actor executor assumption; Expected '\(unownedExecutor)' executor. \(message())"
/// TODO: implement the logic in-place perhaps rather than delegating to precondition()?
assertionFailure(msg, file: file, line: line) // short-cut so we get the exact same failure reporting semantics
return
extension DistributedActor {
/// Performs an executor check in debug builds.
///
/// * In playgrounds and `-Onone` builds (the default for Xcode's Debug
/// configuration): If `condition` evaluates to `false`, stop program
/// execution in a debuggable state after printing `message`.
///
/// * In `-O` builds (the default for Xcode's Release configuration),
/// `condition` is not evaluated, and there are no effects.
///
/// * In `-Ounchecked` builds, `condition` is not evaluated, but the optimizer
/// may assume that it *always* evaluates to `true`. Failure to satisfy that
/// assumption is a serious programming error.
@available(SwiftStdlib 5.9, *)
@_transparent
public nonisolated func assertIsolated(
_ message: @autoclosure () -> String = String(),
file: StaticString = #fileID, line: UInt = #line
) {
guard _isDebugAssertConfiguration() else {
return
}

guard __isLocalActor(self) else {
return
}

guard let unownedExecutor = self.localUnownedExecutor else {
preconditionFailure(
"Incorrect actor executor assumption; Distributed actor \(self) is 'local' but has no executor!",
file: file, line: line)
}

guard _taskIsCurrentExecutor(unownedExecutor._executor) else {
// TODO: offer information which executor we actually got
// TODO: figure out a way to get the typed repr out of the unowned executor
let msg = "Incorrect actor executor assumption; Expected '\(unownedExecutor)' executor. \(message())"
/// TODO: implement the logic in-place perhaps rather than delegating to precondition()?
assertionFailure(msg, file: file, line: line) // short-cut so we get the exact same failure reporting semantics
return
}
}
}

Expand All @@ -116,34 +115,58 @@ public func assertOnExecutor(
// MARK: Assume APIs

@available(SwiftStdlib 5.9, *)
@_unavailableFromAsync(message: "express the closure as an explicit function declared on the specified 'distributed actor' instead")
public func assumeOnLocalDistributedActorExecutor<Act: DistributedActor, T>(
of actor: Act,
_ operation: (isolated Act) throws -> T,
file: StaticString = #fileID, line: UInt = #line
) rethrows -> T {
typealias YesActor = (isolated Act) throws -> T
typealias NoActor = (Act) throws -> T

guard __isLocalActor(actor) else {
fatalError("Cannot assume to be 'isolated \(Act.self)' since distributed actor '\(actor)' is remote.")
}

/// This is guaranteed to be fatal if the check fails,
/// as this is our "safe" version of this API.
guard let executor = actor.localUnownedExecutor else {
fatalError("Distributed local actor MUST have executor, but was nil")
}
guard _taskIsCurrentExecutor(executor._executor) else {
// TODO: offer information which executor we actually got when
fatalError("Incorrect actor executor assumption; Expected same executor as \(actor).", file: file, line: line)
}

// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) {
(_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn(actor)
extension DistributedActor {

/// Assume that the current actor is a local distributed actor and that the currently executing context is the same as that actors
/// serial executor, or crash.
///
/// This method allows developers to *assume and verify* that the currently executing synchronous function
/// is actually executing on the serial executor that this distributed (local) actor is using.
///
/// If that is the case, the operation is invoked with an `isolated` version of the actoe,
/// allowing synchronous access to actor local state without hopping through asynchronous boundaries.
///
/// If the current context is not running on the actor's serial executor, or if the actor is a reference to a remote actor,
/// this method will crash with a fatalError (similar to ``preconditionIsolated()``).
///
/// This method can only be used from synchronous functions, as asynchronous ones should instead
/// perform normal method call to the actor.
///
/// - Parameters:
/// - operation: the operation that will be executed if the current context is executing on the actors serial executor, and the actor is a local reference
/// - file: source location where the assume call is made
/// - file: source location where the assume call is made
/// - Returns: the return value of the `operation`
/// - Throws: rethrows the `Error` thrown by the operation if it threw
@available(SwiftStdlib 5.9, *)
@_unavailableFromAsync(message: "express the closure as an explicit function declared on the specified 'distributed actor' instead")
public nonisolated func assumeIsolated<T>(
_ operation: (isolated Self) throws -> T,
file: StaticString = #fileID, line: UInt = #line
) rethrows -> T {
typealias YesActor = (isolated Self) throws -> T
typealias NoActor = (Self) throws -> T

guard __isLocalActor(self) else {
fatalError("Cannot assume to be 'isolated \(Self.self)' since distributed actor '\(self)' is a remote actor reference.")
}

/// This is guaranteed to be fatal if the check fails,
/// as this is our "safe" version of this API.
guard let executor = self.localUnownedExecutor else {
fatalError("Distributed local actor MUST have executor, but was nil")
}
guard _taskIsCurrentExecutor(executor._executor) else {
// TODO: offer information which executor we actually got when
fatalError("Incorrect actor executor assumption; Expected same executor as \(self).", file: file, line: line)
}

// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) {
(_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn(self)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@
import StdlibUnittest

func checkPreconditionMainActor() /* synchronous! */ {
preconditionTaskOnActorExecutor(MainActor.shared)
MainActor.shared.preconditionIsolated()
MainActor.preconditionIsolated()

// check for the existence of the assert version of APIs
MainActor.shared.assertIsolated()
MainActor.assertIsolated()
}

func checkPreconditionFamousActor() /* synchronous! */ {
preconditionTaskOnActorExecutor(FamousActor.shared)
FamousActor.shared.preconditionIsolated() // instance version for global actor
FamousActor.preconditionIsolated() // static version for global actor
}

@MainActor
Expand Down Expand Up @@ -62,7 +68,7 @@ actor Someone {
if #available(SwiftStdlib 5.9, *) {
// === MainActor --------------------------------------------------------

tests.test("preconditionTaskOnActorExecutor(main): from 'main() async', with await") {
tests.test("precondition on actor (main): from 'main() async', with await") {
await checkPreconditionMainActor()
}

Expand All @@ -72,14 +78,14 @@ actor Someone {
await MainFriend().callCheckMainActor()
}

tests.test("preconditionTaskOnActorExecutor(main): wrongly assume the main executor, from actor on other executor") {
tests.test("precondition on actor (main): wrongly assume the main executor, from actor on other executor") {
expectCrashLater(withMessage: "Incorrect actor executor assumption; Expected 'MainActor' executor.")
await Someone().callCheckMainActor()
}

// === Global actor -----------------------------------------------------

tests.test("preconditionTaskOnActorExecutor(main): assume FamousActor, from FamousActor") {
tests.test("precondition on actor (main): assume FamousActor, from FamousActor") {
await FamousActor.shared.callCheckFamousActor()
}

Expand Down
4 changes: 2 additions & 2 deletions test/Concurrency/Runtime/actor_assume_executor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import StdlibUnittest

func checkAssumeMainActor(echo: MainActorEcho) /* synchronous! */ {
// Echo.get("any") // error: main actor isolated, cannot perform async call here
assumeOnMainActorExecutor {
MainActor.assumeIsolated {
let input = "example"
let got = echo.get(input)
precondition(got == "example", "Expected echo to match \(input)")
Expand All @@ -40,7 +40,7 @@ actor MainFriend {

func checkAssumeSomeone(someone: Someone) /* synchronous */ {
// someone.something // can't access, would need a hop but we can't
assumeOnActorExecutor(someone) { someone in
someone.assumeIsolated { someone in
let something = someone.something
let expected = "isolated something"
precondition(something == expected, "expected '\(expected)', got: \(something)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ actor MyActor {
}

func test(expectedExecutor: NaiveQueueExecutor, expectedQueue: DispatchQueue) {
preconditionTaskOnExecutor(expectedExecutor, message: "Expected deep equality to trigger for \(expectedExecutor) and our \(self.executor)")
expectedExecutor.preconditionIsolated("Expected deep equality to trigger for \(expectedExecutor) and our \(self.executor)")
print("\(Self.self): [\(self.executor.name)] on same context as [\(expectedExecutor.name)]")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ actor MyActor {
}

func test(expectedExecutor: NaiveQueueExecutor) {
preconditionTaskOnExecutor(expectedExecutor, message: "Expected deep equality to trigger for \(expectedExecutor) and our \(self.executor)")
expectedExecutor.preconditionIsolated("Expected deep equality to trigger for \(expectedExecutor) and our \(self.executor)")
print("\(Self.self): [\(self.executor.name)] on same context as [\(expectedExecutor.name)]")
}
}
Expand Down
Loading