Repository navigation
prim_inet: add send_async/2,3 for TCP ports of the inet driver - #122
Merged
Merged
Conversation
This was referenced Oct 18, 2026
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
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
force-pushed
the
261018-gen-tcp-send-async
branch
from
October 18, 2026 19:03
b6ba881 to
a358a9f
Compare
Member
Author
|
ci failure is unrelated. |
keynslug
reviewed
Oct 21, 2026
| returned for `inet_backend = socket`. | ||
| """. | ||
| -doc(#{since => <<"OTP 28.4.1-8 (EMQX)">>}). | ||
| -spec send_async(Socket, Packet) -> ok | {error, Reason} when |
There was a problem hiding this comment.
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.
| Socket :: socket(), | ||
| Packet :: iodata(), | ||
| Options :: [nosuspend | force], | ||
| Reason :: busy | closed | enotsup | inet:posix(). |
There was a problem hiding this comment.
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.
keynslug
approved these changes
Oct 22, 2026
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
force-pushed
the
261018-gen-tcp-send-async
branch
from
October 22, 2026 10:02
6d9ba6f to
173baa2
Compare
keynslug
approved these changes
Oct 22, 2026
This was referenced Oct 22, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add
prim_inet:send_async/2,3for TCP sockets of theinetbackend (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/2gives 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, aninet_db:lookup_socketcall, a port monitor, aterm_to_binary/2call and a timed receive.EMQX 4.4 sent with
erlang:port_command/3and 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 bareport_commandfails withEINVAL. 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_tcplistener), 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 tosend/3, and the rebuilt preloadedprim_inet.beam. No driver change and no public API change: the function is inprim_inetbecause it works only on inet driver ports, takeserlang:port_command/3options and returns the driver's own reply message.send_async(Port, Data)andsend_async(Port, Data, Options), withOptionspassed toerlang:port_command/3([],[nosuspend]).ok,{error, busy}(port busy andnosuspendgiven; nothing is queued), or{error, closed}(the port is gone). A driver error arrives in the reply message, not as a return value.socketbackend is not a port and does not match the function (function_clause).ok, in send order:{inet_reply, Socket, ok | {error, Reason}, send_async}. When the driver queue is abovehigh_watermark, the reply is delayed until the queue drains belowlow_watermark.{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 noterm_to_binary/2call.A zero-length tag was rejected. With a zero-length tag the driver drops the whole reply, including the
{error, timeout}reply ofsend_timeout.Tests
New group
t_send_asyncingen_tcp_api_SUITE(under each backend group). The cases open sockets withgen_tcpand send withprim_inet:send_async/2,3:t_send_async_basic:okreply, data received by the peer, iodata accepted, no other messages.t_send_async_busy:{error, busy}withnosuspend; the reply of a send queued above the high watermark arrives after the peer reads the data.t_send_async_send_timeout:send_timeoutarrives as an{error, timeout}reply.t_send_async_send_timeout_close: aftersend_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_inetloaded at node start (erlang:load_module/2, beforect_run):gen_tcp_api_SUITE31 ok, 0 failed, 2 skipped (environment: no link-local IPv6 address, util NIF). With-kernel inet_backend socket: the five new cases skip asinet-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).