A multithreaded Linux process can share one file descriptor table across its threads. That arrangement is convenient during normal execution: a descriptor opened by one thread becomes available to its peers. It becomes less convenient when one thread is preparing a restricted execution context and wants to discard a broad descriptor range without racing with peers that can still allocate descriptors.
close_range() provides a range operation for this boundary. With CLOSE_RANGE_UNSHARE, the kernel first separates the caller from the shared descriptor table and then applies the requested closure to the caller’s resulting table. The operation is close to combining unshare(CLONE_FILES) with a range close, but the kernel can perform less work in common cases.
A descriptor table can be shared even when execution is separate
Threads created with CLONE_FILES refer to the same descriptor table. Descriptor numbers are entries in that table, so opening, duplicating, or closing an entry changes the table visible to every task sharing it.
That creates a race for cleanup code based on repeated close() calls:
thread A thread B
close(fd 7)
close(fd 8) open(...) -> fd 8
close(fd 9)The exact descriptor allocation depends on concurrent activity. Iterating a snapshot from /proc/self/fd has a similar structural issue: the snapshot and the later closes are separate operations, while another thread can change the shared table between them.
A single close_range(first, last, 0) closes a numeric interval in one system call, but without unsharing it still operates on the shared table. Peer threads therefore observe those entries being closed.
UNSHARE changes the ownership boundary before cleanup
CLOSE_RANGE_UNSHARE changes the operation from shared-table mutation into caller-local cleanup. Conceptually, the sequence is:
shared descriptor table
|
+-- thread A
+-- thread B
thread A: close_range(3, ~0U, CLOSE_RANGE_UNSHARE)
private table A original shared table
fd 0,1,2 fd 0,1,2,3,4,...
| |
thread A thread BThe caller no longer relies on peer threads remaining idle while its unwanted descriptors are removed. Those peers retain their relationship with the original shared table.
This matters most around execution transitions, sandbox setup, and helper-process preparation, where an execution context often needs a tightly controlled descriptor set while the rest of a multithreaded process may still be active.
The kernel can avoid copying entries that are about to disappear
The conceptual model is unshare(CLONE_FILES) followed by close_range(first, last, 0), but an implementation does not always need to construct a complete private copy and then close most of it.
When the range extends beyond the highest allocated descriptor, as with last = ~0U, the kernel can build the unshared table only up to the boundary that must survive. For a common request that preserves standard input, output, and error while discarding everything from descriptor 3 upward, there is little value in copying high-numbered entries merely to remove them immediately.
#define _GNU_SOURCE
#include <unistd.h>
#include <linux/close_range.h>
int rc = close_range(3, ~0U, CLOSE_RANGE_UNSHARE);A successful call leaves descriptors below first available in the caller and removes the requested higher range from its private table. Errors for individual descriptor closes are not reported separately.
The unshare step itself can fail. Linux documents ENOMEM when kernel memory is insufficient, and EMFILE can arise in an unshare case involving the system-wide descriptor-table ceiling exposed through /proc/sys/fs/nr_open.
CLOEXEC provides a different transition boundary
CLOSE_RANGE_CLOEXEC does not immediately close the selected descriptors. It marks them close-on-exec instead. That is useful when setup code still needs descriptors before execve() but wants the eventual program image to start without them.
The distinction is temporal:
flags = 0
range closes during close_range()
CLOSE_RANGE_CLOEXEC
range remains usable now
range closes during successful execThis can help when later setup steps, including security configuration, still require descriptors that should not cross the final execution boundary.
CLOSE_RANGE_UNSHARE and CLOSE_RANGE_CLOEXEC address different concerns. The former separates the caller’s descriptor table from peers; the latter defers descriptor removal until execution replacement. Code selecting flags needs to model both sharing and lifetime rather than treating all range cleanup as equivalent.
Numeric ranges are policy, not object identity
close_range() acts on descriptor numbers. It does not identify sockets, files, pipes, or other kernel objects by semantic role. A request covering 3 through ~0U therefore expresses a policy that every descriptor in that numeric region is disposable for the caller.
That is appropriate only after descriptors that must survive have been placed below the boundary or otherwise accounted for. If a control socket at descriptor 12 is still required, a blanket range beginning at 3 will remove it unless the chosen transition uses close-on-exec and the socket is consumed before execution.
This numeric property also separates close_range() from ownership mechanisms attached to open file descriptions. The syscall edits descriptor-table entries; effects on underlying open file descriptions follow ordinary reference counting after entries are removed.
Range cleanup does not replace descriptor creation discipline
An unshared cleanup boundary prevents peer threads from repopulating the caller’s new table through their shared-table activity. It does not make arbitrary descriptor inheritance policy disappear.
Descriptors created before the boundary still need deliberate treatment. Libraries may hold internal descriptors. Some descriptors may need to survive until a final setup operation. Descriptor creation with O_CLOEXEC, SOCK_CLOEXEC, pipe2(O_CLOEXEC), and similar atomic close-on-exec facilities remains valuable because it establishes inheritance policy at creation time.
The two techniques solve adjacent problems. Atomic close-on-exec creation prevents a descriptor from being accidentally inherited across an execution race. close_range() supplies a bulk transition when a context needs to sanitize an existing table.
The useful guarantee is isolation of the table mutation
The central property of CLOSE_RANGE_UNSHARE is not merely that many descriptors can be closed with one call. It moves the cleanup onto a descriptor table no longer shared with peer threads.
That changes the concurrency model. Peer activity can continue against the old shared table without reopening entries in the caller’s private table, and the caller can establish a compact descriptor boundary before its next execution phase.
For code that treats descriptor state as part of a security or process-launch boundary, that isolation is more significant than the convenience of replacing a loop of close() calls.