skip to Main Content

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)

enter image description here

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


  1. I do not want to force my LogManager to be MainActor isolated as it can be called from many different threads.

    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:

    @objc @MainActor public final class LogManager: NSObject {
    
        static let sharedInstance = LogManager()
        private let loggingActor = LoggingActor()
    
        private override init() {
            super.init()
            Task {
                await loggingActor.log(UIDevice.current.systemName)
            }
        }
    
        func log(_ what: String) {
            Task {
                await loggingActor.log(what)
            }
        }
    
        actor LoggingActor {
            private var _logs: [String] = []
            func log(_ log: String) {
                self._logs.append(log)
                print("****LogManager**** (log)")
            }
        }
    }
    

    Now when we call LogManager.sharedInstance.log(#function) in didFinishLaunching, we get:

    ****LogManager**** iOS
    ****LogManager**** application(_:didFinishLaunchingWithOptions:)
    

    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 say

        Task {
            await LogManager.sharedInstance.log(#function)
        }
    

    and 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:

    @globalActor public actor LoggingGlobalActor {
        public static let shared = LoggingGlobalActor()
    }
    
    @objc @LoggingGlobalActor public final class LogManager: NSObject { // ...
    

    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.

    Login or Signup to reply.
  2. If you want the log messages to appear in the correct order, use your queue, e.g.:

    @objc
    public final class LogManager: NSObject, @unchecked Sendable {
        @objc(sharedInstance)
        static let shared = LogManager()
    
        private var _logs: [String] = []
        private let serialQueue = DispatchQueue(label: "LoggingSerialQueue")
    
        private override init() {
            super.init()
    
            serialQueue.async {
                let message = DispatchQueue.main.sync {            // note, we rarely use `sync`, but this is an exception
                    SysInfo.sysInfo()
                }
                self.appendToLog(message: message)
            }
        }
    
        func log(_ message: String) {
            serialQueue.async {                                    // but do not use `sync` here; why make the caller wait?
                self.appendToLog(message: message)
            }
        }
    }
    
    private extension LogManager {
        func appendToLog(message: String) {
            dispatchPrecondition(condition: .onQueue(serialQueue)) // make sure caller used correct queue
            _logs.append(message)
            print("****LogManager**** (message)")
        }
    }
    
    private final class SysInfo {
        @MainActor
        static func sysInfo() -> String {
            UIDevice.current.systemName
        }
    }
    

    By using your queue, you are guaranteed the order of execution.

    A few observations:

    1. There is no reason why your log function should dispatch synchronously to your serialQueue. You are not returning anything, so sync 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.

    2. 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.

    3. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search