3 điểm bởi GN⁺ 2024-05-09 | 1 bình luận | Chia sẻ qua WhatsApp
  • asyncio của Python là công cụ để xử lý các chương trình I/O-bound, và bài viết tái tạo lại nó bằng một cài đặt đơn giản dựa trên generator để cho thấy nguyên lý hoạt động
  • Cốt lõi là một vòng lặp sự kiện với nhiều tác vụ; nó chạy các tác vụ, nhận lại quyền điều khiển tại yield, rồi chuyển sang tác vụ tiếp theo
  • sleep có thể được tạo thành một sub-generator liên tục yield cho đến khi thời gian trôi qua, còn yield from cho phép quay lại tác vụ gốc sau khi generator con kết thúc
  • Khi chuyển sang async/await, Task.__await__() sẽ chuyển quyền điều khiển cho vòng lặp sự kiện cho đến khi hoàn tất, còn create_taskrun tạo ra hình thức tương tự API asyncio thực tế
  • Nếu đổi lời gọi jacobio tùy biến sang asyncio thì mã sẽ trở thành mã dùng gói thực tế, nhưng asyncio thật phức tạp hơn ví dụ rất nhiều và luồng nội bộ cũng có phần khác biệt

Cấu trúc cơ bản của asyncio nhìn qua generator

  • asyncio được dùng trong Python để xử lý chương trình I/O-bound, và bài viết tái dựng đơn giản nó trên nền generator
  • Giống như range trong Python 3+, generator không lưu toàn bộ chuỗi trong bộ nhớ mà tạo từng giá trị khi cần
    • Nếu tạo range(100_000_000) như một danh sách thì phải lưu 100 triệu phần tử, rất kém hiệu quả về bộ nhớ
    • Generator tạo giá trị mỗi khi cần nên không giữ toàn bộ chuỗi
  • Hàm generator được định nghĩa giống hàm thông thường nhưng dùng yield
    • Khi gọi hàm, thân hàm không chạy ngay mà trả về một đối tượng generator
    • Gọi next(iterator) sẽ chạy đến yield tiếp theo
    • Khi không còn yield, ngoại lệ StopIteration sẽ được phát sinh
  • yield from cho phép generator gọi sub-generator hoặc một đối tượng iterable để tạo thành chuỗi generator
  • Điểm quan trọng ở đây là có thể tạm dừng thực thi hàm rồi tiếp tục lại trong khi vẫn giữ nguyên trạng thái

Đơn giản hóa vòng lặp sự kiện thành một danh sách

  • Vòng lặp sự kiện là cốt lõi của asyncio, chịu trách nhiệm chạy và quản lý các tác vụ hiện tại
  • Vòng lặp sự kiện asyncio thực tế được viết bằng C, nhưng trong mô hình đơn giản có thể xem nó như một danh sách chứa các tác vụ hiện tại
  • Ở ví dụ ban đầu, các tác vụ được xem là đối tượng generator
    • Bộ quản lý vòng lặp sự kiện duyệt qua danh sách tác vụ
    • Gọi next(task) trên từng tác vụ để thực thi
    • Tác vụ sẽ tạm dừng bằng yield tại thời điểm cần chờ như một tác vụ I/O-bound, và trả quyền điều khiển lại cho vòng lặp sự kiện
  • Trong ví dụ task1()task2() mỗi hàm đều in ra rồi yield, đầu ra sẽ xuất hiện xen kẽ
    • Hai hàm đều có vòng lặp while True nên việc thực thi tiếp tục mãi
    • Kết quả lặp lại theo kiểu Task 1, Task 2, Task 1, Task 2

Tạo sleep bằng yield from

  • sleep(seconds) được cài đặt như một generator ghi lại thời điểm bắt đầu rồi liên tục yield cho đến khi đủ thời gian chỉ định
  • Hàm tác vụ sẽ ủy quyền thực thi cho sub-generator sleep như yield from sleep(1) hoặc yield from sleep(5)
    • Trong lúc sleep tiếp tục yield, tác vụ sẽ ở trạng thái tạm dừng
    • Khi đủ thời gian, vòng lặp while trong sleep kết thúc
    • Vì không còn yield, StopIteration xảy ra và yield from sẽ tiếp tục sang dòng kế tiếp của hàm tác vụ
  • Trong ví dụ, task1 in mỗi 1 giây còn task2 in mỗi 5 giây
    • Kết quả có dạng sau Task 1, Task 2 thì Task 1 xuất hiện nhiều lần rồi mới đến Task 2 lần nữa

