Nguyên lý hoạt động của Python asyncio: tái dựng từ đầu
(jacobpadilla.com)- 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 sleepcó thể được tạo thành một sub-generator liên tụcyieldcho đến khi thời gian trôi qua, cònyield fromcho 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òncreate_taskvàruntạo ra hình thức tương tự API asyncio thực tế - Nếu đổi lời gọi
jacobiotùy biến sangasynciothì 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ư
rangetrong 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
- Nếu tạo
- 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 đếnyieldtiếp theo - Khi không còn
yield, ngoại lệStopIterationsẽ được phát sinh
yield fromcho 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
yieldtạ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()vàtask2()mỗi hàm đều in ra rồiyield, đầu ra sẽ xuất hiện xen kẽ- Hai hàm đều có vòng lặp
while Truenê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
- Hai hàm đều có vòng lặp
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ụcyieldcho đế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ặcyield from sleep(5)- Trong lúc
sleeptiếp tụcyield, tác vụ sẽ ở trạng thái tạm dừng - Khi đủ thời gian, vòng lặp
whiletrongsleepkết thúc - Vì không còn
yield,StopIterationxảy ra vàyield fromsẽ tiếp tục sang dòng kế tiếp của hàm tác vụ
- Trong lúc
- Trong ví dụ,
task1in mỗi 1 giây còntask2in mỗi 5 giây- Kết quả có dạng sau
Task 1,Task 2thìTask 1xuất hiện nhiều lần rồi mới đếnTask 2lần nữa
- Kết quả có dạng sau
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
Taskbằng các hàm nhưasyncio.create_task- Đối tượng
Taskkế thừa từ đối tượngFuturecủa asyncio - Đối tượng
Futurecó phương thức__await__
- Đối tượng
- 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
awaitnhưyield fromnhưng có thêm các quy tắc kiểm traawait objectsẽyieldtừ__await__của thực thể đối tượng, hoặc chờ một coroutine khác
- Trong mã nguồn
Futurecủa asyncio cũng có thể thấy rằng khiFuturehoặcTaskchưa hoàn tất,__await__về cơ bản sẽ gọiyield
Cài đặt đơn giản Task, create_task, run
- Cài đặt tùy biến dùng
Queuethay 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
Tasklưu đối tượng generator và trạng thái hoàn tấtself.iterlưu đối tượng generatorself.finishedkhởi đầu làFalse- Nếu generator phát sinh
StopIterationthì đượ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ọiyield selfcho đế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 trongTask, đư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ệnmainnhận ban đầu được bọc thànhTaskrồ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, đặttask.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óaasync/await, dù ở đây vai trò là như nhau
Ví dụ sleep tương thích async và jacobio
sleepban đầu là hàm generator, nhưngawaitkhông thể kết hợp trực tiếp với hàm generator- Đối tượng của
awaitphải là đối tượng có__await__hoặc một hàm coroutine
- Đối tượng của
- Logic chờ thực tế được chuyển sang generator
_sleep(seconds)_sleepsẽyieldcho đến khi đủ thời gian
async def sleep(seconds)tạo một tác vụ từ_sleep(seconds)rồiawaittác vụ đóawait tasksẽ gọiTask.__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.pybao gồm các thành phần sau- hàng đợi vòng lặp sự kiện
_sleepasync sleepTaskcreate_taskrun
- Trong ví dụ sử dụng,
yield fromtrước đó được đổi sangawait, và các hàm dùngawaitđược thêmasynctask1in ra hai lần và mỗi lần chờjacobio.sleep(1)task2in ra ba lần và mỗi lần chờjacobio.sleep(0)maintạo hai tác vụ,awaitcả hai rồi indone
- 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ộ
jacobiotrong ví dụ tùy biến bằngasyncio, 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
awaitcả 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
Ý 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.sleepcũ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ầnCá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 defcó lời gọi chặn thread nào được gọi trực tiếp hoặc gián tiếp hay khôngCá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ộ
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 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
Purchaser.purchasechẳng phải nên làdo_purchasesao?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
selectvới thời hạn chờ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
selectnhư đã nói[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io
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 khimainkế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ặpwhile 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átTừ 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
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àuMộ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 độngViệ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
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
IterablevàAwaitableđơn giản hơnNghe 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ì
asyncfunction là hàm bất đồng bộdefcho 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