หลักการทำงานของ Python asyncio: สร้างใหม่ตั้งแต่ต้น
(jacobpadilla.com)- asyncio ของ Python เป็นเครื่องมือสำหรับจัดการโปรแกรมแบบ I/O-bound และบทความนี้นำมาสร้างใหม่เป็นการใช้งานแบบง่ายบน generator เพื่อแสดงหลักการทำงาน
- หัวใจสำคัญคือโครงสร้างที่ event loop ซึ่งมีหลายงานจะรันงาน แล้วรับสิทธิ์ควบคุมกลับมาที่
yieldจากนั้นจึงย้ายไปงานถัดไป sleepสามารถสร้างเป็น sub-generator ที่yieldไปเรื่อย ๆ จนกว่าเวลาจะผ่านไป และyield fromจะทำให้งานเดิมทำต่อหลังจาก generator ย่อยจบลง- เมื่อเปลี่ยนเป็น
async/awaitแล้วTask.__await__()จะส่งสิทธิ์ควบคุมกลับไปยัง event loop จนกว่างานจะเสร็จ ส่วนcreate_taskและrunจะสร้างรูปแบบที่คล้ายกับ API ของ asyncio จริง - หากเปลี่ยนการเรียก
jacobioแบบกำหนดเองเป็นasyncioก็จะกลายเป็นโค้ดที่ใช้แพ็กเกจจริง แต่ asyncio จริงซับซ้อนกว่าตัวอย่างมาก และลำดับการทำงานภายในก็ต่างกันบางส่วน
โครงสร้างพื้นฐานของ asyncio เมื่อมองผ่าน generator
- asyncio ใช้ใน Python เพื่อจัดการ โปรแกรมแบบ I/O-bound และบทความนี้นำมาสร้างใหม่แบบย่อโดยอิงกับ generator
- generator เช่น
rangeใน Python 3+ จะไม่เก็บทั้ง sequence ไว้ในหน่วยความจำ แต่สร้างค่าที่ต้องใช้ทีละค่า- หากสร้าง
range(100_000_000)ให้เหมือน list จะต้องเก็บองค์ประกอบ 100 ล้านตัว จึงใช้หน่วยความจำไม่มีประสิทธิภาพมาก - generator จะสร้างค่าเมื่อจำเป็นเท่านั้น จึงไม่ต้องเก็บ sequence ทั้งหมดไว้
- หากสร้าง
- ฟังก์ชัน generator นิยามเหมือนฟังก์ชันทั่วไป แต่ใช้
yield- เมื่อเรียกฟังก์ชัน จะไม่รันเนื้อหาทันที แต่คืนค่าเป็น อ็อบเจ็กต์ generator
- เมื่อเรียก
next(iterator)จะรันไปจนถึงyieldถัดไป - หากไม่มี
yieldเหลืออยู่ จะเกิด exceptionStopIteration
yield fromทำให้ generator เรียก sub-generator หรืออ็อบเจ็กต์ iterable ได้ จึงสร้าง chain ของ generator ได้- คุณสมบัติสำคัญในที่นี้คือสามารถหยุดการรันของฟังก์ชันไว้ แล้วเริ่มต่อภายหลังพร้อมคงสถานะเดิมได้
ทำ event loop ให้เรียบง่ายเป็น list
- event loop คือหัวใจของ asyncio ที่รันและจัดการงานปัจจุบัน
- event loop ของ asyncio จริงเขียนด้วย C แต่ในโมเดลแบบง่าย สามารถมองเป็น list ที่เก็บงานปัจจุบันได้
- ในตัวอย่างเริ่มต้น งานถูกมองเป็นอ็อบเจ็กต์ generator
- ตัวจัดการ event loop จะวนผ่าน list งาน
- เรียก
next(task)กับแต่ละงานเพื่อรัน - งานจะหยุดด้วย
yieldณ จุดที่ต้องรอเหมือนงานแบบ I/O-bound แล้วคืนสิทธิ์ควบคุมให้ event loop
- ในตัวอย่างที่
task1()และtask2()ต่างพิมพ์ข้อความแล้วyieldผลลัพธ์จะแสดงสลับกัน- เนื่องจากทั้งสองฟังก์ชันมีลูป
while Trueการรันจึงดำเนินต่อไปเรื่อย ๆ - เอาต์พุตจะวนซ้ำแบบ
Task 1,Task 2,Task 1,Task 2
- เนื่องจากทั้งสองฟังก์ชันมีลูป
สร้าง sleep ด้วย yield from
sleep(seconds)ถูก implement เป็น generator ที่บันทึกเวลาเริ่มต้น แล้วyieldต่อไปเรื่อย ๆ จนกว่าจะครบเวลาที่กำหนด- ฟังก์ชันงานจะมอบหมายการรันให้ sleep sub-generator เช่น
yield from sleep(1)หรือyield from sleep(5)- ระหว่างที่
sleepยังyieldอยู่ งานจะอยู่ในสถานะหยุดชั่วคราว - เมื่อเวลาผ่านไปเพียงพอ ลูป
whileของsleepจะจบลง - เมื่อไม่มี
yieldเหลืออยู่ จะเกิดStopIterationและyield fromจะทำต่อที่บรรทัดถัดไปของฟังก์ชันงาน
- ระหว่างที่
- ในตัวอย่าง
task1จะพิมพ์ทุก 1 วินาที และtask2จะพิมพ์ทุก 5 วินาที- เอาต์พุตจะเป็นรูปแบบที่หลังจาก
Task 1,Task 2แล้วมีTask 1ออกมาหลายครั้ง ก่อนจะมีTask 2อีกครั้ง
- เอาต์พุตจะเป็นรูปแบบที่หลังจาก
เปลี่ยนจาก yield เป็น await
- หากต้องการใช้
awaitอ็อบเจ็กต์เป้าหมายต้องมีเมธอด__await__หรือเป็น coroutine - ใน asyncio โดยทั่วไปจะจัดการอ็อบเจ็กต์
Taskผ่านฟังก์ชันอย่างasyncio.create_task- อ็อบเจ็กต์
Taskสืบทอดจากอ็อบเจ็กต์Futureของ asyncio - อ็อบเจ็กต์
Futureมีเมธอด__await__
- อ็อบเจ็กต์
- เมื่อเรียกฟังก์ชันที่มีคีย์เวิร์ด
asyncจะสร้างอ็อบเจ็กต์ coroutine- coroutine สามารถหยุดการรันไว้แล้วกลับมาทำต่อได้เหมือนฟังก์ชัน generator
awaitสามารถมองว่าคล้ายกับyield fromที่มีกฎตรวจสอบเพิ่มเติมawait objectจะอยู่ในรูปแบบที่yieldจาก__await__ของ instance อ็อบเจ็กต์ หรือรอ coroutine อื่น
- ใน
Futuresource code ของ asyncio ก็เห็นได้ว่าเมื่อFutureหรือTaskยังไม่เสร็จ__await__โดยพื้นฐานจะเรียกyield
การ implement Task, create_task, run แบบง่าย
- implementation แบบกำหนดเองใช้
Queueเป็น event loop แทน list- เป็นโครงสร้างที่ต้องการให้การเพิ่มและลบงานใน loop ทำได้ในเวลาคงที่
- คลาส
Taskเก็บอ็อบเจ็กต์ generator และสถานะว่าเสร็จแล้วหรือไม่- เก็บอ็อบเจ็กต์ generator ไว้ใน
self.iter - เริ่ม
self.finishedเป็นFalse - หาก generator ทำให้เกิด
StopIterationจะถือว่าเสร็จแล้ว done()คืนค่าสถานะว่าเสร็จแล้วหรือไม่
- เก็บอ็อบเจ็กต์ generator ไว้ใน
Task.__await__()จะเรียกyield selfต่อไปเรื่อย ๆ ตราบใดที่งานยังไม่จบ- พฤติกรรมนี้คืนสิทธิ์ควบคุมให้ event loop
create_task(generator)จะห่อ generator เป็นTaskใส่เข้า queue ของ event loop แล้วคืนค่า- มีหน้าที่ schedule งานเข้า event loop
run(main)เริ่ม event loop ด้วยรูปแบบคล้ายasyncio.run()จริง- ห่อ
mainที่ได้รับมาตอนแรกเป็นTaskแล้วใส่เข้า queue - ระหว่างที่ queue ยังไม่ว่าง จะหยิบงานถัดไปออกมา
- เดินหน้างานด้วย
task.iter.send(None) - หากเกิด
StopIterationจะตั้งค่าtask.finished = True - หากไม่มี exception จะใส่งานกลับเข้า queue ของ event loop อีกครั้ง
- ห่อ
- การใช้
task.iter.send(None)แทนnext(task.iter)เป็นลักษณะเฉพาะเมื่อต้องทำงานร่วมกับคีย์เวิร์ดasync/awaitและในที่นี้ทำหน้าที่เดียวกัน
sleep ที่รองรับ async และตัวอย่าง jacobio
sleepเดิมเป็นฟังก์ชัน generator แต่awaitไม่สามารถใช้ร่วมกับฟังก์ชัน generator โดยตรงได้- เป้าหมายของ
awaitต้องเป็นอ็อบเจ็กต์ที่มี__await__หรือเป็นฟังก์ชัน coroutine
- เป้าหมายของ
- ย้าย logic การรอจริงไปไว้ที่ generator
_sleep(seconds)_sleepจะyieldจนกว่าจะครบเวลาหนึ่ง
async def sleep(seconds)จะสร้างงานจาก_sleep(seconds)แล้วawaitงานนั้นawait taskจะเรียกTask.__await__()- หากงานยังไม่จบ จะส่งสิทธิ์ควบคุมให้ event loop ด้วย
yield
- ไฟล์แบบกำหนดเองที่เสร็จแล้ว
jacobio.pyมีองค์ประกอบต่อไปนี้- queue ของ event loop
_sleepasync sleepTaskcreate_taskrun
- ในตัวอย่างการใช้งาน เปลี่ยน
yield fromเดิมเป็นawaitและใส่asyncให้ฟังก์ชันที่ใช้awaittask1พิมพ์สองครั้ง และรอjacobio.sleep(1)ทุกครั้งtask2พิมพ์สามครั้ง และรอjacobio.sleep(0)ทุกครั้งmainสร้างงานสองงาน รอทั้งคู่ด้วยawaitแล้วพิมพ์done
- เอาต์พุตตัวอย่างเรียงเป็น
Task 1,Task 2,Task 2,Task 2,Task 1,done
เปลี่ยนเป็น asyncio จริง
- หากในตัวอย่าง
jacobioแบบกำหนดเอง เปลี่ยนjacobioทั้งหมดเป็นasyncioก็จะกลายเป็นโค้ดที่ใช้ แพ็กเกจ asyncio จริง - ฟังก์ชันที่สอดคล้องกันมีดังนี้
jacobio.sleep()→asyncio.sleep()jacobio.create_task()→asyncio.create_task()jacobio.run()→asyncio.run()
- asyncio จริงทำงานภายในมากกว่านี้มาก
- ตัวจัดการ event loop นี้เป็น implementation ที่ทำให้ง่ายที่สุดเท่าที่เป็นไปได้ จึงแสดงแนวคิดพื้นฐานของ asyncio ได้ แต่เนื่องจากขนาดและความซับซ้อนของแพ็กเกจจริง จึงแตกต่างจาก flow ของ source code จริงอยู่บ้าง
- เมื่อใช้ asyncio จริง สามารถจัดการหลายงานด้วยฟังก์ชันอย่าง
asyncio.gather()ได้ แทนที่จะสร้างงานสองงานแยกกันแล้วawaitทั้งคู่ - มีบทความที่เกี่ยวข้องคือ handling asyncio tasks like a pro
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
Asyncio สามารถเปลี่ยนไปใช้ implementation ที่สร้าง event loop เองได้
ใน Temporal Python จะอธิบาย workflow ด้วย asyncio event loop แบบ durable ที่ปรับแต่งเอง ทำให้แม้แต่
asyncio.sleepก็กลายเป็น timer แบบ durable ได้ กล่าวคือโค้ดสามารถกลับมาทำงานต่อบนเครื่องอื่นได้ จึงสามารถ sleep นานหลายสัปดาห์ได้วิธี implementation อธิบายไว้ในบทความนี้: https://temporal.io/blog/durable-distributed-asyncio-event-l...
ปัญหาใหญ่ที่สุดของ asyncio คือใน Python การเรียกแบบ synchronous อาจไปบล็อกเธรด asyncio และทำให้ทั้งระบบหยุดได้ง่ายมาก Python จำเป็นต้องมี เครื่องมือวิเคราะห์แบบ static ที่สร้าง call graph แล้วตรวจจับว่าภายใน
async defมีการเรียกที่บล็อกเธรดโดยตรงหรือโดยอ้อมหรือไม่อย่างยิ่งการทำงานพื้นฐานอย่าง arithmetic operation หรือการเข้าถึง data structure ก็ใส่ whitelist ให้เป็น synchronous call ที่รวดเร็ว และถ้า event loop หมุนเร็วผิดสังเกต ก็อาจตรวจจับงานอื่นที่ควรเป็น synchronous ได้หรือเปล่า
ตอนรู้ว่าเขา implement asyncio event loop อย่างไร มันเป็นช่วงเวลา เหมือนสมองเปิดโล่ง จริง ๆ
ผมยังพยายามทำความเข้าใจอยู่เลยว่า Python เป็นภาษาที่เหมาะกับ concurrency จริงหรือไม่ Asyncio ให้ความรู้สึกเหมือนประคองตัวแบบหวุดหวิดอยู่ตลอด และสำหรับผม implementation แบบ async ของ C# ดูสะอาดกว่า
Purchaser.purchaseไม่ควรเป็นdo_purchaseหรือ?แม้แต่
read()ธรรมดา ๆ ก็อาจบล็อกหรือไม่บล็อกก็ได้ ขึ้นอยู่กับ descriptor ว่าคืออะไรและถูกตั้งค่าไว้อย่างไร จะตรวจจับสิ่งนี้ด้วย static analysis ได้อย่างไร?implementation นี้ดูเหมือนทำ busy waiting ตอน sleep กล่าวคือแม้ไม่มีงานที่พร้อมรันอยู่ตอนนี้ event loop ก็ยังหมุนต่อไป
จำได้ว่าเคยเห็น implementation แบบเล่น ๆ อีกอันหนึ่ง ซึ่งติดตามเวลาถัดไปที่งานจะพร้อมรันโดยเรียงลำดับไว้ แล้วถ้าไม่มีงานที่พร้อมรันตอนนี้ ก็ให้ event loop เอง sleep ผมเข้าใจว่า asyncio จริง ๆ ก็ทำงานทำนองนี้
ต่อมาจึงขยายให้เงื่อนไขการพร้อมรันถัดไปไม่ใช่แค่เวลาตามนาฬิกาจริง แต่ยังมี dependency กับสิ่งอย่าง socket ได้ด้วย ทำให้ใช้
selectพร้อม timeout ได้implementation ที่มีชื่อเสียงอันหนึ่งคือ uvloop(https://github.com/MagicStack/uvloop) ซึ่งโดยพื้นฐานใช้ libuv implement loop และให้ libuv จัดการงานแบบ
selectที่กล่าวถึง[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io
event loop ไม่จำเป็นต้องวนซ้ำเสมอไป จะเริ่มด้วยการรัน
mainแล้วเมื่อมันจบก็จบตามไปด้วยก็ได้ ลองนึกถึงการเริ่ม server ที่มีลูปwhile trueรอ socket แล้วจบเมื่อมีเงื่อนไขปิดหรือ interrupt โปรแกรมก็จบตามไปด้วยจากมุมมองของ event loop ไม่มี busy waiting และไม่จำเป็นต้องไปแตะ sleep หรือ socket เลย เป็นความต่างระหว่างแบบรันจนเสร็จกับแบบรันตลอดกาล
ถ้าจะทำ loop แบบของเล่น ผมว่าควรไม่ต้องจัดการกรณีที่ รันตลอดกาล จะดีกว่า
ทอล์กเรื่อง asyncio ของ David Beazley ยอดเยี่ยมมาก
ผมใช้สิ่งนั้นเป็นฐานสร้างเครื่องมือ discrete-event simulation ขึ้นมา จุดที่สามารถ implement asyncio เองและเปลี่ยนนาฬิการะบบให้เป็นเวลาจำลองได้นั้นค่อนข้างเจ๋ง
เป็นบทความที่ยอดเยี่ยมจริง ๆ และเป็น คำอธิบายระดับสูง ที่ข้ามส่วนที่ผู้อ่านครั้งแรกอาจรู้สึกน่าเบื่อได้ดี
ถ้าเพิ่มแหล่งข้อมูลอื่นตอนท้ายที่อธิบายว่าภายในจริง ๆ ทำงานอย่างไร จะยิ่งดี
ถ้าบทความแสดงเรื่องเดียวกันโดยไม่ใช้
yieldเลย น่าจะดีกว่านี้ พูดตรง ๆ ส่วนที่รู้สึกเหมือน เวทมนตร์จริง ๆ อยู่ตรงนั้นมีคำอธิบายที่ลึกกว่ามากเกี่ยวกับ coroutine ของ Python อยู่ที่นี่: https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro...
ไม่มีการพูดถึง
poll()เลยหรือ? ถ้าอย่างนั้นก็แตกต่างจากวิธีที่ asyncio ทำงานอย่างสิ้นเชิงการที่ Python ดักใช้
defเพื่อสร้าง object ที่จริง ๆ แล้วไม่ใช่ function นั้นแปลกสุด ๆอย่างน้อยก็น่าจะสร้าง keyword อื่น ได้
เป็นรูปแบบที่คืน generator หรือ coroutine และดู type signature ได้ที่นี่ [1] ถึงจะไม่ใช่ syntax sugar ระดับภาษา ก็สามารถทำสิ่งคล้ายกันใน Python ล้วน ๆ ด้วย decorator อะไรทำนองนั้นได้
[1] https://docs.python.org/3/library/typing.html#typing.Generat...
อย่างไรก็ตาม type signature ที่พูดถึงเป็นประเด็นเชิงวิชาการมากกว่า และในการใส่ type จริง ๆ มักชอบใช้
IterableกับAwaitableที่ง่ายกว่าฟังดูเหมือนให้ semantics ของประสบการณ์นักพัฒนามาก่อนสิ่งอย่างความบริสุทธิ์ของภาษา ผมไม่เคยสับสนเลยว่า
asyncfunction คือ function แบบ asynchronousdefสำหรับ function ทำให้สร้าง inline function แบบ JS arrow function หรือ C++ lambda ไม่ได้นี่ไม่ใช่ปัญหาเรื่องความบริสุทธิ์ของภาษา แต่เป็นแค่ความไม่สะดวก