Skip to content

feat(transport): send asynchronously with prim_inet:send_async/3 - #236

Merged
zmstone merged 4 commits into
emqx:masterfrom
zmstone:261018-async-send-via-gen-tcp-send-async
Oct 23, 2026
Merged

zmstone merged 4 commits into
emqx:masterfrom
zmstone:261018-async-send-via-gen-tcp-send-async

Conversation

@zmstone

@zmstone zmstone commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

esockd_transport:async_send/3 sends asynchronously again on OTP 26 and later when the running OTP exports prim_inet:send_async/3.

Why

Since OTP 26 (OTP-18520), the inet driver requires a caller tag in front of the data. A bare erlang:port_command/3 fails with EINVAL. So async_send/3 became gen_tcp:send/2 plus a reply message that the caller sends to itself (3b304e2). Each send waits for the driver.

The EMQX OTP fork adds prim_inet:send_async/2,3 (emqx/otp#122). It sends with a constant tag and returns when the driver has the data. The driver replies later with {inet_reply, Sock, Status, send_async}, one reply per ok, in send order. In an EMQX benchmark (QoS 0, 1:1 publish/subscribe, 20k messages/s, 40k connections), this lowered broker CPU per message by 12%.

Change

  • For a TCP port, async_send/3 calls prim_inet:send_async/3 when the running OTP exports it. The check runs once. The result is kept in the persistent term {esockd_transport, send_async_module}.
  • The module name is a runtime value, so xref and dialyzer pass on a stock OTP.
  • With nosuspend and a busy port, the function returns {error, busy} and queues nothing. This is the behavior before OTP 26. The caller decides what to do, for example send again with send/2.
  • On a stock OTP 26 or later, the function sends with gen_tcp:send/2, as before.
  • TLS sockets send synchronously, as before.

Reply shape (breaking)

On OTP 26 and later, every path now sends the same reply: {inet_reply, Sock, ok | {error, Reason}, send_async}. The fallback and the TLS path sent {inet_reply, Sock, ok} before. A caller that matches the three-element reply must also match the four-element reply. OTP 25 and older keep the three-element reply of the driver.

The test echo servers and the examples now match both shapes. No caller in EMQX dev-63 uses async_send; the EMQX change that uses it handles both shapes. Because of this change, the next tag can be a minor version bump.

Tests

  • t_async_send_reply: the reply has four elements and the peer receives the data.
  • t_async_send_fallback: with the persistent term set to undefined, the send is synchronous and the reply is sent by the function; a closed socket returns {error, closed}.
  • t_async_send_prim_inet_send_async: skipped when prim_inet:send_async/3 is not exported. Otherwise: {error, busy} with nosuspend on a full port, and one ok reply per accepted send after the peer reads the data.

Results:

  • OTP 28.1 (stock): make xref and rebar3 dialyzer clean; rebar3 as test ct 242 passed, 4 skipped (the new asynchronous case, and esockd_socket_SUITE which needs the compose test server).
  • OTP 28.4.1-9 (emqx-builder 6.2-4 image): esockd_transport_SUITE 36 passed, the asynchronous case included. CI runs the run_test_case job on this image too.
  • OTP 25.3: esockd_transport.erl and the suite compile.

Release

The asynchronous path needs EMQX OTP 28.4.1-9 or later (emqx/otp#122); on other OTP builds the behavior is the fallback described above. emqx/emqx#19627 uses this function and bumps the esockd tag in mix.exs after the tag exists.

Since OTP 26 the inet driver requires a caller tag in front of the
data, so async_send/3 became gen_tcp:send/2 plus a reply message that
the caller sends to itself. Each send then waits for the driver.

EMQX OTP builds add gen_tcp:send_async/3. It sends with a constant tag
and returns when the driver has the data. The driver replies later
with {inet_reply, Sock, Status, send_async}.

async_send/3 now uses gen_tcp:send_async/3 for a TCP port when the
running OTP exports it. The check runs once and its result is kept in a
persistent term. With nosuspend and a busy port it returns
{error, busy} and queues nothing, as before OTP 26.

On OTP 26 and later, the fallback and the TLS path now send the same
four-element reply, so a caller handles one shape. This changes the
reply from {inet_reply, Sock, ok} to {inet_reply, Sock, ok, send_async}.
OTP 25 and older keep the three-element reply of the driver.
zmstone added a commit to zmstone/emqx that referenced this pull request Oct 18, 2026
…eply

gen_tcp:send/2 gives the data to the inet driver and then waits for the
driver's reply message, so every outgoing packet costs the connection
process a round trip through the port. EMQX 4.4 used
erlang:port_command/3 through esockd_transport:async_send/3 and took the
reply from its mailbox later. That path stopped working on OTP 26, where
the driver requires a caller tag in front of the data, and esockd made
async_send/3 synchronous.

The EMQX OTP fork adds gen_tcp:send_async/3 for the inet backend
(emqx/otp#122): it sends with a constant tag and returns when the driver
has the data. The reply comes later as
{inet_reply, Socket, Status, send_async}, one per accepted send, in send
order; a dead port sends no reply and the owner sees the port exit as
before. esockd_transport:async_send/3 uses it when the running OTP
exports it (emqx/esockd#236).

The connection uses esockd_transport:async_send/3 with nosuspend when
the socket is an inet port and the running OTP exports
gen_tcp:send_async/3. On {error, busy} (driver queue above its high
watermark) the packet is sent synchronously, which keeps today's
back-pressure. A reply with an error is handled as a synchronous send
error. The reply of an older esockd ({inet_reply, Socket, Status}) is
accepted too. Other transports and OTP builds without the function keep
the synchronous send.
zmstone added a commit to zmstone/emqx that referenced this pull request Oct 18, 2026
…eply

gen_tcp:send/2 gives the data to the inet driver and then waits for the
driver's reply message, so every outgoing packet costs the connection
process a round trip through the port. EMQX 4.4 used
erlang:port_command/3 through esockd_transport:async_send/3 and took the
reply from its mailbox later. That path stopped working on OTP 26, where
the driver requires a caller tag in front of the data, and esockd made
async_send/3 synchronous.

The EMQX OTP fork adds gen_tcp:send_async/3 for the inet backend
(emqx/otp#122): it sends with a constant tag and returns when the driver
has the data. The reply comes later as
{inet_reply, Socket, Status, send_async}, one per accepted send, in send
order; a dead port sends no reply and the owner sees the port exit as
before. esockd_transport:async_send/3 uses it when the running OTP
exports it (emqx/esockd#236).

The connection uses esockd_transport:async_send/3 with nosuspend when
the socket is a port (gen_tcp, inet backend). EMQX runs only on the EMQX
OTP fork, so the connection does not check that gen_tcp:send_async/3
exists. On {error, busy} (driver queue above its high watermark) the
packet is sent synchronously, which keeps the back-pressure. A reply
with an error is handled as a synchronous send error. The reply of an
older esockd ({inet_reply, Socket, Status}) is accepted too. TLS and
QUIC sockets keep the synchronous send.
The EMQX OTP fork moved send_async/3 from gen_tcp to prim_inet
during review: the function works only on inet driver ports and takes
port_command options. Probe prim_inet instead of gen_tcp.
zmstone added a commit to zmstone/emqx that referenced this pull request Oct 21, 2026
…eply

gen_tcp:send/2 gives the data to the inet driver and then waits for the
driver's reply message, so every outgoing packet costs the connection
process a round trip through the port. EMQX 4.4 used
erlang:port_command/3 through esockd_transport:async_send/3 and took the
reply from its mailbox later. That path stopped working on OTP 26, where
the driver requires a caller tag in front of the data, and esockd made
async_send/3 synchronous.

The EMQX OTP fork adds gen_tcp:send_async/3 for the inet backend
(emqx/otp#122): it sends with a constant tag and returns when the driver
has the data. The reply comes later as
{inet_reply, Socket, Status, send_async}, one per accepted send, in send
order; a dead port sends no reply and the owner sees the port exit as
before. esockd_transport:async_send/3 uses it when the running OTP
exports it (emqx/esockd#236).

The connection uses esockd_transport:async_send/3 with nosuspend when
the socket is a port (gen_tcp, inet backend). EMQX runs only on the EMQX
OTP fork, so the connection does not check that gen_tcp:send_async/3
exists. On {error, busy} (driver queue above its high watermark) the
packet is sent synchronously, which keeps the back-pressure. A reply
with an error is handled as a synchronous send error. The reply of an
older esockd ({inet_reply, Socket, Status}) is accepted too. TLS and
QUIC sockets keep the synchronous send.
@zmstone zmstone changed the title feat(transport): send asynchronously with gen_tcp:send_async/3 feat(transport): send asynchronously with prim_inet:send_async/3 Oct 21, 2026
The image has prim_inet:send_async/3, so the asynchronous send test
runs instead of being skipped.
Comment thread examples/dtls/dtls_echo_server.erl Resolved
Comment thread src/esockd_transport.erl Resolved
@zmstone
zmstone marked this pull request as ready for review October 23, 2026 11:45
@zmstone
zmstone merged commit 438e853 into emqx:master Oct 23, 2026
15 of 16 checks passed
@zmstone
zmstone deleted the 261018-async-send-via-gen-tcp-send-async branch October 23, 2026 14:07
zmstone added a commit to zmstone/emqx that referenced this pull request Oct 26, 2026
…eply

gen_tcp:send/2 gives the data to the inet driver and then waits for the
driver's reply message, so every outgoing packet costs the connection
process a round trip through the port. EMQX 4.4 used
erlang:port_command/3 through esockd_transport:async_send/3 and took the
reply from its mailbox later. That path stopped working on OTP 26, where
the driver requires a caller tag in front of the data, and esockd made
async_send/3 synchronous.

The EMQX OTP fork adds gen_tcp:send_async/3 for the inet backend
(emqx/otp#122): it sends with a constant tag and returns when the driver
has the data. The reply comes later as
{inet_reply, Socket, Status, send_async}, one per accepted send, in send
order; a dead port sends no reply and the owner sees the port exit as
before. esockd_transport:async_send/3 uses it when the running OTP
exports it (emqx/esockd#236).

The connection uses esockd_transport:async_send/3 with nosuspend when
the socket is a port (gen_tcp, inet backend). EMQX runs only on the EMQX
OTP fork, so the connection does not check that gen_tcp:send_async/3
exists. On {error, busy} (driver queue above its high watermark) the
packet is sent synchronously, which keeps the back-pressure. A reply
with an error is handled as a synchronous send error. The reply of an
older esockd ({inet_reply, Socket, Status}) is accepted too. TLS and
QUIC sockets keep the synchronous send.
zmstone added a commit to zmstone/emqx that referenced this pull request Oct 28, 2026
…eply

gen_tcp:send/2 gives the data to the inet driver and then waits for the
driver's reply message, so every outgoing packet costs the connection
process a round trip through the port. EMQX 4.4 used
erlang:port_command/3 through esockd_transport:async_send/3 and took the
reply from its mailbox later. That path stopped working on OTP 26, where
the driver requires a caller tag in front of the data, and esockd made
async_send/3 synchronous.

The EMQX OTP fork adds gen_tcp:send_async/3 for the inet backend
(emqx/otp#122): it sends with a constant tag and returns when the driver
has the data. The reply comes later as
{inet_reply, Socket, Status, send_async}, one per accepted send, in send
order; a dead port sends no reply and the owner sees the port exit as
before. esockd_transport:async_send/3 uses it when the running OTP
exports it (emqx/esockd#236).

The connection uses esockd_transport:async_send/3 with nosuspend when
the socket is a port (gen_tcp, inet backend). EMQX runs only on the EMQX
OTP fork, so the connection does not check that gen_tcp:send_async/3
exists. On {error, busy} (driver queue above its high watermark) the
packet is sent synchronously, which keeps the back-pressure. A reply
with an error is handled as a synchronous send error. The reply of an
older esockd ({inet_reply, Socket, Status}) is accepted too. TLS and
QUIC sockets keep the synchronous send.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants