@Jon_Shier
I see this in the actors proposal:
The second form of permissible cross-actor reference is one that is performed with an asynchronous function invocation. Such asynchronous function invocations are turned into "messages" requesting that the actor execute the corresponding task when it can safely do so. These messages are stored in the actor's "mailbox", and the caller initiating the asynchronous function invocation may be suspended until the actor is able to process the corresponding message in its mailbox. An actor processes the messages in its mailbox sequentially, so that a given actor will never have two concurrently-executing tasks running actor-isolated code. This ensures that there are no data races on actor-isolated mutable state, because there is no concurrency in any code that can access actor-isolated state. For example, if we wanted to make a deposit to a given bank account account
, we could make a call to a method deposit(amount:)
on another actor, and that call would become a message placed in the actor's mailbox and the caller would suspend. When that actor processes messages, it will eventually process the message corresponding to the deposit, executing that call within the actor's isolation domain when no other code is executing in that actor's isolation domain.
This makes it seem like the actor processes messages in it's mailbox in the order that the messages were received. Is there documentation somewhere that says that is not the case?
Also I see this:
When that actor processes messages, it will eventually process the message corresponding to the deposit, executing that call within the actor's isolation domain when no other code is executing in that actor's isolation domain.
Is there a way to force the actor to process any queued messages and wait for them to complete? Because that would make it easier to write test code against actors.
For example, say I want to test this function:
extension BankAccount {
func transfer(amount: Double, to other: BankAccount) async throws {
assert(amount > 0)
if amount > balance {
throw BankError.insufficientFunds
}
print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
// Safe: this operation is the only one that has access to the actor's isolated
// state right now, and there have not been any suspension points between
// the place where we checked for sufficient funds and here.
balance = balance - amount
// Safe: the deposit operation is placed in the `other` actor's mailbox; when
// that actor retrieves the operation from its mailbox to execute it, the
// other account's balance will get updated.
await other.deposit(amount: amount)
}
}
I can write this test and it passes 100/100 times on both iOS sim and macOS catalyst:
func testDeposit1() async throws {
let checking = BankAccount(accountNumber: 1, initialDeposit: 100)
let savings = BankAccount(accountNumber: 2, initialDeposit: 0)
try await checking.transfer(amount: 50, to: savings)
let balance = await savings.balance
XCTAssertEqual(balance, 50)
}
However, my situation is a bit more like this where the transfer happens in a different thread:
func testDeposit2() async throws {
let checking = BankAccount(accountNumber: 1, initialDeposit: 100)
let savings = BankAccount(accountNumber: 2, initialDeposit: 0)
Task {
print("-- about to transfer")
try await checking.transfer(amount: 50, to: savings)
}
await Task.yield()
print("-- about to get balance")
let balance = await savings.balance
XCTAssertEqual(balance, 50)
}
testDeposit2
passes 100/100 times on iOS sim. But on macOS Catalyst it passes only about 60/100 times. But even when it fails I see:
-- about to transfer
-- about to get balance
I interpreted this as the transfer is queued up before the balance check. But maybe we cannot assume that? Maybe actors do sequentially process messages in order received, but the print statement in this case isn't telling the whole story?