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 deadlinesVector<Notifier*> notifiers– file descriptors to monitorVector<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:
- Prepare timers – Converts relative timeouts to absolute times via
absolutize_relative_timeouts() - Compute blocking timeout – Determines the earliest timer expiry for the
poll()timeout - Poll – Calls
System::poll()on the aggregatedpoll_fds(wake pipe + notifiers) - Handle wake pipe – Reads integers from the pipe;
0indicates a standard wake, while non-zero values represent POSIX signal numbers to dispatch - Dispatch notifiers – For each ready file descriptor, posts
Core::Event::Type::NotifierActivationto theThreadEventQueue - Fire timers – Calls
thread_data.timeouts.fire_expired()to extract elapsed timers from the heap and postEvent::Type::Timerevents
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 triggerswake()to break the poll. Usage:Core::deferred_invoke([] { /* UI update */ }); -
Timers –
EventLoop::register_timer()creates anEventLoopTimerstored in the thread'sTimeoutSet. The poll loop monitors expiry and fires callbacks. Usage:int timer_id = Core::EventLoop::register_timer(receiver, 200, true); -
Notifiers –
Notifierobjects register file descriptors with the poll set. Ready notifications becomeCore::Event::Type::NotifierActivationevents. 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 ofpoll()for cross-thread work injection - Lock-free
ThreadEventQueueenables 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
ThreadDataand 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →