swiftswift-concurrencyswift6

How do I create a singleton reference-type in Swift 6?


I wrote this for a little CLI tool I'm making. It's only important that it can receive & run its function calls immediately, because it's for logging, where placing the lines in-order is important.

import Foundation



fileprivate final class OutputCoordinator {
    
    private var output = String()
    
    
    private init() {}
    
    
    deinit {
        flushOutput()
    }
    
    
    func prepare(outputLine line: String) {
        output += line
        output += "\n"
    }
    
    
    func flushOutput() {
        defer { output = .init() }
        Swift.print(output)
    }
    
    
    
    static let shared = OutputCoordinator()
}



func print(queueing items: Any...) {
    let line = items.map(String.init(describing:)).joined(separator: " ")
    OutputCoordinator.shared.prepare(outputLine: line)
}

I work with warnings-as-errors, so it fails with this:

Static property 'shared' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

The only way I could figure out to get this to compile is with @unchecked Sendable:

fileprivate final class OutputCoordinator: @unchecked Sendable {

Surely this isn't the intended way the Swift language designers want me to do this. What should I be doing instead?


Solution

  • The compiler is warning you that your type is not thread-safe. To indicate that it was thread-safe, we would make it Sendable. That means you would either:

    1. Make it an actor:

      actor OutputCoordinator {…}
      
    2. Isolate it to some global actor, such as the main actor:

      @MainActor
      final class OutputCoordinator {…}
      

      You could also create your own global actor for this, too. But, just making the type an actor (point 1, above) would be simpler.

    3. Or manually make it Sendable:

      final class OutputCoordinator: @unchecked Sendable {
          static let shared = OutputCoordinator()
      
          private var output = ""
          private let queue = DispatchQueue(label: "output.coordinator")
      
          deinit {
              flushOutput()
          }
      
          func prepare(outputLine line: String) {
              queue.async { [self] in
                  output += line
                  output += "\n"
              }
          }
      
          func flushOutput() {
              queue.async { [self] in
                  print(output)
                  output = ""
              }
          }
      }
      

      Note, I generally would use locks for manual synchronization, but you suggested that FIFO behavior was important, so I have used a queue here. But given that you said that this is a single-threaded app, that’s not a concern, so I would probably do:

      final class OutputCoordinator: @unchecked Sendable {
          static let shared = OutputCoordinator()
      
          private var output = ""
          private let lock = NSLock()
      
          deinit {
              flushOutput()
          }
      
          func prepare(outputLine line: String) {
              lock.withLock {
                  output += line
                  output += "\n"
              }
          }
      
          func flushOutput() {
              lock.withLock {
                  print(output)
                  output = ""
              }
          }
      }
      

    In general, when merely trying to eliminate data races, I would lean towards an actor. But given that you need strict FIFO behaviors (which actors do not guarantee), you might want to use the dispatch queue pattern.

    Regardless, as I think you already guessed, the key is that you would never use @unchecked Sendable without actually synchronizing the internal state. The compiler is warning you that your original code snippet was not thread-safe, and you should never declare @unchecked Sendable conformance without actually making it thread-safe.