diff --git a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc index 3657d09d9..734c8b786 100644 --- a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc @@ -53,16 +53,17 @@ corosio::io_context ioc; ---- Creates an `io_context` with a concurrency hint of -`std::max(2u, std::thread::hardware_concurrency())` — the default constructor -never selects single-threaded mode. Single-threaded (lockless) mode is keyed -on a `concurrency_hint` of exactly `1`; pass `1` explicitly to opt into it. +`std::max(1u, std::thread::hardware_concurrency())`. The concurrency hint only +tunes performance and never changes the safety contract; single-threaded +(lockless) mode is opt-in exclusively via the `single_threaded_lockless` option +(see xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Single-Threaded Mode]). === With Concurrency Hint [source,cpp] ---- -corosio::io_context ioc(1); // Single-threaded, no synchronization -corosio::io_context ioc(4); // Up to 4 threads, thread-safe +corosio::io_context ioc(1); // one-thread hint, still fully thread-safe +corosio::io_context ioc(4); // up to 4 threads ---- The concurrency hint affects: @@ -73,7 +74,10 @@ The concurrency hint affects: concurrently. It is not a library-managed thread pool, and it is distinct from `thread_pool_size`. -Use `1` for single-threaded programs to avoid synchronization overhead. +The hint never disables locking — a context built with `concurrency_hint == 1` +remains fully thread-safe, so other threads may `post()` into it. To drop +synchronization overhead in a genuinely single-threaded program, set +`io_context_options::single_threaded_lockless = true` instead. == Running the Event Loop @@ -245,8 +249,9 @@ int main() == Thread Safety -The `io_context` can be used from multiple threads when constructed with -a concurrency hint greater than 1: +The `io_context` is thread-safe by default, regardless of the concurrency hint: +multiple threads may call `run()` concurrently, and any thread may `post()` work +into it. [source,cpp] ---- @@ -261,7 +266,9 @@ for (auto& t : threads) ---- Multiple threads can call `run()` concurrently. The `io_context` distributes -work across threads. +work across threads. The one exception is lockless mode: constructing with +`io_context_options::single_threaded_lockless = true` drops these guarantees (see +xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Single-Threaded Mode]). WARNING: Individual I/O objects (sockets, timers) are not thread-safe. Don't access the same socket from multiple threads without synchronization. diff --git a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc index d45620a82..9159adef3 100644 --- a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc @@ -80,12 +80,14 @@ corosio::native_io_context ioc(opts); blocking file I/O and DNS resolution. Ignored on IOCP where file I/O uses native overlapped I/O. -| `single_threaded` +| `single_threaded_lockless` | false | all | Disable all scheduler mutex and condition variable operations. Eliminates synchronization overhead when only one thread calls - `run()`. See <> for restrictions. + `run()`. The only way to enable lockless mode; the concurrency + hint never disables locking. See <> for + restrictions. |=== Options that do not apply to the active backend are silently ignored. @@ -144,16 +146,23 @@ and DNS resolution use a shared thread pool. * *No file I/O*: leave at 1 (the pool is created lazily). [#single-threaded-mode] -=== Single-Threaded Mode (`single_threaded`) +=== Single-Threaded Mode (`single_threaded_lockless`) Disables all mutex and condition variable operations inside the scheduler and per-socket descriptor states. This eliminates 15-25% of overhead on the post-and-dispatch hot path. +Setting `single_threaded_lockless = true` is the *only* way to enable +lockless mode. The `concurrency_hint` passed at construction never +disables locking — in particular, `concurrency_hint == 1` keeps the +context fully thread-safe (unlike some other libraries, where a hint +of `1` silently elides synchronization). Enabling this option +transfers responsibility for the restrictions below to the caller. + [source,cpp] ---- corosio::io_context_options opts; -opts.single_threaded = true; +opts.single_threaded_lockless = true; corosio::io_context ioc(opts); ioc.run(); // only one thread may call this diff --git a/include/boost/corosio/io_context.hpp b/include/boost/corosio/io_context.hpp index 44fc6f6c0..4c28ce40a 100644 --- a/include/boost/corosio/io_context.hpp +++ b/include/boost/corosio/io_context.hpp @@ -97,13 +97,18 @@ struct io_context_options */ unsigned thread_pool_size = 1; - /** Enable single-threaded mode (disable scheduler locking). + /** Enable single-threaded, lockless mode (disable scheduler locking). When true, the scheduler skips all mutex lock/unlock and condition variable operations on the hot path. This eliminates synchronization overhead when only one thread calls `run()`. + Enabling this drops the io_context's thread-safety guarantees: + the caller takes on responsibility for the restrictions below. + Leaving it `false` (the default) keeps the context fully + thread-safe. + @par Restrictions - Only one thread may call `run()` (or any run variant). - Posting work from another thread is undefined behavior. @@ -111,12 +116,14 @@ struct io_context_options - POSIX file I/O returns `operation_not_supported`. - Signal sets should not be shared across contexts. - @note Constructing an `io_context` with `concurrency_hint == 1` - automatically enables single-threaded mode regardless of - this field's value, matching asio's convention. To opt out, - pass `concurrency_hint > 1`. + @note This field is the *only* way to enable lockless mode. The + `concurrency_hint` passed at construction never changes the + safety contract — it only tunes performance. In particular, + `concurrency_hint == 1` keeps full thread safety (unlike + asio's named `ASIO_CONCURRENCY_HINT_UNSAFE` constants, which + this field mirrors). */ - bool single_threaded = false; + bool single_threaded_lockless = false; /** Enable IORING_SETUP_SQPOLL on the io_uring backend. @@ -126,7 +133,7 @@ struct io_context_options path. Most useful for sustained traffic. Idle thread parks after `sq_thread_idle_ms` of no activity. - Independent of `single_threaded`. Default: off. + Independent of `single_threaded_lockless`. Default: off. Ignored on non-io_uring backends. */ @@ -196,8 +203,10 @@ class timer_service; @par Thread Safety Distinct objects: Safe.@n - Shared objects: Safe, if using a concurrency hint greater - than 1. + Shared objects: Safe, unless the context was constructed with + `io_context_options::single_threaded_lockless = true` (lockless + mode), in which case only one thread may use it. The + `concurrency_hint` does not affect thread safety. @see epoll_t, select_t, kqueue_t, iocp_t */ @@ -211,9 +220,6 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context io_context_options const& opts, unsigned concurrency_hint); - /// Switch the scheduler to single-threaded (lockless) mode. - void configure_single_threaded_(); - protected: detail::scheduler* sched_; @@ -223,11 +229,11 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Construct with default concurrency and platform backend. - Uses `std::thread::hardware_concurrency()` clamped to a minimum - of 2 as the concurrency hint, so the default constructor never - silently engages single-threaded mode (see - @ref io_context_options::single_threaded). Pass an explicit - `concurrency_hint == 1` to opt into single-threaded mode. + Uses `std::thread::hardware_concurrency()` (floored to 1, in + case it reports 0) as the concurrency hint. The hint only tunes + performance and never affects thread safety; single-threaded + (lockless) mode is opt-in exclusively via + @ref io_context_options::single_threaded_lockless. */ io_context(); @@ -266,8 +272,6 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context { (void)backend; sched_ = &Backend::construct(*this, concurrency_hint); - if (concurrency_hint == 1) - configure_single_threaded_(); } /** Construct with an explicit backend tag and runtime options. diff --git a/perf/bench/corosio/accept_churn_bench.cpp b/perf/bench/corosio/accept_churn_bench.cpp index 13003b467..16cac690a 100644 --- a/perf/bench/corosio/accept_churn_bench.cpp +++ b/perf/bench/corosio/accept_churn_bench.cpp @@ -132,7 +132,7 @@ bench_sequential_churn_lockless(bench::state& state) using acceptor_type = corosio::native_tcp_acceptor; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); acceptor_type acc(ioc); acc.open(); @@ -397,7 +397,7 @@ bench_burst_churn_lockless(bench::state& state) state.counters["burst_size"] = burst_size; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); acceptor_type acc(ioc); acc.open(); diff --git a/perf/bench/corosio/fan_out_bench.cpp b/perf/bench/corosio/fan_out_bench.cpp index fc189909f..c11c7b61e 100644 --- a/perf/bench/corosio/fan_out_bench.cpp +++ b/perf/bench/corosio/fan_out_bench.cpp @@ -336,7 +336,7 @@ bench_fork_join_lockless(bench::state& state) state.counters["fan_out"] = fan_out; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); std::vector clients; @@ -413,7 +413,7 @@ bench_nested_lockless(bench::state& state) state.counters["subs_per_group"] = subs_per_group; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); std::vector clients; @@ -508,7 +508,7 @@ bench_concurrent_parents_lockless(bench::state& state) state.counters["fan_out"] = fan_out; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); std::vector clients; diff --git a/perf/bench/corosio/http_server_bench.cpp b/perf/bench/corosio/http_server_bench.cpp index bca5ba3be..2f9af1221 100644 --- a/perf/bench/corosio/http_server_bench.cpp +++ b/perf/bench/corosio/http_server_bench.cpp @@ -179,7 +179,7 @@ bench_single_connection_lockless(bench::state& state) using socket_type = corosio::native_tcp_socket; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); auto [client, server] = corosio::test::make_socket_pair< socket_type, corosio::native_tcp_acceptor>(ioc); diff --git a/perf/bench/corosio/io_context_bench.cpp b/perf/bench/corosio/io_context_bench.cpp index c99d4dab5..97f86f0e1 100644 --- a/perf/bench/corosio/io_context_bench.cpp +++ b/perf/bench/corosio/io_context_bench.cpp @@ -263,7 +263,7 @@ void bench_single_threaded_lockless(bench::state& state) { corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); auto ex = ioc.get_executor(); @@ -295,7 +295,7 @@ void bench_interleaved_lockless(bench::state& state) { corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; int handlers_per_iteration = 100; diff --git a/perf/bench/corosio/local_socket_latency_bench.cpp b/perf/bench/corosio/local_socket_latency_bench.cpp index 50cd2aac3..771911e56 100644 --- a/perf/bench/corosio/local_socket_latency_bench.cpp +++ b/perf/bench/corosio/local_socket_latency_bench.cpp @@ -167,7 +167,7 @@ bench_unix_pingpong_latency_lockless(bench::state& state) state.counters["message_size"] = static_cast(message_size); corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); socket_type client(ioc), server(ioc); if (auto ec = corosio::connect_pair(client, server)) @@ -201,7 +201,7 @@ bench_unix_concurrent_latency_lockless(bench::state& state) state.counters["num_pairs"] = num_pairs; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); std::vector clients; diff --git a/perf/bench/corosio/local_socket_throughput_bench.cpp b/perf/bench/corosio/local_socket_throughput_bench.cpp index 6c9c0af64..34fb915a2 100644 --- a/perf/bench/corosio/local_socket_throughput_bench.cpp +++ b/perf/bench/corosio/local_socket_throughput_bench.cpp @@ -195,7 +195,7 @@ bench_unix_throughput_lockless(bench::state& state) state.counters["chunk_size"] = static_cast(chunk_size); corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); socket_type writer(ioc), reader(ioc); if (auto ec = corosio::connect_pair(writer, reader)) @@ -259,7 +259,7 @@ bench_unix_bidirectional_throughput_lockless(bench::state& state) state.counters["chunk_size"] = static_cast(chunk_size); corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); socket_type sock1(ioc), sock2(ioc); if (auto ec = corosio::connect_pair(sock1, sock2)) diff --git a/perf/bench/corosio/socket_latency_bench.cpp b/perf/bench/corosio/socket_latency_bench.cpp index 872736d0c..f98875a80 100644 --- a/perf/bench/corosio/socket_latency_bench.cpp +++ b/perf/bench/corosio/socket_latency_bench.cpp @@ -167,7 +167,7 @@ bench_pingpong_latency_lockless(bench::state& state) state.counters["message_size"] = static_cast(message_size); corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); auto [client, server] = corosio::test::make_socket_pair< socket_type, corosio::native_tcp_acceptor>(ioc); @@ -203,7 +203,7 @@ bench_concurrent_latency_lockless(bench::state& state) state.counters["num_pairs"] = num_pairs; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); std::vector clients; diff --git a/perf/bench/corosio/socket_throughput_bench.cpp b/perf/bench/corosio/socket_throughput_bench.cpp index c29f2247c..33537edf8 100644 --- a/perf/bench/corosio/socket_throughput_bench.cpp +++ b/perf/bench/corosio/socket_throughput_bench.cpp @@ -254,7 +254,7 @@ bench_throughput_lockless(bench::state& state) state.counters["chunk_size"] = static_cast(chunk_size); corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); auto [writer, reader] = corosio::test::make_socket_pair< socket_type, corosio::native_tcp_acceptor>(ioc); @@ -320,7 +320,7 @@ bench_bidirectional_throughput_lockless(bench::state& state) state.counters["chunk_size"] = static_cast(chunk_size); corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); auto [sock1, sock2] = corosio::test::make_socket_pair< socket_type, corosio::native_tcp_acceptor>(ioc); diff --git a/perf/bench/corosio/timer_bench.cpp b/perf/bench/corosio/timer_bench.cpp index 0d950cf36..390220ccd 100644 --- a/perf/bench/corosio/timer_bench.cpp +++ b/perf/bench/corosio/timer_bench.cpp @@ -111,7 +111,7 @@ bench_schedule_cancel_lockless(bench::state& state) using timer_type = corosio::native_timer; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); int64_t counter = 0; int constexpr batch_size = 1000; @@ -147,7 +147,7 @@ bench_fire_rate_lockless(bench::state& state) using timer_type = corosio::native_timer; corosio::io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; corosio::native_io_context ioc(opts, 1); std::atomic running{true}; int64_t counter = 0; diff --git a/src/corosio/src/io_context.cpp b/src/corosio/src/io_context.cpp index 6f0258e71..1e0bb0681 100644 --- a/src/corosio/src/io_context.cpp +++ b/src/corosio/src/io_context.cpp @@ -216,7 +216,7 @@ apply_scheduler_options( init, max, ua); - if (opts.single_threaded) + if (opts.single_threaded_lockless) reactor->configure_single_threaded(true); } #endif @@ -225,7 +225,7 @@ apply_scheduler_options( if (auto* uring_sched = dynamic_cast(&sched)) { - if (opts.single_threaded) + if (opts.single_threaded_lockless) uring_sched->configure_single_threaded(true); if (opts.enable_sqpoll) uring_sched->configure_sqpoll( @@ -235,7 +235,7 @@ apply_scheduler_options( #if BOOST_COROSIO_HAS_IOCP auto& iocp_sched = static_cast(sched); - if (opts.single_threaded) + if (opts.single_threaded_lockless) iocp_sched.configure_single_threaded(true); #endif @@ -258,19 +258,10 @@ construct_default(capy::execution_context& ctx, unsigned concurrency_hint) #endif } -// Tie concurrency_hint == 1 to single_threaded (asio precedent). -io_context_options -normalize_options(io_context_options opts, unsigned concurrency_hint) -{ - if (concurrency_hint == 1) - opts.single_threaded = true; - return opts; -} - } // anonymous namespace io_context::io_context() - : io_context(std::max(2u, std::thread::hardware_concurrency())) + : io_context(std::max(1u, std::thread::hardware_concurrency())) { } @@ -278,8 +269,6 @@ io_context::io_context(unsigned concurrency_hint) : capy::execution_context(this) , sched_(&construct_default(*this, concurrency_hint)) { - if (concurrency_hint == 1) - configure_single_threaded_(); } io_context::io_context( @@ -288,10 +277,9 @@ io_context::io_context( : capy::execution_context(this) , sched_(nullptr) { - auto opts = normalize_options(opts_in, concurrency_hint); - pre_create_services(*this, opts); + pre_create_services(*this, opts_in); sched_ = &construct_default(*this, concurrency_hint); - apply_scheduler_options(*sched_, opts, concurrency_hint); + apply_scheduler_options(*sched_, opts_in, concurrency_hint); } void @@ -305,18 +293,7 @@ io_context::apply_options_post_( io_context_options const& opts_in, unsigned concurrency_hint) { - auto opts = normalize_options(opts_in, concurrency_hint); - apply_scheduler_options(*sched_, opts, concurrency_hint); -} - -void -io_context::configure_single_threaded_() -{ - // Dispatched through the scheduler base's virtual override; avoids - // unsafe downcasts when the active backend is io_uring rather than - // reactor (on Linux both BOOST_COROSIO_HAS_EPOLL and the io_uring - // backend may be enabled simultaneously). - sched_->configure_single_threaded(true); + apply_scheduler_options(*sched_, opts_in, concurrency_hint); } io_context::~io_context() diff --git a/test/unit/io_context.cpp b/test/unit/io_context.cpp index 38be71691..2a26f1e00 100644 --- a/test/unit/io_context.cpp +++ b/test/unit/io_context.cpp @@ -299,9 +299,10 @@ struct io_context_test void testConstructionSingleThreaded() { - // concurrency_hint == 1 enables single-threaded mode automatically. + // Lockless mode is enabled solely by opts.single_threaded_lockless; + // the concurrency hint is unrelated to the safety contract. io_context_options opts; - opts.single_threaded = true; + opts.single_threaded_lockless = true; io_context ioc(Backend, opts, 1); BOOST_TEST(!ioc.stopped()); @@ -642,6 +643,43 @@ struct io_context_test BOOST_TEST(counter.load() == total_handlers); } + void testHintOneIsThreadSafe() + { + // Regression for #310: a plain concurrency_hint of 1 must NOT + // enable lockless mode. Cross-thread post() into a hint==1 + // context must remain thread-safe (TSan-clean); lockless mode is + // reachable only via io_context_options::single_threaded_lockless. + io_context ioc(Backend, 1); + auto ex = ioc.get_executor(); + std::atomic counter{0}; + constexpr int num_threads = 4; + constexpr int handlers_per_thread = 100; + constexpr int total_handlers = num_threads * handlers_per_thread; + + // Post handlers from multiple threads concurrently. + std::vector posters; + posters.reserve(num_threads); + for (int t = 0; t < num_threads; ++t) + { + posters.emplace_back([&ex, &counter]() { + for (int i = 0; i < handlers_per_thread; ++i) + post_coro(ex, make_atomic_coro(counter)); + }); + } + for (auto& t : posters) + t.join(); + + // Run with multiple threads. + std::vector runners; + runners.reserve(num_threads); + for (int t = 0; t < num_threads; ++t) + runners.emplace_back([&ioc]() { ioc.run(); }); + for (auto& t : runners) + t.join(); + + BOOST_TEST(counter.load() == total_handlers); + } + void testMultithreadedStress() { // Stress test: multiple iterations of post-then-run with multiple threads @@ -809,6 +847,7 @@ struct io_context_test testRunOneForWithOutstandingWork(); testExecutorRunningInThisThread(); testMultithreaded(); + testHintOneIsThreadSafe(); testMultithreadedStress(); testMultithreadedNotifyAndWaitFor(); testWhenAllSetEvent(); diff --git a/test/unit/resolver.cpp b/test/unit/resolver.cpp index f0cbb7a59..da4f418dd 100644 --- a/test/unit/resolver.cpp +++ b/test/unit/resolver.cpp @@ -331,7 +331,9 @@ struct resolver_test { // Single-threaded contexts disable the resolver thread pool and // surface operation_not_supported instead of dispatching work. - io_context ioc(1); + io_context_options opts; + opts.single_threaded_lockless = true; + io_context ioc(opts, 1); resolver r(ioc); bool completed = false; @@ -360,7 +362,9 @@ struct resolver_test void testReverseResolveSingleThreadedNotSupported() { - io_context ioc(1); + io_context_options opts; + opts.single_threaded_lockless = true; + io_context ioc(opts, 1); resolver r(ioc); bool completed = false; diff --git a/test/unit/stream_file.cpp b/test/unit/stream_file.cpp index e036bee06..a86fc461a 100644 --- a/test/unit/stream_file.cpp +++ b/test/unit/stream_file.cpp @@ -535,7 +535,9 @@ struct stream_file_test // POSIX file I/O requires the shared thread pool; in single-threaded // mode the service short-circuits with operation_not_supported. temp_file tmp("sf_st_", "data"); - io_context ioc(Backend, 1); + io_context_options opts; + opts.single_threaded_lockless = true; + io_context ioc(Backend, opts, 1); stream_file f(ioc); bool caught = false;