Controlling the number of Pending Send Completions using NWConnection

Context: We are using NWConnection for UDP and TCP Connections, and wanted to know the best way to keep the number of pending send completions in control to limit resource usage

Questions:

  1. Is there a way to control the send rate, such that too many 'send pending completion' does not get queued. Say if I do a ‘extremely dense flurry of 10 million NWConnection.send’ will all go asynchronous without any complications? Or I would be informed once it reaches some threshold.
  2. Or no? And is it the responsibility of the application using NWConnection.send to limit the outstanding completion , as if they were beyond a certain limit, it would have an impact on outstanding and subsequent requests?
  3. If so – how would one know ‘what is supposed to be the limit’ at runtime? Is this a process level or system level limit.
  4. Will errors like EAGAIN and ETIMEOUT ever will be reported. In the test I simulated, where the TCP Server was made to not do receive, causing the 'socket send buffer' to become full on the sender side. On the sender side my send stopped getting complete, and became pending. Millions of sends were pending for long duration, hence wanted to know if we will ever get EAGAIN or ETIMEOUT.
Answered by DTS Engineer in 822865022

Within Network framework, every connection has a send buffer [1]. That buffer has a high-water mark, that is, its expected maximum size. When you send data on the connection, the system always adds the data to the buffer. After that, one of two things happens:

  • If the amount of buffered data is below the high-water mark, the system immediately calls the completion handler associated with the send.

  • If not, it defers calling your completion handler. That is, it holds to on the completion handler and only calls it once the amount of buffered data has dropped to a reasonable level.

If you have a lot of data to send, the easiest approach is to send a chunk of data and, in the completion handler, send the next chunk. Assuming the network is consuming data slower than you’re producing it, the amount of buffered data will rapidly increase until it exceeds the high-water mark. At that point the system will stop calling your completion handler, which means you’ll stop sending new data. This gives the network tranport a big buffer of data, allowing it to optimise its behaviour on the wire.

I think the above will let you resolve all your specific questions, but please do reply here if you need further help.

Finally, if you combine Network framework with another API that uses completion handlers, you might find the techniques shown in Handling Flow Copying to be useful.

Share and Enjoy

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

[1] You can think of this like a socket send buffer and a user-space buffer, but keep in mind that in many cases Network framework doesn’t use BSD Sockets for its networking but instead relies on a user-space networking stack.

Hi @DTS Engineer ,

Thanks for the information. It really helps!

Coming back to what you mentioned here

In practice, however, it’s reasonable to assume that, once you receive the completion handler for the last send, the others are on their way.

Just to follow up on something we've discussed previously, if I’m sending 100 requests in a batch, with the first 99 being idempotent and only the last request having a completion callback, is it guaranteed that all the earlier sends have been completed from send perspective once I receive the callback for the final send?

Written by harshal_goyal in 824912022
is it guaranteed that all the earlier sends have been completed from send perspective once I receive the callback for the final send?

From a send perspective, yes.

Oh, wait, one final caveat. I’m assuming that your connection’s Dispatch queue is a serial queue. If you use a concurrent queue, all bets are off.

But you shouldn’t be using a concurrent queue for network connections. Concurrent queues are a worry in general [1], and would be completely bananas for this case.

Share and Enjoy

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

[1] See Avoid Dispatch Global Concurrent Queues.

Thanks @DTS Engineer / @harshal_goyal

This whole thread is to evaluate below as an proposed approach to do multiple sends (stream of bytes or datagram) on a NWConnection inside batch

• Use the batch(_:) method. • Inside the batch, send each datagram. • Add a completion handler only to the last.

To achieve or undestand:

  1. Efficiently know about their completion (success or failure) - in terms of their acceptance by the OS network stack and they are on their way out on the wire
  2. if they are sent back to back on same thread - can we assume the order would be taken by the OS (and we need wait to send next before receiving completion for prior send).
  3. some discussion using completion call to deallocate the Data and associated buffer vs deallocator function to use to deallocate it (especially for TCP)).

To conclude, few questions / confirmation:

  1. On 1 - does the completion callback for last - confirms acceptance or failure including previous sends (for which completion handler was not given) by network stack?
  2. On 2 - does OS takes sends in the same order in which sends were initiated, irrespective of serial / concurrent Dispatch Queue?

For question 1, the answer is “Yes.”

For question 2, the answer depends on your definition of “initiated”. If you have two concurrent threads submitting sends to a connection, there is no defined ordering. You can’t rely on the order in which those threads call send(…) because one of the threads might just happen to get suspended between when you call send(…) and when send(…) hits its internal serialisation.

You’ll find a similar situation if you try to use a concurrent queue for your connection callbacks. The connection will dispatch work to that queue in order, but the concurrency nature of the queue means that it won’t necessarily call the associated closures in order.