Chuyển từ yield sang await

  • Để dùng await, đối tượng đích phải có phương thức __await__ hoặc là coroutine
  • Trong asyncio, thông thường người ta xử lý đối tượng Task bằng các hàm như asyncio.create_task
    • Đối tượng Task kế thừa từ đối tượng Future của asyncio
    • Đối tượng Future có phương thức __await__
  • Khi gọi một hàm có từ khóa async, một đối tượng coroutine sẽ được tạo ra
    • Coroutine, giống hàm generator, có thể tạm dừng rồi tiếp tục thực thi
  • Có thể xem await như yield from nhưng có thêm các quy tắc kiểm tra
    • await object sẽ yield từ __await__ của thực thể đối tượng, hoặc chờ một coroutine khác
  • Trong mã nguồn Future của asyncio cũng có thể thấy rằng khi Future hoặc Task chưa hoàn tất, __await__ về cơ bản sẽ gọi yield

Cài đặt đơn giản Task, create_task, run

  • Cài đặt tùy biến dùng Queue thay cho danh sách làm vòng lặp sự kiện
    • Mục tiêu là xử lý việc thêm và xóa tác vụ khỏi vòng lặp trong thời gian hằng số
  • Lớp Task lưu đối tượng generator và trạng thái hoàn tất
    • self.iter lưu đối tượng generator
    • self.finished khởi đầu là False
    • Nếu generator phát sinh StopIteration thì được xem là đã hoàn tất
    • done() trả về trạng thái hoàn tất
  • Task.__await__() sẽ liên tục gọi yield self cho đến khi tác vụ kết thúc
    • Hành vi này trả quyền điều khiển về cho vòng lặp sự kiện
  • create_task(generator) bọc generator trong Task, đưa nó vào hàng đợi vòng lặp sự kiện rồi trả về
    • Nó đóng vai trò lập lịch tác vụ vào vòng lặp sự kiện
  • run(main) có hình thức tương tự asyncio.run() thực tế để khởi động vòng lặp sự kiện
    • main nhận ban đầu được bọc thành Task rồi đưa vào hàng đợi
    • Khi hàng đợi chưa rỗng, tác vụ tiếp theo sẽ được lấy ra
    • task.iter.send(None) được dùng để đẩy tác vụ tiến lên
    • Nếu phát sinh StopIteration, đặt task.finished = True
    • Nếu không có ngoại lệ, tác vụ được đưa lại vào hàng đợi vòng lặp sự kiện
  • Việc dùng task.iter.send(None) thay vì next(task.iter) là do đặc tính khi làm việc với từ khóa async/await, dù ở đây vai trò là như nhau

Ví dụ sleep tương thích async và jacobio

  • sleep ban đầu là hàm generator, nhưng await không thể kết hợp trực tiếp với hàm generator
    • Đối tượng của await phải là đối tượng có __await__ hoặc một hàm coroutine
  • Logic chờ thực tế được chuyển sang generator _sleep(seconds)
    • _sleep sẽ yield cho đến khi đủ thời gian
  • async def sleep(seconds) tạo một tác vụ từ _sleep(seconds) rồi await tác vụ đó
    • await task sẽ gọi Task.__await__()
    • Nếu tác vụ chưa xong, nó sẽ chuyển quyền điều khiển cho vòng lặp sự kiện bằng yield
  • Tệp tùy biến hoàn chỉnh jacobio.py bao gồm các thành phần sau
    • hàng đợi vòng lặp sự kiện
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • Trong ví dụ sử dụng, yield from trước đó được đổi sang await, và các hàm dùng await được thêm async
    • task1 in ra hai lần và mỗi lần chờ jacobio.sleep(1)
    • task2 in ra ba lần và mỗi lần chờ jacobio.sleep(0)
    • main tạo hai tác vụ, await cả hai rồi in done
  • Kết quả ví dụ theo thứ tự là Task 1, Task 2, Task 2, Task 2, Task 1, done

