View Javadoc
1   /*
2    * Copyright 2026 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package io.netty.channel.uring;
17  
18  import io.netty.util.ReferenceCounted;
19  import io.netty.util.collection.LongObjectHashMap;
20  import io.netty.util.internal.MathUtil;
21  import io.netty.util.internal.logging.InternalLogger;
22  import io.netty.util.internal.logging.InternalLoggerFactory;
23  
24  import java.util.Arrays;
25  
26  /**
27   * Owns every in-flight write operation a channel tracks, across the four namespaces a channel can have live at
28   * once: a pooled slot array (ids this class hands out itself), an overflow map (a long-id fallback once the
29   * pooled range is exhausted), a foreign slot array (ids an allocator this class does not own hands the caller,
30   * such as a {@link MsgHdrMemoryArray} index), and a single slot for the one non-zero-copy write a stream channel
31   * can have outstanding at a time. One instance per channel, created unconditionally in the channel constructor.
32   */
33  final class WriteOperationTracker {
34      private static final InternalLogger logger = InternalLoggerFactory.getInstance(WriteOperationTracker.class);
35  
36      // Ids index the pooled array, so they run from 1 (0 is reserved for "no id") to Short.MAX_VALUE, the same
37      // bound scheduleWrite(...) already puts on the number of outstanding writes.
38      private static final int MAX_POOLED_ID = Short.MAX_VALUE;
39  
40      // Ids issued by nextId()/nextZeroCopyId(). Dense, reused through freeIds, so allocating one never searches.
41      private WriteOperation[] pooled;
42      // Ids issued by an allocator this class does not own, such as the MsgHdrMemoryArray index the datagram
43      // sendmsg path submits directly. A separate array lets that namespace overlap the pooled one above.
44      private WriteOperation[] foreign;
45      private short[] freeIds;
46      private int freeIdCount;
47      private int issuedIds;
48  
49      // Only ever holds values above MAX_POOLED_ID. Such a value does not survive a round-trip through short,
50      // so IoUringIoHandler.canUseFastPath(...) rejects it and the slow path preserves the full user_data. The
51      // counter only grows and never recycles, so a fallback id can never collide with a live one.
52      private long nextOverflowId = ((long) MAX_POOLED_ID) + 1;
53      // Never allocated before the short pool runs dry, so the common path never touches a map.
54      private LongObjectHashMap<WriteOperation> overflow;
55  
56      // The one non-zero-copy write a stream channel can have in flight. Reused by direct field access -- no id,
57      // no array, no opcode match -- because WRITE_SCHEDULED caps a stream channel to one outstanding write.
58      // Exposed through recordStream/completeStream/abandonStream/isStreamActive below, and touched directly by
59      // retainAll()/releaseAll() the same way those touch the pooled/foreign/overflow namespaces; the field itself
60      // never leaves this class.
61      private final WriteOperation single = new WriteOperation();
62  
63      /**
64       * Returns a write-operation id that no in-flight write owns, or {@code 0} when every id is held by a write
65       * that has not seen its terminal CQE yet. Released ids come back through a free list, so this does not
66       * search.
67       */
68      short nextId() {
69          if (freeIdCount > 0) {
70              return freeIds[--freeIdCount];
71          }
72          if (issuedIds == MAX_POOLED_ID) {
73              return 0;
74          }
75          return (short) ++issuedIds;
76      }
77  
78      /**
79       * Returns a write-operation id for a zero-copy write, falling back to a long id outside the short range once
80       * every short id is held by an in-flight write, so the write still gets submitted instead of being deferred.
81       * Never returns 0.
82       */
83      long nextZeroCopyId() {
84          short id = nextId();
85          if (id != 0) {
86              return id;
87          }
88          return nextOverflowId++;
89      }
90  
91      /**
92       * Registers a write whose id came from {@link #nextId()} or {@link #nextZeroCopyId()}. A pooled id goes back
93       * to the free list once the terminal CQE arrives; an overflow id is never reused, its map entry is simply
94       * dropped.
95       */
96      void record(long id, byte opCode, ReferenceCounted reference) {
97          if (id > MAX_POOLED_ID) {
98              recordOverflow(id, opCode, reference);
99              return;
100         }
101         short slot = (short) id;
102         WriteOperation[] grown = ensureCapacity(pooled, slot);
103         if (grown != pooled) {
104             pooled = grown;
105         }
106         slot(pooled, slot).record(opCode, reference);
107     }
108 
109     // Split out of record(...) above so the overflow path -- taken only once every pooled id is in flight --
110     // does not add to the bytecode size of the hot method and keep it eligible for inlining.
111     private void recordOverflow(long id, byte opCode, ReferenceCounted reference) {
112         overflowSlot(id).record(opCode, reference);
113     }
114 
115     /**
116      * Registers a write of multiple references whose id came from {@link #nextId()} or {@link #nextZeroCopyId()}.
117      * Copies {@code references}, so the caller may reuse the array.
118      */
119     void record(long id, byte opCode, ReferenceCounted[] references, int count) {
120         if (id > MAX_POOLED_ID) {
121             recordOverflow(id, opCode, references, count);
122             return;
123         }
124         short slot = (short) id;
125         WriteOperation[] grown = ensureCapacity(pooled, slot);
126         if (grown != pooled) {
127             pooled = grown;
128         }
129         slot(pooled, slot).record(opCode, references, count);
130     }
131 
132     // Split out for the same reason as the single-reference overflow above: keep it out of the hot method.
133     private void recordOverflow(long id, byte opCode, ReferenceCounted[] references, int count) {
134         overflowSlot(id).record(opCode, references, count);
135     }
136 
137     /**
138      * Registers a write whose id is owned by another allocator, such as the {@link MsgHdrMemoryArray} index used
139      * by the datagram sendmsg path. That id lives in its own slot array and never enters the free list.
140      */
141     void recordForeign(short id, byte opCode, ReferenceCounted reference) {
142         WriteOperation[] grown = ensureCapacity(foreign, id);
143         if (grown != foreign) {
144             foreign = grown;
145         }
146         slot(foreign, id).record(opCode, reference);
147     }
148 
149     /**
150      * Ends the slot identified by {@code id}/{@code opCode} without it seeing a completion CQE: the submission
151      * itself failed, so the kernel never saw the SQE and no CQE will ever arrive for it. A pooled id goes back to
152      * the free list, an overflow entry is dropped. Deregistration ends its slots through {@link #releaseAll()}
153      * instead.
154      */
155     void abandon(long id, byte opCode) {
156         if (id > MAX_POOLED_ID) {
157             abandonOverflow(id, opCode);
158             return;
159         }
160         short slot = (short) id;
161         WriteOperation op = matching(pooled, slot, opCode);
162         if (op != null) {
163             op.abandon();
164             recycleId(slot);
165             return;
166         }
167         op = matching(foreign, slot, opCode);
168         if (op != null) {
169             op.abandon();
170         }
171     }
172 
173     // Split out of abandon(...) above to keep the overflow path -- the rare case where every pooled id is
174     // in flight -- out of the hot method's bytecode.
175     private void abandonOverflow(long id, byte opCode) {
176         WriteOperation op = matchingOverflow(id, opCode);
177         if (op != null) {
178             op.abandon();
179             // Overflow ids are never recycled, so the entry has to go or the map grows without bound.
180             overflow.remove(id);
181         }
182     }
183 
184     /**
185      * Applies a completion CQE to the slot identified by {@code id}/{@code opCode}. A terminated pooled slot's id
186      * is recycled back to the free list; a terminated overflow slot is removed from the map.
187      */
188     void complete(long id, byte opCode, int flags) {
189         if (id > MAX_POOLED_ID) {
190             completeOverflow(id, opCode, flags);
191             return;
192         }
193         short slot = (short) id;
194         WriteOperation op = matching(pooled, slot, opCode);
195         if (op != null) {
196             op.complete(flags);
197             if (!op.isActive()) {
198                 recycleId(slot);
199             }
200             return;
201         }
202         op = matching(foreign, slot, opCode);
203         if (op != null) {
204             op.complete(flags);
205         }
206     }
207 
208     // Split out of complete(...) above for the same reason as abandonOverflow(...): keep the rare overflow
209     // path out of the hot method's bytecode.
210     private void completeOverflow(long id, byte opCode, int flags) {
211         WriteOperation op = matchingOverflow(id, opCode);
212         if (op != null) {
213             op.complete(flags);
214             if (!op.isActive()) {
215                 overflow.remove(id);
216             }
217         }
218     }
219 
220     /**
221      * Retains the references held by the active slot identified by {@code id}/{@code opCode}, if any. Called from
222      * the zero-copy completion path, where {@code IORING_CQE_F_MORE} says the kernel still owns the memory until
223      * the follow-up {@code IORING_CQE_F_NOTIF}. The shutdown path uses {@link #retainAll()} instead.
224      */
225     void retainReferences(long id, byte opCode) {
226         if (id > MAX_POOLED_ID) {
227             retainOverflowReference(id, opCode);
228             return;
229         }
230         short slot = (short) id;
231         WriteOperation op = matching(pooled, slot, opCode);
232         if (op != null) {
233             op.retainReferences();
234             return;
235         }
236         op = matching(foreign, slot, opCode);
237         if (op != null) {
238             op.retainReferences();
239         }
240     }
241 
242     // Split out of retainReferences(...) above for the same reason as abandonOverflow(...): keep the rare
243     // overflow path out of the hot method's bytecode. Named distinctly from retainOverflow() below, which
244     // retains every overflow slot instead of matching a single id/opCode pair.
245     private void retainOverflowReference(long id, byte opCode) {
246         WriteOperation op = matchingOverflow(id, opCode);
247         if (op != null) {
248             op.retainReferences();
249         }
250     }
251 
252     /**
253      * The number of write operations parked in the overflow map. Test-only: no production caller, used to assert
254      * completions and abandons drop their entry.
255      */
256     int overflowCount() {
257         return overflow == null ? 0 : overflow.size();
258     }
259 
260     /**
261      * Records a single reference on the non-zero-copy stream write slot. No id, no array, no opcode match.
262      */
263     void recordStream(byte opCode, ReferenceCounted reference) {
264         single.record(opCode, reference);
265     }
266 
267     /**
268      * Records multiple references (e.g. writev) on the non-zero-copy stream write slot. Copies {@code references}.
269      */
270     void recordStream(byte opCode, ReferenceCounted[] references, int count) {
271         single.record(opCode, references, count);
272     }
273 
274     /**
275      * Ends the stream slot without it ever seeing a completion CQE, for the same reason as
276      * {@link #abandon(long, byte)}: the submission itself failed. A no-op if the slot is inactive. There is no id,
277      * so there is no opcode match either -- the slot simply finishes. Deregistration ends this slot through
278      * {@link #releaseAll()} instead.
279      */
280     void abandonStream() {
281         if (single.isActive()) {
282             single.abandon();
283         }
284     }
285 
286     /**
287      * Applies a completion CQE to the stream slot.
288      */
289     void completeStream(int flags) {
290         single.complete(flags);
291     }
292 
293     /**
294      * Whether the stream slot is active. Test-only: no production caller, used to assert completions and abandons
295      * leave it inactive, same as {@link #overflowCount()}.
296      */
297     boolean isStreamActive() {
298         return single.isActive();
299     }
300 
301     /**
302      * Retains every active slot's references across all four members (pooled, foreign, overflow, single) right
303      * before a shutdown, so a write completion that races the shutdown still finds a live reference to release
304      * instead of one the outbound buffer already dropped.
305      */
306     void retainAll() {
307         retainArray(pooled);
308         retainArray(foreign);
309         retainOverflow();
310         retainSingle();
311     }
312 
313     /**
314      * Abandons every active slot across all four members and empties them, via {@link WriteOperation#abandon()}
315      * on each: a release for a slot {@link #retainAll()} retained, a plain discard for one it never reached.
316      * Named for the former, more consequential case -- the one this method exists to guard against -- rather
317      * than the latter, more common one. No further completion arrives once a channel is deregistered, so
318      * references a shutdown retained on a slot would otherwise leak forever.
319      */
320     void releaseAll() {
321         releaseArray(pooled);
322         releaseArray(foreign);
323         releaseOverflow();
324         if (single.isActive()) {
325             single.abandon();
326         }
327     }
328 
329     private static WriteOperation[] ensureCapacity(WriteOperation[] operations, short id) {
330         if (operations == null) {
331             return new WriteOperation[Math.max(MathUtil.safeFindNextPositivePowerOfTwo(id + 1), 4)];
332         }
333         if (id >= operations.length) {
334             return Arrays.copyOf(operations, MathUtil.safeFindNextPositivePowerOfTwo(id + 1));
335         }
336         return operations;
337     }
338 
339     private static WriteOperation slot(WriteOperation[] operations, short id) {
340         WriteOperation operation = operations[id];
341         if (operation == null) {
342             operation = new WriteOperation();
343             operations[id] = operation;
344         }
345         return operation;
346     }
347 
348     private WriteOperation overflowSlot(long id) {
349         if (overflow == null) {
350             overflow = new LongObjectHashMap<WriteOperation>(2);
351         }
352         WriteOperation operation = overflow.get(id);
353         if (operation == null) {
354             operation = new WriteOperation();
355             overflow.put(id, operation);
356         }
357         return operation;
358     }
359 
360     private WriteOperation matchingOverflow(long id, byte opCode) {
361         if (overflow == null) {
362             return null;
363         }
364         WriteOperation operation = overflow.get(id);
365         return operation != null && operation.isActive() && operation.opCode() == opCode ? operation : null;
366     }
367 
368     private static WriteOperation matching(WriteOperation[] operations, short id, byte opCode) {
369         if (operations == null || id < 0 || id >= operations.length) {
370             return null;
371         }
372         WriteOperation operation = operations[id];
373         // Write user_data is not allocated from a single namespace. Every non-zero-copy stream write takes its id
374         // from AbstractIoUringChannel.nextOpsId(), and a splice picks a fixed id of its own to tell its two stages
375         // apart; both register in the single stream slot (see recordStream), not in this array, so their
376         // completions can land on a slot an unrelated zero-copy write occupies. Matching the opcode keeps such a
377         // completion from terminating what it finds.
378         return operation != null && operation.isActive() && operation.opCode() == opCode ? operation : null;
379     }
380 
381     private void recycleId(short id) {
382         if (freeIds == null) {
383             freeIds = new short[8];
384         } else if (freeIdCount == freeIds.length) {
385             freeIds = Arrays.copyOf(freeIds, freeIdCount << 1);
386         }
387         freeIds[freeIdCount++] = id;
388     }
389 
390     private static void retainArray(WriteOperation[] operations) {
391         if (operations == null) {
392             return;
393         }
394         for (WriteOperation operation : operations) {
395             if (operation == null) {
396                 continue;
397             }
398             // One slot failing to retain must not stop the remaining slots from being retained.
399             try {
400                 operation.retainReferences();
401             } catch (Throwable cause) {
402                 logger.warn("Failed to retain in-flight write operation before shutdown", cause);
403             }
404         }
405     }
406 
407     private void retainOverflow() {
408         if (overflow == null) {
409             return;
410         }
411         for (WriteOperation operation : overflow.values()) {
412             try {
413                 operation.retainReferences();
414             } catch (Throwable cause) {
415                 logger.warn("Failed to retain in-flight write operation before shutdown", cause);
416             }
417         }
418     }
419 
420     // A failure here must not stop retainAll() from returning, the same guarantee retainArray(...) and
421     // retainOverflow() above give the array/map namespaces: doShutdownOutput() still has to reach
422     // doShutdownOutput0() -- the actual shutdown(2) -- even when this slot fails to retain.
423     private void retainSingle() {
424         try {
425             single.retainReferences();
426         } catch (Throwable cause) {
427             logger.warn("Failed to retain in-flight write operation before shutdown", cause);
428         }
429     }
430 
431     private static void releaseArray(WriteOperation[] operations) {
432         if (operations == null) {
433             return;
434         }
435         for (int i = 0; i < operations.length; i++) {
436             WriteOperation operation = operations[i];
437             if (operation == null) {
438                 continue;
439             }
440             if (operation.isActive()) {
441                 operation.abandon();
442             }
443             operations[i] = null;
444         }
445     }
446 
447     private void releaseOverflow() {
448         if (overflow == null) {
449             return;
450         }
451         for (WriteOperation operation : overflow.values()) {
452             if (operation.isActive()) {
453                 operation.abandon();
454             }
455         }
456         overflow.clear();
457     }
458 }