Phá vỡ tư duy Tuần tự – Bản chất của Bất đồng bộ (Asyncio) & Event Loop!

Trước khi viết code web hay websocket, chúng ta cần làm rõ bản chất bên dưới: Tại sao code Python bình thường lại bị "nghẽn", và cơ chế nào giúp một chương trình đơn luồng (single-thread) có thể phục vụ hàng nghìn kết nối cùng lúc?

PHẦN 1: TƯ DUY NỀN TẢNG (CPU-BOUND VS I/O-BOUND)

Trong máy tính, các tác vụ thường chia làm 2 nhóm:

  1. CPU-bound (Nghẽn tính toán):

    • Các tác vụ mà chip CPU phải chạy hết 100% công suất: mã hóa mật mã RSA, huấn luyện mô hình AI, xử lý ma trận ảnh OpenCV, tính tích phân/ma trận lớn.

    • Để chạy nhanh hơn, ta cần đa tiến trình (Multiprocessing) hoặc dùng GPU.

  2. I/O-bound (Nghẽn vào/ra dữ liệu):

    • Các tác vụ mà CPU hoàn toàn ngồi chơi xơi nước để chờ thiết bị ngoại vi hoặc mạng phản hồi: đọc/ghi file từ ổ cứng SSD, gửi truy vấn SQL vào cơ sở dữ liệu và chờ kết quả, gửi HTTP request sang server khác, chờ tin nhắn từ kết nối WebSocket.

    • Thời gian chờ mạng thường tính bằng mili-giây ($ms$), trong khi CPU xử lý ở mức nano-giây ($ns$). 100ms chờ mạng tương đương hàng triệu chu kỳ xung nhịp CPU bị lãng phí!

💡 Asyncio sinh ra để giải quyết triệt để bài toán I/O-bound!

PHẦN 2: EVENT LOOP LÀ GÌ? (ẨN DỤ NHÀ HÀNG)

Hãy giải thích cho bé bằng câu chuyện quen thuộc: Người bồi bàn và Nhà bếp.

1. Phục vụ tuần tự (Synchronous):

  • Một người bồi bàn nhận order của Bàn 1 $\rightarrow$ đi vào bếp đưa phiếu $\rightarrow$ đứng im trong bếp khoanh tay chờ đầu bếp nấu xong (mất 15 phút) $\rightarrow$ bưng ra cho Bàn 1.

  • Sau đó mới sang Bàn 2 nhận order $\rightarrow$ lại đứng chờ tiếp 15 phút.

  • Kết quả: Khách ở Bàn 2, Bàn 3 phải chờ đợi rất lâu dù người bồi bàn chẳng làm gì ngoài việc đứng đợi.

2. Phục vụ bất đồng bộ qua Event Loop (Asynchronous):

  • Event Loop chính là người bồi bàn duy nhất nhưng cực kỳ thông minh:

    1. Nhận order từ Bàn 1 $\rightarrow$ đưa vào bếp.

    2. Không đứng chờ! Quay ra ngay lập tức để lấy order của Bàn 2, Bàn 3.

    3. Bất cứ khi nào nhà bếp chuông reo "Món của Bàn 1 xong rồi!" (sự kiện hoàn thành I/O), bồi bàn quay lại lấy đĩa thức ăn mang ra cho Bàn 1.

  • Nhờ vậy, chỉ cần 1 luồng xử lý duy nhất (Single Thread), hệ thống vẫn phục vụ trơn tru hàng chục bàn ăn cùng lúc.

Plaintext
       +---------------------------------------------+
       |                 EVENT LOOP                  |
       |  (Vòng lặp liên tục quét các tác vụ sẵn sàng)  |
       +---------------------------------------------+
             ^                                  |
             | Trả kết quả khi xong              | Giao việc I/O
             | (Mạng đã có dữ liệu)             V (Gửi request)
   [ Danh sách công việc đang chờ ]    [ Hệ thống mạng / Ổ cứng / DB ]

PHẦN 3: CÚ PHÁP CỐT LÕI (async def VÀ await)

