0

I'm trying to figure out, what actor brings to use. It's not clear for me - are they truly parallel or just concurrent.

I did little test to check myself:

actor SomeActor {
    func check() {
        print("before sleep")
        sleep(5)
        print("after sleep")
    }
}

let act1 = SomeActor()
let act2 = SomeActor()
Task {
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            await act1.check()
        }

        group.addTask {
            await act2.check()
        }
    }
}

Output is:

before sleep // Waiting 5 sec
after sleep
before sleep // Starting after 5 sec
after sleep

I block current executor thread so it doesn't yield it. But second task doesn't start in parallel.

So does it mean that each instance of the same actor type share executor instance?

If so then multiple instance of same actor don't support truly parallelism, but concurrent execution?

UPD:

I have a little bit new observations. So example above use inherited isolation when we run group.add(). So it can't pass second actor call immediately.

My second observation - when we run tasks this way:

        let act1 = SomeActor()
        let act2 = SomeActor()
        
        Task.detached {
            await act1.check()
        }
        Task { @MainActor in
            await act2.check()
        }

Output is:

before sleep
before sleep
after sleep
after sleep

So it's truly parallel

But when I use detached task, output is serial the same:

        let act1 = SomeActor()
        let act2 = SomeActor()
        
        Task.detach {
            await act1.check()
        }
        Task.detached { @MainActor in
            await act2.check()
        }

Output is:

before sleep
after sleep
before sleep
after sleep
  • 1
    You should not use `sleep` in combination with Swift Concurrency because it blocks. – Sweeper Jul 03 '22 at 11:01
  • @Sweeper it is for test purposes. There is need for blocking to check it – Pyrettt Pyrettt Jul 03 '22 at 11:09
  • 1
    Did you try using : await task.sleep(5 second duration ) ? – Ptit Xav Jul 03 '22 at 11:20
  • @PtitXav My initial purpose was to find out do actor share executor or each instance has its own. So there is need in blocking operation, because async one would immediately yield thread – Pyrettt Pyrettt Jul 03 '22 at 11:24
  • 3
    Actors are concurrent. In many cases, they are also parallel, but they do not promise how they are parallel. Except for some specific cases around the main thread, they do not make any promises about *how* tasks are run. Tasks must always make forward progress and they may not block, so calling `sleep` makes this all undefined behavior. Yes, it will act inconsistently. Swift makes no promises what happens if you break the rules. So this is useful for exploring the details of how it is *currently* implemented, but it not useful for finding things you can rely on. It is not like other languages. – Rob Napier Jul 03 '22 at 14:29
  • 1
    Generally there will only be as many threads in Swift Concurrency as there are CPU cores, since there is no value to having more threads than can be scheduled (they're expensive resources). It does not create threads simply because Tasks are blocked, because Tasks are not allowed to block. So there is definitely parallelism, but that doesn't mean threads. For the kinds of details you're probably looking for, see https://developer.apple.com/videos/play/wwdc2021/10254 – Rob Napier Jul 03 '22 at 14:37
  • @RobNapier yup, thanks, I found it at swift rocks: >> When a job is enqueued, the actor registers the job is something called a global executor, which is essentially the C++ implementation of the class that handles async/await's Cooperative Threading Model. In short, actors can own and yield threads, and the global executor will notify an actor when it's allowed to own a specific thread. When this happens, the default actor will execute the first job in the queue and yield the thread. Seems that all actors rely on global executor, that doesn't notify them to ensue task. Maybe cuz of lock – Pyrettt Pyrettt Jul 03 '22 at 14:54
  • Yes, but keep in mind that this is subject to change (and I expect it *will* change over the next several versions of Swift). For example, there has been talk about how to better handle CPU-bound Tasks and Actors, since currently you need to manually inject `yield()` calls for these. And there may be more changes around reentrancy, which has been a tricky issue. So great to explore and understand, but don't write anything that relies on the current implementation. – Rob Napier Jul 03 '22 at 16:14
  • @RobNapier yes, totally agree with you, it still looks raw – Pyrettt Pyrettt Jul 03 '22 at 17:24
  • 1
    As a general aside, I am always wary of using `sleep` to test parallelism. If your intent is to simulate “the processor is busy”, I always spin for the allotted time rather than sleeping. Sleeping actually allows the core to switch to some other thread, which can lead one to draw some incorrect conclusions about the hardware’s maximum degree of parallelism. In this case (where the cooperative thread pool is the major constraint) it doesn’t matter, but just a FYI. – Rob Jul 04 '22 at 16:29
  • 1
    @RobNapier - “Generally there will only be as many threads in Swift Concurrency as there are CPU cores” … The notable exception to this rule is the iOS simulator. It artificially constrains the cooperative thread pool. GCD (e.g. `concurrentPerform`) on simulator will fully avail itself of the host machine’s CPUs, but the [cooperative thread pool will not](https://stackoverflow.com/q/67978028/1271826). – Rob Jul 04 '22 at 17:36

2 Answers2

4

It might help you if you add in some extra debug like so:

actor SomeActor {
    var name: String
    init(name: String) {
        self.name = name
    }

    func check() {
        print("Actor: \(name) check(), sleeping now, blocking thread \(Thread.current)")
        sleep(1)
        print("Actor: \(name), sleep done, unblocking thread \(Thread.current)")
    }
}

let act1 = SomeActor(name: "A")
let act2 = SomeActor(name: "B")
Task() {
    print("in task, on thread: \(Thread.current)")
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            print("Group task: 1 (on thread: \(Thread.current)")
            await act1.check()
        }

        group.addTask {
            print("Group task: 2 (on thread: \(Thread.current)")
            await act2.check()
        }
    }
}

