Skip to content

Proposal: Socket.TryAccept and TcpDeferAccept #127923

@DeagleGross

Description

@DeagleGross

TLDR - show me the code

Here is the vibe-coded implementation
Note: this is only the rough implementation, it will be redone carefully once API is approved

Background and motivation

High-performance servers that drive non-blocking I/O from a dedicated poll thread (epoll/kqueue) need a lightweight accept path. The current Socket.Accept() API has several costs that are avoidable for consumers who only need the raw file descriptor — not a full Socket object:

Problem 1: Exception-driven EAGAIN on the hot path

In a poll-driven accept loop, the worker drains all pending connections until there are none left. With Socket.Accept() on a non-blocking socket, the "no more connections" signal is a thrown SocketException(WouldBlock) — an exception allocation + stack trace capture + throw + catch on every single wakeup.

Problem 2: Full Socket object allocation per accept

Socket.Accept() calls CreateAcceptSocket which:

  • Allocates a new Socket object with managed state (address family, socket type, protocol, blocking flags, event tracking)
  • Calls UpdateAcceptSocket to copy listener state and configure the accepted socket
  • Calls InternalSetBlocking which issues an fcntl / ioctl syscall to set blocking mode
  • Creates an EndPoint object from the peer address

For a server that only needs the raw fd (to register with a poll handle and drive non-blocking I/O), this is ~320 bytes of unnecessary allocation per connection.

Problem 3: Accepted fd is always blocking

The runtime's native SystemNative_Accept uses accept4(SOCK_CLOEXEC) on Linux — without SOCK_NONBLOCK. On macOS, it explicitly resets the accepted fd to blocking mode via fcntl. A consumer driving non-blocking I/O via poll must then make an additional fcntl(O_NONBLOCK) syscall per accepted connection.

Problem 4: TCP_DEFER_ACCEPT requires magic numbers

TCP_DEFER_ACCEPT is only available via Socket.SetRawSocketOption((int)SocketOptionLevel.Tcp, 9, …). For a TLS server this is high-value: the kernel only completes the accept once the client's first data byte (the TLS ClientHello) is in the buffer, which guarantees SSL_do_handshake makes immediate forward progress.

Benchmark evidence

I benchmarked Socket.Accept() against a raw accept() P/Invoke to quantify the overhead:

BenchmarkDotNet v0.15.6, Linux Ubuntu 24.04.3 LTS
AMD Ryzen 9 7950X3D, .NET 10.0.3

| Method            | Mean     | Allocated |
|------------------ |---------:|----------:|
| SocketAccept      | 7.677 us |     320 B |  ← full Socket object per accept
| RawAccept         | 8.155 us |      64 B |  ← SafeSocketHandle only (5x less alloc)
|                   |          |           |
| EagainException   | 5.865 us |     569 B |  ← Socket.Accept() throws on EAGAIN
| EagainReturnValue | 3.839 us |       9 B |  ← raw accept returns -1 (1.5x faster, 63x less alloc)
  • EAGAIN path: Returning false instead of throwing is 1.5x faster with 63x less allocation (9 B vs 569 B). At 100k+ accepts/sec, the EAGAIN fires on every poll wakeup.
  • Accept path: Per-call latency is similar (dominated by the kernel syscall), but allocation drops from 320 B to 64 B — a 5x reduction. At scale, this translates directly to GC pressure savings.

Goals

  • A Try-pattern accept that returns bool instead of throwing on EAGAIN.
  • Returns SafeSocketHandle instead of a full Socket object.
  • Captures the peer address in the same accept() syscall (no extra getpeername).
  • The accepted fd should be non-blocking and CLOEXEC.
  • TCP_DEFER_ACCEPT as a first-class enum value.

API Proposal

namespace System.Net.Sockets;

