When it comes to GUI programming, a single rule is paramount: you may only interact with the UI from the main thread (in Apple's platforms also variously called main queue, main run loop, or main actor depending on context). This thread is responsible for all UI events, and also responsible for owning window buffers and other critical GUI elements. This thread is therefore something like a networking event loop: it must not be blocked by user code, because if it is then the UI will become entirely unresponsive, and you'll get the spinning beach ball.
Similarly, NIO event loops also own threads (or queues in some cases). These are by intention and construction different from the main thread. As a result, you get the problem you have here: a question of thread safety.
Unfortunately, it's not so straightforward as "show me the code to make things thread safe". There is no one way to make things thread safe. Instead, it's a question of application architecture. Different components have different "threading rules" and "threading guarantees". These rules might be: "may only be called on the main queue", "may only be called on the event loop thread", or "must not block". There are also guarantees some APIs make: things like "may be called from any thread", "guarantees you will be called back on the appropriate thread", etc. A program is made thread safe by thinking about all places where multithreaded operations may take place and asking ourselves three questions:
- On what thread is this code going to be executing?
- On what other threads may this code execute?
- On what thread do I need to be to perform the operation I want to perform?
In the case of SwiftUI, all SwiftUI view code must execute on the main thread, and all events triggered by SwiftUI will be executed on the main thread. Additionally, all mutation of @ObservableObject
s must occur on the main thread as well. Practically speaking, all objects that are directly associated with UI state must be mutated on the main thread.
SwiftNIO's I/O code does not run on the main thread: it runs on its own event loop threads. As you have rightly asked, then, the question is: what operations are safe to perform on NIO objects from any thread, and what operations are safe to perform only from a specific thread?
NIO notes in the Channel
documentation that:
All operations on Channel
are thread-safe.
This concretely means you can call any method on a Channel
from any thread, and nothing bad will happen. An important caveat is warranted here: while Channel
is thread-safe, ChannelPipeline
is not, and you can get to the ChannelPipeline
from the Channel
. So just remember to take some care there.
I did mention, however, that .wait()
was a source of danger. Because Channel
methods are thread-safe, they cannot generally return a result directly: after all, you may not be on the thread that needs to execute the operation. This is why NIO returns an EventLoopFuture
. Similarly, it's common for Apple APIs to take a completion handler when doing the same thing.
In your case, it seems like the NetworkService
class is likely going to be used to interact with the UI. In that case, it should live on the main queue. The result would be you'd want it to look like this:
class NetworkService {
// These two fields may only be read or written on DispatchQueue.main.
private var connected = false
private var channel: NIO.Channel?
// These two fields are `let` and so may be accessed on any queue.
private let group: NIO.MultiThreadedEventLoopGroup
private let bootstrap: NIO.ClientBootstrap
init() {
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
self.bootstrap = ClientBootstrap(group: group)
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
channel.pipeline.addHandler(ResponseHandler())
}
}
public func connect(host: String, port: Int, completion: (_ success:Bool, _ error:String) -> Void) {
// This will catch errors in our assumptions. Note that as we read `connected`
// we are required to be on the main queue.
dispatchPrecondition(condition: .onQueue(DispatchQueue.main))
if connected {
completion(false, "ERROR: NetworkService already connected")
return
}
// whenCompleteBlocking is a NIO helper that will execute the callback provided
// on the given queue. As we want to edit `connected` and `channel` we have to
// execute this on `main`. We also want good hygiene around `completion`, which
// we should aim to always call back on a single, well-defined queue. In this case,
// it'll be `main`.
bootstrap.connect(host: host, port: port).whenCompleteBlocking(onto: DispatchQueue.main) { result in
do {
self.channel = try result.get()
self.connected = true
completion(true, "")
} catch {
self.connected = false
completion(false, "ERROR: NetworkService failed to connect")
return
}
}
}
public func disconnect(completion: (_ success:Bool, _ error:String) -> Void) {
// This will catch errors in our assumptions. Note that as we read `connected`
// and `channel` we are required to be on the main queue.
dispatchPrecondition(condition: .onQueue(DispatchQueue.main))
if !connected {
completion(false, "ERROR: already disconnected")
return
}
channel?.close().whenCompleteBlocking(onto: DispatchQueue.main) { result in
do {
self.channel = try result.get()
self.connected = false
completion(true, "")
} catch {
completion(false, "ERROR: NetworkService failed to disconnect")
return
}
}
}