The Task is like dispatching onto a global queue, the priority of which is taken from Task.init's priority: argument

If you block the Thread that the task is using, with sleep (don't do that) that's not yielding the thread. If you use the async sleep, the try? await Task.sleep then you see the cooperative behaviour:

actor SomeActor {
    var name: String
    init(name: String) {
        self.name = name
    }

    func check() async throws {
        print("Actor: \(name) check(), sleeping now, yielding thread \(Thread.current)")
        try await Task.sleep(nanoseconds: NSEC_PER_SEC)
        print("Actor: \(name), Task.sleep done (was not cancelled), back now on thread \(Thread.current)")
    }
}

let act1 = SomeActor(name: "A")
let act2 = SomeActor(name: "B")
Task() {
    print("in task, on thread: \(Thread.current)")
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            print("Group task: 1 (on thread: \(Thread.current)")
            try? await act1.check()
        }

        group.addTask {
            print("Group task: 2 (on thread: \(Thread.current)")
            try? await act2.check()
        }
    }
}

Gives output like:

in task, on thread: <NSThread: ...>{number = 4, name = (null)}
Group task: 1 (on thread: <NSThread:...>{number = 4, name = (null)}
Actor: A check(), sleeping now, yielding thread <NSThread: ...>{number = 4, name = (null)}
Group task: 2 (on thread: <NSThread: ...>{number = 4, name = (null)}
Actor: B check(), sleeping now, yielding thread <NSThread: ...>{number = 4, name = (null)}
Actor: A, Task.sleep done (was not cancelled), back now on thread <NSThread: ...>{number = 7, name = (null)}
Actor: B, Task.sleep done (was not cancelled), back now on thread <NSThread: ...>{number = 7, name = (null)}
Shadowrun
  • 3,572
  • 1
  • 15
  • 13
  • Yup, but try to block thread with just sleep(). And run each actor method inside different Task.detached. They will block each other - why?? – Pyrettt Pyrettt Jul 03 '22 at 12:00
  • 1
    My bad, I thought Task.sleep throws if cancelled so won’t reach the print - but that needs to replace the try? with just try – Shadowrun Jul 04 '22 at 17:16
3

In answer to your question, independent actor instances can run in parallel (as long as the cooperative thread pool is not constrained). When I ran your code snippets, I enjoyed parallel execution, so I suspect that there is some other aspect of your tests that yielded the serial behavior. We need a MCVE to diagnose this further.

For example, if you run this on an iOS simulator, that has an artificially constrained cooperative thread pool. See Maximum number of threads with async-await task groups. While I have been unable to manifest the behavior you describe, the simulator’s constrained cooperative thread pool can easily lead one to incorrect inferences about the potential parallelism.

If you ran this on a simulator, try testing this on a physical device or a macOS target, and you will enjoy a cooperative thread pool that will avail itself of all of the cores available on your device.

Rob
  • 415,655
  • 72
  • 787
  • 1,044