Help needed designing low level library for swift 6 concurrency world

Hi guys,

I have a design question related to swift 6 concurrency. I am familiar with actor concepts and isolation and I have successfully migrated lot of my existing swift code to be swift 6 clean, however I struggle in this one case.

I have a nice 3rd party high performance C library that provides some primitives (shared state) (in form of C structs) and methods operating on that shared state. It's synchronisation free and is job of the caller to ensure proper synchronisation.

I have a nice swift wrapper where each C primitive and associated methods are wrapped as swift classes so the API is comfortable to use from Swift. All working great.

Because there is no synchronisation happening neither at C level, nor at Swift level, turning on strike concurrency produces lots of warnings around concurrent use of shared state. All expected.

Now due to the nature how this library and its primitives are used - as they are high performance, there are the options I found so far to make the library concurrency safe:

  1. Naive, tie each C primitive wrapping Swift class into @MainActor. For obvious reasons, do not want to do that.
  2. Turn every C primitive (and its wrapping Swift class into its own actor). That would work but would introduce actor jump at every invocation assuming the primitive may be interacted with from other actors.
  3. Make each wrapping Swift class @unchecked Sendable to silence the compiler and continue manually ensuring that it's used properly.
  4. ???

What I'd like to ideally do is to tie my wrapper Swift class to a certain actor that may be specified for example when instantiating the class and then ensure that all methods are invoked on that actor.

Something like the following pseudo code:

class SwiftWrapperAroundCStruct {
  private let thisClassIsolation: ???
  private let underlyingCStruct: CStruct

  // init will derive isolation from caller and remember it
  public init(isolation: ???) {
    thisClassIsolation = isolation
    underlyingCStruct = ... // init the C shared state
  }

  public metodOperatingOnCStruct(args) {
    // either modify the method signature 
    // to make sure it is always invoked on `thisClassIsolation`
    // or perhaps precondition we are on `thisClassIsolation`

    invokeCMethodOperatingOn(underlyingCStrunct, args)
  }
}

This would allow the consumers of the library to choose what actor to use the C shared state and methods on, and would skip unnecessary context switches and actor jumps when calling the high performance methods while still getting the benefits of swift 6.

I'm trying to follow all concurrency proposals but I am getting lost lately and maybe I have missed some proposals to achieve this. Any pointers how to design such API?

thanks for your help,
Martin

I'm a bit struggling to understand what the errors are you getting exactly? Is that a global state from C lib?

In general, this

should be fine if it then is going to be used in one isolation. Synchronous methods inherit isolation, and your wrapper will be non-Sendable so that it can't cross isolation boundary, isolating it to whatever actor that creates it. So with the current given input I'd stick with this way of having such nonisolated and non-Sendable type. If you can provide examples of what are the errors/warnings you are getting currently, that will help.

5 Likes

Thanks @vns for your help.

I think I missed the point that if I create a class that is (by default) not @Sendable then that actually should do the trick.

So whenever I create a new instance somewhere, the compiler will be able to infer the isolation domain where that was done and ensure that calls to the instance methods will be from the same isolation because the class is non-sendable. Is my understanding correct?

One concrete example where I am hitting the problem is Swift wrapper for MBEDTLS RNG module (mbed TLS v3.1.0: ctr_drbg.h File Reference).

I have created a class RNG that allocates the C struct, and initialises everything in init(), and frees everything in deinit. That actually seems to work fine. Here is the simplified example:

class RNG {
    var context = mbedtls_ctr_drbg_context()

    init() {
        mbedtls_ctr_drbg_init(& context)
    }

    deinit {
        mbedtls_ctr_drbg_free(& context)    
    }

    func generate(count: UInt) -> Data? {
        // use mbedtls_ctr_drbg_random on context to generate random bytes
    }
}

This compiles cleanly.

Now for convenience I also added a singleton public let shared = RNG() member so that I can access shared random number generator from multiple places, as I only need one shared random number generator, and that obviously generates an error:

Static property 'shared' is not concurrency-safe because non-'Sendable' type 'RNG' may have shared mutable state; this is an error in the Swift 6 language mode

I think I understand this error, and to make the RNG module concurrency safe, I should not add a shared singleton at all, but rather initialise and remember the RNG() instance where I need it. Problem solved.

So I think to answer my own question, I should not provide shared singletons for any non-sendable class as they are not compatible with concurrency world.

Sounds like a conclusion? Opinions?

thanks for your help,
Martin

2 Likes

Yes, that’s correct [1]. With concurrency checks on, compiler will ensure that the instance is accessed safely, that is — only from one isolation.

You can have shared instance isolated to a global actor like MainActor if that makes sense, but in general yes — the simplest solution is to avoid shared instances on such types. [2]

So expanding this I’d added

*unless you ensure this shared instance is safe to access concurrently in other way

P.S. I happened to put a lot of details in a footnotes, they provide a ways to explore this more, just didn’t want them to shift main attention in the first place :sweat_smile:


  1. As a side note, not that compiler infers isolation domain, but rather instances are created in certain isolation or without isolation and when they are non-Sendable, they cannot cross any isolation boundary and “stick” to isolation where they were created. There is an extension for these rules with region-based isolation, but safety is guaranteed all the way. ↩︎

  2. I guess there is a room for experimentation to provide some wrapper, probably using Mutex, to protect shared global instance so that it is not isolated to a global actor and safe to access without making type itself Sendable, if that’s an important detail, yet this is a much easier to address in comparison. ↩︎

2 Likes

Thanks @vns, all clear now. I was actually able to re-architect the code in a way that I do not rely on shared singletons for shared things but rather pull such dependencies out from the API and provide them via dependency injection which actually makes more sense and makes the code even cleaner. Building and testing now with 0 warnings, nice!

thanks for your help,
Martin

1 Like

Another option here would be to use a ~Copyable, Sendable struct. You would need to explicitly always borrow the shared instance, and also make sure that the generate method doesn’t mutate the context, but if both of those are possible, you don’t need to worry about the more stringent rules around Sendable classes since there won’t be any shared mutable state.

2 Likes

@j-f1 I think the main issue I faced (when looking at it now) was that if you define a singleton of something non Sendable via traditional:

class X {
  public static let shared: X = X()
}

Then you have no control in which isolation the .shared singleton will get instantiated, and therefore to which isolation domain it will be bound in the future.

That’s why I suggested making the struct Sendable (but non-copyable) :​)

1 Like

But compiler wouldn't be able to reason about safety here? context still is non-Sendable, so you'd have to still use @unchecked Sendable on a wrapper type. I'm still trying to figure out is this really safe? Haven't worked with non-copyable structs a lot, I understand that compiler ensures unique ownership that should make this safe— and if this the case should the compiler be able to analyze that? That could’ve been a useful tool for concurrency I guess. Yeah, that won't be safe anyway due to the pointer/ref-semantics.

If the C struct should be marked as Sendable because it does not reference any shared mutable state (e.g. via a pointer), then you can safely annotate it as NS_SWIFT_SENDABLE (or the raw annotation equivalent) in the header so it gets imported as Sendable. But from taking another look at the APIs here, I think the real issue is that the functions that operate on context all expect to receive a mutable pointer to the context. So you do actually need some mutable state inside RNG. So you’ll either need to make do with a non-Sendable type and create an instance per isolation domain, or create an actor that can provide async, serialized access to the context, or use a mutex to provide synchronous serialized access to the context.

2 Likes