Home About

Non-Sendable Core, Sendable Shell

-- Minutes to Read

Swift Concurrency seems to be one of the hard Swift topics, and one thing we can do in Computer Science is to continue pushing hard problems away to another layer of abstraction. Naturally, we can do that with Swift Concurrency too!

This isn’t an article about understanding isolation or Sendable. I assume you already are somewhat familiar with basic Swift Concurrency concepts. Rather, I more or less want to outline the pain points around those concepts, and demonstrate a technique for pushing them to the edge.

TLDR Example

Since this article naturally draws out the explanation to include many details, I’ll give a TLDR code example right away. That is, non-Sendable types are much more flexible than Sendable types. Sendable types require either locking or actor isolation overhead, so it’s much easier to defer such mechanisms as long as possible.


// ✅ By making CactusLanguageModel non-Sendable, we can reuse it 
// in any Sendable type whether it's an actor, or class with a lock.

final class CactusLanguageModel {
  // A non-Sendable type that can be used from any actor isolation context...
}

final actor CactusLanguageModelActor {
  let model: CactusLanguageModel

  init(model: sending CactusLanguageModel) {
    self.model = model
  }

  // ...
}

final class SomeState: Sendable {
  struct State {
    let model: CactusLanguageModel
    var otherState: Value
  }

  private let state = Mutex(State())

  // ...
}
      

With that out of the way, now we can focus on dragging out the explanation from first principles. However, I would first like to look at a corollary idea, and where this article gets its naming scheme from.

Functional Core, Imperative Shell

The name of this article comes from the “Functional Core, Imperative Shell” idea. If you’re unfamiliar with “Functional Core, Imperative Shell”, here’s a simple code example to illustrate.


struct Item {
  // ...
}

// ❌ Bad, mixes IO (side effects) and business logic.

func processBad() async throws {
  let items = try await loadItems()
  let transformed = items.filter { /* ... */ }
    .map { /* ... */ }
    .reversed()
    .dropFirst()
  try await save(transformed)
}

// ✅ Good, separates IO (side effect) from business logic

func processGood() async throws {
  let items = try await loadItems()
  try await save(items.transformed)
}

extension Sequence where Element: Item {
  var transformed: some Sequence<TransformedItem> {
    items.filter { /* ... */ }
      .map { /* ... */ }
      .reversed()
      .dropFirst()
  }
}
      

By separating out the collection transformation, we gain more flexibility. That is, we can choose to reuse the transformation logic regardless of how the underlying data is persisted, or even write isolated unit tests just for the transformation logic. In general, once IO or side effects are introduced to your code, it becomes much less flexible.

In the above example, the transformation logic is what we call the “functional core”, and the IO is what we call the “imperative shell”.

Async Makes Things Harder

It turns out, that we can also apply this concept to Swift Concurrency. Generally speaking, once async/await is required to call a method, things get much harder. If you’re in a synchronous context, you’ll either have to create an unstructured task, or mark the enclosing method as async.


func foo() {
  // ❌
  await someWork()
}

// ✅ Either Approach Compiles

func foo() async {
  // 🔵 Delegates async responsibility to whoever calls foo
  await someWork()
}

func foo() {
  // 🔵 Completely hides the fact that async unstructured work has been started
  Task { await someWork() }
}
      

Furthermore, you open a whole can of worms regarding Sendable once you start getting into concurrency land. When it comes to dealing with non-Sendable types, you have to take special care when passing them across actor isolation boundaries, otherwise the Swift compiler will hate you.

Thankfully, the sending keyword can help somewhat when it comes to non-Sendable types and tasks. This keyword allows you to transfer non-Sendable values across isolation boundaries, which means that you can use non-Sendable values within tasks.


func foo(value: sending NonSendable) {
  Task {
    // ✅ value is being explicitly transferred into the Task's 
    // isolation context
    let v = value
  }
}
      

For instance, you can’t use sending to capture a non-Sendable value inside an @Sendable closure.


func badTransfer(
  value: sending NonSendableValue
) -> @Sendable () -> Void {
  // ❌
  return { work(value) }
}
      

The reason this doesn’t work is because @Sendable closures are meant to be thread-safe, and as a result may be called multiple times from different threads. Once you understand that fact, it becomes clear why the above code example doesn’t work. The point of the sending keyword is only to send a value to a different isolation context, and nothing more. In fact, the original proposal for sending actually named the keyword transferring at first.

Conforming to Sendable can be Annoying

If we can’t use the sending keyword to get around concurrency issues, the next step up is to conform to Sendable directly. For simple structs, this is trivial because the compiler can infer thread-safety for you. In fact, I personally do this by default whenever I make a new simple struct data type.


// ✅ Trivially Sendable.
//
// I always tend to conform simple structs to Hashable and Sendable 
// without second thought.
struct Item: Hashable, Sendable {
  let fieldA: Int
  let fieldB: String
}
      

However, things get harder when we need to deal with reference types. Any non-final class, or even simple mutable properties will prevent an auto synthesized Sendable conformance.