Regarding that second point, I want to be clear that I strongly recommend that you use a serial queue for your connection callbacks.

Share and Enjoy

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

Written by DTS Engineer in 824638022
Create a no-copy DispatchData with the init(bytesNoCopy:deallocator:) initialiser. If you pass the .custom(…) deallocator, you get called back when it’s safe to free the memory. IMPORTANT Your current approach, which relies on send completion callbacks, is incorrect and could lead to subtle memory corruption bugs.

Hi @DTS Engineer ,

I'm trying to use the DispatchData initializer with bytesNoCopy and a custom deallocator, as you suggested above. However, when I try the following code, I encounter a build error: "No exact matches in call to initializer". Below is the code I'm using:

 
let serial_dispatch = DispatchQueue(label: "com.custom.serial.queue")

let bhagavadGitaExcerpt = """
Your right is to perform your duty only, but never to its fruits. Let not the fruits of action be your motive, nor let your attachment be to inaction.
"""

let oneString = String(repeating: bhagavadGitaExcerpt, count: 1024 / bhagavadGitaExcerpt.count)

if let utf8Data = oneString.data(using: .utf8) {
    let rawPointer = UnsafeMutableRawPointer(mutating: utf8Data.withUnsafeBytes { $0.baseAddress! })
   
    let dispatchData = DispatchData(bytesNoCopy: rawPointer, deallocator: .custom (serial_dispatch, {
        print("Data is safe to de-allocate")
    })
}

I am using macOS Sonoma (14.5 and 14.2.1), and it seems that the init(bytesNoCopy:deallocator:) initializer is not available. Can someone confirm if this initializer is exposed to public API or if there's a different way to use DispatchData with no-copy functionality?

Also, When I am using Data with bytesNoCopy and a custom deallocator in my code, even though I receive the send completion callbacks, the deallocator is never called. I have verified that the data is actually being delivered to another machine (checked using Wireshark), but the deallocator doesn't seem to be triggered.

import Foundation
import Network
 
 
var connect = NWConnection(host: "10.20.5.190", port: NWEndpoint.Port(rawValue: 28000)!, using: .udp)
connect.stateUpdateHandler = { state in
    print ("Connection did change state: \(state)")
}
let bhagavadGitaExcerpt = """
Your right is to perform your duty only, but never to its fruits. Let not the fruits of action be your motive, nor let your attachment be to inaction.
"""

let oneMBString = String(repeating: bhagavadGitaExcerpt, count: 1024 / bhagavadGitaExcerpt.count)
 
let utf8Data = oneMBString.cString(using: .utf8)!
 
 
    
    let data = Data(bytesNoCopy: UnsafeMutableRawPointer(mutating: utf8Data),
                    count: utf8Data.count,
                    deallocator: .custom({ ptr, size in
        print ("Data is safe to de-allocate")
    }))
for i in 1...1000 {
    
    connect.send(content: data, completion: NWConnection.SendCompletion.contentProcessed({ error in
        print ("Send \(i) completed with error code \(error?.errorCode)")
    }))
}
 
let serial_dispatch = DispatchQueue(label: "com.custom.serial.queue")
 
connect.start(queue: serial_dispatch)
 
RunLoop.main.run()

Could you help me understand why the deallocator isn't being triggered in this case? Is there something in the Data or NWConnection lifecycle that I might be overlooking?

let rawPointer = UnsafeMutableRawPointer(mutating: utf8Data.withUnsafeBytes { $0.baseAddress! })

This is deeply wrong. You are not allowed to escape pointers from closures like withUnsafeBytes(…).

Written by harshal_goyal in 825820022
it seems that the init(bytesNoCopy:deallocator:) initializer is not available.

This is a type checking error. You’re trying to pass it a pointer (UnsafeMutableRawPointer) and it’s expecting a buffer pointer (UnsafeRawBufferPointer).

Here’s an example of how to use this mechanism correctly:

import Foundation

func main() {
    print("will allocate and release")
    do {
        let bytes = calloc(1024, 1)!
        let buffer = UnsafeRawBufferPointer(start: bytes, count: 1024)
        let d = DispatchData(bytesNoCopy: buffer, deallocator: .custom(nil, {
            free(bytes)
        }))
        print(d)
    }
    print("did allocate and release")
    dispatchMain()
}

main()

On my main Mac (macOS 15.2, really gotta update) it prints:

will allocate and release
1024
did allocate and release
will free

Note the order of events here; the custom allocator is run on a queue and, in this case, runs after the program blocks in dispatchMain().

Also, the .custom(…) is unnecessary here because I could use .free. But I figured it’d be useful to show it in play. And if you set a breakpoint on the line that calls free(…) you can look at the backtrace, which is interesting.

Share and Enjoy

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

Controlling the number of Pending Send Completions using NWConnection
 
 
Q