Struct invoking async functions

Hi,

For a long time I had been classes when ever I had asynchronous code, like fetching data etc.

My understanding (could be wrong)

  • struct can be used for async functions
  • When using struct need to make sure we don't pass them around as they would be copied (value type) and the call to the original async function might have been initiated in a different instance of the struct.
  • As long the same struct instance is held somewhere async functions should be ok

Questions

  1. Can a struct have async functions in them (see example below)?
  2. Is there anything that I need to keep in mind while using struct for async functions?
  3. Is my understanding (stated above correct)?

Code

func f1(completion: @escaping () -> ()) {
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2) {
        print("f1 done")
        completion()
    }
}

func f2() async {
    try? await Task.sleep(nanoseconds: 2_000_000_000)
    print("f2 done")
}


struct Service {
    func f3() {
        f1 {
            print("f3 done")
        }
    }
    
    func f4() async {
        await f2()
        print("f4 done")
    }
}

let s = Service()
s.f3()
await s.f4()

Yes, absolutely.

The law of exclusivity!

This applies when you write a func that is both mutating and async. During the entire duration of the call to that function, you must not access the struct in question from any other Task, even when the call is waiting for an async function to complete.

In general, this isn't going to be something you need to think about, but it's worth bearing in mind in some interesting places. Consider a silly example:

struct AsyncDelayCounter {
    private var count = 0

    mutating func slowlyAdd() async -> Int {
        self.count += 1
        try! await Task.sleep(nanoseconds: 100_000_000)
        return self.count
    }
}

If you, say, store this value in a class and call it from multiple Tasks at once, you'll trip a runtime exclusivity violation and crash. In fact, if you store this in an actor like this:

actor ThreadSafeCounter {
    private var delayCounter = AsyncDelayCounter()

    init() { }

    func addOne() async -> Int {
        return await self.delayCounter.slowlyAdd()
    }
}

Then the compiler will simply fail to compile:

test.swift:17:40: error: cannot call mutating async function 'slowlyAdd()' on actor-isolated property 'delayCounter'
        return await self.delayCounter.slowlyAdd()
                                       ^

As a result, you want to think of structs with async methods in a very similar way to the way you think of structs with sync methods: they transform from state to state, and continue not to have identity. You still cannot share them across multiple tasks unless they are guaranteed not to mutate.

Not quite. Specifically:

Remember that self is always retained strongly by a method. Once you've fired off an async method on a struct you don't need to hold onto it.

Additionally:

This should not matter. If it matters, you are either calling a mutating func on the struct (which you definitionally cannot share with another Task without violating the law of exclusivity) or you have referentiality associated with the struct, in which case copying it won't matter.

2 Likes

@lukasa Thanks a lot for the detailed explanation.

Please bear with me as I try to understand.

Doubts:

Consider the following:

class AsyncDelayCounter { ... }
actor ThreadSafeCounter { ... }

This doesn't seem to throw any compilation errors.
1.1 Why doesn't the above throw any compilation errors (is this valid)? (even with Exclusive access to memory set to Full enforcement? (or is this runtime check?)
1.2 Will this cause runtime issues?


This applies when you write a func that is both mutating and async . During the entire duration of the call to that function, you must not access the struct in question from any other Task , even when the call is waiting for an async function to complete.

2.1 Is the above statement applicable only to structs or even classes?
2.2 AsyncDelayCounter being a struct or class - would anything change in terms of safety?
3. Is there any purpose for allowing a mutating async func (in a class or struct)?
4. Is the following a good approach?

  • class or struct can contain async functions as long as they are not mutating state
  • Any state that needs to be mutated across by different tasks (threads or asynchronous environment) needs to be protected with in an actor?

Classes and actors are not covered by the law of exclusivity as a whole, but memberwise. You are required to make this safe yourself. The above construction will be safe unless you do something very odd.

It may or it may not: you'll need to enforce correctness yourself. You will not hit runtime exclusivity checks.

Structs and enums only.

It depends what you mean by safety, but I'd argue not really. However, in my view, using structs for things like this make it easier to reason about your program. Because classes can have overlapping accesses to different fields, they are subject to "spooky action at a distance" where part of a class changes under your feet. Structs are immune to this. There are definitely still times to use classes.

There is no such thing as a mutating func in a class.

As for structs, yes: if you need to mutate them and they need to wait for something. The capability remains useful. However, it's definitely less useful.

I think this is a bit strong. Both can have async functions and mutate state so long as concurrency warnings are on and you're playing by the rules there.

Yes, this is always true.

1 Like

@lukasa Thanks a lot!! for patiently answering the questions. Really appreciate it!