Skip to content

Port to tokio (supersedes #856, closes #944) - #951

Merged
bee-san merged 10 commits into
masterfrom
feat/tokio-port
Oct 2, 2026
Merged

bee-san merged 10 commits into
masterfrom
feat/tokio-port

Conversation

@bee-san

@bee-san bee-san commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

RustScan moves off the discontinued async-std (RUSTSEC-2025-0052) onto tokio. The scanner keeps its design and the library API keeps its signatures. A new opt-in CI benchmark compares the change against master in GitHub Actions. On every platform the port is at least as fast as master and uses no more memory. On Linux x64, sweeps are 39–74% faster, the first open port is reported in 6 ms instead of 185 ms, and CPU time is about halved.

Supersedes #856. It builds on @vrtgs's port (the tokio::time::timeout handling of UDP receives, the shape of the crate example), and the first commit credits them as co-author. Closes #944.

Design

  • Same scan loop, no task per socket. One FuturesUnordered keeps at most batch_size connection attempts in flight, and all of them are polled from the one future you await. port to tokio again #856 used tokio-par-stream, which spawns a task per socket; that is gone, as are blackrock2, either and futures-lite. tokio was already in the dependency tree through hickory-resolver, so Cargo.lock loses 28 packages and gains none. The LCG port order is unchanged.
  • Current-thread runtime, only around the scan. run_scan() in main.rs builds a current-thread runtime with I/O and timers enabled. The scan, the I/O driver and the timers all run on one thread. With a multi-threaded runtime, TCP sweeps ran at master's speed (−3% to +3%), while the current-thread build was 25–55% faster (table below); it also started worker threads that the scan never uses.
  • No blocking work on the runtime. parse_addresses (DNS through hickory, host files) and the config read stay synchronous and run before the runtime is created. Scripts run after it is dropped. hickory's sync resolver owns its own tokio runtime, so it must not run inside ours anyway; parse_addresses now documents this for library users.
  • The scan yields to the runtime explicitly (WORK_PER_TURN = 128). Socket starts and completions are handled in rounds of at most 128 (about 1 ms of work on Linux), with tokio::task::yield_now() between rounds, so the runtime polls the OS selector and fires timers between rounds. The first CI round showed why: without it, starting a whole batch, or a run of UDP probes that finish straight away, happened in one poll. Answers that arrived meanwhile were seen late. On Windows, with -b 10000 -t 500, the timeouts fired first, and that build found 0 open ports in 9 of 9 runs.
  • Opted out of tokio's cooperative budget (tokio::task::unconstrained), because the scan bounds its own polls. Under the budget, FuturesUnordered re-polls every other ready socket once the budget runs out, only for each to return Pending again. That cost up to 30% throughput with large batches.
  • UDP sends before registering with tokio. tokio's I/O waits for reactor readiness before every send/recv. async-io tried the call first. Probes now go out on a non-blocking std socket, and the first receive is tried at once (on loopback the ICMP "port unreachable" is usually already queued). The socket is only registered with tokio if it has to wait.
  • UDP waits also wake on ICMP errors (recv_or_error). On Linux, a queued ICMP error only raises EPOLLERR, which tokio does not treat as readable, so UdpSocket::recv slept until the timeout. Every closed port of a remote host would have looked filtered and cost a full timeout. The wait now uses READABLE | ERROR readiness and returns the error from take_error().
  • Same TCP semantics. Connections time out with tokio::time::timeout, which reports the same TimedOut error as before. --tries, --closed and --interval are unchanged. Open sockets are still shut down in both directions (through into_std(), because tokio only has a write-side shutdown). No socket options changed. Nagle doesn't matter because nothing is written.
  • Unchanged otherwise. The ulimit/batch-size logic is the same. Ctrl-C still terminates the process immediately (no signal handler is installed). The scan uses one thread instead of async-std's two.

What changed

Commit
refactor(scanner): run the scanner on tokio instead of async-std The like-for-like port, docs, socket-free tests (the futures are Send, and a scan with nothing to scan finishes on a current-thread runtime)
perf(scanner): opt the scan out of tokio's cooperative budget
perf(scanner): send UDP probes before registering the socket with tokio
ci: add an opt-in scan benchmark comparing a change with its base .github/workflows/scan-benchmark.yml, .github/scan-benchmark/scan_bench.py, contributing.md
fix(scanner): let the runtime poll I/O while a batch is started Rounds of 128, from the first benchmark run (its message says "2.6x"; the measured slowdown was 3.0x on arm64 and 3.6x on x64)
fix(scanner): see ICMP errors on UDP sockets that wait in tokio
ci(scan-benchmark): add a delayed host, fit Windows, compare misses
docs(address): say that parse_addresses must not run inside async code

The history also has a temporary CI commit that compared intermediate builds, plus its revert. A squash merge drops both.

Library API

  • The signatures are unchanged: Scanner::new, run, run_with_status, the builder methods, parse_addresses/parse_address (still synchronous, still taking hickory's Resolver) and PortStrategy.
  • Behaviour change for embedders. Scanner::run/run_with_status must now be polled inside a tokio runtime with I/O and time enabled. Polling them from another executor (e.g. async_std::task::block_on) panics. They spawn nothing, so a current-thread runtime is enough, and the futures are Send, so they can also be spawned on a multi-threaded one. The crate docs, the Scanner docs and the crate example show how.
  • I'd release this as a new minor version with a release note (or 3.0 if you treat the runtime as API). This PR doesn't bump the version.

Scan benchmark (GitHub Actions, medians)

The workflow runs for PRs that touch the scanner, its runtime or its dependencies, and can be started by hand. It builds the PR and its base in release mode and runs scan_bench.py on Linux x64 and arm64, macOS and Windows runners. The script scans:

  • 315 TCP listeners and 32 UDP responders it opens on 127.0.0.1, plus every closed port in 1–32767 (below the ephemeral range, so no socket can connect to itself);
  • on Linux, 2,000 ports that iptables drops (filtered);
  • on Linux, a host 5 ms away: a network namespace behind a netem-delayed veth pair, with its own listeners, so answers arrive a round trip later as from a remote host.

Builds run interleaved, 9 runs each (27 for tcp-1-port). The job fails if any build misses more listeners than the base, or disagrees with it about other ports. It also fails if the PR's median throughput or wall time is more than 15% worse, or its peak RSS more than 25% worse, in any scenario. Unit tests still never open sockets, as #942 requires. Nothing outside the runner is scanned.

Final run (all green): https://github.com/bee-san/RustScan/actions/runs/36914068659. Every job summary has the full tables, and the raw data is in the artifacts.

Linux x64 (AMD EPYC 7763, 4 vCPUs), master (cfb8645) → this PR:

Scenario Throughput (sockets/s) Wall time First open port Peak RSS CPU time Peak fds
tcp-1-port: -p 1024 n/a 2 → 2 ms 1.7 → 1.5 ms 21.0 → 21.0 MiB 2 → 1 ms 3 → 3
tcp-sweep: 32,767 ports, -b 4500 21,415 → 37,348 (+74%) 1,539 → 881 ms (−43%) 185 → 5.9 ms 20.0 → 20.0 MiB 1,965 → 866 ms (−56%) 4,506 → 1,030
tcp-sweep-b500 25,152 → 38,301 (+52%) 1,306 → 859 ms (−34%) 21.7 → 5.5 ms 20.0 → 20.0 MiB 1,679 → 847 ms (−50%) 504 → 506
tcp-sweep-b10000 24,343 → 37,245 (+53%) 1,364 → 883 ms (−35%) 370 → 5.6 ms 29.4 → 20.1 MiB (−32%) 1,700 → 870 ms (−49%) 10,006 → 1,030
tcp-8-hosts: 127.0.0.1–8 × 4,096 24,687 → 37,789 (+53%) 1,337 → 871 ms (−35%) 186 → 21.4 ms 20.1 → 20.1 MiB 1,677 → 841 ms (−50%) 4,506 → 1,030
tcp-delayed: 32,767 ports, 5 ms away 21,848 → 33,607 (+54%) 1,508 → 978 ms (−35%) 211 → 38.3 ms 20.1 → 20.1 MiB 1,857 → 961 ms (−48%) 4,488 → 396
tcp-filtered: 2,000 dropped, -b 500 -t 250 1,968 → 1,964 (−0.2%) 1,019 → 1,021 ms (+0.2%) – 20.1 → 20.1 MiB 75 → 46 ms (−39%) 506 → 506
tcp-mixed: 32,767 + 2,000 dropped, -t 500 17,846 → 24,792 (+39%) 1,956 → 1,407 ms (−28%) 188 → 5.9 ms 20.1 → 20.1 MiB 1,908 → 905 ms (−53%) 4,506 → 2,006
udp-sweep: 32,767 ports, -t 500 30,980 → 50,813 (+64%) 1,061 → 648 ms (−39%) 178 → 27.0 ms 20.1 → 20.1 MiB 1,261 → 629 ms (−50%) 13 → 9
udp-delayed: 32,767 ports, 5 ms away 22,376 → 34,609 (+55%) 1,473 → 949 ms (−36%) 200 → 41.6 ms 20.1 → 20.1 MiB 1,823 → 918 ms (−50%) 4,490 → 454
udp-filtered: 2,000 dropped, -b 500 -t 250 1,960 → 1,960 (0%) 1,023 → 1,023 ms (0%) – 20.1 → 20.1 MiB 85 → 48 ms (−44%) 506 → 506

Peak threads were 2 → 1 in every scenario except tcp-1-port (1 → 1). The filtered rows are bound by the timeout (500 sockets per 250 ms). Their identical throughput shows the timers fire as accurately as before.

Other runners, same run (this PR vs master; tcp-1-port aside):

  • Linux arm64: throughput +17% (udp-sweep) to +90% (tcp-8-hosts). First open port 81–99% sooner. CPU time 48–57% lower. Peak RSS equal, or 33% lower at -b 10000. Filtered scenarios ±0.2%.
  • macOS (M1, 3 vCPUs; sweeps, udp-sweep and tcp-1-port only): TCP throughput +10% to +45%, UDP +16%. Wall time 9–31% lower. First open port 55–98% sooner. Peak RSS 18–86% lower. CPU time 23–40% lower.
  • Windows Server 2025: throughput −0.3% to +12%. Windows retries a refused connection for about a second, so its sweeps are bound by the timeout. First open port 57–97% sooner, peak RSS 1–9% lower, CPU time 17–30% lower, handles within 1%.

Nothing regressed beyond noise. Every median that came out worse is within 0.2 ms or 1%: for example macOS tcp-1-port wall time 4 → 4 ms (+5%, 0.2 ms; +0.1% in the previous run), and Windows' timeout-bound tcp-sweep-b500 at +0.3%. The one exception is macOS tcp-sweep-b500 peak descriptors, 400 → 504. This build keeps the 500-socket batch full, while master never filled it, and that is part of why the build is 45% faster there.

Why these design choices. These numbers are from the first CI run, Linux x64, each build against master in the same run (https://github.com/bee-san/RustScan/actions/runs/36907201462):

Build tcp-sweep throughput tcp-sweep-b10000 throughput udp-sweep wall time udp-sweep first open port
Plain port (cooperative budget, tokio UDP calls) +51% +24% 4,099 vs 1,019 ms (closed ports waited for the timeout: ICMP errors not seen) −68%
+ unconstrained +53% +37% 4,102 vs 1,019 ms −51%
+ UDP sent before registering (first-run head) +55% +35% 649 vs 1,019 ms +259% (runtime starved)
Same, on a multi-threaded runtime +3% −2% 666 vs 1,019 ms −33%
This PR (rounds of 128 + ICMP wake-up), final run +74% +53% 648 vs 1,061 ms −85%

Remaining risks

  • Embedders have to use tokio (see Library API) and need a release note.
  • parse_addresses still blocks and drives hickory's own runtime. It's documented now. An async resolver (step (b) of Track: port RustScan to tokio (PR #856) #944) is left for a follow-up.
  • Peak sockets can stay below -b. The batch fills in rounds of 128 and finished sockets are replaced at once, so against fast targets the number of sockets open at the same time stays well below -b (1,030 instead of 4,506 on loopback). -b is still the upper bound, and against slow or filtered targets the batch fills completely (see the filtered and delayed rows).
  • Timer resolution. tokio's timer wheel has 1 ms resolution and rounds deadlines up, so below roughly -t 10 a timeout can last up to 1 ms longer than with async-io.
  • WORK_PER_TURN is fixed at 128. It wasn't tuned per platform. Each round costs one non-blocking selector poll.
  • Cross-compiled release targets weren't built. i686/armv7/aarch64 Linux, both macOS architectures and 32-bit Windows are only built by the CD pipeline on pushes to master. tokio supports them all, but the first master build after merging is what will confirm it.
  • Windows numbers say less about per-socket overhead. Windows sweeps are bound by the timeout, so its numbers say more about correctness and latency than about per-socket cost.
  • The benchmark needs sudo on Linux runners (iptables, a network namespace, tc netem) and takes 4–9 minutes per runner. It only runs for PRs that touch the scanner, its runtime or its dependencies.

bee-san and others added 10 commits October 1, 2026 18:10
async-std is discontinued upstream (RUSTSEC-2025-0052). tokio is already in
the dependency tree through hickory-resolver, so switching adds no crate and
removes 28 from Cargo.lock (async-std, async-io, polling,
async-global-executor, blocking, futures-executor, ...).

This is a like-for-like port. The scanner keeps its architecture and the
library API keeps its signatures:

- a single `FuturesUnordered` keeps at most `batch_size` sockets in flight
  from one future; nothing is spawned per socket;
- connect and UDP receive timeouts use `tokio::time::timeout` and report the
  same `TimedOut` error that async-std's `io::timeout` did;
- open TCP sockets are still shut down in both directions before they are
  closed (through `into_std()`, because tokio only has a write-side shutdown);
- `--interval` sleeps with `tokio::time::sleep`.

The binary runs the scan on a current-thread runtime that exists only for
the scan. Address resolution (blocking DNS lookups and file reads) runs
before the runtime is created and scripts run after it is dropped, so neither
can stall the scan, and hickory's internal runtime is never nested in ours.

Library users now have to poll the scan futures from a tokio runtime with the
I/O and time drivers enabled. The crate and `Scanner` docs say so, and the
crate example shows how.

Builds on the earlier port in #856 by @vrtgs.

Co-authored-by: Vrtgs <vrtgs@vrtgs.xyz>
The scan polls every in-flight socket from one future through a
`FuturesUnordered`. tokio's cooperative scheduling gives that future a budget
of 128 units per poll. Once it is spent, every tokio resource returns
`Pending` and defers its wake-up, but `FuturesUnordered` still polls all the
other ready sockets in that same poll. When thousands of connections finish
together (a burst of refusals, or a batch of timeouts firing at once), the
scan keeps re-polling sockets only to park them again, roughly 128 at a time.

async-std had no budget. `tokio::task::unconstrained` restores that
behaviour: one poll handles every ready socket. The future still returns
`Pending`, and so lets the runtime poll I/O and timers, as soon as no socket
is ready.
tokio's socket I/O is readiness based: `UdpSocket::send` and `recv` first
wait until the reactor has reported the socket ready and only then make the
system call. async-io, which async-std used, made the call first and only
waited after `WouldBlock`.

With a straight port, every UDP probe therefore made an extra trip through
the event loop before its send. Probes to closed ports on the local host
also no longer finished in their first poll: there the ICMP "port
unreachable" is usually queued before `send` returns, so async-std's first
`recv` already saw the refusal.

Bind a non-blocking std socket instead, send the probe and try the first
receive straight away. The socket is only handed to tokio, and registered
with its reactor, when there is really something to wait for. Errors and
timeouts are reported exactly as before.
The test suite deliberately never opens sockets (see #942), so nothing in CI
showed whether a change to the scanner makes RustScan slower, hungrier or
wrong. The tokio port (#944) needs exactly that evidence.

The "Scan benchmark" workflow runs for pull requests that touch the scanner,
its runtime or its dependencies, and on demand. It builds the change and its
base in release mode on Linux x64/arm64, macOS and Windows runners. It then
runs .github/scan-benchmark/scan_bench.py, which:

- opens 315 TCP listeners and 32 UDP responders on 127.0.0.1 inside the
  runner. Nothing outside the runner is scanned. Ports stay out of the
  ephemeral range, so a scan can never connect a socket to itself;
- scans them with both builds, alternating the build order between runs:
  full TCP sweeps with batch sizes 500/4500/10000, 8 loopback hosts, one
  port, a UDP sweep, and on Linux ports that iptables drops (filtered TCP and
  UDP, and a mixed sweep), so timeouts are measured too;
- fails unless every run finds every listener and no build reports ports the
  base never found, or misses ports the base always found;
- reports medians of throughput (from RustScan's own scan timer), wall time,
  time to the first open port, peak RSS, CPU time, and sampled peak
  descriptors and threads. The table goes to the job summary, the raw data
  to an artifact;
- fails if the change's median throughput or wall time is more than 15%
  worse, or its peak RSS more than 25% worse, in any scenario.

contributing.md documents the workflow as the place where scanning is
measured, next to the rule that tests must not scan.
…te builds (to be reverted)"

This reverts commit 30ac107.
The scan drives every socket from one future on a current-thread runtime,
so the runtime only polls for I/O and fires timers between two polls of
that future. A poll only ended once no socket was ready, which could take
a long time:

- starting a whole batch at once: 8,191 sockets took longer than a 500 ms
  timeout on the Windows runner, so connections that had succeeded during
  the fill were only seen after their timers fired. The benchmark found no
  open port at all with `-b 10000 -t 500` there;
- UDP probes to closed ports on the local host finish straight away, so a
  whole sweep ran in one poll, and the replies from open ports waited until
  the end: the first open UDP port was reported 2.6x later than with
  async-std.

`scan_sockets` now starts and finishes sockets in rounds of at most 128
(`WORK_PER_TURN`, both counted together) and calls `yield_now()` between
rounds, which defers its wake-up until the runtime has polled the OS
selector without blocking. Each round still keeps up to `batch_size`
sockets in flight; the batch now fills over several rounds instead of in
one go. The cost is one extra non-blocking `epoll_wait` (or equivalent)
per 128 sockets.
A UDP probe that gets no answer straight away is handed to tokio to wait for
the reply. On Linux, an ICMP "port unreachable" that arrives later only
raises EPOLLERR on the socket. tokio does not count that as readable, so
`UdpSocket::recv` slept until the timeout instead of returning the refusal.
async-io treated EPOLLERR as readable.

The straight port (before the eager probe) showed the effect: the UDP sweep
of the local host took 4.1 s instead of 1.0 s, because every closed port
waited for the 500 ms timeout. With the eager probe it only hit closed ports
whose ICMP arrived after the first receive. That is rare on the local host
but the common case for any remote host, where it turned every closed port
into a timeout.

`recv_or_error` waits for `READABLE | ERROR` readiness and hands out the
queued error (`take_error`), so a refused probe ends as soon as the ICMP
arrives, as before.
What the first runs showed:

- Only answers that arrive while the probe is still being sent were
  measured: on the loopback interface, refusals and ICMP errors are queued
  before connect()/send() returns. A remote host answers a round trip
  later, which is the path where tokio's UDP receive missed ICMP errors.
  On Linux the workflow now also sets up a host 5 ms away (a network
  namespace behind a netem-delayed veth pair, with ICMP rate limiting off,
  running its own listeners), and the benchmark sweeps it over TCP and UDP.
- Windows retries a refused connection for about a second. With
  `-t 500`, 8,191 concurrent attempts made even the base miss listeners.
  Windows now uses the default timeout and sweeps 8,191 ports (2,047 for
  the 500-socket batch).
- A build now fails the check only if it misses more listeners than the
  base does (any miss at all if the base misses none). Misses by the base
  are reported as a warning in the summary.
- The macOS runner has no `net.inet.icmp.icmplim`, which failed the
  "describe" step, and caps descriptors at 10240, so the 65536 limit
  could not be set. The describe step can no longer fail the job, and
  10240 is tried as well.
hickory's synchronous resolver runs its own tokio runtime, and tokio panics
when a runtime is dropped inside an asynchronous context. That was already
true before, but library users now have to run the scanner on tokio, so
they are more likely to call `parse_addresses` from async code. Document
the two safe ways: call it before starting the runtime (as the binary does)
or through `spawn_blocking`.
@bee-san
bee-san merged commit b7119f7 into master Oct 2, 2026
8 checks passed
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.

Track: port RustScan to tokio (PR #856)

1 participant