Skip to content

Commit 65106c7

Browse files
committed
feat(io_context): decompose locking config into asio-parity tiers (#310)
Holistic follow-up to the concurrency_hint audit. Replaces the single coarse lockless switch with a locking-tier model mirroring Boost.Asio's SAFE / UNSAFE_IO / UNSAFE, and adds the single-run-thread optimization asio derives from concurrency_hint == 1. Interface: - Add io_context_options::locking (locking_mode enum: safe / unsafe_io / unsafe), replacing single_threaded_lockless. safe = full thread safety (default); unsafe_io = per-descriptor I/O locks off, scheduler locks on; unsafe = all locks off. - concurrency_hint stays an unsigned perf knob and never changes the safety contract; hint == 1 now engages a single-run-thread fast path with locks still on. Internals: - Replace the scheduler base virtuals configure_single_threaded(bool) / is_single_threaded() with configure_threading(threading_config) + scheduler_locking_disabled(). threading_config carries the three decomposed signals (scheduler_locking, reactor_io_locking, one_thread). - reactor_scheduler: scheduler_locking gates mutex_/cond_; reactor_io_locking gates the per-descriptor mutexes; one_thread elides the two do_one inter-run-thread peer-wakes (unlock_and_signal_one), keeping locks on so cross-thread post() is still woken via the foreign post path. Private-queue routing is unchanged. - io_uring: dispatch/cond gated by scheduler_locking, ring_mutex by reactor_io_locking; SINGLE_ISSUER/DEFER_TASKRUN and the eventfd interrupt elision now key on reactor-I/O locking off (single ring user, cross-thread post forbidden) rather than the coarse flag. - IOCP: dispatch_mutex_ gated by scheduler_locking. - The plain io_context(unsigned) and backend-tag ctors now apply the threading config (so hint == 1 engages one_thread on every path) via a dedicated apply_threading_ path that deliberately leaves the reactor budget at its defaults. They do NOT run the options ctor's multi-thread post-everything budget heuristic — preserving the established default- constructed behavior (routing them through the full heuristic regressed latency-bound single-flow workloads 15-31%). Service parity: resolver and POSIX file services now key their operation_not_supported restriction on scheduler_locking_disabled() (the unsafe tier only), so they remain available under unsafe_io — matching asio, whose restriction keys on the scheduler lock. Tests: add unsafe_io coverage (io_context runs work; resolver and file services stay available) alongside the existing unsafe restriction tests; keep testHintOneIsThreadSafe. Update benchmark setters to opts.locking. Docs: rewrite the option docstring and the io-context/configuration guide pages with the three-tier table and the corosio<->asio mapping. Verified: 149/149 tests pass; io_context/resolver/stream_file suites TSan-clean including the new unsafe_io tests; instrumentation confirmed hint == 1 engages one_thread with locks on and unsafe_io yields scheduler-locked + io-lockless. Benchmarked vs the pre-change commit (focused categories, CCX-pinned, performance governor): safe-tier and lockless paths performance-neutral (all diffs within noise).
1 parent 8ca47df commit 65106c7

27 files changed

Lines changed: 427 additions & 160 deletions

‎doc/modules/ROOT/pages/4.guide/4c.io-context.adoc‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ corosio::io_context ioc;
5454

5555
Creates an `io_context` with a concurrency hint of
5656
`std::max(1u, std::thread::hardware_concurrency())`. The concurrency hint only
57-
tunes performance and never changes the safety contract; single-threaded
58-
(lockless) mode is opt-in exclusively via the `single_threaded_lockless` option
59-
(see xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Single-Threaded Mode]).
57+
tunes performance and never changes the safety contract; lockless modes are
58+
opt-in exclusively via the `locking` option
59+
(see xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Locking Tiers]).
6060

6161
=== With Concurrency Hint
6262

@@ -75,9 +75,10 @@ The concurrency hint affects:
7575
from `thread_pool_size`.
7676

