NWConnection and DispatchQueue Lifecycle During Connection Teardown

I’m using Apple’s Network framework to implement a UDP client using NWConnection, and I have a question regarding the lifecycle of the DispatchQueue associated with an NWConnection instance.

Let's assume I have an NWConnection instance, and I associate it with a dispatch queue using the start(queue:) API, such that network OS events for the NWConnection instance can be delivered to this queue.

My understanding is that this association would result in NWConnection holding a strong reference to the DispatchQueue object.

Now, I perform some I/O (send/receive) on the NWConnection instance and immediately perform the following steps. Also, assume that the completion closures for those I/O operations do not capture or otherwise retain the NWConnection.

  1. Call connection.cancel() and then release my last strong reference to the NWConnection.
  2. Without waiting for the connection to transition to the .cancelled state, I also release my last strong reference to the associated DispatchQueue.

My question is:

Does NWConnection, during its teardown, retain the DispatchQueue until the cancellation completions for all pending I/O operations associated with the connection have been delivered/executed, given that the application no longer holds any strong references to either the NWConnection or the DispatchQueue?

Or, once cancel() is called, does NWConnection immediately release its reference to the DispatchQueue, in which case whether the pending callbacks are ultimately executed depends on whether the application has kept the queue alive?

Answered by DTS Engineer in 898956022
this … would result in NWConnection holding a strong reference to the DispatchQueue object.

Correct.

whether the pending callbacks are ultimately executed depends on whether the application has kept the queue alive?

That’s never the case. The connection will maintain the strong reference until it’s completely done with the queue.

You can break this up into two cases:

  • Completion handlers
  • Event handlers

For completion handlers the connection is required to call all completion handlers once and only once. It’s not safe to simple drop them. And the completion handlers must run on the connection’s queue. Thus, the connection must retain its strong reference to the queue until all completion handlers have returned.

For event handlers — state update handlers and so on — my advice is that you set the handler property to nil before calling cancel(). This isn’t because of the issue you’re concerned with, but rather because earlier versions of Network framework would generate retain loops if you didn’t do that [1] )-:

Here’s how I usually handle this:

  1. If I’m not already running on the connection’s queue, I bounce to that.
  2. I update my internal state to indicate that the connection is done.
  3. I set all event handlers to nil.
  4. I cancel the connection.

If any events were pending during this sequence, or were generated by this sequence, then I’m safe because:

  • If the event happens after setting the handler to nil, it won’t be delivered.
  • If the event happened before that, it can’t be delivered in the middle of the sequence because I’m running on the connection’s queue. Once my code returns and the queue is freed up, the event handler can run. I then have it check the internal state, as set by step 2, and immediately return if the connection is done.

Having said that, I see a lot of code that takes an opposite tack, and triggers all of its clean up on the .cancel event. I’m not massively opposed to that approach. I originally adopted my approach without a good reason, and only years later did I learn that it was protecting me from the above-mentioned retain loop!

Share and Enjoy

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

[1] See macOS Ventura 13 Release Notes.

this … would result in NWConnection holding a strong reference to the DispatchQueue object.

Correct.

whether the pending callbacks are ultimately executed depends on whether the application has kept the queue alive?

That’s never the case. The connection will maintain the strong reference until it’s completely done with the queue.

You can break this up into two cases:

  • Completion handlers
  • Event handlers

For completion handlers the connection is required to call all completion handlers once and only once. It’s not safe to simple drop them. And the completion handlers must run on the connection’s queue. Thus, the connection must retain its strong reference to the queue until all completion handlers have returned.

For event handlers — state update handlers and so on — my advice is that you set the handler property to nil before calling cancel(). This isn’t because of the issue you’re concerned with, but rather because earlier versions of Network framework would generate retain loops if you didn’t do that [1] )-:

Here’s how I usually handle this:

  1. If I’m not already running on the connection’s queue, I bounce to that.
  2. I update my internal state to indicate that the connection is done.
  3. I set all event handlers to nil.
  4. I cancel the connection.

If any events were pending during this sequence, or were generated by this sequence, then I’m safe because:

  • If the event happens after setting the handler to nil, it won’t be delivered.
  • If the event happened before that, it can’t be delivered in the middle of the sequence because I’m running on the connection’s queue. Once my code returns and the queue is freed up, the event handler can run. I then have it check the internal state, as set by step 2, and immediately return if the connection is done.

Having said that, I see a lot of code that takes an opposite tack, and triggers all of its clean up on the .cancel event. I’m not massively opposed to that approach. I originally adopted my approach without a good reason, and only years later did I learn that it was protecting me from the above-mentioned retain loop!

Share and Enjoy

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

[1] See macOS Ventura 13 Release Notes.

Thanks @DTS Engineer .

I understand that NWConnection needs to invoke all completion handlers for the pending I/O operations associated with it.

Just to confirm my understanding of the completion-handler case:

Suppose an NWConnection has some outstanding I/O operations with completion handlers registered. If the application calls cancel() and then releases its strong references to both the NWConnection and the DispatchQueue passed to connection.start(queue:).

Since those completion handlers must execute on the connection's queue, does this mean that Network framework internally keeps the necessary connection/queue resources alive after the application releases its references, until all outstanding completion handlers have been invoked and have returned?

In other words, is it correct to think of the lifecycle as:

The application calls connection.cancel(). The application releases its references to the NWConnection and the DispatchQueue. Network framework retains the necessary internal state and the queue because outstanding completion handlers still need to be delivered. The pending completion handlers are invoked on the connection's queue. Once all completion handlers have returned, Network framework can release its internal references to the queue and complete the connection's teardown.

Or, alternatively, is the application expected to track all pending I/O operations and retain the DispatchQueue reference until all completion handlers have been delivered? If so, would releasing the queue reference before all completions are delivered cause any issue, or would it simply be retained internally by Network framework?

I want to confirm whether the application needs to explicitly manage this lifetime, or whether Network framework manages the queue's lifetime internally until all outstanding completion handlers have completed.

does this mean that Network framework internally keeps the necessary connection/queue … until all outstanding completion handlers have been invoked and have returned?

Yes.

Although it’s probably better to replace “Network framework” with “the system”, because the exact code that does this is an implementation detail. Consider this sequence:

  1. Network framework schedules a completion handler on a queue.
  2. Dispatch retains the queue.
  3. Some time later, Dispatch calls the completion handler.
  4. The completion handler returns.
  5. Dispatch releases the reference to the queue it took in step 2.
  6. Network framework notices that there’s no more work to do and releases its reference to the queue.

You could move step 6 to between steps 2 and 3 and everything would continue working, and we reserve the right to change such implementation details.

I want to confirm whether the application needs to explicitly manage this lifetime …

It does not. It’s perfectly valid to write code like this:

func createAndStartConnection(endpoint: NWEndpoint, params: NWParameters) -> NWConnection {
    let queue = DispatchQueue(label: "my-queue")
    let connection = NWConnection(to: endpoint, using: params)
    connection.start(queue: queue)
    return connection
}

By the time this function returns, only the connection is keeping the queue alive, and that’s fine.

Now, I normally don’t write code like this because:

  • I tend to use a single queue for all my network connections, rather than one queue per connection.
  • I like to be able to schedule my own work on that queue — for serialisation purposes — and thus I need to maintain my own reference to it.

However, that’s a design choice on my part; it’s not required by the API.

Share and Enjoy

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

Thanks @DTS Engineer .

I understand now that the Network Framework/System ensures that all pending I/Os associated with an NWConnection have their associated completion handlers invoked, even if the application has released its strong references to the NWConnection (after calling cancel(), which will eventually cause the pending I/Os to be cancelled) and the DispatchQueue.

So the flow looks like:

  1. The application performs an asynchronous I/O which have a completion handler/callback associated with it.
  2. The I/O remains pending in the Network framework / underlying I/O machinery.
  3. The application calls cancel() on the NWConnection and then releases its own strong references to both the NWConnection and the DispatchQueue.
  4. The pending I/O eventually reaches completion or cancellation.
  5. The Network framework then schedules the associated completion handler to execute on the connection's queue.
  6. Once there is no further work requiring the queue, the Network framework/System can release its internal references/resources associated with the NWConnection and the DispatchQueue, allowing the connection to be fully torn down and the queue to be deallocated when there are no remaining references.

Is the above understanding correct?

Yep.

Or at least that’s how it’s supposed to work (-;

I’ve not heard any complaints about it failing (other than the retain loop issue I mentioned above) so I think you’re on pretty safe ground here. However, if you see anything different, please let us know.


I want to stress that this design pattern — where each subsystem is responsible for maintaining the strong references it needs — is pretty central to Apple platforms. It does back to the days of manual retain-release (MRR) in Cocoa, where code had to explicitly call retain and release routines for strong references. A key property of that model was locality, that is, you could look at a piece of code in isolation and know whether it was doing MRR correctly or not. That means that the Objective-C static analyser could check your MRR code automatically, and that in turn lead to Objective-C automatic reference counting (ARC), which fed directly into Swift’s ARC.

Share and Enjoy

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

NWConnection and DispatchQueue Lifecycle During Connection Teardown
 
 
Q