How LibCore's EventLoop Manages Concurrency and Asynchronous Operations in Ladybird

Ladybird's LibCore implements a per-thread event loop using poll()-driven multiplexing, lock-free cross-thread queues, and mutex-protected thread-local data to handle timers, I/O notifiers, and deferred work without spawning OS threads for each task.

The LadybirdBrowser/ladybird project relies on LibCore's EventLoop system to drive all asynchronous operations—from GUI updates to network I/O—using a portable, single-threaded concurrency model. This architecture ensures that LibCore EventLoop concurrency and asynchronous operations remain efficient by multiplexing multiple event sources through a single blocking primitive per thread, avoiding the overhead of thread-per-task patterns.

Architecture Overview

LibCore enforces a one event loop per thread design. Each thread that processes events owns a Core::EventLoopImplementation instance, created lazily upon first construction of Core::EventLoop. Thread-local isolation is maintained through ThreadData, a structure holding the poll set, timer heap, notifiers, and a wake pipe used to interrupt poll() when external threads inject work.

Concurrency safety is achieved through four primary mechanisms:

  • Mutexes protecting modifications to per-thread ThreadData (timer and notifier registration)
  • Atomic flags (is_being_deleted, is_scheduled) preventing race conditions during timer cancellation
  • RWLocks guarding global signal-handler tables
  • Lock-free queues (ThreadEventQueue) enabling cross-thread event posting without blocking the sender

Core Components and Source Files

EventLoop and EventLoopImplementation

The public API resides in Libraries/LibCore/EventLoop.h. The Core::EventLoop class provides methods like exec(), pump(), quit(), and deferred_invoke(). It delegates platform-specific behavior to Core::EventLoopImplementation, an abstract base defined in Libraries/LibCore/EventLoopImplementation.h.

EventLoopImplementationUnix and ThreadData

The Unix-specific implementation in Libraries/LibCore/EventLoopImplementationUnix.cpp contains the core logic. It maintains a ThreadData structure (thread-local) aggregating:

  • TimeoutSet timeouts – a binary heap managing timer deadlines
  • Vector<Notifier*> notifiers – file descriptors to monitor
  • Vector<pollfd> poll_fds – the aggregate poll set including the wake pipe
  • Wake pipe file descriptors and a protective mutex

The constructor captures the write end of the wake pipe:

EventLoopImplementationUnix::EventLoopImplementationUnix()
    : m_wake_pipe_write_fd(ThreadData::the().wake_pipe_fds[1])
{ }

ThreadEventQueue

Defined in Libraries/LibCore/ThreadEventQueue.h, this lock-free queue allows any thread to enqueue a Core::Event or deferred lambda for a target thread's loop. When posting to another thread, the implementation automatically triggers wake() to break that thread's current poll() wait.

Notifier and Timer Classes

Core::Notifier (in Libraries/LibCore/Notifier.h) wraps a file descriptor and event mask (read/write/hang-up/error). Core::Timer (in Libraries/LibCore/Timer.h) provides a convenience wrapper around EventLoop::register_timer(), managing the underlying EventLoopTimer objects stored in the thread-local TimeoutSet.

The Event Loop Life Cycle

1. Construction and Initialization

When a thread first creates a Core::EventLoop, the system instantiates EventLoopImplementationUnix, which initializes the wake pipe and registers the thread-local data store.

2. The Exec Loop

The exec() method (in Libraries/LibCore/EventLoop.cpp) enters an infinite loop calling pump() until quit() is requested:

int EventLoop::exec() {
    for (;;) {
        if (m_exit_requested) return m_exit_code;
        pump(PumpMode::WaitForEvents);
    }
}

3. Pumping Events

The pump(PumpMode mode) method delegates to the global EventLoopManagerUnix:

size_t EventLoopImplementationUnix::pump(PumpMode mode) {
    static_cast<EventLoopManagerUnix&>(EventLoopManager::the()).wait_for_events(mode);
    return ThreadEventQueue::current().process();
}

4. Waiting for Events

EventLoopManagerUnix::wait_for_events() performs the following sequence:

  1. Prepare timers – Converts relative timeouts to absolute times via absolutize_relative_timeouts()
  2. Compute blocking timeout – Determines the earliest timer expiry for the poll() timeout
  3. Poll – Calls System::poll() on the aggregated poll_fds (wake pipe + notifiers)
  4. Handle wake pipe – Reads integers from the pipe; 0 indicates a standard wake, while non-zero values represent POSIX signal numbers to dispatch
  5. Dispatch notifiers – For each ready file descriptor, posts Core::Event::Type::NotifierActivation to the ThreadEventQueue
  6. Fire timers – Calls thread_data.timeouts.fire_expired() to extract elapsed timers from the heap and post Event::Type::Timer events

5. Cross-Thread Wake

Any thread may interrupt the loop via wake(), which writes to the wake pipe:

void EventLoopImplementationUnix::wake() {
    int wake_event = 0;
    auto result = Core::System::write(m_wake_pipe_write_fd,
                                     { &wake_event, sizeof(wake_event) });
    // Ignore EBADF when the thread is shutting down.
}

Asynchronous Primitives