Chuyển sang asyncio thực tế

  • Nếu thay toàn bộ jacobio trong ví dụ tùy biến bằng asyncio, mã sẽ trở thành mã dùng gói asyncio thực tế
  • Các hàm tương ứng như sau
    • jacobio.sleep()asyncio.sleep()
    • jacobio.create_task()asyncio.create_task()
    • jacobio.run()asyncio.run()
  • asyncio thực tế làm nhiều việc hơn rất nhiều ở bên trong
  • Bộ quản lý vòng lặp sự kiện này được làm đơn giản tối đa để cho thấy ý tưởng cốt lõi của asyncio, nhưng do quy mô và độ phức tạp của gói thực tế nên luồng mã nguồn thật có phần khác
  • Khi dùng asyncio thực tế, thay vì tạo từng tác vụ rồi await cả hai riêng lẻ, có thể dùng các hàm như asyncio.gather() để xử lý nhiều tác vụ
  • Bài liên quan được dẫn là handling asyncio tasks like a pro

1 bình luận

 
GN⁺ 2024-05-09
Ý kiến trên Hacker News
  • Asyncio có thể được thay thế bằng một triển khai tự tạo của event loop
    Trong Temporal Python, workflow được biểu diễn bằng một event loop asyncio bền vững tùy chỉnh, nên cả những thứ như asyncio.sleep cũng trở thành timer bền vững. Nghĩa là mã có thể được tiếp tục trên một máy khác, nên thậm chí có thể sleep trong nhiều tuần
    Cách triển khai được giải thích trong bài này: https://temporal.io/blog/durable-distributed-asyncio-event-l...
    Vấn đề lớn nhất của asyncio là trong Python, bạn rất dễ làm treo toàn bộ hệ thống bằng cách chặn thread asyncio bằng một lời gọi đồng bộ. Python rất cần một công cụ phân tích tĩnh có thể xây dựng đồ thị lời gọi để phát hiện xem trong async def có lời gọi chặn thread nào được gọi trực tiếp hoặc gián tiếp hay không

    • Có thể đây là một ý tưởng tệ, nhưng tôi tự hỏi liệu có nên đảo ngược cách tiếp cận hiện tại, thay vì để mọi lời gọi mặc định là đồng bộ
      Các thao tác cơ bản như phép toán số học hay truy cập cấu trúc dữ liệu có thể được đưa vào danh sách cho phép dưới dạng lời gọi đồng bộ nhanh, và nếu event loop quay nhanh một cách đáng ngờ thì có lẽ cũng có thể phát hiện những tác vụ khác lẽ ra phải là đồng bộ
    • Temporal thật sự rất ngầu
      Khi biết họ đã triển khai event loop asyncio như thế nào, đó thực sự là một khoảnh khắc bừng sáng với tôi
    • Tôi thắc mắc vì sao lại không dùng thread
      Tôi vẫn đang cố hiểu liệu Python có thực sự là ngôn ngữ phù hợp cho đồng thời hay không. Asyncio lúc nào cũng cho tôi cảm giác chỉ vừa đủ hoạt động, còn theo cảm nhận của tôi thì triển khai bất đồng bộ của C# gọn gàng hơn
    • Trong ví dụ workflow, Purchaser.purchase chẳng phải nên là do_purchase sao?
    • Khó mà bắt được chuyện đó bằng cách tĩnh được
      Ngay cả một read() đơn giản cũng có thể chặn hoặc không, tùy descriptor là gì và nó được cấu hình ra sao. Làm sao bạn phát hiện chuyện đó bằng phân tích tĩnh?
  • Triển khai này có vẻ đang busy-wait khi sleep. Tức là ngay cả khi hiện tại không có tác vụ nào có thể chạy, event loop vẫn tiếp tục quay
    Tôi nhớ từng thấy một triển khai đồ chơi khác, ở đó họ theo dõi thời điểm chạy tiếp theo của tác vụ theo thứ tự sắp xếp, rồi nếu không có tác vụ nào chạy được ở thời điểm hiện tại thì cho chính event loop sleep. Theo tôi hiểu thì asyncio thực tế cũng hoạt động theo kiểu này
    Sau đó nó được mở rộng để điều kiện chạy tiếp theo không chỉ là thời gian đồng hồ tường đơn giản mà còn có thể phụ thuộc vào những thứ như socket, để có thể dùng select với thời hạn chờ

    • Ngay trong asyncio cũng có thể dùng triển khai event loop tùy chỉnh thay vì bản mặc định
      Một triển khai nổi tiếng là uvloop(https://github.com/MagicStack/uvloop), về cơ bản dùng libuv để triển khai loop, và libuv xử lý các tác vụ kiểu select như đã nói
    • Nghe khá giống SimPy [1] / Simpy.io [2], nhưng SimPy đã có trước asyncio nhiều năm
      [1] https://simpy.readthedocs.io/en/latest/
      [2] https://gitlab.com/team-simpy/simpy.io
    • Ở một góc nhìn nào đó thì cách này chẳng có vấn đề gì
      Event loop đâu nhất thiết phải lặp mãi; nó có thể bắt đầu bằng cách chạy main, rồi khi main kết thúc thì nó cũng kết thúc theo. Hãy hình dung việc khởi động một server có vòng lặp while true để chờ socket, rồi khi có điều kiện dừng hoặc ngắt thì server kết thúc và chương trình cũng thoát
      Từ góc nhìn của event loop thì không có busy-wait, cũng không cần đụng tới sleep hay socket. Đây là khác biệt giữa việc chạy cho tới khi hoàn tất và chạy mãi mãi
      Nếu làm một loop đồ chơi, tôi nghĩ tốt nhất là đừng cố xử lý trường hợp chạy vĩnh viễn
    • Cuốn sách nào là tốt nhất để học những nội dung kiểu này?
  • Bài nói về asyncio của David Beazley rất xuất sắc
    Tôi đã dùng nó làm nền tảng để tạo ra một công cụ mô phỏng sự kiện rời rạc. Việc có thể tự triển khai asyncio và thay đồng hồ hệ thống bằng thời gian mô phỏng thật sự khá hay

  • Đây thực sự là một bài viết rất hay, một giải thích ở mức cao đã lược qua tốt những phần dễ làm người mới chán
    Sẽ còn tốt hơn nữa nếu ở cuối bài có thêm tài liệu khác giải thích cách mọi thứ thực sự vận hành bên trong

  • Tôi nghĩ bài sẽ hay hơn nếu trình bày cùng nội dung mà hoàn toàn không dùng yield. Thành thật mà nói, đó mới là phần thực sự có cảm giác như phép màu
    Một giải thích sâu hơn nhiều về coroutine trong Python có ở đây: https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro...

  • Không hề nhắc tới poll() sao? Nếu vậy thì nó hoàn toàn khác với cách asyncio hoạt động

  • Việc Python chặn def để dùng nó tạo ra một đối tượng, thay vì thực sự là một hàm, thật sự rất kỳ quặc
    Ít nhất họ cũng có thể tạo một từ khóa khác

    • Nó vẫn là một hàm, chỉ là được biến đổi thành một hàm có kiểu trả về khác
      Theo dạng trả về generator hoặc coroutine, và có thể xem kiểu chữ ký ở đây [1]. Ngay cả khi không có cú pháp đường mật ở mức ngôn ngữ, bạn vẫn có thể làm điều tương tự khá dễ trong Python thuần bằng decorator chẳng hạn
      [1] https://docs.python.org/3/library/typing.html#typing.Generat...
      Dù vậy, kiểu chữ ký này chỉ được nhắc tới vì mục đích học thuật; trong chú thích kiểu thực tế, người ta thường dùng IterableAwaitable đơn giản hơn
    • Vấn đề thực sự ở đây là gì? Hay đây chỉ là câu chuyện về tính thuần khiết của ngôn ngữ?
    • Tôi không nghĩ đây là chuyện bi kịch gì cả
      Nghe như đang đặt ngữ nghĩa của trải nghiệm lập trình viên lên trước mấy thứ như tính thuần khiết của ngôn ngữ. Tôi chưa từng thấy rối vì async function là hàm bất đồng bộ
    • Vấn đề lớn hơn của Python là ngay từ đầu đã phải dùng def cho hàm, nên không thể tạo hàm nội tuyến như arrow function của JS hay lambda của C++
      Đây không phải vấn đề về tính thuần khiết ngôn ngữ mà đơn giản là bất tiện
    • Ý bạn là decorator à? Trong ngữ cảnh Python thì khá khó hiểu bạn đang muốn nói gì