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.
-
The
SerialExecutorprotocol allows an actor to customize its execution environment.- Useful if you need to run things on a dedicated thread.
-
Any non-Sendable type returned from an actor method also requires
sending- Furthermore, the value returned from the method also cannot be an instance member of the actor, because doing so would make the non-Sendable type present in 2 separate isolation contexts.
-
Actor interleaving.
-
Only 1 task can enter an actor at a time, so if a task must
await another
asyncmethod within the actor, it must leave the current actor to swap isolation contexts. This allows another task to enter the actor in the meantime.
-
Only 1 task can enter an actor at a time, so if a task must
await another
-
Actor re-entrancy.
- Once the original task from the interleaving explanation is done performing its async work, it must re-enter the original actor that it once left to continue execution past the suspension point it previously awaited.
-
isolatedparameters.- This allows one to declare methods external to the actor as being isolated to the actor.
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.