// ❌ Cannot have mutable properties
final class Item: Sendable {
  var fieldA: Int
  var fieldB: String
}

// ❌ Must be a final class 
// (Subclasses could add mutable/non-Sendable properties otherwise)
class Item: Sendable {
  let fieldA: Int
  let fieldB: String
}
      

Unchecked Sendable

Of course, you can use @unchecked Sendable, but that usually isn’t something you want to deliberately touch because it turns off compiler checking for Sendable. That is, the compiler can no longer offer any guarantees that your type is thread-safe. Think of it in the same vein as force-unwrapping an optional. It’s allowed, but you better know what you’re doing.

I’m not suggesting that you should never use @unchecked Sendable. In fact, if you go deep enough it’s almost certain that you will need to use it at some point. Just make sure you justify yourself when you do so.

Actors

Another way to get automatic an Sendable conformance is to convert your type to an actor (or just throw a global actor like @MainActor at the problem).


// ✅ Actors are Sendable by default.

actor Item {
  var fieldA: Int
  var fieldB: String
}

@MainActor
final class Item {
  var fieldA: Int
  var fieldB: String
}
      

For @MainActor specifically, new Xcode projects even make MainActor isolation the default for all declarations. However, if you want a type be usable from multiple isolation contexts, you will have to mark it nonisolated in addition to a Sendable conformance.


nonisolated struct Item: Sendable {
  var fieldA: Int
  var fieldB: String
}
      

On a personal note, I tend not to use default MainActor isolation. At least for me, I like to be explicit when something is main actor compatible, and since I tend to write a lot of thread-agnostic non-UI code in addition UI code, I find it simpler to keep everything consistent and turn off default Main Actor isolation.

Regardless of how you choose to use actors, you’ll be dealing with async/await when you’re not isolated to the actor. As previously mentioned above, adding async just complicates things.

With actors, there’s also more lesser-understood points of conceptual overhead.

Avoiding Sendable

Since Sendable brings a lot of inconveniences, we have to ask ourselves the question of how long it can be avoided. In today’s world, the answer to this that we can get quite far with non-Sendable types.

Async Methods in Non-Sendable Types

One of the previous issues with non-Sendable types was the notion of having async methods on them. Invoking them would cause an isolation hop to a non-isolated context, which would produce often very non-intuitive errors.


class NonSendable {
  func work() async {
    // ...
  }
}

@MainActor
@Observable
final class MyViewModel {
  let nonSendable = NonSendable()

  func buttonTapped() async {
    // ❌
    await self.nonSendable.work()
  }
}
      

However, today you can use nonisolated(nonsending) to fix this error.


class NonSendable {
  nonisolated(nonsending) func work() async {
    // ...
  }
}

@MainActor
@Observable
final class MyViewModel {
  let nonSendable = NonSendable()

  func buttonTapped() async {
    // ✅
    await self.nonSendable.work()
  }
}
      

What this tells the compiler is that work will execute on the isolation context that it is invoked on. In fact, you can even enable this behavior by default through the NonIsolatedNonSendingByDefault flag.

This theoretical example is a textbook definition of “NonSendable core, Sendable Shell”. Now, we can look at some more practical examples involving wrapping a C library that I’m involved in semi-regularly.

Practical Example: Swift Cactus

In particular, say you need to wrap a C library with weird threading semantics, but need to integrate it with Swift Concurrency. For this example, I’ll wrap the cactus inference engine, which is highly energy efficient and performant alternative to MLX, ONNX, llama, etc. that runs primarily on the CPU. I’m not going to make a sales pitch for the engine in this article, and instead I’ll focus on wrapping it from Swift. In fact, the principles in these code examples can be found in the swift-cactus package that I actively maintain (which is much more idiomatic than the Swift example in the main cactus repo).

The Cactus FFI exposes many functions, so I’ll only include heavily simplified signatures of some of the basic FFI functions for simplicity.


// Loads model weights.
CACTUS_FFI_EXPORT cactus_model_t cactus_init(
    const char* model_path
);

// Deallocate a model.
CACTUS_FFI_EXPORT void cactus_destroy(cactus_model_t model);

// Stops an active ongoing generation for a model.
CACTUS_FFI_EXPORT void cactus_stop(cactus_model_t model);

// Runs inference for a given model, and outputs the 
// result into response_buffer.
CACTUS_FFI_EXPORT int cactus_complete(
    cactus_model_t model,
    const char* messages_json,
    char* response_buffer,
    size_t buffer_size
);
      

cactus_model_t itself is an opaque pointer, but under the hood it’s represented as a struct in C++ that looks something like this. (Once again, very simplified from the actual handle[^1].)


struct CactusModelHandle {
    std::unique_ptr<cactus::engine::Model> model;
    std::atomic<bool> should_stop;

    CactusModelHandle() : should_stop(false) {}
};
      

As we can see, we have an atomic for whether or not ongoing inference should stop. Furthermore, the Model class does not use any locks under the hood. This puts us in a weird case where cactus_stop is safe to call from different isolation contexts in Swift, but cactus_complete is not.

