Runtime
ntex uses a single-threaded execution model on each runtime thread. Tasks stay
on the thread where they were started, so services and futures can use Rc,
Cell, RefCell, and other types that are not Send or Sync.
That does not limit an application to one CPU core. A server normally starts
several worker threads, each with its own single-threaded runtime. State shared
between workers must still use thread-safe types such as Arc, atomics, or
locks.
Most applications only need #[ntex::main], #[ntex::test], and
ntex::rt::spawn. The lower-level System and Arbiter APIs are useful for
explicit startup, shutdown, and cross-thread coordination.
Choosing a runtime backend
DefaultRuntime selects the async runtime and matching ntex network reactor
together. Cargo features choose the backend:
tokiouses Tokio. When a Tokio runtime handle is entered on the current thread, the adapter uses it; otherwise it creates a current-thread Tokio runtime. ntex tasks run in a TokioLocalSet.compiocreates a Compio runtime on each runtime thread. Compio selects the I/O driver for the host platform and configuration.- without either feature, ntex uses its native runtime. On Linux it tries io_uring and falls back to polling; other Unix platforms use polling, and Windows uses IOCP.
If both tokio and compio are enabled, Tokio takes precedence. With the
native backend, neon-polling forces polling on Unix and neon-uring requires
io_uring on Linux. Do not enable both explicit native reactor features.
For example:
[dependencies]
ntex = { version = "4", features = ["tokio"] }
The Tokio and Compio backends are convenient when an application also uses libraries from those ecosystems. The native backend avoids an additional general-purpose runtime and lets ntex choose its platform reactor directly.
SystemRunner::block_on blocks the current thread. Do not call it from inside
an async Tokio task to try to nest one runtime inside another. Usually the
cleanest boundary is to let #[ntex::main] own the process entry point.
Spawning tasks
ntex::rt::spawn starts a future on the current runtime thread:
#[ntex::main]
async fn main() {
let task = ntex::rt::spawn(async {
// `Rc`, `RefCell`, and other `!Send` values may be used here.
10usize
});
assert_eq!(task.await.unwrap(), 10);
}
The future and its result do not need to implement Send, but the future must
be 'static. Use async move when it captures owned values. spawn() panics
when called outside an active ntex runtime.
The returned JoinHandle can be awaited, canceled with cancel(), detached
with detach(), or queried with is_finished(). A task panic or cancellation
is reported as JoinError, not as the task’s normal output.
Dropping a local join handle detaches the task; it does not cancel it. Keep and await the handle when the result matters. A detached task is not guaranteed to finish before its arbiter or the whole system shuts down.
To submit work to another runtime thread, obtain an arbiter’s handle and call
spawn() on it. The future and its output must both be Send + 'static
because they cross thread boundaries. Once the future is running on that
arbiter, it can start more non-Send work with ntex::rt::spawn.
Do not use a remote join handle as a portable abort mechanism. With the Tokio and Compio backends, canceling work sent through another arbiter abandons the result but does not stop the remote task. Use an explicit cancellation signal when cross-thread work must be stoppable.
Timers and timeouts
Use ntex::time instead of a backend-specific timer so the code works with
all three runtime backends:
use ntex::time::{Millis, sleep, timeout};
#[ntex::main]
async fn main() {
sleep(Millis(10)).await;
let result = timeout(Millis(100), async { "done" }).await;
assert_eq!(result.unwrap(), "done");
}
ntex timers are intended for scheduling and timeouts, not high-resolution
measurement. Their granularity is roughly 16 milliseconds. A zero-duration
sleep or timeout still waits for at least one timer tick.
timeout_checked is the variant that treats a zero timeout as disabled.
System lifecycle
System is the top-level runtime context. It owns the shared runtime
configuration, tracks arbiters, manages signal delivery, and provides the
blocking thread pool. #[ntex::main] builds a system automatically.
Building one manually returns a SystemRunner:
fn main() {
let result = ntex::rt::System::build()
.name("worker")
.build(ntex::rt::DefaultRuntime)
.block_on(async { 10usize });
assert_eq!(result, 10);
}
SystemRunner has two common ownership models:
block_on(future)starts a system, drives one root future, and returns its output when that future completes;run(callback)invokes a synchronous startup callback inside the running system, then keeps the event loop alive untilSystem::stop()orSystem::stop_with_code()is called.
For a process driven by an explicit stop request:
use std::io;
use ntex::rt::{self, DefaultRuntime, System};
use ntex::time::{Millis, sleep};
fn main() -> io::Result<()> {
System::build()
.name("my-app")
.build(DefaultRuntime)
.run(|| {
rt::spawn(async {
sleep(Millis(10)).await;
System::current().stop();
});
Ok(())
})
}
run_until_stop() is run() without a startup callback. All these methods
consume the runner, so choose one model rather than calling block_on() and
then run_until_stop() on the same value. A non-zero stop code is returned as
an I/O error.
Signal and panic handling are disabled by default. Enabling signal handling
makes process signals available through ntex::rt::signals; it does not by
itself define the application’s shutdown policy. ntex servers listen for
process signals while they are running and coordinate their own shutdown.
Arbiters and runtime threads
An Arbiter represents one runtime event-loop thread. Every system has a
primary arbiter. Arbiter::new() or Arbiter::with_name() starts another OS
thread using the same system runtime configuration. ntex server workers are
also arbiter threads.
Arbiter::current() returns the arbiter for the current thread and panics when
called outside one. For an arbiter created with new() or with_name(),
stop() requests shutdown and join() waits for its thread to exit.
The primary arbiter runs on the thread that started the system. It cannot be
stopped independently, has no owned thread handle, and join() returns
immediately. Stop the System to end the primary arbiter.
The two levels have different shutdown scopes:
Arbiter::stop()stops one separately created runtime thread;System::stop()stops every registered arbiter and ends the system.
The runtime also offers two typed storage scopes. System::get_value() stores
a thread-safe value shared by the complete system, while
Arbiter::get_value() stores a cloneable value local to one runtime thread.
The latter is useful for per-worker clients or caches that should not be
shared.
Blocking work
Blocking an arbiter thread pauses connection handling, timers, and every other
task on that thread. Move CPU-heavy work and blocking system calls to
spawn_blocking():
#[ntex::main]
async fn main() {
let total = ntex::rt::spawn_blocking(|| (0..1_000u64).sum::<u64>())
.await
.unwrap();
assert_eq!(total, 499_500);
}
The closure and its result must implement Send. Inside a system, the work
runs on a dynamically sized blocking pool. The pool allows up to 256 workers
by default, and an idle worker exits after 60 seconds. Configure those values
with thread_pool_limit() and thread_pool_recv_timeout():
use std::time::Duration;
fn main() {
ntex::rt::System::build()
.thread_pool_limit(32)
.thread_pool_recv_timeout(Duration::from_secs(30))
.build(ntex::rt::DefaultRuntime)
.block_on(async {});
}
If spawn_blocking() is called outside a running system, it does not
create a background worker. The closure runs immediately on the calling thread
and the returned future is already ready. Treat that as a fallback, not as a
way to start asynchronous work before the runtime.
Dropping the returned future prevents queued work from starting, but cannot
interrupt a closure that is already running. Use detach() when queued work
should continue even if nobody needs its result. Cancellation and closure
panics are reported as BlockingError.
Runtime diagnostics
The system pings spawned arbiters every two seconds and keeps their ten most
recent round-trip records. System::list_arbiter_pings() exposes those
records. Set ping_interval(0) to disable the checks, or tune
ping_interval() and ping_threshold() on System::build().
On Linux, System::set_latency_callback() can receive a backtrace when an
arbiter misses the configured threshold, which defaults to one second.
Backtrace capture requires ntex signal handling and uses SIGUSR2; an
embedding application should not reserve that signal for another purpose.
Custom runners
System::build().build(...) accepts any implementation of ntex_rt::Runner.
This is an advanced integration point, not merely an executor switch: ntex
networking also expects a compatible reactor on every runtime thread. A custom
runner must establish everything expected by the selected backend.
Use DefaultRuntime unless the application deliberately provides both sides
of that integration.
Runtime attributes
#[ntex::main] turns an async function into a synchronous entry point, builds
a System, and drives the function’s future:
#[ntex::main]
async fn main() {
// Start the application.
}
Supported options are:
name = "..."for the system name;signals = trueorfalse;panic_handling = trueorfalse;ping_interval = Nin milliseconds, where zero disables arbiter pings;rt = Typeto select a custom runtime runner.
For example:
#[ntex::main(
name = "my-service",
signals = true,
panic_handling = true,
ping_interval = 250,
)]
async fn main() {
// Start the application.
}
The async function may return a value such as Result<(), E>; the generated
synchronous function returns the root future’s output.
#[ntex::test] creates a fresh system named after the test, marks it as a
testing system, disables signal and panic handling, and initializes ntex test
logging:
#[ntex::test]
async fn runtime_test() {
let value = ntex::rt::spawn(async { 10usize }).await.unwrap();
assert_eq!(value, 10);
}
Test logging initialization is skipped when the no-test-logging feature is
enabled.
The useful boundaries to remember are:
- local tasks may be non-
Send, but cross-arbiter work must beSend; - dropping a join handle does not stop its task;
- blocking closures belong in
spawn_blocking; - ntex timers keep code independent of the selected backend;
- stop a separately created
Arbiterfor one runtime thread and theSystemfor the whole runtime; - use
DefaultRuntimeunless the application also provides the matching ntex network reactor.