สาเหตุที่ทำให้โค้ด Async ช้าลงและวิธีแก้ไข
(secondb.ai)สาเหตุที่ทำให้โค้ด Async ช้าลงและวิธีแก้ไข (สรุปเชิงเทคนิค)
วิดีโอนี้อธิบายถึงสาเหตุทั่วไปที่ทำให้โค้ด asyncio ของ Python ช้ากว่าโค้ดแบบ synchronous และแนวทางเชิงเทคนิคในการแก้ไขปัญหาเหล่านั้น
1. แนวคิดหลักของ Asyncio
- อีเวนต์ลูป (Event Loop): เป็นแกนหลักของแอปพลิเคชันอะซิงโครนัสทั้งหมด เริ่มต้นด้วย
asyncio.run()และทำหน้าที่จัดการกับการรันและการจัดตารางงานของ task บนเธรดเดียว - คอรูทีน (Coroutines): เป็นฟังก์ชันอะซิงโครนัสที่ประกาศด้วย
async defเมื่อพบคีย์เวิร์ดawaitก็สามารถหยุดการทำงานชั่วคราวและคืนการควบคุมกลับให้อีเวนต์ลูปได้ - ทาสก์ (Tasks): ใช้ห่อคอรูทีนและจัดตารางให้รันพร้อมกันบนอีเวนต์ลูป สร้างได้ผ่าน
asyncio.create_task() - ฟิวเจอร์ (Futures): เป็นอ็อบเจ็กต์ระดับล่างที่แสดงผลลัพธ์สุดท้ายของงานอะซิงโครนัส
2. ตัวอย่างการแปลงโค้ด synchronous เป็น asynchronous
แทนที่ time.sleep() แบบ synchronous เดิมด้วย await asyncio.sleep() แบบ asynchronous ประกาศฟังก์ชันด้วย async def และรันเมนคอรูทีนด้วย asyncio.run()
ความผิดพลาดที่พบบ่อยซึ่งทำให้ประสิทธิภาพลดลง และวิธีแก้
ความผิดพลาด 1: การรันแบบลำดับ (Sequential Execution)
หาก await งานอิสระทีละตัวโดยไม่รันแบบขนาน เวลารวมในการรันจะเท่ากับผลรวมของเวลาที่ใช้ในทุก task
-
ตัวอย่างที่ไม่ถูกต้อง (แบบลำดับ):
# await แต่ละตัวจะรอจนกว่างานก่อนหน้าจะเสร็จ await get_user_notifications() await get_recent_activity() await get_unread_messages() -
วิธีแก้ (แบบขนาน): ใช้
asyncio.gatherหรือasyncio.TaskGroupเพื่อรัน task ที่เป็นอิสระพร้อมกัน เวลารวมจะลดลงเหลือเท่ากับเวลาของ task ที่ช้าที่สุด# งานทั้งสามเริ่มพร้อมกัน await asyncio.gather( get_user_notifications(), get_recent_activity(), get_unread_messages() )
เปรียบเทียบเครื่องมือสำหรับการรันแบบขนาน
asyncio.gather:- รันหลายคอรูทีนพร้อมกัน
- ข้อเสีย: การจัดการข้อผิดพลาดยังไม่ดีนัก หากมี exception ใน task หนึ่ง task อื่นที่กำลังรันอยู่จะถูกยกเลิก
asyncio.create_task:- ควบคุมและจัดการข้อผิดพลาดราย task ได้
- เหมาะกับการรันเบื้องหลัง แต่มีความยุ่งยากตรงที่ต้อง
awaitหลาย task แยกกัน
asyncio.TaskGroup(Python 3.11+):- เป็นทางเลือกสมัยใหม่สำหรับ 'structured concurrency'
- ใช้ไวยากรณ์
async withเพื่อจัดการกลุ่ม task และเมื่อออกจากคอนเท็กซ์ จะรับประกันว่า task ทั้งหมดเสร็จสิ้นหรือถูกจัดการ exception แล้ว
async with asyncio.TaskGroup() as tg: tg.create_task(some_coro_1()) tg.create_task(some_coro_2()) # เมื่อบล็อก 'async with' จบลง ทุก task จะถูก await
ความผิดพลาด 2: ใช้ไลบรารี synchronous
หากใช้ไลบรารีแบบ synchronous (blocking) เช่น requests หรือ pathlib ภายในโค้ด asyncio อีเวนต์ลูปทั้งตัวจะถูกบล็อก แม้จะใช้ภายใน asyncio.gather ก็ตาม แต่ในการทำงานจริงก็ยังเป็นแบบลำดับอยู่ดี
- วิธีแก้: ควรใช้ไลบรารีเฉพาะที่รองรับ asynchronous (non-blocking) เช่น
aiohttp(แทน requests) และaiofiles(แทน files/pathlib)
ความผิดพลาด 3: งาน CPU-bound บล็อกอีเวนต์ลูป
เนื่องจาก asyncio ทำงานบนเธรดเดียว งานคำนวณหนัก (CPU-bound) จะหยุดอีเวนต์ลูปและทำให้งาน I/O อื่นล่าช้า
- วิธีแก้: ใช้
loop.run_in_executor()เพื่อออฟโหลดงาน CPU-bound ไปยัง thread pool แยกต่างหาก (ค่าเริ่มต้น) หรือ process poolloop = asyncio.get_running_loop() # รันฟังก์ชันที่ใช้ CPU สูงบนเธรดแยก await loop.run_in_executor( None, # ใช้ thread pool เริ่มต้น cpu_bound_function, arg1 )
ความผิดพลาด 4: การบล็อกจากงานที่ไม่สำคัญ
หาก await งานที่ไม่ใช่งานหลัก เช่น logging ซึ่งไม่เกี่ยวกับการตอบสนองผู้ใช้โดยตรง จะทำให้เวลาในการตอบสนองช้าลงโดยไม่จำเป็น
- วิธีแก้: ใช้
asyncio.create_task()แยกงานนั้นออกเป็น background task และไม่ต้องawaituser_profile = await get_user_profile() # รัน logging ในเบื้องหลังโดยไม่ await asyncio.create_task(send_logs_to_external_service()) return user_profile
ความผิดพลาด 5: สร้าง task มากเกินไป
หากเปลี่ยนงานเล็กมากจำนวนมากให้เป็น task ทั้งหมด อาจเกิด overhead จากการสลับคอนเท็กซ์จนประสิทธิภาพลดลง
- วิธีแก้ 1: รวมงานเล็ก ๆ เข้าด้วยกัน (batching) ให้กลายเป็น task ขนาดใหญ่เพียงไม่กี่ตัว
- วิธีแก้ 2: ใช้
asyncio.Semaphoreเพื่อจำกัดจำนวน task สูงสุดที่รันพร้อมกัน# อนุญาตให้มีงานพร้อมกันได้สูงสุด 10 งาน semaphore = asyncio.Semaphore(10) async with semaphore: await fetch_data()
ความผิดพลาดอื่น ๆ
- คอรูทีนแบบ "Never Awaited": เรียกคอรูทีนแล้วไม่
awaitทำให้งานไม่ถูกรันเลยและล้มเหลวแบบเงียบ ๆ สามารถตรวจจับได้ด้วย linter เช่นflake8-async - การจัดการทรัพยากรที่ไม่เหมาะสม: หากใช้ไฟล์, DB connection ฯลฯ โดยไม่มี
try...finallyอาจทำให้เกิด resource leak ได้ แก้ได้ด้วย async context manager ที่ใช้async with
การดีบักและการเลือกโมเดล concurrency
โหมดดีบักของ Asyncio
โดยปกติโหมดดีบักจะปิดอยู่ แต่เมื่อเปิดใช้งาน (asyncio.run(debug=True)) จะช่วยตรวจจับปัญหาต่อไปนี้ได้
- คอรูทีนที่ไม่ได้
await(RuntimeWarning) - การเรียก async API จากเธรดที่ไม่ถูกต้อง
- callback ที่ใช้เวลารันเกิน 100ms
- งาน I/O selector ที่ช้า
เครื่องมือดีบักอื่น ๆ
- Scalene: โปรไฟเลอร์สำหรับ CPU และหน่วยความจำ
- aio-monitor: เครื่องมือตรวจสอบและ CLI สำหรับแอปพลิเคชัน
asyncio - pdb: ดีบักเกอร์พื้นฐานของ Python
- py-stack: แสดง stack trace ของ Python process ที่กำลังรันอยู่ เพื่อหาจุดที่เกิดการบล็อก
แนวทางเลือกโมเดล concurrency
- Asyncio (เธรดเดียว): เหมาะที่สุดสำหรับงาน I/O-bound จำนวนมากที่มี latency สูง (เช่น network request, file I/O)
- Threads (มัลติเธรด): ใช้กับงาน I/O-bound ที่ต้องเข้าถึงข้อมูลร่วมกัน แม้จะไม่ใช่การประมวลผลแบบขนานจริงเพราะ GIL (Global Interpreter Lock) แต่ระหว่างรอ I/O เธรดอื่นยังทำงานได้
- Processes (มัลติโปรเซส): ใช้กับงาน CPU-bound (เช่น ประมวลผลภาพ, การคำนวณหนัก) สามารถใช้หลายคอร์ของ CPU เพื่อให้เกิด parallelism จริง แต่มี overhead ด้านหน่วยความจำและการสื่อสารสูง
12 ความคิดเห็น
Python เป็นภาษาที่เยี่ยมยอดก็จริง แต่ดูเหมือนว่าอินเทอร์เฟซแบบอะซิงก์จะเป็นฟีเจอร์ที่ออกแบบมาได้ไม่ดีนัก
ข้อ 4 ตก
eager_start=Trueไปนะครับcreate_taskสร้าง weakref ไว้ ก็เลยเป็นโค้ดที่อาจกลายเป็น task ที่จะไม่ถูกรันเลย....> https://rosettalens.com/s/ko/python-to-node
คนนี้ก็บอกว่าเปลี่ยนไปใช้ Node.js เพราะปัญหา async ของ Python เหมือนกัน
สรุป: อินเทอร์เฟซ async ของ Python ยังไม่ใช่สิ่งที่เข้าใจได้โดยสัญชาตญาณนัก
จริง ๆ แล้ว ถ้าเป็นโปรเจ็กต์ที่ถึงขั้นต้องปรับแต่ง Python async ให้เหมาะสม การเขียนด้วยภาษาอื่นจะให้ทั้งประสิทธิภาพและความเสถียรที่ดีกว่ามาก
ถ้าไม่ได้จะไปใช้ภาษาแบบคอมไพล์แล้ว ประสิทธิภาพต่างกันมากไหมครับ? ถ้าเป็นมัลติเธรดก็คงต่างกันมากเพราะการมีอยู่ของ GIL แต่ถ้าเป็นโครงสร้างอะซิงก์ที่ทำงานด้วย event loop อยู่แล้ว ก็สงสัยว่าความแตกต่างตามภาษาจะเกิดขึ้นในด้านไหนบ้างครับ
ผลกระทบจากการมีหรือไม่มี JIT compile นั้นมากกว่าที่คิดไว้พอสมควร V8 ถูกปรับแต่งมาได้ดีมาก
แม้จะยังไม่ได้ตรวจดูวิดีโอต้นฉบับ แต่โค้ดวิธีแก้สำหรับข้อผิดพลาดข้อ 4 นั้นเป็นโค้ดที่ไม่ถูกต้อง
อินสแตนซ์ task ที่
create_task()คืนค่ามาจะต้องถูกเก็บไว้ในตัวแปรอย่างน้อยหนึ่งตัว และตัวแปรนั้นต้องยังคงอยู่จนกว่า task จะจบการทำงาน ไม่เช่นนั้นมีความเสี่ยงที่อินสแตนซ์ task จะถูก garbage collection ระหว่างที่ coroutine กำลังทำงานอยู่หากเป็นกรณีที่ฟังก์ชันซึ่งสร้าง task แบบข้างต้นกำลังจะจบทันที ก็ควรใช้วิธีอย่างเช่นคืนค่าอินสแตนซ์ task ออกไป, เก็บไว้ในตัวแปร global หรือเก็บไว้ในตัวแปรของอินสแตนซ์
P.S)
ถึงจะไม่ได้จำเป็นต้องใช้ค่าที่คืนมาก็ตาม และมั่นใจว่า coroutine จะจบในเวลาอันสั้น ก็ควรเขียนให้มี
awaitกับอินสแตนซ์ task ในสักจุดหนึ่งในภายหลังจะดีกว่า หากไม่อยากทำแบบนั้น ก็ต้องวางโครงสร้างให้ coroutine ที่จะทำงานเป็น task แต่ละตัวมีการจัดการ exception อย่างเข้มงวด พร้อมแสดง log message อย่างไม่ให้ตกหล่น มิฉะนั้นก็อาจเกิดกรณีที่ task ก่อปัญหาร้ายแรงแค่ไหนก็ตาม แต่ Exception ไม่ถูกจัดการและล้มเหลวแบบเงียบ ๆ ได้ในโปรเจ็กต์ที่ผมพัฒนา/ดูแลเป็นอาชีพ ผมเคยออกแบบแพตเทิร์นที่หลายสิบโมดูลต่างก็สร้าง task ของตัวเองขึ้นมาทีละตัวแล้วรันต่อเนื่องในลักษณะประมาณ
while self.ok(): cmd = await self.cmd_queue.get(); await self.process(cmd);ก่อนที่จะวางแพตเทิร์นการจัดการ exception ได้ลงตัว ทุกครั้งที่มีปัญหาเกิดขึ้นสักครั้ง ก็เป็นประสบการณ์หายากที่สภาพจิตใจผมระเบิดตามไปด้วยเหมือนกัน ฮ่าแม้ในมุมของคนที่ทำงานอยู่ในบริษัทที่ใช้ C# ซึ่งอาจเรียกได้ว่าเป็นต้นตำรับ(?) ของแพตเทิร์น Async/Await ก็ยังเห็นโค้ดที่ผิดพลาดในลักษณะข้อ 1 คือเอา
awaitมาเรียงต่อกันตามลำดับแบบตรง ๆ อยู่ค่อนข้างบ่อยเหมือนกันพอเห็นโค้ดแบบนั้นก็ให้ความรู้สึกว่า โดยทั่วไปผู้เขียนรู้แค่ว่าต้องใส่คีย์เวิร์ด
awaitหน้าเมธอดคอลasyncแต่ไม่ได้คิดต่อมากนักเรื่องลำดับการทำงานของ asynchronous execution เลยออกมาเป็นโค้ดแบบนี้เวลามี
awaitหลายตัว บางตัวผลลัพธ์ถูกใช้ทันทีในบรรทัดถัดไป ก็เลยรับค่าผลลัพธ์จากการawaitของอ็อบเจ็กต์Task<T>ตรงนั้นไปเลย ส่วนบางตัวกว่าจะถูกใช้ก็ค่อนข้างท้าย ๆ ก็รับมาแค่Task<T>ก่อนแล้วค่อยawaitทีหลัง การเขียนโค้ดโดยคำนึงถึง async flow แบบนี้เป็นงานที่ต้องใช้ความคิดพอสมควรอยู่แล้วอย่างน้อยผมเองก็เขียนเมธอดที่ประกาศเป็น asynchronous โดยคำนึงถึง flow ของการประมวลผลแบบนี้อยู่ แต่บางครั้งเวลาไปดูโค้ดเดิมของคนที่ลาออกไปแล้วซึ่งต้องดูแลต่อ ก็มีบางทีที่ให้ความรู้สึกว่า “จริง ๆ ฉันแค่อยากเขียนโค้ด synchronous แบบง่าย ๆ แต่เมธอดที่ต้องใช้ระหว่างทางดันมีแต่แบบ asynchronous ก็เลยเขียนออกมาเป็นแบบนี้แหละ”
ถ้าข้อ 1 เป็นอิสระเสมอ การทำแบบนั้นก็ดีอยู่หรอกครับ
แต่ถ้าแก้โค้ดไปแล้วมันไม่เป็นอิสระอีกต่อไป ก็ดูเหมือนจะมีความยุ่งยากตรงที่ต้องไล่ตรวจและแก้ไขทุกจุดที่เรียกใช้ฟังก์ชันนั้นทั้งหมดด้วย
ถ้าเป็นงานที่ใช้เวลาไม่นานมาก การ
awaitแบบอนุกรมอาจจะดีกว่าในแง่การดูแลโค้ดก็ได้ดูเหมือนว่าควรจะเข้าหาด้วยแนวคิดว่า "เนื่องจาก overhead ของมัลติเธรดเป็นภาระ จึงแก้ปัญหาการประมวลผลแบบขนานด้วยการแยก single thread ออกเป็นส่วนย่อยแทน" เพราะแบบนั้น โดยพื้นฐานแล้วก็ดูเหมือนว่าจะต้องใส่ใจมากกว่ามัลติเธรดเสียอีกในบางกรณีครับ
ก็จริงครับ
ดูเหมือนว่าโค้ด async ที่ทำได้อย่างถูกต้องนั้น โดยเนื้อแท้แล้วเป็นโค้ดที่ต้องใส่ใจอย่างมากครับ