public partial class Socket
{
    /// <summary>
    /// Attempts to accept a pending connection without blocking.
    /// </summary>
    /// <param name="acceptedHandle">
    /// On success, receives the accepted connection's handle. The handle is
    /// non-blocking and CLOEXEC. The caller owns the handle and is responsible
    /// for disposing it.
    /// </param>
    /// <param name="remoteEndPoint">
    /// On success, receives the peer's address, captured in the same syscall
    /// as the accept (no separate <c>getpeername</c> call).
    /// </param>
    /// <returns>
    /// <see langword="true"/> if a connection was accepted;
    /// <see langword="false"/> if no connection was pending (would block / EAGAIN).
    /// </returns>
    /// <exception cref="InvalidOperationException">
    /// The socket is not bound and listening.
    /// </exception>
    /// <exception cref="SocketException">
    /// An error other than would-block occurred during the accept.
    /// </exception>
    /// <remarks>
    /// <para>
    /// This method is designed for high-performance accept loops driven by a
    /// readiness poll mechanism (e.g., <see cref="Threading.SafePollHandle"/>).
    /// It avoids the overhead of creating a full <see cref="Socket"/> wrapper
    /// for the accepted connection.
    /// </para>
    /// <para>
    /// On Linux, uses <c>accept4(SOCK_NONBLOCK | SOCK_CLOEXEC)</c> under the hood.
    /// On macOS/FreeBSD, falls back to <c>accept()</c> followed by
    /// <c>fcntl(O_NONBLOCK | FD_CLOEXEC)</c>.
    /// </para>
    /// </remarks>
    [UnsupportedOSPlatform("windows")]
    [UnsupportedOSPlatform("browser")]
    [UnsupportedOSPlatform("wasi")]
    public bool TryAccept(
        out SafeSocketHandle? acceptedHandle,
        out EndPoint? remoteEndPoint);
}
namespace System.Net.Sockets;

public enum SocketOptionName
{
    /// <summary>
    /// Linux only. The kernel delays completing <c>accept()</c> until the
    /// client sends its first data byte (e.g., TLS ClientHello). The value
    /// is the timeout in seconds; 0 disables.
    /// Maps to <c>TCP_DEFER_ACCEPT</c> (option 9 on <c>SOL_TCP</c>).
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Timeout behavior:</b> If the client connects but never sends data
    /// within the specified timeout, the kernel silently drops the connection.
    /// The client's TCP handshake completed successfully, but the server
    /// application never sees the connection.
    /// </para>
    /// <para>
    /// <b>Shutdown / draining:</b> When the listen socket is closed, any
    /// connections still deferred in the kernel's accept queue are silently
    /// dropped — no notification is sent to the client.
    /// </para>
    /// <para>
    /// This option has no effect on macOS, FreeBSD, or Windows. On macOS,
    /// the analogous feature is <c>SO_ACCEPTFILTER("dataready")</c>, which
    /// is a different API not covered by this enum value.
    /// </para>
    /// </remarks>
    TcpDeferAccept = 9,
}

API Usage

Worker accept loop

// listenSocket is bound, listening, Blocking == false.

void OnListenReadable()
{
    // Drain all pending connections from this wakeup.
    while (listenSocket.TryAccept(out var handle, out var remote))
    {
        // handle is non-blocking, CLOEXEC. No Socket object allocated.
        var conn = connectionManager.CreateConnection(handle!, remote!);

        // epoll invocation
        poll.TryAdd(handle!, PollEvents.Read, PollRegistrationOptions.None,
                    state: conn.Index, out _);
    }
    // false return means EAGAIN — done for this wakeup. No exception thrown.
}

TCP_DEFER_ACCEPT on the listen socket

listenSocket.SetSocketOption(
    SocketOptionLevel.Tcp,
    SocketOptionName.TcpDeferAccept,
    optionValue: 1); // 1-second timeout

// Now accept() only returns connections that have data ready.
// For TLS servers, this means the ClientHello is already in the buffer
// when the connection is accepted, so SSL_do_handshake() can make
// immediate progress without bouncing through WantRead.

Design details

Why not reuse Socket.Accept() with Blocking = false?

Socket.Accept() on a non-blocking socket does call the same native SystemNative_Acceptaccept4 under the hood, and SocketPal.Accept correctly returns SocketError.WouldBlock on EAGAIN. However, three problems prevent reuse:

  1. Throws on EAGAIN: Socket.Accept() turns WouldBlock into a thrown SocketException (via UpdateStatusAfterSocketErrorAndThrowException). In a drain loop, EAGAIN is the normal exit — not an exceptional condition. The benchmark shows this costs 569 B of allocation and 1.5x more time per occurrence.

  2. Creates a full Socket object: CreateAcceptSocket allocates a Socket, copies listener state, calls InternalSetBlocking (an fcntl/ioctl syscall), and creates an EndPoint object. That's 320 B per accept. TryAccept returns just the SafeSocketHandle (64 B).

  3. Returns a blocking fd: The native code uses accept4(SOCK_CLOEXEC) without SOCK_NONBLOCK. On macOS, it explicitly resets the accepted fd to blocking via fcntl(fd, F_SETFL, ~O_NONBLOCK). A poll-driven consumer needs non-blocking fds.

These are not fixable without breaking the existing Socket.Accept() contract (blocking fd by default, returns Socket, throws on error).

Open questions

  1. EndPoint allocation: TryAccept creates an EndPoint from the sockaddr returned by accept(). For maximum performance, a future overload could accept a Span<byte> for the raw sockaddr to avoid the EndPoint allocation. This can be added later without breaking changes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions