How to make a copy-on-write struct Sendable and thread-safe?

This actually got me thinking… I have a similar pattern for my own CoW types… but I'm seeing a (potential) missing piece here WRT Sendable.

Suppose you start with your example and then try to make the CoW Sendable. Here is the error:

public struct MyCopyOnWriteStruct : Sendable {
  public init() {}

  public var value: Int {
    get {
      storage.value
    }
    set {
      copyStorageIfNecessary()
      storage.value = newValue
    }
  }

  private var storage = Storage()
  //          `- error: stored property 'storage' of 'Sendable'-conforming struct 'MyCopyOnWriteStruct' has non-sendable type 'Storage'

  private mutating func copyStorageIfNecessary() {
    if !isKnownUniquelyReferenced(&storage) {
      storage = storage.copy()
    }
  }
}

fileprivate final class Storage {
//                      `- note: class 'Storage' does not conform to the 'Sendable' protocol

  init() {}
  var value = 0

  func copy() -> Storage {
    let copy = Storage()
    copy.value = value
    return copy
  }
}
  private var storage = Storage()

  private mutating func copyStorageIfNecessary() {
    if !isKnownUniquelyReferenced(&storage) {
      storage = storage.copy()
    }
  }
}

fileprivate final class Storage {
  init() {}
  var value = 0

  func copy() -> Storage {
    let copy = Storage()
    copy.value = value
    return copy
  }
}

Ok… what if we mark Storage as Sendable?

fileprivate final class Storage : Sendable {
  init() {}
  var value = 0
  //  `- error: stored property 'value' of 'Sendable'-conforming class 'Storage' is mutable

  func copy() -> Storage {
    let copy = Storage()
    copy.value = value
    return copy
  }
}

Almost there… one more step:

fileprivate final class Storage : Sendable {
  init() {}
  nonisolated(unsafe) var value = 0

  func copy() -> Storage {
    let copy = Storage()
    copy.value = value
    return copy
  }
}

Ok… no errors. All good? What if we go on vacation and a new engineer comes here to add a new value to our CoW (trying to follow the same pattern we just did)?

final public class Item {
  var value: Int = 0
}

public struct MyCopyOnWriteStruct : Sendable {
  public init() {}

  public var value: Int {
    get { storage.value }
    set {
      copyStorageIfNecessary()
      storage.value = newValue
    }
  }
  
  public var item: Item {
    get { storage.item }
    set {
      copyStorageIfNecessary()
      storage.item = newValue
    }
  }

  private var storage = Storage()

  private mutating func copyStorageIfNecessary() {
    if !isKnownUniquelyReferenced(&storage) {
      storage = storage.copy()
    }
  }
}

fileprivate final class Storage : Sendable {
  init() {}
  nonisolated(unsafe) var value = 0
  nonisolated(unsafe) var item = Item()

  func copy() -> Storage {
    let copy = Storage()
    copy.value = value
    return copy
  }
}

Hmm… no errors… what if we put these same properties in a type that is not Cow?

public struct MyStruct : Sendable {
  public var value = 0
  public var item = Item()
  //         `- error: stored property 'item' of 'Sendable'-conforming struct 'MyStruct' has non-sendable type 'Item'
}

Ahh… there it is. By marking our Storage instance variables as nonisolated(unsafe)… we opt-out of strict concurrency checking and enable our Storage to be Sendable. But this is a big hammer… we would actually prefer something with a little more control behind it.

  • We want our instance variable to be unsafe WRT our own access (so we opt out of Strict Concurrency checking).
  • We want our instance variable to be safe WRT the underlying Sendable conformance of the type of the instance variable itself (so we do not opt out of Strict Concurrency checking).

Which gives us a dilemma… do we make this type compatible with Sendable (and let the engineer manage the risk of losing Strict Concurrency errors when passing in a type that is not Sendable)?

As of right now… I'm not sure we can get one without the other. It feels like we are missing one additional dimension of nonisolated specificity. Hmm… any ideas about how else to work around this in 5.10?