7777
The hint never disables locking — a context built with `concurrency_hint == 1`
78-
remains fully thread-safe, so other threads may `post()` into it. To drop
79-
synchronization overhead in a genuinely single-threaded program, set
80-
`io_context_options::single_threaded_lockless = true` instead.
78+
remains fully thread-safe, so other threads may `post()` into it (a hint of `1`
79+
does enable a single-run-thread wakeup fast path, with locks still on). To drop
80+
synchronization overhead in a genuinely single-threaded program, set the
81+
`io_context_options::locking` tier instead.
8182

8283
== Running the Event Loop
8384

@@ -266,9 +267,10 @@ for (auto& t : threads)
266267
----
267268

268269
Multiple threads can call `run()` concurrently. The `io_context` distributes
269-
work across threads. The one exception is lockless mode: constructing with
270-
`io_context_options::single_threaded_lockless = true` drops these guarantees (see
271-
xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Single-Threaded Mode]).
270+
work across threads. The one exception is a lockless `locking` tier: constructing
271+
with `io_context_options::locking` set to `unsafe_io` or `unsafe` drops these
272+
guarantees (see
273+
xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Locking Tiers]).
272274

273275
WARNING: Individual I/O objects (sockets, timers) are not thread-safe.
274276
Don't access the same socket from multiple threads without synchronization.

‎doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc‎

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,14 @@ corosio::native_io_context<corosio::epoll> ioc(opts);
8080
blocking file I/O and DNS resolution. Ignored on IOCP where
8181
file I/O uses native overlapped I/O.
8282

83-
| `single_threaded_lockless`
84-
| false
83+
| `locking`
84+
| `locking_mode::safe`
8585
| all
86-
| Disable all scheduler mutex and condition variable operations.
87-
Eliminates synchronization overhead when only one thread calls
88-
`run()`. The only way to enable lockless mode; the concurrency
89-
hint never disables locking. See <<single-threaded-mode>> for
90-
restrictions.
86+
| Locking-safety tier: `safe` (default, full thread safety),
87+
`unsafe_io` (per-descriptor I/O locks off), or `unsafe` (all locks
88+
off). The only way to enable lockless behavior; the concurrency
89+
hint never disables locking. See <<single-threaded-mode>> for the
90+
tiers and their restrictions.
9191
|===
9292

9393
Options that do not apply to the active backend are silently ignored.
@@ -146,36 +146,75 @@ and DNS resolution use a shared thread pool.
146146
* *No file I/O*: leave at 1 (the pool is created lazily).
147147

