The event loop: One thread, many clients

Before the code: why Redis uses one thread, not many

Picture a small restaurant. One way to serve fifty tables is to hire fifty waiters, one per table. That is what we have been doing so far: one thread per client. It works for tens of clients, but every thread costs memory and the kernel has to keep switching between them. The other way is one very alert waiter who walks the floor and checks who needs something next. That is an event loop, and it is exactly how real Redis serves thousands of clients on a single thread.

Many threads versus one event loop

Threads have stacks and need locking. An event loop has one stack and asks the kernel who is ready right now.

Quiz: Quiz

Loading practice…

One thread per client works fine for tens. For thousands, kernel context switches and per-thread stack memory dominate. Real Redis is single-threaded with an event loop. Same data structures. Same dispatch. Different concurrency model.

09-event-loop/server.py
python
sel = selectors.DefaultSelector()

def main():
    server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_sock.bind((HOST, PORT))
    server_sock.listen(128)
    server_sock.setblocking(False)
    sel.register(server_sock, selectors.EVENT_READ, "ACCEPTOR")

    while True:
        events = sel.select(timeout=1.0)
        for key, mask in events:
            if key.data == "ACCEPTOR":
                on_accept(key.fileobj)
            else:
                state: ClientState = key.data
                if mask & selectors.EVENT_READ:
                    on_readable(state)
                if mask & selectors.EVENT_WRITE:
                    on_writable(state)

The whole loop. sel.select() blocks until the kernel tells us at least one socket is ready. Each ready socket gets read or written. No threads. No locks.

09-event-loop/server.py
python
class ClientState:
    __slots__ = ("sock", "addr", "in_buf", "out_buf")

    def __init__(self, sock, addr):
        self.sock = sock
        self.addr = addr
        self.in_buf = bytearray()
        self.out_buf = bytearray()

Per-client state. The event loop owns these. Bytes arrive into in_buf, replies queue in out_buf.

setblocking(False) is what changes the contract. recv returns immediately, raises BlockingIOError if nothing is ready. send returns the bytes it took (may be less than asked). The event loop only calls them when the kernel says they will not block.

asyncio is a higher-level wrapper around the same idea. async/await syntax replaces the explicit state machine. For teaching purposes the raw selectors approach is clearer. For production code, asyncio is usually the right choice.

Quiz: Quiz

Loading practice…

AI prompt: Try it: race threads against the event loop

Loading practice…