However, LLM inference is also incredibly expensive, and certainly you should not be doing it from the Main Actor. However, only exposing the model API behind an actor would limit the Swifty wrapper to purely async contexts.

As such, I think this is a perfect case for non-Sendable core, Sendable shell. Since we’re wrapping a pointer from an underlying C library, we can use a non-Sendable and non-Copyable struct to ensure ownership semantics when passing the language model around. Then, we can create a wrapper CactusLanguageModelActor that wraps our non-Copyable type to grant proper built-in Sendable and background execution to the inference engine.


  struct CactusLanguageModel: ~Copyable {
  private let pointer: cactus_model_t

  init(pointer: consuming cactus_model_t) {
    self.pointer = pointer
  }

  init(path: URL) {
    guard let pointer = cactus_init(path.absoluteString) else {
      throw CactusLanguageModelError.failedToLoadModel
    }
    self.init(pointer: pointer)
  }

  deinit {
    cactus_destroy(self.pointer)
  }

  func complete(messages: [ChatMessage]) throws -> ChatCompletion {
    let encodedMessages = try JSONEncoder().encode(messages)
    let buffer = UnsafeMutableBufferPointer<CChar>.allocate(capacity: 8192)
    defer { buffer.deallocate() }
    let result = cactus_complete(
      self.pointer,
      String(decoding: encodedMessages, as: UTF8.self),
      buffer,
      8192 * MemoryLayout<CChar>.stride
    )
    if result < 0 {
      throw CactusLanguageModelError.badResponse
    }
    let data = bufferToData(buffer)
    return try JSONDecoder().decode(ChatCompletion.self, from: data)
  }

  func stop() {
    cactus_stop(self.pointer)
  }
}

actor CactusLanguageModelActor {
  private actor DefaultIsolation {}

  private let defaultIsolation = DefaultIsolation()
  private let executor: (any SerialExecutor)?
  private let model: CactusLanguageModel

  nonisolated var unownedSerialExeuctor: UnownedSerialExecutor {
    self.executor?.asUnownedSerialExecutor() ?? self.defaultIsolation.unownedSerialExecutor
  }

  init(
    executor: (any SerialExecutor)? = nil,
    model: consuming sending CactusLanguageModel
  ) {
    self.executor = executor
    self.model = model
  }

  func complete(messages: [ChatMessage]) async throws -> ChatCompletion {
    try self.model.complete(messages: messages)
  }

  func stop() {
    self.model.stop()
  }

  borrowing func withModel<T, E: Error>(
    operation: (CactusLanguageModel) throws(E) -> sending T
  ) throws(E) -> sending T {
    try operation(self.model)
  }
}
      

As you can see, the actor provides quite a few concurrency specific additions, such as even allowing a custom SerialExecutor to be used. This latter bit could be useful for an application because the inference engine will block the cooperative thread pool for a long time. A custom executor allows you to create a dedicated thread outside of the cooperative thread-pool for inference in your application.

However, because we split the non-Sendable core from the Sendable shell, we could also decide to use the language model inside another completely synchronous context. This could include a class with a lock, a non-main global actor, or another actor that bundles various non-Sendable types together. If our needs are simple enough, we could even just rely on region based isolation with sending.


final class LockedModel: Sendable {
  let model: Mutex<CactusLanguageModel>

  // ...
}

final actor CactusModelAgent {
  var transcript: [ChatMessage]
  var nonSendable: NonSendable
  let model: CactusLanguageModel

  // ...
}

@NonMainGlobalActor
final class ModelActor {
  let model: CactusLanguageModel
}

func infer(model: sending CactusLanguageModel) async throws {
  // ...
}
      

Of course, it is also possible to use CactusLanguageModel irresponsibly like this.


@MainActor
@Observable
final class Store {
  // ❌ Really bad idea
  let model: CactusLanguageModel
}
      

However, the flexibility out weights the potential cons of doing something not so bright. Even if someone were to attempt to do inference on the main actor, we could always check if the caller is on the main actor and emit a purple runtime warning discouraging that behavior using a library such as IssueReporting.

Conclusion

Non-Sendable types are actually quite nice to work with. You don’t have to worry about thread-safety, isolation, or any other weird cryptic errors. Even nonisolated(nonsending) will eventually become the default for all async functions, but you can enable default behavior for it today through the NonIsolatedNonSendingByDefault flag.

Further, when you focus on making things non-Sendable, you gain more control over how you want to handle the concurrency behavior for that logic. Whether that’s through local actor isolation, @MainActor/global actor isolation, a class with a lock, or even just through region based isolation with sending, you get that flexibility.

At the end of the day, concurrency is difficult, and it’s why we have tools like Swift Concurrency in the first place. That being said, the simplest form of concurrency is often no concurrency, so I would try to stay in that land for as long as possible.

— 2/25/26

[^1]: At the time of writing this, if you look at the actual source code for the engine, you will find a model_mutex inside the handle struct. However, it isn’t actually used in cactus_complete. As a result, the model pointer still must be treated as non-thread-safe in higher level code that calls into the inference engine.