LibCore provides five primary mechanisms for asynchronous work:

  • Deferred Invoke – Enqueues a lambda into the current thread's ThreadEventQueue. When called from a foreign thread, it automatically triggers wake() to break the poll. Usage: Core::deferred_invoke([] { /* UI update */ });

  • Timers – EventLoop::register_timer() creates an EventLoopTimer stored in the thread's TimeoutSet. The poll loop monitors expiry and fires callbacks. Usage: int timer_id = Core::EventLoop::register_timer(receiver, 200, true);

  • Notifiers – Notifier objects register file descriptors with the poll set. Ready notifications become Core::Event::Type::NotifierActivation events. Usage: Notifier noti(fd, Notifier::Read);

  • Signals – register_signal() installs a handler that writes the signal number into the wake pipe. The poll loop later transforms this into a dispatched callback on the event loop thread.

  • Cross-Thread Events – ThreadEventQueue::post_event(target, event_type) pushes events across thread boundaries, processed after the next wake-up.

Concurrency Safety Mechanisms

Thread-local isolation ensures that timers, notifiers, and wake pipes are owned by a single thread, eliminating data races when two threads manipulate their own loops concurrently.

Mutex protection guards all modifications to a thread's ThreadData. Adding or removing timers and notifiers requires acquiring thread_data.mutex.

Atomic deletion flags prevent use-after-free during timer cancellation. When a timer is cancelled, is_being_deleted is set atomically, preventing the event loop from dispatching a deleted timer while another thread might hold a reference.

Signal table RWLock (s_thread_data_lock) allows concurrent reads of the global signal handler map while safely serializing updates.

Practical Implementation Examples

Running a Simple Event Loop

// main.cpp
#include <LibCore/EventLoop.h>
#include <LibCore/Timer.h>
#include <LibCore/DeferredInvocation.h>

int main()
{
    Core::EventLoop loop;

    // Fire a message after 1 second, then quit.
    Core::Timer::single_shot(1000, [] {
        dbgln("Timer fired – exiting");
        Core::EventLoop::current().quit(0);
    });

    // Schedule a deferred lambda from the same thread.
    Core::deferred_invoke([] {
        dbgln("Deferred work executed before the first poll");
    });

    return loop.exec();   // Blocks until quit() is called.
}

Watching a Socket with a Notifier

#include <LibCore/EventLoop.h>
#include <LibCore/Notifier.h>
#include <sys/socket.h>

int main()
{
    int sv[2];
    socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
    Core::Notifier read_notifier(sv[0], Core::Notifier::Read);

    read_notifier.on_ready = [&](auto&) {
        char buf[64];
        ssize_t n = read(sv[0], buf, sizeof(buf));
        dbgln("Received {} bytes", n);
    };

    Core::EventLoop loop;
    return loop.exec();
}

Cross-Thread Wake and Deferred Invoke

#include <LibCore/EventLoop.h>
#include <LibThread/Thread.h>

void worker_thread(Core::EventLoop& loop)
{
    // Simulate work, then ask the main loop to quit.
    sleep(2);
    Core::deferred_invoke([&] {
        dbgln("Worker asks main loop to quit");
        loop.quit(0);
    });
}

int main()
{
    Core::EventLoop loop;
    Thread::create([&] { worker_thread(loop); });
    return loop.exec();
}

Summary

  • LibCore provides one event loop per thread, eliminating shared-state complexity across threads
  • The wake pipe mechanism (implemented in EventLoopImplementationUnix.cpp) allows instantaneous interruption of poll() for cross-thread work injection
  • Lock-free ThreadEventQueue enables safe, non-blocking event posting between threads
  • Binary heap timer management (TimeoutSet) provides O(log n) insertion and O(1) expiry retrieval for high-frequency timer operations
  • Mutex-protected ThreadData and atomic flags ensure safe concurrent modification of event sources without coarse-grained global locks

Frequently Asked Questions

How does LibCore handle cross-thread event posting?

LibCore uses the lock-free ThreadEventQueue class to post events across threads. When ThreadEventQueue::post_event() is called from a foreign thread, it enqueues the event and automatically invokes wake() on the target thread's event loop, writing to the wake pipe to interrupt the current poll() wait and ensure immediate processing.

What mechanism interrupts the poll() wait when new events arrive?

The system uses a wake pipe, a standard Unix pipe registered in the poll set. When wake() is called (either explicitly or automatically during cross-thread posting), the implementation writes an integer to the pipe. The EventLoopManagerUnix::wait_for_events() function detects this read-ready state and processes the wake event before returning to the caller.

How are timers managed in the EventLoop?

Timers are stored in a per-thread TimeoutSet (a binary heap) inside ThreadData. When register_timer() is called, an EventLoopTimer object is allocated and inserted into the heap. During wait_for_events(), the loop calculates the earliest expiry time to set the poll() timeout, then calls fire_expired() to dispatch callbacks for elapsed timers.

Is the EventLoop thread-safe for multiple threads accessing the same loop?

No, individual EventLoop instances are not thread-safe for concurrent access by multiple threads. Each thread must own its own loop. However, LibCore provides thread-safe mechanisms to interact with a foreign loop: ThreadEventQueue for posting events, wake() for interruption, and atomic flags for timer cancellation. All direct manipulation of a loop's timers and notifiers must occur on that loop's own thread or be protected by the ThreadData mutex.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →