23 điểm bởi darjeeling 2025-11-16 | 12 bình luận | Chia sẻ qua WhatsApp

Nguyên nhân làm chậm mã Async và cách khắc phục (tóm tắt kỹ thuật)

Video này trình bày các nguyên nhân phổ biến khiến mã asyncio trong Python chạy chậm hơn mã đồng bộ, cùng với các phương pháp kỹ thuật để khắc phục.

1. Các khái niệm cốt lõi của Asyncio

  • Vòng lặp sự kiện (Event Loop): Đây là trung tâm của mọi ứng dụng bất đồng bộ. Nó được khởi động bằng asyncio.run(), quản lý và lập lịch thực thi các tác vụ trên một luồng duy nhất.
  • Coroutine: Là các hàm bất đồng bộ được khai báo bằng async def. Khi gặp từ khóa await, chúng có thể tạm dừng việc thực thi và trả quyền điều khiển về cho event loop.
  • Task: Bao bọc coroutine và lập lịch để chúng chạy đồng thời trong event loop. Chúng được tạo thông qua asyncio.create_task().
  • Future: Là đối tượng cấp thấp biểu diễn kết quả cuối cùng của một tác vụ bất đồng bộ.

2. Ví dụ chuyển mã đồng bộ sang bất đồng bộ

Thay time.sleep() đồng bộ hiện có bằng await asyncio.sleep() bất đồng bộ, khai báo hàm bằng async def, và chạy coroutine chính bằng asyncio.run().


Những lỗi phổ biến gây suy giảm hiệu năng và cách khắc phục

Lỗi 1: Thực thi tuần tự (Sequential Execution)

Nếu await các tác vụ độc lập theo thứ tự thay vì chạy song song, tổng thời gian thực thi sẽ bằng tổng thời gian của tất cả các tác vụ.

  • Ví dụ sai (tuần tự):

    # Mỗi await sẽ chờ cho đến khi công việc trước đó kết thúc  
    await get_user_notifications()  
    await get_recent_activity()  
    await get_unread_messages()  
    
  • Cách khắc phục (song song): Dùng asyncio.gather hoặc asyncio.TaskGroup để chạy đồng thời các tác vụ độc lập. Tổng thời gian thực thi sẽ giảm xuống còn thời gian của tác vụ lâu nhất.

    # Ba công việc được bắt đầu cùng lúc  
    await asyncio.gather(  
        get_user_notifications(),  
        get_recent_activity(),  
        get_unread_messages()  
    )  
    

So sánh các công cụ thực thi song song

  • asyncio.gather:
    • Chạy nhiều coroutine cùng lúc.
    • Nhược điểm: xử lý lỗi còn hạn chế. Nếu một task phát sinh ngoại lệ, các task khác đang chạy sẽ bị hủy.
  • asyncio.create_task:
    • Cho phép kiểm soát và xử lý lỗi theo từng task.
    • Hữu ích cho chạy nền, nhưng bất tiện vì phải await từng task riêng lẻ.
  • asyncio.TaskGroup (Python 3.11+):
    • Là lựa chọn hiện đại cho “structured concurrency”.
    • Dùng cú pháp async with để quản lý nhóm task, và khi thoát khỏi ngữ cảnh thì bảo đảm mọi task đều đã hoàn tất hoặc ngoại lệ đã được xử lý.
    async with asyncio.TaskGroup() as tg:  
        tg.create_task(some_coro_1())  
        tg.create_task(some_coro_2())  
    # Khi khối 'async with' kết thúc, mọi task đều đã được await  
    

Lỗi 2: Dùng thư viện đồng bộ

Nếu dùng các thư viện đồng bộ (blocking) như requests hay pathlib bên trong mã asyncio, toàn bộ event loop sẽ bị chặn. Dù đặt chúng trong asyncio.gather, thực tế chúng vẫn hoạt động theo kiểu tuần tự.

  • Cách khắc phục: Cần dùng các thư viện chuyên dụng hỗ trợ bất đồng bộ (non-blocking) như aiohttp (thay cho requests), aiofiles (thay cho files/pathlib).

Lỗi 3: Chặn event loop bằng tác vụ CPU-bound

asyncio chạy trên một luồng duy nhất, các tác vụ tính toán nặng (CPU-bound) sẽ làm dừng event loop và khiến các tác vụ I/O khác bị trì hoãn.

  • Cách khắc phục: Dùng loop.run_in_executor() để offload tác vụ CPU-bound sang một thread pool riêng (mặc định) hoặc process pool.
    loop = asyncio.get_running_loop()  
    # Chạy hàm tiêu tốn CPU trên một luồng riêng  
    await loop.run_in_executor(  
        None,  # dùng thread pool mặc định  
        cpu_bound_function,  
        arg1  
    )  
    

Lỗi 4: Bị chặn bởi các tác vụ không quan trọng

Nếu await các tác vụ không cốt lõi như logging, vốn không liên quan đến phản hồi cho người dùng, thời gian phản hồi sẽ bị kéo dài không cần thiết.

  • Cách khắc phục: Dùng asyncio.create_task() để tách chúng thành background task và không await.
    user_profile = await get_user_profile()  
    # Chạy logging ở nền mà không await  
    asyncio.create_task(send_logs_to_external_service())  
    return user_profile  
    

Lỗi 5: Tạo quá nhiều task

Nếu biến một lượng lớn công việc rất nhỏ thành task, chi phí overhead do context switching có thể làm giảm hiệu năng.

  • Cách khắc phục 1: Gom các công việc nhỏ lại (batching) thành vài task lớn hơn.
  • Cách khắc phục 2: Dùng asyncio.Semaphore để giới hạn số task tối đa chạy đồng thời.
    # Chỉ cho phép tối đa 10 công việc chạy cùng lúc  
    semaphore = asyncio.Semaphore(10)  
    
    async with semaphore:  
        await fetch_data()  
    

Các lỗi khác

  • Coroutine “Never Awaited”: Gọi coroutine nhưng không await, khiến công việc thậm chí không được chạy và thất bại một cách âm thầm. Có thể phát hiện bằng các linter như flake8-async.
  • Quản lý tài nguyên không đúng cách: Dùng file, kết nối DB... mà không có try...finally có thể gây rò rỉ tài nguyên. Có thể khắc phục bằng async context manager với async with.

Debug và lựa chọn mô hình đồng thời

Chế độ debug của Asyncio

Khi bật chế độ debug vốn bị tắt mặc định (asyncio.run(debug=True)), bạn sẽ dễ phát hiện các vấn đề sau hơn.

  • Coroutine chưa được await (RuntimeWarning).
  • API bất đồng bộ bị gọi từ sai luồng.
  • Callback có thời gian chạy vượt quá 100ms.
  • Các thao tác selector I/O chậm.

Các công cụ debug khác

  • Scalene: Trình profiler CPU và bộ nhớ.
  • aio-monitor: Công cụ giám sát và CLI cho ứng dụng asyncio.
  • pdb: Trình gỡ lỗi mặc định của Python.
  • py-stack: In stack trace của tiến trình Python đang chạy để phát hiện điểm blocking.

Hướng dẫn chọn mô hình đồng thời

  • Asyncio (một luồng): Phù hợp nhất cho số lượng lớn tác vụ I/O-bound có độ trễ cao, chẳng hạn như request mạng hoặc file I/O.
  • Threads (đa luồng): Dùng cho các tác vụ I/O-bound cần truy cập dữ liệu dùng chung. Do GIL (Global Interpreter Lock), đây không phải là xử lý song song thực sự, nhưng trong lúc chờ I/O thì luồng khác vẫn có thể chạy.
  • Processes (đa tiến trình): Dùng cho các tác vụ CPU-bound như xử lý ảnh hoặc tính toán nặng. Cách này tận dụng được nhiều lõi CPU để đạt song song thực sự, nhưng chi phí bộ nhớ và giao tiếp cao.

https://youtu.be/wGDOwNW6lVk

12 bình luận

 
savvykang 2025-11-18

Python đúng là một ngôn ngữ tuyệt vời, nhưng có vẻ như giao diện bất đồng bộ là một tính năng được thiết kế chưa tốt.

 
ceruns 2025-11-17

Ở mục 4 bị thiếu eager_start=True. Vì create_task tạo weakref, nên đoạn code đó sẽ trở thành một task có thể không bao giờ được thực thi....

 
tested 2025-11-17

https://rosettalens.com/s/ko/python-to-node

Nghe nói người này cũng đã chuyển sang Node.js vì Python async

 
kandk 2025-11-17

Kết luận: giao diện bất đồng bộ của Python vẫn chưa trực quan.

 
bungker 2025-11-17

Thực ra, nếu dự án của bạn đã đến mức phải tối ưu hóa lập trình bất đồng bộ trong Python, thì viết bằng ngôn ngữ khác sẽ cho hiệu năng và độ ổn định tốt hơn rất nhiều.

 
euphcat 2025-11-17

Nếu không chuyển sang ngôn ngữ biên dịch, thì hiệu năng có chênh lệch nhiều không? Nếu là đa luồng thì do sự tồn tại của GIL nên chắc sẽ có khác biệt lớn, nhưng vì đây dù sao cũng là cấu trúc bất đồng bộ vận hành bằng event loop, nên tôi tò mò không biết sẽ phát sinh những khác biệt nào tùy theo ngôn ngữ.

 
vwjdalsgkv 2025-11-17

Việc có hay không có JIT compile ảnh hưởng lớn hơn tôi nghĩ. V8 được tối ưu hóa rất tốt.

 
euphcat 2025-11-16

Tôi chưa kiểm tra video nguồn, nhưng đoạn mã giải pháp cho lỗi số 4 là sai.

Phiên bản task do create_task() trả về phải được gán cho ít nhất một biến, và biến đó phải còn tồn tại cho đến khi task kết thúc. Nếu không, sẽ có nguy cơ phiên bản task bị garbage collection trong khi coroutine vẫn đang chạy.

Nếu hàm tạo task như trên sắp kết thúc ngay, thì nên dùng các cách như trả về phiên bản task, gán vào biến toàn cục, hoặc gán vào biến thực thể.

P.S)
Ngay cả khi không thực sự cần giá trị trả về và bạn tin chắc coroutine sẽ kết thúc trong thời gian ngắn, thì với phiên bản task, tốt hơn là vẫn nên thiết kế để đến lúc nào đó có await cho nó. Nếu không thích cách đó, thì ít nhất hãy áp dụng xử lý ngoại lệ thật chặt cho từng coroutine chạy dưới dạng task để có cấu trúc ghi log đầy đủ, không bỏ sót. Nếu không làm vậy, sẽ có trường hợp Exception không được xử lý và bị fail một cách im lặng dù task có gây ra sự cố lớn đến đâu.

Trong một dự án mà tôi kiếm sống bằng việc phát triển/vận hành, tôi từng thiết kế một mẫu trong đó hàng chục module mỗi cái tự tạo một task kiểu như while self.ok(): cmd = await self.cmd_queue.get(); await self.process(cmd); rồi cứ thế chạy liên tục. Trước khi thiết lập được mẫu xử lý ngoại lệ, mỗi lần có một vấn đề nổ ra là tôi lại có một trải nghiệm hiếm có: tinh thần của tôi cũng nổ tung theo luôn, ha.

 
kunggom 2025-11-16

Ngay cả từ góc nhìn của một người đang làm ở công ty sử dụng C# — có thể xem là ông tổ(?) của pattern Async/Await — tôi vẫn khá thường thấy kiểu code sai như lỗi số 1, tức là chỉ đơn giản xếp await nối tiếp nhau theo thứ tự.

Nhìn những đoạn code như vậy thì tôi thường có cảm giác rằng điểm chung là người viết chỉ biết rằng trước lời gọi phương thức async thì phải dùng từ khóa await, còn không suy nghĩ nhiều hơn về thứ tự thực thi bất đồng bộ, nên mới sinh ra kiểu code này.
Khi có nhiều await, có cái thì kết quả được dùng ngay bên dưới nên nhận giá trị kết quả await từ đối tượng Task<T> ở ngay trước đó; còn có cái thì phải khá lâu phía sau mới dùng đến, nên chỉ nhận Task<T> trước rồi await sau. Việc viết code theo cách cân nhắc luồng bất đồng bộ như vậy rõ ràng là công việc đòi hỏi phải động não tương ứng.

Ít nhất thì trong các phương thức được khai báo là bất đồng bộ, tôi vẫn đang viết code theo hướng cân nhắc luồng xử lý như thế này. Nhưng đôi khi khi nhìn vào code của người đã nghỉ việc mà mình đang phải bảo trì, tôi cũng có cảm giác kiểu như: “Tôi chỉ muốn viết code đồng bộ một cách đơn giản thôi, nhưng vì phương thức cần dùng ở giữa chỉ có loại bất đồng bộ, nên cứ viết đại như thế này vậy.”

 
skageektp 2025-11-17

Nếu mục 1 luôn độc lập thì làm như vậy đúng là tốt,
nhưng nếu sau khi sửa code mà nó không còn độc lập nữa thì có vẻ cũng có sự bất tiện là phải rà soát và sửa toàn bộ những chỗ đang dùng hàm đó.
Nếu là tác vụ không mất quá nhiều thời gian thì có khi await tuần tự sẽ tốt hơn về mặt quản lý code

 
euphcat 2025-11-17

Có vẻ nên tiếp cận theo khái niệm rằng “vì multithreading có gánh nặng về overhead, nên như một phương án thay thế, ta chia nhỏ single thread để giải quyết xử lý song song”. Vì vậy, về cơ bản, có lẽ đúng là trong một số trường hợp còn phải chú ý hơn cả multithreading.

 
kunggom 2025-11-17

Đúng là như vậy.
Có vẻ như mã bất đồng bộ đúng nghĩa về bản chất là loại mã buộc phải chú ý rất nhiều mới có thể viết cho chuẩn.