iosswiftswift-concurrency

Synchronising a MainActor isolated method during init and satisfying swift 6 concurrency


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.


Solution

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


    As an aside, you might consider retiring this custom logging manager entirely and use the native Unified Logging system. In legacy codebases, we would use OSLog. I know this does not apply in your case, but in iOS 14 and later, one would use Logger in lieu of OSLog.

    The “Unified Logging” system offers many advantages over a custom logging systems:

    1. You can right-click on a logging message and jump directly to the source that generated the log in Xcode 15+.

    2. When running app on device, you can monitor logging messages in your macOS console app. This is useful in general, but invaluable when monitoring background tasks, monitoring on-device logging, etc.

    3. In both Xcode and the macOS console, you can categorize your logging messages as debugging messages vs errors and filter accordingly.

    4. You can even download on-device logging messages after the fact and review them inside the macOS Console app:

      $ log collect --device --start '2024-06-12 08:00:00' —output Foo.logarchive
      $ open Foo.logarchive
      

    This will feel cumbersome at first (especially if you are stuck with OSLog for support on old OS targets rather than the much-improved Logger of iOS 14+), but the advantages are compelling, and once you start using it, you will not look back. Definitely consider “Unified Logging”.