3 คะแนน โดย GN⁺ 2024-05-09 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • 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 เหลืออยู่ จะเกิด exception StopIteration
  • 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 อื่น
  • ใน Future source 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() คืนค่าสถานะว่าเสร็จแล้วหรือไม่
  • 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
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • ในตัวอย่างการใช้งาน เปลี่ยน yield from เดิมเป็น await และใส่ async ให้ฟังก์ชันที่ใช้ await
    • task1 พิมพ์สองครั้ง และรอ 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 ความคิดเห็น

 
GN⁺ 2024-05-09
ความคิดเห็นบน 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 มีการเรียกที่บล็อกเธรดโดยตรงหรือโดยอ้อมหรือไม่อย่างยิ่ง

    • อาจเป็นความคิดที่ไม่ดีก็ได้ แต่ผมสงสัยว่าถ้าทำกลับกัน แทนที่จะให้ทุก call เป็น synchronous โดยปริยายอย่างตอนนี้จะเป็นอย่างไร
      การทำงานพื้นฐานอย่าง arithmetic operation หรือการเข้าถึง data structure ก็ใส่ whitelist ให้เป็น synchronous call ที่รวดเร็ว และถ้า event loop หมุนเร็วผิดสังเกต ก็อาจตรวจจับงานอื่นที่ควรเป็น synchronous ได้หรือเปล่า
    • Temporal เจ๋งมากจริง ๆ
      ตอนรู้ว่าเขา implement asyncio event loop อย่างไร มันเป็นช่วงเวลา เหมือนสมองเปิดโล่ง จริง ๆ
    • สงสัยว่าทำไมไม่ใช้ thread
      ผมยังพยายามทำความเข้าใจอยู่เลยว่า Python เป็นภาษาที่เหมาะกับ concurrency จริงหรือไม่ Asyncio ให้ความรู้สึกเหมือนประคองตัวแบบหวุดหวิดอยู่ตลอด และสำหรับผม implementation แบบ async ของ C# ดูสะอาดกว่า
    • ในตัวอย่าง workflow Purchaser.purchase ไม่ควรเป็น do_purchase หรือ?
    • การจับสิ่งนั้นแบบ static คงไม่ง่าย
      แม้แต่ read() ธรรมดา ๆ ก็อาจบล็อกหรือไม่บล็อกก็ได้ ขึ้นอยู่กับ descriptor ว่าคืออะไรและถูกตั้งค่าไว้อย่างไร จะตรวจจับสิ่งนี้ด้วย static analysis ได้อย่างไร?
  • implementation นี้ดูเหมือนทำ busy waiting ตอน sleep กล่าวคือแม้ไม่มีงานที่พร้อมรันอยู่ตอนนี้ event loop ก็ยังหมุนต่อไป
    จำได้ว่าเคยเห็น implementation แบบเล่น ๆ อีกอันหนึ่ง ซึ่งติดตามเวลาถัดไปที่งานจะพร้อมรันโดยเรียงลำดับไว้ แล้วถ้าไม่มีงานที่พร้อมรันตอนนี้ ก็ให้ event loop เอง sleep ผมเข้าใจว่า asyncio จริง ๆ ก็ทำงานทำนองนี้
    ต่อมาจึงขยายให้เงื่อนไขการพร้อมรันถัดไปไม่ใช่แค่เวลาตามนาฬิกาจริง แต่ยังมี dependency กับสิ่งอย่าง socket ได้ด้วย ทำให้ใช้ select พร้อม timeout ได้

    • ใน asyncio เองก็สามารถใช้ implementation ของ custom event loop นอกเหนือจาก implementation ที่มีมาให้ได้
      implementation ที่มีชื่อเสียงอันหนึ่งคือ uvloop(https://github.com/MagicStack/uvloop) ซึ่งโดยพื้นฐานใช้ libuv implement loop และให้ libuv จัดการงานแบบ select ที่กล่าวถึง
    • ฟังดูค่อนข้างคล้าย SimPy [1] / Simpy.io [2] แต่ SimPy ออกมาก่อน asyncio หลายปี
      [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 อื่น ได้

    • มันเป็น function จริง ๆ เพียงแต่ถูกแปลงให้เป็น function ที่มี return type ต่างออกไป
      เป็นรูปแบบที่คืน 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 ของประสบการณ์นักพัฒนามาก่อนสิ่งอย่างความบริสุทธิ์ของภาษา ผมไม่เคยสับสนเลยว่า async function คือ function แบบ asynchronous
    • ปัญหาที่ใหญ่กว่าของ Python คือแต่แรกก็ต้องใช้ def สำหรับ function ทำให้สร้าง inline function แบบ JS arrow function หรือ C++ lambda ไม่ได้
      นี่ไม่ใช่ปัญหาเรื่องความบริสุทธิ์ของภาษา แต่เป็นแค่ความไม่สะดวก
    • หมายถึงเรื่อง decorator หรือเปล่า? ในบริบทของ Python ค่อนข้างสับสนว่ากำลังพูดถึงอะไร