Skip to content

prim_inet: add send_async/2,3 for TCP ports of the inet driver - #122

Merged
zmstone merged 3 commits into
emqx:emqx-OTP-28.4.1from
zmstone:261018-gen-tcp-send-async
Oct 22, 2026
Merged

zmstone merged 3 commits into
emqx:emqx-OTP-28.4.1from
zmstone:261018-gen-tcp-send-async

Conversation

@zmstone

@zmstone zmstone commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Add prim_inet:send_async/2,3 for TCP sockets of the inet backend (ports of the inet driver). The function gives the data to the inet driver and returns at once. The result of the send arrives later in the mailbox of the caller.

Why

gen_tcp:send/2 gives the data to the inet driver and then waits for the reply of the driver. Each outgoing packet costs the caller a round trip through the port, an inet_db:lookup_socket call, a port monitor, a term_to_binary/2 call and a timed receive.

EMQX 4.4 sent with erlang:port_command/3 and read the reply from the mailbox later. Since OTP 26 (OTP-18520), the driver requires a caller tag in front of the data, and a bare port_command fails with EINVAL. esockd therefore made its asynchronous send synchronous on OTP 26 and later.

In an EMQX benchmark (1:1 publish/subscribe, QoS 0, 1 KB payload, 20k messages/s, 40k connections, 8 schedulers, gen_tcp listener), the asynchronous send lowered broker CPU per message from 256.5 µs to 225.5 µs (mean of 3 runs each, -12.1%).

Change

erts/preloaded/src/prim_inet.erl, next to send/3, and the rebuilt preloaded prim_inet.beam. No driver change and no public API change: the function is in prim_inet because it works only on inet driver ports, takes erlang:port_command/3 options and returns the driver's own reply message.

  • send_async(Port, Data) and send_async(Port, Data, Options), with Options passed to erlang:port_command/3 ([], [nosuspend]).
  • Return: ok, {error, busy} (port busy and nosuspend given; nothing is queued), or {error, closed} (the port is gone). A driver error arrives in the reply message, not as a return value.
  • A socket of the socket backend is not a port and does not match the function (function_clause).
  • One reply per ok, in send order: {inet_reply, Socket, ok | {error, Reason}, send_async}. When the driver queue is above high_watermark, the reply is delayed until the queue drains below low_watermark.
  • A port that dies before it replies sends no reply. The owner receives the exit signal of the port link, or {tcp_closed, Socket} in active mode.

The function prepends a constant tag, term_to_binary(send_async), pre-encoded as a literal binary, instead of a reference for each call. The driver only copies the tag and echoes it in the reply. So a send costs no port monitor and no term_to_binary/2 call.

A zero-length tag was rejected. With a zero-length tag the driver drops the whole reply, including the {error, timeout} reply of send_timeout.

Tests

New group t_send_async in gen_tcp_api_SUITE (under each backend group). The cases open sockets with gen_tcp and send with prim_inet:send_async/2,3:

  • t_send_async_basic: ok reply, data received by the peer, iodata accepted, no other messages.
  • t_send_async_busy: {error, busy} with nosuspend; the reply of a send queued above the high watermark arrives after the peer reads the data.
  • t_send_async_send_timeout: send_timeout arrives as an {error, timeout} reply.
  • t_send_async_send_timeout_close: after send_timeout_close, the next send gets an error reply (enotconn).
  • t_send_async_port_death: a killed port with a pending reply sends no reply; the owner gets {'EXIT', Socket, killed}.

On the socket backend each case skips.

Run on OTP 28.4.1-7 with the new prim_inet loaded at node start (erlang:load_module/2, before ct_run): gen_tcp_api_SUITE 31 ok, 0 failed, 2 skipped (environment: no link-local IPv6 address, util NIF). With -kernel inet_backend socket: the five new cases skip as inet-only testcase.

Release

This PR targets the 28.4.1 line (emqx-OTP-28.4.1), which EMQX 6.3 uses. The port to the 28.5 line (EMQX 7.0) is for the maintainer of that line.

The EMQX changes that use the function: emqx/esockd#236 (esockd_transport:async_send/3) and emqx/emqx#19627 (emqx_connection).

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
zmstone marked this pull request as ready for review October 18, 2026 13:46
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.
@zmstone
zmstone force-pushed the 261018-gen-tcp-send-async branch from b6ba881 to a358a9f Compare October 18, 2026 19:03
@zmstone

zmstone commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

ci failure is unrelated.

Comment thread lib/kernel/src/gen_tcp.erl Outdated
returned for `inet_backend = socket`.
""".
-doc(#{since => <<"OTP 28.4.1-8 (EMQX)">>}).
-spec send_async(Socket, Packet) -> ok | {error, Reason} when

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes more sense to put directly in prim_inet as it: 1) does not work for socket-backed sockets, 2) skips implementation module lookups, 3) and exposes port-specific options.

Comment thread lib/kernel/src/gen_tcp.erl Outdated
Socket :: socket(),
Packet :: iodata(),
Options :: [nosuspend | force],
Reason :: busy | closed | enotsup | inet:posix().

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. inet:posix() is probably impossible to get here?

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 gen_tcp: add send_async/2,3 for the inet backend prim_inet: add send_async/2,3 for TCP ports of the inet driver Oct 21, 2026
Comment thread OTP_VERSION Resolved
Send data without waiting for the inet driver's reply. The driver
requires the caller-reference header introduced in OTP 26 (OTP-18520),
so the function prepends a constant pre-encoded tag instead of a
per-call reference: no port monitor and no term_to_binary/2 per send.
The reply arrives as {inet_reply, Socket, Status, send_async}.

A zero-length tag was rejected: the driver then drops the reply
entirely, so send_timeout errors would be invisible to the owner.

Only the inet backend is supported. The socket backend returns
{error, enotsup}.

The tests cover the reply on success, {error, busy} with nosuspend and
the delayed reply above the high watermark, the {error, timeout} reply
from send_timeout, the error after send_timeout_close, and a port that
dies with a pending reply.
send_async works only on ports of the inet driver, takes
erlang:port_command/3 options and returns the driver's own reply
message. That belongs in prim_inet, next to send/3, not in the
generic gen_tcp API: a gen_tcp function suggests that it works with
every backend, and the call to it went through a module lookup that it
did not need.

The spec now lists only the errors that the function itself returns,
busy and closed. A driver error arrives in the reply message.
@zmstone
zmstone force-pushed the 261018-gen-tcp-send-async branch from 6d9ba6f to 173baa2 Compare October 22, 2026 10:02
@zmstone
zmstone merged commit de1fccb into emqx:emqx-OTP-28.4.1 Oct 22, 2026
19 of 22 checks passed
@zmstone
zmstone deleted the 261018-gen-tcp-send-async branch October 22, 2026 11:39
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