148148
[#single-threaded-mode]
149-
=== Single-Threaded Mode (`single_threaded_lockless`)
149+
=== Locking Tiers (`locking`)
150150

151-
Disables all mutex and condition variable operations inside the
152-
scheduler and per-socket descriptor states. This eliminates
153-
15-25% of overhead on the post-and-dispatch hot path.
151+
The `locking` option selects which internal locks the scheduler and
152+
reactor elide, trading thread-safety guarantees for reduced
153+
synchronization overhead. It is the *only* way to enable lockless
154+
behavior — the `concurrency_hint` passed at construction never disables
155+
locking (see <<concurrency-hint-and-locking>>).
154156

155-
Setting `single_threaded_lockless = true` is the *only* way to enable
156-
lockless mode. The `concurrency_hint` passed at construction never
157-
disables locking — in particular, `concurrency_hint == 1` keeps the
158-
context fully thread-safe (unlike some other libraries, where a hint
159-
of `1` silently elides synchronization). Enabling this option
160-
transfers responsibility for the restrictions below to the caller.
157+
[cols="1,3"]
158+
|===
159+
| Tier | Behavior
160+
161+
| `locking_mode::safe` (default)
162+
| Full thread safety. All locks enabled; any thread may use the
163+
context.
164+
165+
| `locking_mode::unsafe_io`
166+
| Disables only the per-descriptor I/O locks; scheduler locking stays
167+
on. A single thread must run and drive the context, but DNS
168+
resolution and POSIX file I/O remain available (they rely on
169+
scheduler locking).
170+
171+
| `locking_mode::unsafe`
172+
| Disables all locking. Eliminates 15-25% of overhead on the
173+
post-and-dispatch hot path. Imposes the full restrictions below.
174+
|===
161175

162176
[source,cpp]
163177
----
164178
corosio::io_context_options opts;
165-
opts.single_threaded_lockless = true;
179+
opts.locking = corosio::locking_mode::unsafe;
166180
167181
corosio::io_context ioc(opts);
168182
ioc.run(); // only one thread may call this
169183
----
170184

171-
WARNING: Single-threaded mode imposes hard restrictions.
185+
WARNING: The `unsafe` and `unsafe_io` tiers impose hard restrictions.
172186
Violating them is undefined behavior.
173187

174188
* Only **one thread** may call `run()` (or any run/poll variant).
175189
* **Posting work from another thread** is undefined behavior.
176-
* **DNS resolution** returns `operation_not_supported`.
177-
* **POSIX file I/O** (`stream_file`, `random_access_file`) returns
178-
`operation_not_supported` on `open()`.
179190
* **Signal sets** should not be shared across contexts.
180191
* **Timer cancellation via `stop_token`** from another thread
181192
remains safe (the timer service retains its own mutex).
193+
194+
The `unsafe` tier additionally makes:
195+
196+
* **DNS resolution** return `operation_not_supported`.
197+
* **POSIX file I/O** (`stream_file`, `random_access_file`) return
198+
`operation_not_supported` on `open()`.
199+
200+
These two services stay available under `unsafe_io` because they rely
201+
on scheduler locking, which that tier keeps enabled.
202+
203+
[NOTE]
204+
====
205+
These tiers mirror Boost.Asio's `SAFE` / `UNSAFE_IO` / `UNSAFE`
206+
concurrency-hint constants. corosio exposes them as an option rather
207+
than folding them into the hint, keeping the safety contract separate
208+
from the performance knob.
209+
====
210+
211+
[#concurrency-hint-and-locking]
212+
==== The concurrency hint is orthogonal
213+
214+
The `concurrency_hint` constructor argument only tunes performance and
215+
never changes the safety contract. In particular, `concurrency_hint ==
216+
1` keeps the context fully thread-safe — unlike some other libraries,
217+
where a hint of `1` silently elides synchronization. A hint of `1`
218+
does enable a single-run-thread fast path (eliding inter-run-thread
219+
wakeups) while keeping all locks on, so cross-thread `post()` remains
220+
safe.

‎include/boost/corosio/backend.hpp‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,8 @@ struct iocp_t
360360
@param ctx The execution context that owns the scheduler.
361361
@param concurrency_hint Hint for the number of threads that
362362
will call `run()`. Only tunes performance; it never
363-
changes the safety contract. Single-threaded (lockless)
364-
mode is opt-in exclusively via
365-
@ref io_context_options::single_threaded_lockless.
363+
changes the safety contract. Lockless modes are opt-in
364+
exclusively via @ref io_context_options::locking.
366365
367366
@return Reference to the newly created scheduler.
368367
*/

‎include/boost/corosio/detail/scheduler.hpp‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,38 @@ struct BOOST_COROSIO_DECL scheduler
9797
*/
9898
virtual void register_signal_reader(int read_fd) { (void)read_fd; }
9999

100-
/// True if the scheduler is configured for single-threaded use.
101-
/// Default false; overridden by backends that support the mode.
102-
virtual bool is_single_threaded() const noexcept { return false; }
103-
104-
/// Enable or disable single-threaded mode. Default no-op for
105-
/// backends that don't support the mode.
106-
virtual void configure_single_threaded(bool) noexcept {}
100+
/** Decomposed threading configuration for a scheduler.
101+
102+
Mirrors the orthogonal locking facilities Boost.Asio derives from
103+
its concurrency hint, plus the single-run-thread optimization flag:
104+
105+
@li @ref scheduler_locking — the scheduler's own mutex/condvar (and,
106+
where a backend folds registration into it, the descriptor table).
107+
Disabled only in the fully-lockless (`unsafe`) tier.
108+
@li @ref reactor_io_locking — the per-descriptor state mutex taken on
109+
the I/O completion fast path (reactors), or the ring mutex
110+
(io_uring). Disabled in both the `unsafe_io` and `unsafe` tiers.
111+
@li @ref one_thread — enables the single-run-thread fast path: elide
112+
inter-run-thread wakeups. Locks are unaffected; this stays safe for
113+
cross-thread `post()`. Set when the concurrency hint is 1 or any
114+
lockless tier is selected.
115+
*/
116+
struct threading_config
117+
{
118+
bool scheduler_locking = true;
119+
bool reactor_io_locking = true;
120+
bool one_thread = false;
121+
};
122+
123+
/// True when scheduler locking is disabled (the fully-lockless tier).
124+
/// Services that hand work to other threads (resolver, POSIX file I/O)
125+
/// key their `operation_not_supported` restriction on this. Default
126+
/// false; overridden by backends that support lockless mode.
127+
virtual bool scheduler_locking_disabled() const noexcept { return false; }
128+
129+
/// Apply a decomposed threading configuration. Default no-op for
130+
/// backends that don't support the modes.
131+
virtual void configure_threading(threading_config) noexcept {}
107132
};
108133

109134
} // namespace boost::corosio::detail

‎include/boost/corosio/io_context.hpp‎

Lines changed: 67 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,42 @@
2626

2727
namespace boost::corosio {
2828

29+
/** Locking-safety tier for an @ref io_context.
30+
31+
Selects which internal locks the scheduler and reactor elide, trading
32+
thread-safety guarantees for reduced synchronization overhead. This is
33+
the analog of Boost.Asio's `SAFE` / `UNSAFE_IO` / `UNSAFE` concurrency
34+
hint constants. The `concurrency_hint` passed at construction is a
35+
separate, orthogonal performance knob and never changes the safety
36+
contract.
37+
38+
@see io_context_options::locking
39+
*/
40+
enum class locking_mode
41+
{
42+
/** Full thread safety (default). All locks enabled; equivalent to
43+
Boost.Asio's `SAFE`/`DEFAULT`. Any thread may use the context. */
44+
safe,
45+
46+
/** Disable only the per-descriptor I/O locks; keep scheduler locking.
47+
Equivalent to Boost.Asio's `UNSAFE_IO`. The context must be run
48+
and driven by a single thread, but resolver and POSIX file
49+
services remain available (they rely on scheduler locking, which
50+
stays on). */
51+
unsafe_io,
52+
53+
/** Disable all locking (fully lockless). Equivalent to Boost.Asio's
54+
`UNSAFE`.
55+
56+
@par Restrictions
57+
- Only one thread may call `run()` (or any run variant).
58+
- Posting work from another thread is undefined behavior.
59+
- DNS resolution returns `operation_not_supported`.
60+
- POSIX file I/O returns `operation_not_supported`.
61+
- Signal sets should not be shared across contexts. */
62+
unsafe
63+
};
64+
2965
/** Runtime tuning options for @ref io_context.
3066
3167
All fields have defaults that match the library's built-in
@@ -97,33 +133,21 @@ struct io_context_options
97133
*/
98134
unsigned thread_pool_size = 1;
99135

100-
/** Enable single-threaded, lockless mode (disable scheduler locking).
101-
102-
When true, the scheduler skips all mutex lock/unlock and
103-
condition variable operations on the hot path. This
104-
eliminates synchronization overhead when only one thread
105-
calls `run()`.
136+
/** Locking-safety tier (thread-safety vs. lock elision).
106137
107-
Enabling this drops the io_context's thread-safety guarantees:
108-
the caller takes on responsibility for the restrictions below.
109-
Leaving it `false` (the default) keeps the context fully
110-
thread-safe.
111-
112-
@par Restrictions
113-
- Only one thread may call `run()` (or any run variant).
114-
- Posting work from another thread is undefined behavior.
115-
- DNS resolution returns `operation_not_supported`.
116-
- POSIX file I/O returns `operation_not_supported`.
117-
- Signal sets should not be shared across contexts.
138+
Selects which internal locks are elided. The default,
139+
@ref locking_mode::safe, keeps full thread safety. The
140+
`unsafe_io` and `unsafe` tiers drop locks in exchange for lower
141+
overhead and impose the restrictions documented on
142+
@ref locking_mode.
118143
119-
@note This field is the *only* way to enable lockless mode. The
120-
`concurrency_hint` passed at construction never changes the
121-
safety contract — it only tunes performance. In particular,
122-
`concurrency_hint == 1` keeps full thread safety (unlike
123-
asio's named `ASIO_CONCURRENCY_HINT_UNSAFE` constants, which
124-
this field mirrors).
144+
This is the *only* way to enable lockless behavior. The
145+
`concurrency_hint` passed at construction never changes the
146+
safety contract — it only tunes performance (e.g. the
147+
single-run-thread wakeup fast path when `concurrency_hint == 1`,
148+
and, on IOCP, the completion-port concurrency).
125149
*/
126-
bool single_threaded_lockless = false;
150+
locking_mode locking = locking_mode::safe;
127151

128152
/** Enable IORING_SETUP_SQPOLL on the io_uring backend.
129153
@@ -133,7 +157,7 @@ struct io_context_options
133157
path. Most useful for sustained traffic. Idle thread parks
134158
after `sq_thread_idle_ms` of no activity.
135159
136-
Independent of `single_threaded_lockless`. Default: off.
160+
Independent of `locking`. Default: off.
137161
138162
Ignored on non-io_uring backends.
139163
*/
@@ -203,9 +227,9 @@ class timer_service;
203227
204228
@par Thread Safety
205229
Distinct objects: Safe.@n
206-
Shared objects: Safe, unless the context was constructed with
207-
`io_context_options::single_threaded_lockless = true` (lockless
208-
mode), in which case only one thread may use it. The
230+
Shared objects: Safe, unless the context was constructed with a
231+
lockless @ref io_context_options::locking tier (`unsafe_io` or
232+
`unsafe`), in which case a single thread must drive it. The
209233
`concurrency_hint` does not affect thread safety.
210234
211235
@see epoll_t, select_t, kqueue_t, iocp_t
@@ -220,6 +244,15 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context
220244
io_context_options const& opts,
221245
unsigned concurrency_hint);
222246

247+
/** Apply only the decomposed threading configuration (locking tiers +
248+
one-thread fast path). Used by the plain constructors, which — unlike
249+
the options constructors — deliberately leave the reactor budget at
250+
its defaults rather than engaging the multi-thread post-everything
251+
heuristic. */
252+
void apply_threading_(
253+
io_context_options const& opts,
254+
unsigned concurrency_hint);
255+
223256
protected:
224257
detail::scheduler* sched_;
225258

@@ -231,9 +264,8 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context
231264
232265
Uses `std::thread::hardware_concurrency()` (floored to 1, in
233266
case it reports 0) as the concurrency hint. The hint only tunes
234-
performance and never affects thread safety; single-threaded
235-
(lockless) mode is opt-in exclusively via
236-
@ref io_context_options::single_threaded_lockless.
267+
performance and never affects thread safety; lockless modes are
268+
opt-in exclusively via @ref io_context_options::locking.
237269
*/
238270
io_context();
239271

@@ -272,6 +304,10 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context
272304
{
273305
(void)backend;
274306
sched_ = &Backend::construct(*this, concurrency_hint);
307+
// Apply threading config only (one-thread fast path for
308+
// concurrency_hint == 1, locking tier). Unlike the options ctor,
309+
// the plain path leaves the reactor budget at its defaults.
310+
apply_threading_(io_context_options{}, concurrency_hint);
275311
}
276312

277313
/** Construct with an explicit backend tag and runtime options.

‎include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ epoll_scheduler::register_descriptor(int fd, reactor_descriptor_state* desc) con
265265
desc->registered_events = ev.events;
266266
desc->fd = fd;
267267
desc->scheduler_ = this;
268-
desc->mutex.set_enabled(!single_threaded_);
268+
desc->mutex.set_enabled(reactor_io_locking_);
269269
desc->ready_events_.store(0, std::memory_order_relaxed);
270270

271271
conditionally_enabled_mutex::scoped_lock lock(desc->mutex);

0 commit comments

Comments
 (0)