Hello guys, could you tell me guys. Do swift actors support parallel execution overall?
After some source code research and few articles read - I make assumption that all custom actor direct they calls to global executor, which manages global thread pool. Exception is MainActor which is whole standalone thing.
I know that blocking operations inside task is not allowed, it may broke dispatching of executor, but I don't know any other ideas how to test it.
So my example is:
actor SomeActor {
let name: String
init(_ name: String) { self.name = name }
func run() {
print("Start run: (name)")
sleep(5)
print("End run: (name)")
}
}
let act1 = SomeActor("A")
let act2 = SomeActor("B")
Task.detached {
await act1.run()
}
Task.detached {
await act2.run()
}
Output is:
Start run: A
End run: A
Start run: B
End run: B
Why do actors run serially, taking into account that they are invoked from detached task?
Do actors support parallelism, or they are concurrent only?
Will appreciate any ideas and assumptions)
If I change your code as follows to make it compile and to await the two tasks before exiting, and then run it on the command line (swift actors.swift), the two tasks do run concurrently as you would expect.
Tried your changes still serially, my config are:
macOS 12.4
Apple Swift version 5.6 (swiftlang-5.6.0.323.62 clang-1316.0.20.8)
Target: x86_64-apple-macosx12.0
Xcode Version 13.3 (13E113)
I don't get this. My code doesn't even compile for me in Xcode 13.3 (or 13.4.1), presumably because SE-0343 Concurrency in Top-level Code was only implemented in Swift 5.7.
$ sudo xcode-select --switch /Applications/Xcode-13.3.app/Contents/Developer/
$ swift --version
swift-driver version: 1.45.2 Apple Swift version 5.6 (swiftlang-5.6.0.323.62 clang-1316.0.20.8)
Target: arm64-apple-macosx12.0
$ swift actors.swift
actors.swift:23:7: error: 'async' property access in a function that does not support concurrency
await t1.value
^
actors.swift:24:7: error: 'async' property access in a function that does not support concurrency
await t2.value
^
I think it doesn't make any sense. Probably there are changes between versions of swift that bring something new.
Even these proposal could change it's implementation:
But if you do just that, the tasks won't even run to completion because the program will exit before the tasks finish, won't they?
With just this single change, the output is this for me (edit: macOS 12.4, Xcode 13.3 (Swift 5.6)):
$ swift actors.swift
Start run: B
Start run: A
# program exits here
$ (new prompt)
You'd need to add something like RunLoop.current.run() to the main program (at the top level) to keep the program running. And then it does the right thing again (the two actors run concurrently).
The simulator needs to run on older OS-es which don't have the new cooperative thread pool with kernel support. As such, we have to make locally optimal decisions in libdispatch on how many threads to bring up in libdispatch
If you had kicked off tasks with different QoS-es, you'd see that we will bring up at least a thread per QoS class but we don't have intelligent load balancing
So the simulator only uses one thread per task priority. Since your sleep call blocks the thread, nothing else can run on the cooperative thread pool in the meantime.
Thanks man, btw I recently noticed that on simulator Task.detached runs on: com.apple.root.default-qos.cooperative (serial)
And on device it's running on:
com.apple.root.default-qos.cooperative (concurrent)
Actors are concurrent only, and only one task can be executed on actor at the same time. There is no parallelism primitives in Swift Concurrency, still in Swift 5.7.
You mean no explicit primitives, like DispatchQueue.concurrentPerform, right? Because TaskGroups and async let are usually parallel, and independent Tasks are parallel if they don't touch the same resources.
I want additionally to notice that they can be parallel, but it is not guaranteed. Example:
(the implementation of concurrentMap() can be found here)
Task {
let elementsArray = Array(1...1000)
let maped = await elementsArray.concurrentMap { number in
print("concurrentMap start \(number) ")
defer { print("concurrentMap end \(number) ") }
return (1...number).map { String(describing: $0) }
}
}
Most often I see at first sequential prints:
concurrentMap start 1
concurrentMap end 1
concurrentMap start 2
concurrentMap end 2
concurrentMap start 3
concurrentMap end 3
concurrentMap start 4
concurrentMap end 4
...
Later I see lots of task first created:
AsyncMap start 994
AsyncMap start 995
AsyncMap start 996
...
and then finished
AsyncMap end 994
AsyncMap end 995
AsyncMap end 996
....
The order of prints changes on every execution, which is expected.
After experimenting with actors I found that while the actor is doing something everything stays on the same thread even if using withTaskGroup (each task would stay on the same thread). I’m not sure if, when or why an actor might switch threads (possibly if using async await and the actor has been idle during an await?). I don’t think this is guaranteed by any kind of spec, it just appears to be so and it makes total sense.
On the other hand using classes allows withTaskGroup to use multiple threads and each await is likely to wake up on a different thread just like the docs say.
For the simple case of only using async/await for parallelism, all blocks of code inside an actor which run without encountering any awaits would be synchronous to that actor’s thread so it will never run in parallel with other code in the actor because that’s how threads work.
However, once an await is introduced in the call stack, even if it doesn’t happen experimentally all the time (like some of you tried), code would run “in parallel” to other code in the actor. I’m using quotes because it is not true parallelism (not on a different thread) but logic parallelism, in the sense that once the await is encoutered other awaiting code elsewhere in the actor will be allowed to continue if it is ready to do so which can lead to bugs in some scenarions, so queues are wanted (or sometimes you can get away with a spinlock around using a while(){Task.sleep}, and sometimes with a simple FILO array for scheduling stuff).
I’ve discovered that in both V8 JS and Swift running the CPU at 100% even on the iPad/iPhone (even if using a separate tool like a burn-in test) causes many parallelism bugs to reveal themselves, for both multithreading (async/await in classes which uses multiple threads) and, separately, async/await in actors (same thread, order of execution bugs)
When using classes, unless they’re part of the @MainActor, it is very easy to run into multithreading issues when using async/await when modifying the same variables that other code is accessing or modifying.
Note: there are no actors in NodeJS and awaits always wake up on the same thread, while threads shared memory is handled by the programmer explicitly between threads (worker_threads).
Concurrency is the ability to execute multiple tasks at the same time, even if only one task makes progress at a time. Parallelism is concurrent execution where multiple tasks can make progress at the same time. All parallelism is concurrent, but not all concurrency is parallel.
In terms of Swift concurrency, we can run a Swift program with a runtime concurrency width of one. If the program is soundly constructed it should run correctly (this should really be a controllable test mode) despite not being able to actually run anything in parallel. For the first couple years of Swift concurrency the iOS simulator did exactly that.