Skip to content

Optimize timer service for lock-free schedule/cancel#112

Merged
sgerbino merged 1 commit into
cppalliance:developfrom
sgerbino:pr/timer
Feb 9, 2026
Merged

Optimize timer service for lock-free schedule/cancel#112
sgerbino merged 1 commit into
cppalliance:developfrom
sgerbino:pr/timer

Conversation

@sgerbino

@sgerbino sgerbino commented Feb 9, 2026

Copy link
Copy Markdown
Collaborator

Replace heap-allocated timer_op with an embedded completion_op in timer_impl, eliminating new/delete per fire and cancel. Defer heap insertion from expires_after() to wait() so idle timers never touch the mutex. Add thread-local caches for both timer_impl recycling and service lookup to avoid locking on the common single-thread path. Cache the nearest expiry in an atomic to make nearest_expiry() and empty() lock-free. Add might_have_pending_waits_ flag so cancel returns immediately when no wait was ever issued.

With all fast paths hit, the schedule/cancel cycle takes zero mutex locks. Already-expired timers (e.g. 0ns delay) short-circuit in wait() by posting directly to the executor, bypassing the heap and epoll entirely.

Summary by CodeRabbit

  • Performance Improvements
    • Reduced memory allocations in timer operations for improved efficiency and responsiveness
    • Enhanced caching mechanisms deliver better performance for timer tasks
    • Optimized same-thread timer scenarios with faster processing paths
    • Improved resource cleanup and lifecycle management during application shutdown

Replace heap-allocated timer_op with an embedded completion_op in
timer_impl, eliminating new/delete per fire and cancel. Defer heap
insertion from expires_after() to wait() so idle timers never touch
the mutex. Add thread-local caches for both timer_impl recycling and
service lookup to avoid locking on the common single-thread path.
Cache the nearest expiry in an atomic to make nearest_expiry() and
empty() lock-free. Add might_have_pending_waits_ flag so cancel
returns immediately when no wait was ever issued.

With all fast paths hit, the schedule/cancel cycle takes zero mutex
locks. Already-expired timers (e.g. 0ns delay) short-circuit in
wait() by posting directly to the executor, bypassing the heap and
epoll entirely.
@coderabbitai

coderabbitai Bot commented Feb 9, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The timer service implementation is refactored to embed a completion operation within timer_impl, replacing external timer_op handling. Thread-local caching mechanisms are introduced to reduce allocations and improve performance. The service data structures are reorganized from intrusive lists to free-list pointers with live counting and lock-free cache queries.

Changes

Cohort / File(s) Summary
Embedded Completion & Core Structure
src/corosio/src/detail/timer_service.cpp
Embeds completion_op directly in timer_impl for unified completion handling. Adds new member fields: op_ (completion operation), ec_value_ (error code), next_free_ (free-list linkage), and might_have_pending_waits_ flag to optimize cancellation logic.
Thread-Local Caching Infrastructure
src/corosio/src/detail/timer_service.cpp
Introduces thread-local cached contexts (cached_ctx, cached_svc) and per-thread timer_impl cache (tl_impl_cache) with RAII cleanup. Adds helper functions try_pop_tl_cache() and try_push_tl_cache() for efficient cache management. New timer_service_invalidate_cache() function for shutdown cleanup.
timer_service_impl Refactoring
src/corosio/src/detail/timer_service.cpp
Replaces intrusive lists with raw pointer free_list_ and live_count tracking. Introduces mutable atomic cached_nearest_ns_ for lock-free expiry queries. Restructures create_impl(), destroy_impl(), wait(), update_timer(), cancel_timer(), and process_expired() to work with new caches, free-list management, and completion_op posting.
New Exported API
src/corosio/src/detail/timer_service.cpp
Adds four new public-facing functions: timer_service_invalidate_cache(), try_pop_tl_cache(), try_push_tl_cache(), and timer_service_destroy() to support cache lifecycle and external cleanup coordination.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Cache as Thread-Local<br/>Cache
    participant TimerImpl as timer_impl
    participant Service as timer_service_impl
    participant Executor as Executor/<br/>Scheduler

    rect rgba(0, 128, 255, 0.5)
    Note over Client,Executor: Timer Creation with Caching
    Client->>Cache: try_pop_tl_cache()
    alt Cache Hit
        Cache-->>Client: timer_impl*
    else Cache Miss
        Client->>Service: create_impl()
        Service->>TimerImpl: allocate & initialize
        TimerImpl->>TimerImpl: embed completion_op
        Service-->>Client: timer_impl*
    end
    end

    rect rgba(34, 139, 34, 0.5)
    Note over Client,Executor: Timer Expiry & Completion
    Service->>Service: process_expired()
    Service->>Executor: post(&impl.op_)
    Executor->>TimerImpl: invoke completion_op
    TimerImpl->>Executor: post coroutine handle
    Executor->>Executor: signal work complete
    end

    rect rgba(255, 140, 0, 0.5)
    Note over Client,Executor: Cleanup & Caching
    Client->>Cache: try_push_tl_cache(impl)
    alt Cache Available
        Cache->>TimerImpl: store in thread-local
    else Cache Full
        Client->>Service: destroy_impl()
        Service->>TimerImpl: deallocate
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A cache so swift, a list set free,
Embedded ops, lock-light and spry,
Thread-local stores, no heap to see,
Timers dance with less alloc cry,
Clean shutdown paths reach the sky! 🌟

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Optimize timer service for lock-free schedule/cancel' directly aligns with the main objective of the PR, which is to eliminate mutex locks in the common scheduling/cancellation path through embedded completion operations, thread-local caching, and lock-free fast paths.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cppalliance-bot

Copy link
Copy Markdown

An automated preview of the documentation is available at https://112.corosio.prtest3.cppalliance.org/index.html

If more commits are pushed to the pull request, the docs will rebuild at the same URL.

2026-02-09 16:03:25 UTC

@codecov

codecov Bot commented Feb 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.71875% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.47%. Comparing base (2e13bb5) to head (f0a69be).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
src/corosio/src/detail/timer_service.cpp 86.71% 17 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop     #112      +/-   ##
===========================================
+ Coverage    80.42%   80.47%   +0.05%     
===========================================
  Files           65       65              
  Lines         5594     5630      +36     
===========================================
+ Hits          4499     4531      +32     
- Misses        1095     1099       +4     
Files with missing lines Coverage Δ
src/corosio/src/detail/timer_service.cpp 88.43% <86.71%> (-0.37%) ⬇️

... and 2 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 2e13bb5...f0a69be. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/corosio/src/detail/timer_service.cpp`:
- Around line 200-234: The thread-local cache deletions never decrement
live_count_, causing a leak: update timer_service_invalidate_cache() so that
when it removes and deletes cached impls it also decrements the owning
TimerService's live_count_ (use the service pointer available in the impl to do
--live_count_ or call a new TimerService::release_impl/count_decrement helper)
before calling delete; in shutdown() ensure that when you call
timer_service_invalidate_cache() you also attempt to invalidate other threads'
caches where possible and that the code path which deletes impls from heap_ or
free_list_ consistently updates live_count_ (heap_ loop and free-list draining
should keep the existing --live_count_ behavior), and for ~tl_impl_cache
document that at thread-exit the service may be gone so raw delete without
decrement is unavoidable. Ensure references to timer_service_invalidate_cache(),
~tl_impl_cache, destroy_impl, live_count_, shutdown(), heap_, and free_list_ are
used to locate the changes.
🧹 Nitpick comments (4)
src/corosio/src/detail/timer_service.cpp (4)

344-355: Non-atomic reads of heap_index_ and waiting_ are technically data races (UB).

Lines 346 and 350–351 read might_have_pending_waits_, heap_index_, and waiting_ without synchronization. Meanwhile, process_expired() writes heap_index_ and waiting_ under the mutex on the scheduler thread. Although the fast-path checks are conservatively correct (a stale value always falls through to the locked path), unsynchronized concurrent reads/writes of non-atomic variables are undefined behavior under the C++ memory model.

The same pattern exists in update_timer() (line 285–288) and destroy_impl() (line 266).

Consider making heap_index_, waiting_, and might_have_pending_waits_ std::atomic with relaxed loads on the fast path and release stores under the lock — or adding a brief comment justifying the data race as intentional on the target platforms.


264-280: destroy_impl can acquire the mutex twice for a heap-resident timer.

When heap_index_ != max, the mutex is acquired to remove the timer from the heap (line 268), released, and then acquired again to push onto the free-list (line 276). This is functionally correct but doubles the locking cost for timers destroyed while still in the heap.

Suggested consolidation
 void destroy_impl(timer_impl& impl)
 {
-    if (impl.heap_index_ != (std::numeric_limits<std::size_t>::max)())
-    {
-        std::lock_guard lock(mutex_);
-        remove_timer_impl(impl);
-        refresh_cached_nearest();
-    }
-
-    if (try_push_tl_cache(&impl))
-        return;
-
-    std::lock_guard lock(mutex_);
-    impl.next_free_ = free_list_;
-    free_list_ = &impl;
-    --live_count_;
+    bool was_in_heap =
+        (impl.heap_index_ != (std::numeric_limits<std::size_t>::max)());
+
+    if (!was_in_heap && try_push_tl_cache(&impl))
+        return;
+
+    std::lock_guard lock(mutex_);
+    if (impl.heap_index_ != (std::numeric_limits<std::size_t>::max)())
+    {
+        remove_timer_impl(impl);
+        refresh_cached_nearest();
+    }
+    impl.next_free_ = free_list_;
+    free_list_ = &impl;
+    --live_count_;
 }

569-607: Thread-local caches on other threads are not invalidated by shutdown().

timer_service_invalidate_cache() only clears the calling thread's thread_local state. If worker threads have cached timer_impl* pointers, those caches persist and the ~tl_impl_cache destructor will delete the impl at thread exit. Today this is safe because timer_impl's destructor doesn't dereference svc_, but it is fragile — any future addition to timer_impl's destructor that touches the service (e.g., logging, assertions, metrics) would be a use-after-free.

Consider adding a brief comment near ~tl_impl_cache documenting this invariant ("timer_impl's destructor must never access svc_"), so future maintainers don't inadvertently break it.


385-389: nearest_expiry() assumes steady_clock::duration::rep fits int64_t.

refresh_cached_nearest() stores time_since_epoch().count() into an int64_t atomic, and nearest_expiry() reconstructs via time_point::duration(ns). This is correct as long as duration::rep is int64_t (which is the case on all major implementations), but a static_assert would make the assumption explicit and catch exotic platforms.

static_assert(
    sizeof(time_point::duration::rep) <= sizeof(std::int64_t),
    "duration::rep must fit in int64_t for the atomic cache");

Comment on lines 200 to 234
void shutdown() override
{
// Cancel all waiting timers and destroy coroutine handles
// This properly decrements outstanding_work_ for each waiting timer
while (auto* impl = timers_.pop_front())
timer_service_invalidate_cache();

// Cancel waiting timers still in the heap
for (auto& entry : heap_)
{
auto* impl = entry.timer_;
if (impl->waiting_)
{
impl->waiting_ = false;
// Destroy the coroutine handle without resuming
impl->h_.destroy();
// Decrement work count to avoid leak
sched_->on_work_finished();
}
impl->heap_index_ = (std::numeric_limits<std::size_t>::max)();
delete impl;
--live_count_;
}
while (auto* impl = free_list_.pop_front())
delete impl;
heap_.clear();
cached_nearest_ns_.store(
(std::numeric_limits<std::int64_t>::max)(),
std::memory_order_release);

// Delete free-listed impls
while (free_list_)
{
auto* next = free_list_->next_free_;
delete free_list_;
free_list_ = next;
}

// Any live timers not in heap and not on free list are still
// referenced by timer objects — they'll call destroy_impl()
// which will delete them (live_count_ tracks this).
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

live_count_ leaks when thread-local cache deletes impls.

When timer_service_invalidate_cache() (line 202/605) or ~tl_impl_cache (line 573) deletes a cached impl, live_count_ is never decremented. The impl was counted as "live" when destroy_impl pushed it to the TL cache (skipping --live_count_), but the deletion bypasses destroy_impl.

Additionally, shutdown() only calls invalidate_cache() on the calling thread. Other threads with cached impls will delete them on thread exit via ~tl_impl_cache, again without decrementing live_count_. Since live_count_ isn't used for runtime decisions today, this is cosmetic, but it would break any future assertion or diagnostic that relies on the count reaching zero.

Proposed fix in invalidate_cache
 void
 timer_service_invalidate_cache() noexcept
 {
     cached_ctx = nullptr;
     cached_svc = nullptr;
-    delete tl_cached_impl.ptr;
+    if (tl_cached_impl.ptr)
+    {
+        // Put it back on the free-list via destroy_impl so live_count_ stays correct
+        tl_cached_impl.ptr->svc_->destroy_impl(*tl_cached_impl.ptr);
+    }
     tl_cached_impl.ptr = nullptr;
 }

For ~tl_impl_cache, the service may already be destroyed at thread-exit time, so a raw delete is the only safe option there — consider documenting this known leak explicitly.

🤖 Prompt for AI Agents
In `@src/corosio/src/detail/timer_service.cpp` around lines 200 - 234, The
thread-local cache deletions never decrement live_count_, causing a leak: update
timer_service_invalidate_cache() so that when it removes and deletes cached
impls it also decrements the owning TimerService's live_count_ (use the service
pointer available in the impl to do --live_count_ or call a new
TimerService::release_impl/count_decrement helper) before calling delete; in
shutdown() ensure that when you call timer_service_invalidate_cache() you also
attempt to invalidate other threads' caches where possible and that the code
path which deletes impls from heap_ or free_list_ consistently updates
live_count_ (heap_ loop and free-list draining should keep the existing
--live_count_ behavior), and for ~tl_impl_cache document that at thread-exit the
service may be gone so raw delete without decrement is unavoidable. Ensure
references to timer_service_invalidate_cache(), ~tl_impl_cache, destroy_impl,
live_count_, shutdown(), heap_, and free_list_ are used to locate the changes.

@cppalliance-bot

Copy link
Copy Markdown

GCOVR code coverage report https://112.corosio.prtest3.cppalliance.org/gcovr/index.html
LCOV code coverage report https://112.corosio.prtest3.cppalliance.org/genhtml/index.html
Coverage Diff Report https://112.corosio.prtest3.cppalliance.org/diff-report/index.html

Build time: 2026-02-09 16:17:02 UTC

@sgerbino sgerbino merged commit 4ac065f into cppalliance:develop Feb 9, 2026
43 of 45 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Beast2 Feb 9, 2026
@sgerbino sgerbino deleted the pr/timer branch February 9, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants