Hồi ức · 2008–2009

Chúng tôi đã giải bài toán C10K thế nào

Từ mổ source Nginx, vặt lông Redis đến "phát minh ra" async/await.

Gần đây lướt mạng, thấy có ông anh nhắc lại bài toán C10K, tự nhiên bao nhiêu ký ức của cái thời 2008–2009 dội về.

Đó là cái thời web bùng nổ, và anh em làm backend hồi đó ai cũng đụng cùng một bức tường: Làm sao cho một con server cõng được mười nghìn connection cùng lúc? Anh Dan Kegel gọi nó là C10K problem.

Cách cũ bấy giờ là thread-per-connection (kiểu Apache prefork). Code đọc dễ, debug dễ, nhưng chết ở scale. Mười nghìn thread tức là bay ngay vài chục GB RAM chỉ để... ngồi đợi I/O. Cộng thêm việc context switch liên tục khiến OS scheduler ngộp thở. Resource cháy hết cho overhead chứ không cho work thật. Bọn tôi biết mình phải tìm một con đường khác.

Và đây là câu chuyện về cách chúng tôi đã giải nó, bằng cách chắp vá từ những người khổng lồ, và vô tình "chạm" vào tương lai của lập trình bất đồng bộ trước cả thế giới gần một thập kỷ.

1 Bài học vỡ lòng từ Nginx

Anh Nga ngố Igor Sysoev làm Nginx từ 2004 đi một con đường khác hẳn: event-driven, non-blocking I/O, một worker process cõng hàng vạn connection.

Mổ source Nginx ra, cái core của nó đơn giản đến mức choáng váng. Nó chỉ là một vòng lặp:

for (;;) {
    timer = find_next_timer();
    events = wait_for_io(timer);   // Chỗ duy nhất block
    for (each event in events) {
        handler(event);             // Không bao giờ block
    }
    process_timers();
}

Dòng wait_for_io() thì tùy hệ điều hành. Windows hồi đó chỉ có select() chạy O(n) theo số file descriptor (fd), còn Linux 2.6 đã có epoll chạy O(1), gồng được hàng trăm nghìn fd. Thế là anh em ôm mịe Linux, vừa đỡ phải mất tiền bản quyền

Có điều codebase Nginx to oành, đầy macro của anh Igor, viết để chạy production chứ không phải để học. Đọc mãi không vô. May thay, đầu năm 2009, một "vị cứu tinh" khác xuất hiện.

2 Mổ source Redis để học Event Loop

Tháng 2/2009, anh Ý đảo Sicilia — antirez — công bố Redis. Một thread, một event loop, mà throughput khủng khiếp.

Bí mật nằm gọn trong vài file: ae.h cho interface, ae.c cho core (chỉ tầm 400 dòng), rồi ae_epoll.c / ae_kqueue.c / ae_select.c cho từng OS. Tên ae viết tắt của "A simple Event-driven programming library". Cả file chính tôi ngồi đọc một buổi sáng là xong.

API public gọn lỏn vài hàm: tạo/hủy event loop, đăng ký file event cho một fd, time event, aeMain để khởi động vòng lặp. Phần platform-specific giấu sạch sau wrapper thống nhất.

Anh em tôi bê nguyên ae về xài luôn, không động dao kéo gì vào bên trong. Cái chúng tôi mang lên tầng framework ứng dụng là kiến trúc — cách tách public API khỏi platform-specific code.

Event-driven tự nhiên "dễ như ăn kẹo"

Có cái event loop từ Redis trong tay, viết event-driven nhàn tênh. Có fd ready? Đăng ký callback. Cần timer? Đẩy vào time event queue. Engine ae lo nốt. Cái HTTP server tự nhiên rơi xuống: accept-loop trên port 80, parse request ra, tra routing table, gọi handler đã đăng ký.

@http.get("/users/*")
def get_user(httpContext):
    user_id = httpContext.path.split("/")[-1]
    # ... build response ...

Pattern đó áp dụng cho mọi protocol. Hồi bọn tôi làm game backend, chỉ cần thay parser và dispatcher, đăng ký handler theo opcode thay vì HTTP path:

@socket_event(opcode=0x0A)        # MOVE
def on_move(player, dx, dy):
    player.x += dx; player.y += dy
    broadcast_to_room(player.room_id, MoveEvent(player.id, dx, dy))

Engine không đổi một dòng. Cùng một backend vừa cõng REST API cho team product, vừa cõng game realtime cho team game. Đây mới đúng là "platform" — một cái nền cho nhiều sản phẩm khác nhau đứng lên, chứ không chỉ là một con server giải C10K.

3 Nửa kia của tảng băng: Nỗi đau mang tên Callback Hell

Đến đây, Redis giải đẹp phần connection đi vào. Còn phần I/O đi ra thì Redis bó tay.

Vì khi handler chạy, nghiệp vụ thật mới bắt đầu: query MySQL lấy user, gọi HTTP service nội bộ lấy profile, ghi log, v.v. Mọi bước I/O đó phải non-blocking, vì chỉ cần một query MySQL chậm là đóng băng cả 9.999 connection còn lại đang share cái event loop kia.

Hồi 2008–2009, để viết code non-blocking, anh em chỉ có hai lựa chọn, và cả hai đều... "khó chịu thí mịa".

Lúc Node.js mới ra, anh em cũng phát cuồng với cảm giác hiện đại, "đây mới là tương lai". Nhưng đến lúc viết app thật, lồng 5–7 tầng cho một flow nghiệp vụ phức tạp, mới thấy tận cùng của nỗi đau:

def handle_request(conn):
    def on_read(req):
        def on_user(user):
            def on_profile(profile):
                conn.write(render(user, profile))
            http.get_async(f"http://profile-svc/{user.id}", on_profile)
        db.query_async("SELECT * FROM users WHERE id=?", req.user_id, on_user)
    conn.read_async(on_read)

Đây chính là thứ mà mãi sau này cộng đồng mới đặt tên: Callback Hell. Bốn cấp indent cho một flow tầm thường. Try/catch vô nghĩa vì exception ném từ callback có lan ngược lên được đâu. Stack trace thì cụt ngủn ở event loop.

Workaround của bọn tôi hồi đó là... tống hết logic xuống Stored Procedure của MySQL. Một cú CALL sp_handle_order(...) thay cho 5 callback lồng nhau. Sạch trên giấy, nhưng đau khổ trong dài hạn:

Đến lúc phải hack MySQL bằng C plugin chỉ để vá một mô hình lập trình sai từ đầu, chúng tôi biết mình đã đi quá xa rồi.

4 Khoảnh khắc "Eureka" với yield

Một hôm lang thang đọc tài liệu Python 2.5/2.6, tôi vớ phải một feature thú vị: generator với keyword yield, kèm method bổ trợ .send(value) để caller truyền giá trị ngược vào.

Python đẻ ra cặp này không phải để giải quyết bài toán concurrency. yield sinh ra để viết iterator cho gọn (thay vì phải đẻ ra cả một class với __iter__ / __next__):

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

Còn .send(value) thì cho phép caller đẩy value vào generator — value đó trở thành kết quả của biểu thức yield đã ngủ từ lần trước. Hồi đó người ta vẫn chỉ coi combo này là "iterator nâng cao".

Cái làm nên chuyện của yield là một thuộc tính cực kỳ ăn tiền: Một hàm có thể dừng giữa chừng, trả quyền điều khiển ra ngoài, rồi được caller "đánh thức" để chạy tiếp từ chính chỗ vừa dừng — với toàn bộ biến local vẫn nguyên xi.

Hơn nữa, yield là một biểu thức hai chiều: đẩy giá trị ra, và nhận giá trị vào (qua .send()). Đặt code bên cạnh mô hình giao tiếp này là hiểu ngay:

Code
def gen():
    x = yield 10
    y = yield 20
    return x + y
Mô hình giao tiếp
Caller
Generator
g = gen()
(paused, chưa chạy dòng nào)
val = next(g)
chạy đến yield 10, dừng, đẩy 10 ra
val == 10
10
val = g.send(100)
100
100 chui vào, x = 100
chạy đến yield 20, dừng, đẩy 20 ra
val == 20
20
val = g.send(200)
200
200 chui vào, y = 200
chạy đến return x + y
StopIteration(300)
300
send vào (caller → generator)
yield ra (generator → caller)

Ba điểm cốt lõi lộ ra:

Có cơ chế này thì một hàm Runner hoàn toàn có thể làm được việc: ném kết quả I/O vừa xong vào generator (qua send), nhận việc kế tiếp ra, rồi lặp lại. Và ai sẽ gọi .send() để đánh thức generator khi I/O xong? Câu trả lời đã nằm sẵn trên bàn: chính cái event loop ae.c chôm từ Redis.

5 Ráp lại thành Coroutine Model (async/await đồ đá)

Ý tưởng gom lại cực gọn: Khi code application cần I/O, nó không gọi callback nữa. Nó yield ra một object mô tả việc cần làm.

Bên ngoài sẽ có một hàm run đóng vai trò "lái" generator: nhận object về, gọi schedule() của nó, lúc nào I/O xong thì lấy kết quả ném ngược vào generator. Lặp lại cho đến khi xong việc (StopIteration).

Bọn tôi gọi mỗi hàm như vậy là một task (mãi sau này từ học thuật coroutine mới thành chuẩn chung). Cái ghép nối tất cả lại — generator của user, event loop Redis, mấy cái YieldReturn — là một cái decorator @task đơn giản. Decorate một hàm có yield bằng @task thì khi gọi, hàm đó không chạy ngay mà trả về một TaskReturn (chính là một YieldReturn bọc generator).

