I have a LogManager
singleton
class that saves strings into an array for logging purposes. I want the first entry into the log to be some system information so it gets added during init
@objc
public final class LogManager: NSObject, @unchecked Sendable {
static let sharedInstance = LogManager()
private var _logs: [String] = []
private let serialQueue = DispatchQueue(label: "LoggingSerialQueue")
private override init() {
super.init()
log(SysInfo.sysInfo())
}
func log(_ log: String) {
serialQueue.sync {
self._logs.append(log)
print("****LogManager**** (log)")
}
}
}
The problem is that the system information needs to be MainActor
isolated as it calls MainActor
isolated properties on UIDevice
and several others.
private final class SysInfo {
@MainActor
static func sysInfo() -> String {
UIDevice.current.systemName
}
}
I do not want to force my LogManager
to be MainActor
isolated as it can be called from many different threads.
I know I can add the log
call from init
into a Task
like this:
private override init() {
super.init()
Task {
await log(SysInfo.sysInfo())
}
}
but that actually leads to it being the second log in the array and not the first, when I initiate the class by sending a log command:
LogManager.sharedInstance.log(#function)
I’m wondering what’s the best approach to take here?
Before Swift Concurrency and if I remove MainActor
from the SysInfo
, it all works as if it’s synchronous.
2
Answers
That’s the heart of the matter: you need to stop thinking this way. This is exactly the kind of silliness that Swift Concurrency solves.
Get rid of all the DispatchQueue objects in your code (except for rare cases where you may need them in order to make an
@unchecked Sendable
type be genuinely thread-safe — but this is not actually such a case), and adopt Swift Concurrency thoroughly and consistently. Then there are no "threads" and context switching ceases to matter. Just isolate LogManager to some actor and stop worrying.Let us suppose, just for the sake of the example, that we isolate LogManager to the main actor. Then we can rewrite it like this:
Now when we call
LogManager.sharedInstance.log(#function)
indidFinishLaunching
, we get:which is the correct order. Everything is being ordered and will stay in the correct order, because it is backed by the full faith and credit of Swift Concurrency. If you happen to be on a different actor, not the main actor, and you happen not to be in an
async
method, then you will sayand you will still get everything in the correct order.
Here’s the really nice part. Let’s decide to take LogManager entirely off the main actor and isolate it to our own global actor:
What happens when we do this? Absolutely nothing! All our code keeps working the same way as before. And that’s my point: your "it can be called from many different threads" completely falls away.
If you want the log messages to appear in the correct order, use your queue, e.g.:
By using your queue, you are guaranteed the order of execution.
A few observations:
There is no reason why your
log
function should dispatch synchronously to yourserialQueue
. You are not returning anything, sosync
is unnecessary and only introduces inefficiencies. Right now it might not make too much of a difference, but let us imagine that your logger was recording information to persistent storage, too; you would not want to measurably impact performance of your app because of your logging system.The
dispatchPrecondition
is not needed, but it is good practice that a method dependent upon a particular queue be explicit about this requirement. That way, debug builds will warn you of misuse. It is a private method, so we know the potential misuse is limited to this class, but it is still a prudent defensive programming technique when writing GCD code. This is a NOOP in release builds, so there is no downside to programming defensively.Unrelated, to the question at hand, but the convention for singletons in Swift is
shared
, so I have renamed that accordingly. But I also specified@objc(sharedInstance)
so Objective-C callers enjoy Objective-C naming conventions.But if you really want to keep the Swift rendition named
sharedInstance
, that is your call. But just appreciate that it is not the standard convention.