Chuyển từ JavaScript sang Python cảm giác giống như chuyển đến một thành phố có cùng biển báo đường phố nhưng luật giao thông lại khác nhau. Cú pháp trông thân thiện và quen thuộc. Bạn thấy async và await nằm ngay trong ngữ pháp, vì vậy bạn cho rằng mô hình tư duy (mental model) sẽ được chuyển đổi một cách trơn tru. Nhưng thực tế không phải vậy. Một thói quen từ JavaScript sẽ âm thầm làm giảm hiệu suất Python của bạn mà không gây ra lỗi, không ghi log và không hề xuất hiện trong các buổi review code nhanh chóng.
Cách JavaScript dạy bạn "Bắt đầu và Quên đi"
Trong JavaScript, một lời gọi hàm async là "eager" (chủ động thực hiện ngay). Ngay khi bạn gọi nó, engine sẽ tạo ra một Promise và công việc bắt đầu ngay lập tức. Event loop đã bắt đầu chạy ngay. Đó là lý do tại sao các lập trình viên JavaScript thường viết mã như thế này:
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
Cả hai yêu cầu mạng đều đang được thực hiện trước khi chạm tới lệnh await đầu tiên. Lệnh await thứ nhất sẽ tạm dừng hàm hiện tại cho đến khi fetchUser hoàn tất, nhưng fetchOrders đã chạy ngầm kể từ dòng trước đó. Vào thời điểm bạn cần biến orders, yêu cầu thứ hai có thể đã hoàn thành xong. Mô hình này cảm thấy quá tự nhiên trong JavaScript đến mức nhiều lập trình viên thậm chí không coi đó là một thủ thuật xử lý đồng thời (concurrency). Đó chỉ đơn giản là cách async hoạt động.
Sự bất ngờ của Python: Một Coroutine "lạnh lẽo"
Python sử dụng một cơ chế khác. Khi bạn gọi một hàm async def trong Python, bạn không bắt đầu bất kỳ công việc nào. Bạn chỉ nhận được một đối tượng coroutine. Hãy coi nó như một công thức nấu ăn được viết trên giấy. Các nguyên liệu đã được liệt kê, các bước thực hiện đã rõ ràng, nhưng chưa có gì được cho vào lò nướng cả. Cho đến khi có thứ gì đó đẩy coroutine đó qua event loop một cách rõ ràng, nó vẫn sẽ ở trạng thái trì trệ.
Đây chính là cái bẫy. Một kỹ sư JavaScript khi cần lấy thông tin người dùng và các đơn hàng của họ có thể viết như thế này trong Python:
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
Trông có vẻ đồng thời. Cảm giác như đang đồng thời. Nhưng thực tế nó hoàn toàn tuần tự.
Dòng đầu tiên gán một coroutine đang ngủ yên cho user_coro. Dòng thứ hai gán một coroutine đang ngủ yên khác cho orders_coro. Khi việc thực thi chạm đến await user_coro, Python cuối cùng mới bắt đầu tác vụ đầu tiên và chạy cho đến khi hoàn tất. Chỉ sau khi fetch_user kết thúc, trình thông dịch mới chạm đến await orders_coro và bắt đầu tác vụ thứ hai. Tổng thời gian thực thi của bạn là tổng của cả hai hoạt động I/O, chứ không phải là thời gian của hoạt động dài nhất. Bạn đã không chạy chúng song song. Bạn đã chạy chúng lần lượt từng cái một với thêm các bước trung gian.
Tại sao lỗi này lại vô hình
Đây là loại lỗi suy giảm hiệu suất (performance regression) có thể tồn tại trong nhiều tháng. Mã nguồn vẫn là Python hợp lệ. Nó vượt qua các trình kiểm tra kiểu (type checkers). Nó trả về kết quả chính xác. Nó chỉ chạy với tốc độ bằng một nửa, hoặc tệ hơn. Vì không có stack trace và không có cảnh báo, các đội ngũ kỹ thuật thường tìm kiếm ở mọi nơi khác trước. Họ thêm Redis cache, nâng cấp các tầng cơ sở dữ liệu, hoặc chuyển đổi vùng lưu trữ (hosting regions). Thủ phạm thực sự là sự sai lệch tinh vi trong kỳ vọng về việc await thực sự làm gì.
Ba cách để Python thực sự chạy các tác vụ đồng thời
Để khắc phục điều này, bạn phải yêu cầu event loop của Python lập lịch (schedule) công việc ngay lập tức. Bạn cần thứ gì đó chủ động hơn là một coroutine thuần túy. Bạn cần một Task.
1. asyncio.create_task
Cách chuyển đổi trực tiếp nhất từ mô hình JavaScript là bao bọc coroutine của bạn trong một Task. Một Task sẽ được lập lịch trên event loop ngay khi bạn tạo ra nó. Nó là thực thể tương đương nhất trong Python với một JavaScript Promise đang hoạt động.
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
Giờ đây, cả fetch_user và fetch_orders đều đang được thực hiện trước lệnh await đầu tiên. Khi bạn chạm đến await user_task, bạn chỉ tạm dừng cho đến khi Task cụ thể đó hoàn thành, nhưng Task còn lại vẫn tiếp tục chạy. Nếu fetch_orders hoàn thành trước, kết quả của nó sẽ đơn giản là đợi bên trong orders_task cho đến khi bạn yêu cầu nó.
Tuy nhiên, hãy cẩn thận. Nếu bạn tạo một Task mà không bao giờ await nó, Python sẽ báo lỗi về một task đang chờ (pending task) đã bị hủy. Bạn vẫn phải thu thập kết quả của mình.
2. asyncio.gather
Nếu bạn có nhiều coroutine cần hoàn thành trước khi tiếp tục, asyncio.gather sẽ xử lý các phần mã lặp lại (boilerplate) cho bạn. Nó sẽ lập lịch cho mỗi coroutine dưới dạng một Task ở bên trong và đợi chúng cùng lúc.
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
Cách này rất ngắn gọn và dễ đọc. Nó phát huy tác dụng tốt nhất khi các hoạt động độc lập với nhau và bạn muốn một dòng mã duy nhất thể hiện ý định: "chạy tất cả những thứ này, sau đó đưa cho tôi mọi kết quả." Nó cũng bảo toàn thứ tự của các đối số trong danh sách hoặc tuple được trả về, ngay cả khi các tác vụ bên dưới hoàn thành theo thứ tự khác nhau.
3. asyncio.TaskGroup
Python 3.11 đã giới thiệu TaskGroup, mang tính chất lập trình đồng thời có cấu trúc (structured concurrency) vào thư viện tiêu chuẩn. Thay vì tạo các task một cách thủ công, bạn sử dụng một context manager để đảm bảo mọi task được tạo ra đều kết thúc một cách chính xác. Nếu một task nảy sinh ngoại lệ (exception), các task khác sẽ tự động bị hủy.
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
Mô hình này rất tuyệt vời cho các quy trình làm việc (workflows) phức tạp. Nó loại bỏ rủi ro để lại các Task mồ côi (orphaning a Task), đồng thời nhóm vòng đời của các hoạt động liên quan dưới một phạm vi logic duy nhất. Nếu mã nguồn của bạn chạy trên Python 3.11 hoặc mới hơn, đây thường là kiến trúc sạch nhất cho mô hình fan-out concurrency.
Mô hình tư duy: await có nghĩa là "Chạy cái này ngay bây giờ"
Bài học cốt lõi nằm ở ngôn ngữ. Trong JavaScript, bạn có thể hiểu await là "trong khi đó". Bạn bắt đầu công việc, làm những việc khác, và chỉ tạm dừng khi bạn cần giá trị đó. Trong Python, await có nghĩa là "thúc đẩy coroutine này đến điểm tạm dừng tiếp theo hoặc cho đến khi hoàn tất". Nếu coroutine chưa được lập lịch (scheduled), await chính là thứ thực hiện việc lập lịch đó. Đó là lý do tại sao bạn không thể bắt đầu hai coroutine thô rồi sau đó mới await chúng. Bạn đã không đưa cho event loop bất cứ việc gì để làm trong khoảng thời gian chờ đó.
Hãy coi các coroutine trong Python giống như các generator function. Việc gọi generator không có nghĩa là thực hiện vòng lặp qua nó. Bạn cần phải lặp qua nó, gọi next(), hoặc chuyển nó cho một consumer. Async cũng hoạt động theo cách tương tự. asyncio.create_task chính là consumer nói rằng "hãy đưa cái này vào event loop ngay lập tức". Lệnh await sau đó chỉ đơn giản là chờ đợi tín hiệu hoàn tất.
Một thói quen cụ thể giúp ích cho bạn: bất cứ khi nào bạn gán một lời gọi hàm async vào một biến mà không có await, hãy tự hỏi liệu bạn đã lập lịch cho nó chưa. Nếu vế phải không được bao bọc trong create_task, gather, hoặc TaskGroup, thì nó chưa hề chạy. Nó chỉ giống như một công thức nấu ăn đang nằm trên bàn bếp mà thôi.
Bài học rút ra
Runtime async của Python rất mạnh mẽ, nhưng nó đòi hỏi ý định rõ ràng. Ngôn ngữ này không tự động bắt đầu các công việc chạy ngầm chỉ vì bạn vừa gọi một hàm. Nếu bạn chuyển từ JavaScript sang, hãy kiểm tra kỹ mọi nơi mà bạn lưu trữ một coroutine vào một biến rồi mới await nó sau đó. Trừ khi bạn đã nâng cấp nó lên thành một Task trước, nếu không, bạn chỉ đang viết mã tuần tự (sequential code) dưới lớp vỏ bọc async mà thôi. Hãy bắt đầu công việc với một Task, sau đó mới chờ đợi kết quả. Đó là cách bạn biến async của Python từ một nút thắt cổ chai thầm lặng thành một công cụ lập trình đồng thời thực thụ.
