Skywalk netif: logical links, queues, and driver servicing
Networking · Skywalk overview · Controllers and providers · Packet scheduling and FQ-CoDel
A netif nexus connects Skywalk packet objects to an interface provider. Its work includes selecting a queue, asking a provider to service queued output, receiving packets, and keeping those activities separate from channel synchronization and final device completion. This chapter follows the local implementation in xnu-12377.121.6. Native ring providers, native logical-link providers and compatibility providers have different transfer contracts; the presence of a channel alone does not select one universal path.
The filter and reinjection companion follows interception, retained original packets and processed TX queues. Netif agents and traffic rules explains how assigned ports become receive callbacks, and how published Ethernet/INET rules supply the qset hints used here.
1. Objects: a logical link is not a ring or a hardware DMA queue
The hierarchy begins at nx_netif, which records the nexus, ifnet, device/host bindings, optional flowswitch, packet-copy operations, filtering/flow state and logical-link list. A device adapter supplies rings when the chosen provider uses the ring interface. Logical-link providers instead expose queue sets through the version-2 provider callback table. See nx_netif and kern_nexus_netif_provider_init.
| Object | State that matters | Relationship and protection |
|---|---|---|
netif_llink |
External nll_link_id, generated nll_link_id_internal, state, reference count, default qset, optional nll_ifcq |
Membership is protected by nif_llink_lock; qset topology is fixed for the link's lifetime. |
netif_qset |
Parent link, encoded ID/index, default/AQM/low-latency flags, provider context, RX/TX counts, nqs_ifcq |
Owns an inline array of netif_queue; retaining a qset retains its parent link. |
netif_queue |
Parent qset, nq_pktq, provider context, service class, RX/init flags, counters and mutex |
Local RX staging and provider identity. Its existence is not evidence of a physical device queue or its DMA format. |
ifclassq |
Scheduler state, references, packet/byte counts and lock | An AQM qset points to a scheduler group within this object, rather than owning a separate scheduler per driver queue. |
__kern_channel_ring |
Slot metadata and producer/consumer indices, busy/drop state, pending notification/doorbell counters | Used by ring providers; kr_enter() serializes ring servicing. |
Sources: link/qset/queue layouts, scheduler layout, ring ownership.
Mermaid source
flowchart TB
N["nx_netif / ifnet"] -->|logical-link list| L["netif_llink: reference count and immutable qsets"]
L -->|owns| Q["netif_qset: ID, index, provider context"]
Q -->|inline storage| RX["netif_queue RX: staged packet chain"]
Q -->|inline storage| TX["netif_queue TX: service class and provider context"]
L -->|retains| A["ifclassq: scheduler"]
Q -->|AQM group selected by qset index| A
A -->|dequeued packet chain| TX
TX -->|provider consumes| D["External driver / device"]
RX -->|flush| U["netif_receive: filters, flow demux, upper callback"]
The RX queue array precedes the TX queue array. Use the actual NETIF_QSET_RX_QUEUE/NETIF_QSET_TX_QUEUE macros when interpreting offsets: TX index zero is after all RX elements. The nearby array-layout comment's second range is less precise than those macros. The encoded qset ID uses the generated internal link ID in bits 16–31 and the qset index in bits 0–15; it is not the external provider link ID. Array addressing and ID encoding.
Retaining a qset keeps all its containing link storage alive. There is no independent qset reference counter:
SK_NO_INLINE_ATTRIBUTE
void
nx_netif_qset_retain(struct netif_qset *qset)
{
/*
* Logical link is immutable, i.e. Queue Sets can't added/removed
* from it. We will rely on this property to simply acquire a refcnt
* on the logical link, which is the parent structure of a qset.
*/
nx_netif_llink_retain(qset->nqs_llink);
}
SK_NO_INLINE_ATTRIBUTE
void
nx_netif_qset_release(struct netif_qset **pqset)
{
struct netif_qset *qset = *pqset;
struct netif_llink *__single llink = qset->nqs_llink;
*pqset = NULL;
nx_netif_llink_release(&llink);
}
A final link release requires NETIF_LLINK_STATE_DESTROYED, destroys its qsets and driver queues, releases scheduler references and frees the link. Destroying each driver queue verifies that its packet queue is empty. A reference protects storage, not continued eligibility for enqueue or provider service. Final release, qset destruction, empty-queue invariant.
2. Link construction and partial provider initialization
nx_netif_llink_init() enables the hierarchy only for native interfaces; development/debug builds can disable it. A legacy native ring provider receives a synthesized default link/qset based on its ring counts. A logical-link provider supplies the configuration. The synthesized low-latency variant omits AQM, whereas ordinary default operation requests AQM. Compatibility adapters do not acquire this hierarchy merely because they expose channel rings. Initialization gates, default configuration.
kern_nexus_netif_llink_add() requires an attached nexus, validates the nondefault configuration, then enters nx_netif_llink_add(). Validation checks default-link flag agreement, a nonzero qset count, a supplied qset array, duplicate default qsets, nonzero TX counts and the four-queue WMM condition. There is a source limitation worth preserving: it does not reject the absence of any default qset before returning success. Construction later VERIFYs that one was supplied. A valid configuration must therefore satisfy more than the validator's explicit error returns. Public add, validator, construction invariant.
Construction under the link-list write lock allocates the link/qsets, assigns IDs, initializes queues and adds the link to the list. The default AQM link shares ifp->if_snd; another AQM link allocates its own ifclassq. Each AQM qset retains that scheduler and installs the group identified by its index. Driver-managed scheduling assigns four TX queues the BE, BK, VI and VO service classes; other scheduling models use unspecified service. Scheduler sharing, link insertion.
The code then drops the list lock before invoking provider initialization. Local list insertion and successful external initialization are distinct events:
llink = nx_netif_llink_create_locked(nif, llink_init);
lck_rw_unlock_exclusive(&nif->nif_llink_lock);
VERIFY(llink != NULL);
ASSERT((llink->nll_flags & NETIF_LLINK_FLAG_DEFAULT) == 0);
err = nx_netif_llink_ext_init_queues(nif->nif_nx, llink);
if (err != 0) {
lck_rw_lock_exclusive(&nif->nif_llink_lock);
nx_netif_llink_destroy_locked(nif, &llink);
lck_rw_unlock_exclusive(&nif->nif_llink_lock);
} else {
/*
* Increment reference to keep the same pattern as default llink
* refcnt is 2 after this retain.
*/
nx_netif_llink_retain(llink);
*pllink = llink;
}
return err;
For each qset, nxnpi_qset_init supplies a provider-owned context. RX and then TX nxnpi_queue_init calls receive that context and supply per-queue contexts. The code sets EXT_INITED only after each successful callback. On failure, nx_netif_llink_ext_fini_queues() calls fini only for successfully initialized queues, then for initialized qsets. The add path subsequently marks the link destroyed, removes list membership and releases it. This is partial-construction cleanup, not a reverse replay of every attempted callback; the provider's failing callback must obey its own failure contract. Initialization and success flags, conditional fini.
For example, if qset initialization and RX queue 0 succeed but TX queue 0 initialization fails, RX queue 0 gets a fini call and the qset gets a fini call; the failed TX queue has no success bit and is skipped. The local queue structures are subsequently destroyed. This example assumes those are the only queues, and does not imply a callback order across multiple qsets beyond the source list traversal.
3. Queue selection and admission do not imply transmission
nx_netif_find_qset() decodes a hint and searches links/qsets under the shared list lock. A matching qset is retained before unlocking. An absent or unusable hint normally falls back to the default qset; a destroyed default link can produce null. A development experiment can choose a random qset. nx_netif_find_qset_with_pkt() obtains hints from Ethernet or INET traffic rules, or preserved Ethernet packet metadata; no matching rule can leave selection to the caller. The rule families are mutually exclusive on an interface. Hint lookup, packet rule selection.
netif_qset_enqueue() is the AQM path used by the flowswitch. It frees the incoming chain if the link is already destroyed. Otherwise it passes either a same-service-class chain or individual packets to ifnet_enqueue_pkt[_chain](), gathering flow-control and drop counts. These callees consume ownership even on their normal rejection paths. EQFULL can mean a packet was queued but the producer should slow down; pkt_drop distinguishes that result from a drop. The wrapper can finally return EIO because some packets were flow controlled or dropped even when other packets remain queued. Retrying the original chain would reuse consumed objects. Qset enqueue, interface consumption, scheduler result table.
Queue choice and admission are separate from the notification that asks the provider to pull packets. They are also separate from eventual device completion. The flowswitch overview explains the preceding user-pool/device-pool conversion.
4. Native output: doorbells, ring refill and queue dequeue
netif_transmit() decides whether the caller may invoke the native doorbell immediately or should request ifnet_start(). Reasons for the starter thread include compatibility/virtual devices, token-bucket shaping, delayed start, pacing, RX-notify or asynchronous-transmit context, certain host calls during synchronization, an already active starter and insufficient remaining kernel stack. Interface flow control suppresses direct service. This is an execution-context decision, not a choice of transport protocol. Starter decision.
For an active native device adapter, nx_netif_doorbell_internal() selects one of two contracts:
- Logical-link provider: walk links and initialized qsets, calling
nx_tx_qset_notify()with the provider qset context. The provider pulls throughkern_netif_queue_tx_dequeue(). The local notification loop discards individual callback return values. - Ring provider: use TX ring 0 for the doorbell request.
nx_netif_na_txsync()separates reclaim/sync from doorbell work: it calls provider sync when requested, and either invokes the driver's doorbell from starter context or schedules the starter for an asynchronous request. Direct user channels take the sync-only branch.
Sources: doorbell dispatch, qset callback wrapper.
nx_netif_llink_notify_all() holds nif_llink_lock shared while traversing links and invoking their qset notifications. These provider notifications therefore run inside the list-read interval, unlike the initialization and removal callbacks whose callers drop the list lock first. The callback context matters when reasoning about provider operations that also need to change the link list. Notification locking.
The ring-provider refill path does the following:
- Validate limits, attachment and interface flow control; enter ring serialization and mark the thread as synchronizing.
- Reject inactive/drop-mode state. Compute available slots using the sampled kernel tail and current ring head, including wraparound.
- Bound dequeue by ring space and the native doorbell batch limit. If no space remains, set pending-doorbell state and return/reclaim with
EAGAIN. - Dequeue AQM packets, attach each packet's metadata to a ring slot, clear packet-list linkage and advance
ckr_rhead. - Invoke provider sync. Track whether more queued packets need another doorbell. Verify that every slot the provider reports consumed no longer contains valid metadata.
A busy ring is deliberately treated as an already serviced request, not a failed transmission:
/*
* if the ring is busy, it means another dequeue is in
* progress, so ignore this request and return success.
*/
if (kr_enter(ring, canblock) != 0) {
rc = 0;
goto out;
}
After sync, slot consumption has an explicit invariant:
/*
* Verify that the driver has detached packets from the consumed slots.
*/
idx = ring->ckr_khead_pre;
consumed = 0;
while (idx != ring->ckr_khead) {
struct __kern_slot_desc *ksd = KR_KSD(ring, idx);
consumed++;
VERIFY(!KSD_VALID_METADATA(ksd));
idx = SLOT_NEXT(idx, ring->ckr_lim);
}
ring->ckr_khead_pre = ring->ckr_khead;
That assertion establishes transfer out of the ring's slot. It does not establish that the packet reached the wire or peer. Device DMA descriptors, completion queues and firmware scheduling remain provider-specific; use the DMA guide for the separately documented IOKit address/descriptor contracts.
With a logical-link provider, kern_netif_queue_tx_dequeue() checks link state, applies the native notify-context batch limit and passes the qset scheduler/group plus queue service class to netif_deq_packets(). The latter returns a packet chain and computes pending from queue length. EAGAIN can coexist with queued packets that cannot currently be dequeued, for example because of pacing. The public wrapper writes the output packet handle only when a nonnull chain exists; the caller should initialize its output rather than infer that every error clears it. Queue dequeue, AQM dequeue and pending count.
5. Host and compatibility paths change representation
The host adapter intercepts ordinary BSD output for a native interface. nx_netif_host_output() checks active/drop/size conditions, converts each mbuf to a newly allocated device-pool packet, propagates queue hints and expiry metadata, then enqueues it into AQM. nx_netif_mbuf_to_kpkt() copies bytes with the configured checksum-copy routine and finalizes packet metadata. The original mbuf is freed even on success. A failed prefix does not roll back packets already enqueued; remaining input mbufs are freed and the transmit kick still occurs. Host output, allocation/copy.
Compatibility TX travels in the opposite representation direction: nx_netif_compat_na_txsync() takes a reusable mbuf from the per-slot TX pool and nx_netif_compat_xmit_frame() activates its special ring cluster, copies packet bytes/checksum state, installs completion data and calls dlil_output_handler(). Lack of a replacement mbuf or a lower output error can stop progress before the requested ring head. The sync routine updates ckr_khead to the actual progress point and returns zero after reclamation; zero is not proof that every requested slot was sent. Compat TX sync, copy/output.
The special mbuf cluster callback is the local completion bridge. It checks/clears the atomic TX-notify property and can generate nx_netif_compat_tx_intr(). nx_netif_compat_tx_clean() walks completed slots until it encounters an active cluster, replenishes missing mbufs and advances the kernel tail. Notification selection plus a second cleanup check addresses completion arriving around the request to be notified. This is not the Mach channel event-payload protocol described in the readiness companion. Cluster callback, clean and notify.
Mermaid source
flowchart LR
B["BSD host mbuf"] -->|copy then free original| P["Native device-pool packet"]
P -->|AQM enqueue and dequeue| R["Native ring or qset provider"]
C["Compat TX channel packet"] -->|copy into reusable ring-cluster mbuf| M["Legacy driver mbuf"]
M -->|cluster completion notification| T["Compat reclaim advances tail"]
I["Compat RX mbuf list"] -->|queue on RX ring 0| Q["Temporary receive queue"]
Q -->|kernel flowswitch path attaches backing| K["Packet retaining mbuf"]
Q -->|direct user channel copies then frees mbuf| U["Packet with copied bytes"]
6. Receive: staging, flushing and the upper consumer
For native ring providers, netif_rx_notify() enters the ring, rejects stopped/detached state, synchronizes with the provider, samples the tail once and applies the input-rate limit. It detaches available packet metadata with consume_pkts() and passes the resulting chain to netif_receive(). That function can consume packets through filters, demultiplex a subset to netif flows, deliver the remainder through na_rx, or free them when input is disabled or no receiver exists. The callback may be a flowswitch path; it is not necessarily BSD protocol input. Native receive, upper delivery/drop.
For logical-link providers, kern_netif_queue_rx_enqueue() stages a packet chain on the RX queue. The FLUSH flag detaches all staged packets, resets the local queue and calls netif_receive() under synchronization protection. A destroyed link consumes/frees the incoming chain. The count argument is unused in this implementation; accounting derives from delivered work. Although netif_queue contains a mutex, this entry point does not acquire it around queue operations. Provider-side serialization and quiescence are therefore part of the call boundary; the local body is not evidence that concurrent arbitrary callers are safe. RX queue API body.
Compatibility receive always selects ring 0 in this snapshot. It queues incoming mbufs under ckr_rx_queue protection, drops incoming lists when the adapter is inactive or the queue is already beyond its allowance, then signals either polling or mitigation. The allowance is enlarged in the opportunistic-polling case to absorb wakeup latency; this is a pre-enqueue check and is not a hard post-enqueue length bound. Receive admission and context.
During compat RX sync, released ring slots become available first. The code moves queued mbufs to a temporary queue under the queue lock, then unlocks before allocation/conversion. A kernel-only flowswitch attachment wraps retained mbuf backing; direct user delivery copies bytes into pool storage and frees the mbuf. Bad lengths are dropped. Allocation/space limits can leave unprocessed mbufs, which are prepended ahead of arrivals queued during the unlocked interval. This preserves ordering across partial import without holding the receive queue lock over copies. RX sync and partial batches.
7. Mitigation and polling select when work runs
Mitigation coalesces ring notifications. nx_netif_mit_{tx,rx}_intr() increments request counts under a spin lock and wakes an eligible worker when one exists; without a mitigation worker it calls nx_netif_common_intr() directly. The common handler records a pending interrupt and calls the saved/redirected upper notification, not the currently intercepted callback, avoiding recursion. RX additionally uses thread-local notify protection because a provider's RX sync may itself notify. Interrupt-to-worker dispatch, saved callback and recursion guards.
The regular mitigation worker snapshots the request counter, drops the spin lock, calls upstream processing and computes its mitigation interval. It delays for the configured interval, or backs off and increments requests when the ring is busy. It rechecks requests/termination before sleeping; rate-limited RX can arrange a timed wake rather than waiting indefinitely for another interrupt. The simple worker is a separate branch with its own shorter servicing loop. Cleanup sets terminating, wakes the worker and waits for its terminated flag before clearing ring pointers and destroying the lock. Regular worker, simple worker, cleanup.
Compatibility opportunistic polling is a different feedback loop. The poll thread either processes already queued interrupt input or calls if_input_poll() to obtain a bounded mbuf batch. Each active iteration obtains an interface I/O reference so detach cannot dismantle its provider during the call. Packet/byte averages and high/low watermarks, constrained by sampling and mode hold times, select polling on/off. Both packet and byte predicates are tested in the transition branches. Mode changes are requested through if_input_ctl() after releasing the poll lock. A failed driver mode request is counted/logged; the helper still updates the local poll cycle rather than rolling back all state. Threshold decisions, mode request, poll thread and detach reference.
8. Low-latency push and teardown boundaries
A low-latency virtual-port path bypasses AQM: it converts an input chain to provider packets and calls nxnpi_queue_tx_push() on the default link/qset's first TX queue. Its in/out packet handle denotes the unconsumed suffix, with zero meaning complete consumption. The caller frees every remaining packet after the callback and counts the provider's accepted packet/byte totals separately. An error value alone cannot describe how much the provider consumed. Callback contract, push and suffix cleanup.
Link removal first invokes external queue/qset fini, then drops the additional link reference and destroys list membership under the link lock. Destruction marks the link ineligible while retained qsets may keep storage alive. The remove routine borrows a list result across the unlocked provider calls; it does not itself retain that result at lookup. Concurrent provider add/remove/use must therefore obey the surrounding provider lifecycle; the local function should not be described as an independently safe arbitrary-concurrency container API. Removal.
Netif cleanup has a wider barrier. Where on-the-fly detach requires it, nx_netif_clean() suspends data movement, drops the global Skywalk lock while draining, then clears callbacks/agents/capabilities/flows/filters/links and releases device/host adapters. Normal interface detach supplies its own quiescence. nx_netif_llink_fini() expects only the default link to remain, so removing all nondefault links is a prerequisite, not an automatic sweep performed there. Cleanup, link finalization, final list checks.
9. Configuration, tests and remaining boundaries
| Evidence | Invariant or boundary |
|---|---|
SKYWALK, CONFIG_NEXUS_NETIF, provider version and native/compat flags |
Source availability, provider ABI and selected runtime path are separate decisions. Build options, provider registration checks. |
skt_steering.c |
Exercises rule insertion/removal and rejection of concurrent Ethernet/INET rule families. It is not a hardware queue-scheduling performance test. |
skt_netifcompat.c |
Exercises detach/controller/channel lifetime permutations and expected repeated-detach errors. |
skt_netifdirect.c |
Shows direct channel TX/RX/event handling and expiry notification consumers; provider eligibility and harness setup still apply. |
Queue destruction and consumed-slot verification |
Local assertions require empty staging queues and detached metadata for consumed slots; they do not prove device completion or synchronization outside the inspected boundary. |
The source paths above were read, but these tests were not run and no kernel was built. Full netif filter/agent algorithms, GSO segmentation, device-specific DMA/completion, and every traffic-rule matcher remain separate subjects. The next chapter explains the shared interface scheduler and FQ-CoDel, including why admission, congestion feedback, dequeue and transmission are different events.