Trong Python:

  • async def: Khai báo một hàm bất đồng bộ. Khi gọi hàm này, nó chưa chạy ngay, mà trả về một đối tượng gọi là Coroutine (giống như tấm phiếu order thức ăn).

  • await: Tạm dừng coroutine hiện tại và nhường quyền điều khiển CPU lại cho Event Loop để đi làm việc khác trong lúc chờ kết quả.

    • Chỉ được dùng await bên trong một hàm có async def.

    • Chỉ được await các đối tượng hỗ trợ (Awaitable: Coroutine, Task, Future).

  • asyncio.run(main()): Điểm khởi đầu (entrypoint) để bật Event Loop lên và chạy coroutine gốc main().

PHẦN 4: THỰC NGHIỆM ĐO HIỆU NĂNG BẰNG CODE

Chúng ta sẽ làm một thí nghiệm so sánh trực quan: Giả lập 5 tác vụ tải dữ liệu từ mạng (mỗi tác vụ mất đúng 2 giây để chờ phản hồi).

Tạo file buoi1_sync_vs_async.py:

import time
import asyncio

# ==========================================
# 1. PHƯƠNG PHÁP TUẦN TỰ (SYNCHRONOUS)
# ==========================================
def download_sync(task_id):
    print(f"[Sync] Bắt đầu tải dữ liệu cho Task {task_id}...")
    # time.sleep() là lệnh CHẶN (Blocking) hoàn toàn luồng đang chạy
    time.sleep(2)
    print(f" -> [Sync] Hoàn thành Task {task_id}!")

def run_synchronous():
    print("\n--- CHẠY THỬ NGHIỆM TUẦN TỰ (SYNCHRONOUS) ---")
    start = time.time()
    for i in range(1, 6):
        download_sync(i)
    total_time = time.time() - start
    print(f"⏱️ Tổng thời gian chạy Tuần tự: {total_time:.2f} giây")


# ==========================================
# 2. PHƯƠNG PHÁP BẤT ĐỒNG BỘ (ASYNCHRONOUS)
# ==========================================
async def download_async(task_id):
    print(f"[Async] Bắt đầu tải dữ liệu cho Task {task_id}...")
    # asyncio.sleep() là Non-blocking: nhường quyền điều khiển cho Event Loop
    await asyncio.sleep(2)
    print(f" -> [Async] Hoàn thành Task {task_id}!")

async def run_asynchronous():
    print("\n--- CHẠY THỬ NGHIỆM BẤT ĐỒNG BỘ (ASYNCIO) ---")
    start = time.time()

    # Gom 5 tác vụ lại để Event Loop chạy đồng thời
    tasks = [download_async(i) for i in range(1, 6)]
    await asyncio.gather(*tasks)

    total_time = time.time() - start
    print(f"⏱️ Tổng thời gian chạy Bất đồng bộ: {total_time:.2f} giây")


if __name__ == "__main__":
    # Chạy tuần tự trước
    run_synchronous()

    # Sau đó khởi động Event Loop để chạy bất đồng bộ
    asyncio.run(run_asynchronous())

PHẦN 5: CHẠY THỬ & THỬ THÁCH SUY LUẬN CHO BÉ

  1. Quan sát kết quả trên màn hình:

    • Phần Sync: Chạy lần lượt từ 1 đến 5, mất tổng cộng $2 \times 5 = \mathbf{10.0}$ giây.

    • Phần Async: Cả 5 thông báo "Bắt đầu tải" xuất hiện cùng một lúc. Sau đúng 2 giây, cả 5 tác vụ kết thúc gần như đồng thời! Tổng thời gian chỉ mất vỏn vẹn $\approx 2.0$ giây.

  2. Thử thách suy luận cho bé:

    • Câu hỏi 1: Trong hàm download_async(), nếu bé vô tình thay dòng await asyncio.sleep(2) bằng lệnh cũ time.sleep(2), điều gì sẽ xảy ra với tổng thời gian chạy? Tại sao?

    • Câu hỏi 2 (Phân tích lỗi sai kinh điển): Nếu gọi một hàm async def mà quên không đặt từ khóa await phía trước nó (ví dụ: chỉ gõ download_async(1) thay vì await download_async(1)), Python sẽ báo cảnh báo gì và hàm đó có thực sự chạy không?