Nhờ vậy cả hai use case cùng work trên một cơ chế:

Một decorator, hai use case, không có syntax riêng cho sub-task call. Flow callback hell nát bét ở trên, giờ viết lại đẹp như mơ:

@task
def handle_request(conn):
    req     = yield conn.read()
    user    = yield db.query("SELECT * FROM users WHERE id=?", req.user_id)
    profile = yield http.get(f"http://profile-svc/{user.id}")
    yield log.write(f"served {user.id}")
    yield conn.write(render(user, profile))

Cùng logic, cùng performance, chạy trên cùng cái event loop một thread cõng hàng vạn connection. Khác mỗi việc rải mấy chữ yield vào đúng chỗ. Code tuần tự, bắt try/catch ngon lành.

Toàn bộ pattern này được chúng tôi gói gọn trong vài chục dòng code. Mỗi loại I/O là một subclass nhỏ của YieldReturn, tự override hàm schedule():

import functools

class YieldReturn:
    def schedule(self):
        raise NotImplementedError

class MySqlReturn(YieldReturn):
    def __init__(self, sql): self.sql = sql
    def schedule(self):
        # Bridging giữa callback và event loop giấu hết vào đây
        return loop.await_mysql(self.sql)

class db:
    @staticmethod
    def query(sql): return MySqlReturn(sql)

def run(gen):
    """Drive generator đến khi kết thúc, trả về final value."""
    value = None
    while True:
        try:
            yr = gen.send(value)
        except StopIteration as stop:
            return getattr(stop, 'value', None)
        value = yr.schedule()

class TaskReturn(YieldReturn):
    """Bọc một generator. Triết lý: yield một TaskReturn = chạy nó đến hết."""
    def __init__(self, gen): self.gen = gen
    def schedule(self):
        return run(self.gen)

def task(genfunc):
    @functools.wraps(genfunc)
    def wrapper(*args, **kwargs):
        return TaskReturn(genfunc(*args, **kwargs))
    return wrapper

Mental model lúc này cực kỳ nhất quán: Gọi hàm để tạo task, yield để chạy và lấy kết quả. Cần chạy song song? Thêm YieldParallel(*tasks) (đúng cái mà sau này JS gọi là Promise.all, Python gọi là asyncio.gather). Cần timeout hay fallback? Đẻ ra YieldFirst(*tasks) (giống Promise.race).

Mỗi class phụ trợ như vậy chưa tới 30 dòng code, không hề động chạm gì tới core. Xong cái core này, mọi thứ khác (Connection pooling, Rate limiting, Circuit breaker, Retry with backoff...) đều tự động vào guồng. Anh em dev ứng dụng cứ thế mà múa yield, không cần biết bên dưới đang direct hay qua pool.

Mà cái sướng nhất của mô hình này là anh em không cần đau đầu với mấy thứ kinh điển của thread-based concurrency. Không deadlock (cả process có mỗi một thread thì lấy đâu ra mà tự khóa chính mình), không race condition kiểu lock/mutex, không phải ngồi tune thread pool size, không có cái cảnh đang iterate dict thì thread khác chen vào sửa. Code đọc thẳng từ trên xuống như sync, chỉ thêm vài chữ yield đúng chỗ.

Scale thì cũng đơn giản nốt: server có bao nhiêu CPU thì bật bấy nhiêu process — thực ra phải là n_cpu - 1 cái thôi, chừa một core cho OS với mấy thứ network handling, không thì lúc traffic peak server lăn ra đơ anh em không SSH vào nổi để vá. Mỗi process chạy một event loop độc lập, không share state với nhau (share thì lại quay về đúng cái thread issues vừa tránh được). Cần scale tiếp thì thêm máy, không phải tune gì thêm.

6 Nhìn lại: Bài học đắt giá về sự chia sẻ

Cái framework "nhà làm" này được gói trong 3 layer xếp chồng:

Hồi đó anh em trong team thấy nó rất xịn nên giấu mịe đi. Giấu như mèo giấu cứt. Không public, không viết blog, không open source.

Giờ ngồi nhìn lại, có lẽ đó là cái sai lớn nhất của cả hành trình.

Vài năm sau, cả ngành công nghiệp phần mềm tự đi đến đúng cái mô hình ấy bằng những con đường vòng:

Mà thôi, chuyện cũ rồi. Giờ là thời AI, anh em hỏi gì cũng có GPT trả lời trong nốt nhạc, không ai rảnh đi viết code nữa. Mà thực ra cả bài này tôi cũng nhờ ChatGPT viết chứ dốt văn bỏ mịe.

Viết cho vui · 2026