Best Practice for Scheduling EASession Input and Output Streams

My company builds an application using the External Accessory framework to communicate with our hardware. We have followed the documentation and example here and use the stream delegate pattern for scheduling the handling of the EASession's InputStream and OutputStream: https://developer.apple.com/library/archive/featuredarticles/ExternalAccessoryPT/Articles/Connecting.html

Our application works, however we have had some issues that cause us to doubt our implementation of the Stream handling for our EASession.

All the examples I can find for how to set up this RunLoop based implementation for managing and using the streams associated with the EASession seem to use RunLoop.current to schedule the InputStream and OutputStream. What is not clear to me is what thread the processing of these streams is actually getting scheduled upon.

We have occasionally observed our app "freezing" when our connected accessory disconnects, which makes me worry that we have our Stream processing on the main thread of the application. We want these streams to be processed on a background thread and never cause problems locking up our main thread or UI.

How exactly do we achieve this? If we are indeed supposed to only use RunLoop.current, how can we make sure we're opening the EASession and scheduling its streams on a non-main thread?

On what thread will we receive EAAccessoryDidConnect and EAAccessoryDidDisconnect notifications? Is it safe to schedule streams using RunLoop.current from that thread? What about when the app returns from the background, how are we meant to reconnect to an accessory that the iOS device is already connected to?

Hopefully someone here can help guide us and shed some light on how to achieve our desired behavior here.

Answered by DTS Engineer in 790319022

After writing the above I decided to try this for myself and… sheesh… the details are a quite persnickety. So, pasted in below is a concrete example of the process I’m talking about.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


import Foundation

final class RunLoopThread: NSObject {

    static let shared: RunLoopThread = RunLoopThread(name: "RunLoopThread.shared")
    
    init(name: String) {
        // We have to call `super` before initialising `thread` because
        // initialising it requires that `self` be fully initialised.  To make
        // this work we make `thread` an optional, initialise it to `nil`, and
        // then reinitialise it with our actual thread.
        self.thread = nil
        super.init()
        let thread = Thread(
            target: self,
            selector: #selector(threadEntryPoint),
            object: nil
        )
        thread.name = name
        thread.start()
        self.thread = thread
    }
    
    private var thread: Thread?
    
    @objc
    private func threadEntryPoint() {
        // We need an initial run loop source to prevent the run loop from
        // stopping.  We use a timer that fires roughly once a day.
        Timer.scheduledTimer(withTimeInterval: 100_000, repeats: true, block: { _ in })
        RunLoop.current.run()
        fatalError()
    }
    
    func run(_ body: @escaping () -> Void) {
        self.perform(
            #selector(runBody(_:)),
            on: self.thread!,
            with: body,
            waitUntilDone: false,
            modes: [RunLoop.Mode.default.rawValue]
        )
    }
    
    @objc
    private func runBody(_ body: Any) {
        let body = body as! (() -> Void)
        body()
    }
}

func main() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        RunLoopThread.shared.run {
            Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
                print("timer did fire, thread name: \(Thread.current.name ?? "-")")
            }
        }
    }
    dispatchMain()
}

main()

Run loops are one of those things where their behaviour seems obvious once you know what’s going on (-: Many years ago I explained this on stage at WWDC. WWDC 2010 Session 207 Run Loops Section has the transcript.

All the examples I can find for how to set up this RunLoop based implementation for managing and using the streams associated with the EASession seem to use RunLoop.current to schedule the InputStream and OutputStream.

Yes. This is best practice. Try to avoid doing cross-thread run loop manipulation.

What is not clear to me is what thread the processing of these streams is actually getting scheduled upon.

That’d be the current thread.

We want these streams to be processed on a background thread and never cause problems locking up our main thread or UI. How exactly do we achieve this?

I generally recommend that you bounce to the thread on which you want the callbacks to run and then use RunLoop.current from there. My preferred mechanism to do this inter-thread bounce is the various -[NSThread perform*] methods.

[quote='756281021, jlucier, /thread/756281, /profile/jlucier'] On what thread will we receive EAAccessoryDidConnect and EAAccessoryDidDisconnect notifications? [/quote]

We want these streams to be processed on a background thread and never cause problems locking up our main thread or UI. How exactly do we achieve this?

[quote='756281021, jlucier, /thread/756281, /profile/jlucier'] Is it safe to schedule streams using RunLoop.current from that thread? [/quote]

On what thread will we receive EAAccessoryDidConnect and EAAccessoryDidDisconnect notifications?

My general advice is:

  • Try to do as much run loop work as possible on the main thread.

  • If your run loop callbacks need to do significant work, think about bouncing that work to a secondary thread (or queue, or actor) while keeping the run loop source on the main thread.

  • If you decide that you must use your own thread for run loop work, create a single thread for that work using NSThread. Then bounce to that thread to add and remove run loop sources.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Can you please link me to documentation for the functions you've mentioned here (and/or share an example)? I'm having a hard time finding anything about them.

My preferred mechanism to do this inter-thread bounce is the various -[NSThread perform*] methods.

Yeah, this is tricky because these methods are added via a category. The specific thing I was referring to is the perform(_:on:with:waitUntilDone:modes:) method. However, there’s also the perform(inModes:block:) method, which is much more ergonomic.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks for the replies Quinn. Here's a little more explicit line of questioning.


The stream programming guide explicitly states:

You should never attempt to access a scheduled stream from a thread different than the one owning the stream’s run loop.

Given that statement, it seems to me that I want to be extremely explicit about scheduling those streams on a particular thread which is not the Main (assuming your goal is to have blocking operations off the Main). Not just using whichever thread happens to be the one owning RunLoop.current. It's pretty intuitive that RunLoop.current means "the current thread", that was always clear. However, the real question is "which thread is the 'current' one?" If I don't want my stream processing to happen on the Main thread, how do I do that if my only options are RunLoop.current or RunLoop.main? What if current is main?

I'm left with basically no idea how to schedule the streams on a thread intentionally. Obviously, I might have a better idea how to do that if I was an expert on your concurrency model, but alas I am not.

Is that recommendation in the stream programming guide not actually accurate? Or, if it is accurate, how can I achieve what I want with DispatchQueue or Thread. I'm not totally getting how this can be accomplished. I think a little example code would go a long way. Thanks!

I'm left with basically no idea how to schedule the streams on a thread intentionally. Obviously, I might have a better idea how to do that if I was an expert on your concurrency model, but alas I am not.

Is that recommendation in the stream programming guide not actually accurate? Or, if it is accurate, how can I achieve what I want with DispatchQueue or Thread. I'm not totally getting how this can be accomplished. I think a little example code would go a long way. Thanks!

Allow me to clarify a little bit here. I've figured out a couple of ways to schedule the streams on a specific thread, but I simultaneously need to be able to trigger bytes to be written to the output stream on that same thread owning the RunLoop.

It is not clear what is the best way to stitch together your concurrency primitives to achieve this, so that we never access the streams from the Main thread and we keep all RunLoop and stream I/O associated with our streams on a single, separate thread.

If I don't want my stream processing to happen on the Main thread, how do I do that if my only options are RunLoop.current or RunLoop.main?

The trick is to start your own thread for all the work involving run loop sources [1]. Use Thread to start the thread. Use the above-mentioned perform APIs to get to that thread to start and stop your streams, write data, and so on. That means that, when it comes time to schedule your stream, you can pass in .current for the run loop. It also means that events associated with that stream will be delivered to that thread.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Well, the run loop sources you don’t want to handle on the main thread. IMO it’s generally best to use the main thread for all your run loop sources, and then move stuff off the main thread only if you have evidence that it’s causing delays.

After writing the above I decided to try this for myself and… sheesh… the details are a quite persnickety. So, pasted in below is a concrete example of the process I’m talking about.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


import Foundation

final class RunLoopThread: NSObject {

    static let shared: RunLoopThread = RunLoopThread(name: "RunLoopThread.shared")
    
    init(name: String) {
        // We have to call `super` before initialising `thread` because
        // initialising it requires that `self` be fully initialised.  To make
        // this work we make `thread` an optional, initialise it to `nil`, and
        // then reinitialise it with our actual thread.
        self.thread = nil
        super.init()
        let thread = Thread(
            target: self,
            selector: #selector(threadEntryPoint),
            object: nil
        )
        thread.name = name
        thread.start()
        self.thread = thread
    }
    
    private var thread: Thread?
    
    @objc
    private func threadEntryPoint() {
        // We need an initial run loop source to prevent the run loop from
        // stopping.  We use a timer that fires roughly once a day.
        Timer.scheduledTimer(withTimeInterval: 100_000, repeats: true, block: { _ in })
        RunLoop.current.run()
        fatalError()
    }
    
    func run(_ body: @escaping () -> Void) {
        self.perform(
            #selector(runBody(_:)),
            on: self.thread!,
            with: body,
            waitUntilDone: false,
            modes: [RunLoop.Mode.default.rawValue]
        )
    }
    
    @objc
    private func runBody(_ body: Any) {
        let body = body as! (() -> Void)
        body()
    }
}

func main() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        RunLoopThread.shared.run {
            Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
                print("timer did fire, thread name: \(Thread.current.name ?? "-")")
            }
        }
    }
    dispatchMain()
}

main()

Thanks again for the reply and the detailed example. Very helpful!

One followup:

IMO it’s generally best to use the main thread for all your run loop sources, and then move stuff off the main thread only if you have evidence that it’s causing delays.

If this is your colloquial wisdom, how do we square this with the recommendations from the stream programming guide?

My train of thought goes as follows, and hopefully you can see where this breaks down:

  1. Your recommendation is to use the Main thread for run loops generally. Let's assume we aren't special and fall into the general case, so maybe we should use Main.
  2. That means the streams should get be scheduled (via schedule) on the Main thread.
  3. The stream programming guide says that you should never access a stream from a thread other than the one owning its RunLoop. Ok, that also must happen on Main I suppose.
  4. Read and write operations have the potential to cause indefinite blocking (apparently), which I suppose we just have to... tolerate as a risk?

If you have confidence that other EAAccessory users are successfully using the Main thread for scheduling their stream processing, I think that would be confidence enough for me. I am just getting the sense that I must be a bit crazy following this line of questioning. This threading / deadlock problem appears to be given absolutely zero attention in the EAAccessory guides, but explicitly warned about in the Stream programming guide, leaving me unsure about what is actually the recommended approach. If using Main is "fine" or "recommended", then I suppose that's what we'll keep doing.

If this is your colloquial wisdom, how do we square this with the recommendations from the stream programming guide?

Programming is an art, not a science, so it’s not uncommon for folks to have differing opinions as to best practice. However, in this case I think the confusion stems from this:

Read and write operations have the potential to cause indefinite blocking

If you schedule your streams on a run loop, you are expected to using them in a non-blocking fashion. That means:

  • You only call the read method after receiving a has-data-available event.

  • You only call the write method after receiving a has-space-available event.

If you do that then neither the read not write calls should block, in which case it’s fine to do this all on the main thread.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

That is quite helpful! Thank you. Did I miss that somewhere in the docs?

Can the same guarantee be made about the corresponding properties that exist on the stream classes? For instance, will checking hasSpaceAvailable before calling write guarantee no blocking the same as waiting for a hasSpaceAvailable event before writing?

The doc here suggests maybe not since a write may need to be attempted to find out? https://developer.apple.com/documentation/foundation/outputstream/1411335-hasspaceavailable

hasSpaceAvailable

true if the receiver can be written to or if a write must be attempted in order to determine if space is available, false otherwise.

Accepted Answer
Did I miss that somewhere in the docs?

I’m not sure. It’s one of those ‘obvious’ things that everyone knows, except that they don’t )-:

Can the same guarantee be made about the corresponding properties that exist on the stream classes?

I wouldn’t expect those to block but I recommend against using them. When scheduling a stream on a run loop, it’s best to rely on the events being delivered to you. So, you don’t need to use the hasSpaceAvailable property because you track that state yourself based on the .hasSpaceAvailable event.

This speaks to another one of those ‘obvious’ things that’s probably not written down anywhere: It’s best not to loop in your event handler. I recommend an event handler that looks something this:

class OutputStreamWrangler: NSObject, StreamDelegate {
    
    … other stuff …
    
    let outputStream: OutputStream
    
    var bufferedData: Data
    var hasSpaceAvailable: Bool
    
    func stream(_: Stream, handle eventCode: Stream.Event) {
        switch (eventCode) {
        case .hasSpaceAvailable:
            self.hasSpaceAvailable = true
            self.writeBufferedData()
        … other cases …
        }
    }
    
    private func writeBufferedData() {
        guard !self.bufferedData.isEmpty, self.hasSpaceAvailable else {
            return
        }
        self.hasSpaceAvailable = false
        let bytesWritten = self.outputStream.writeData(bufferedData)
        if bytesWritten > 0 {
            self.bufferedData.removeFirst(bytesWritten)
        }
    }

    func writeData(_ data: Data) {
        self.bufferedData.append(data)
        self.writeBufferedData()
    }
}

In this design, once you write a chunk of data you assume that there’s no more space available until you get your next has-space-available event.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

This was a winding conversation, but I think I've got the information I need now. Thank you kindly for all the replies!

Best Practice for Scheduling EASession Input and Output Streams
 
 
Q