23 คะแนน โดย darjeeling 2025-11-16 | 12 ความคิดเห็น | แชร์ทาง WhatsApp

สาเหตุที่ทำให้โค้ด 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 pool
    loop = 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 และไม่ต้อง await
    user_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 ด้านหน่วยความจำและการสื่อสารสูง

https://youtu.be/wGDOwNW6lVk

12 ความคิดเห็น

 
savvykang 2025-11-18

Python เป็นภาษาที่เยี่ยมยอดก็จริง แต่ดูเหมือนว่าอินเทอร์เฟซแบบอะซิงก์จะเป็นฟีเจอร์ที่ออกแบบมาได้ไม่ดีนัก

 
ceruns 2025-11-17

ข้อ 4 ตก eager_start=True ไปนะครับ create_task สร้าง weakref ไว้ ก็เลยเป็นโค้ดที่อาจกลายเป็น task ที่จะไม่ถูกรันเลย....

 
tested 2025-11-17

> https://rosettalens.com/s/ko/python-to-node

คนนี้ก็บอกว่าเปลี่ยนไปใช้ Node.js เพราะปัญหา async ของ Python เหมือนกัน

 
kandk 2025-11-17

สรุป: อินเทอร์เฟซ async ของ Python ยังไม่ใช่สิ่งที่เข้าใจได้โดยสัญชาตญาณนัก

 
bungker 2025-11-17

จริง ๆ แล้ว ถ้าเป็นโปรเจ็กต์ที่ถึงขั้นต้องปรับแต่ง Python async ให้เหมาะสม การเขียนด้วยภาษาอื่นจะให้ทั้งประสิทธิภาพและความเสถียรที่ดีกว่ามาก

 
euphcat 2025-11-17

ถ้าไม่ได้จะไปใช้ภาษาแบบคอมไพล์แล้ว ประสิทธิภาพต่างกันมากไหมครับ? ถ้าเป็นมัลติเธรดก็คงต่างกันมากเพราะการมีอยู่ของ GIL แต่ถ้าเป็นโครงสร้างอะซิงก์ที่ทำงานด้วย event loop อยู่แล้ว ก็สงสัยว่าความแตกต่างตามภาษาจะเกิดขึ้นในด้านไหนบ้างครับ

 
vwjdalsgkv 2025-11-17

ผลกระทบจากการมีหรือไม่มี JIT compile นั้นมากกว่าที่คิดไว้พอสมควร V8 ถูกปรับแต่งมาได้ดีมาก

 
euphcat 2025-11-16

แม้จะยังไม่ได้ตรวจดูวิดีโอต้นฉบับ แต่โค้ดวิธีแก้สำหรับข้อผิดพลาดข้อ 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 ได้ลงตัว ทุกครั้งที่มีปัญหาเกิดขึ้นสักครั้ง ก็เป็นประสบการณ์หายากที่สภาพจิตใจผมระเบิดตามไปด้วยเหมือนกัน ฮ่า

 
kunggom 2025-11-16

แม้ในมุมของคนที่ทำงานอยู่ในบริษัทที่ใช้ C# ซึ่งอาจเรียกได้ว่าเป็นต้นตำรับ(?) ของแพตเทิร์น Async/Await ก็ยังเห็นโค้ดที่ผิดพลาดในลักษณะข้อ 1 คือเอา await มาเรียงต่อกันตามลำดับแบบตรง ๆ อยู่ค่อนข้างบ่อยเหมือนกัน

พอเห็นโค้ดแบบนั้นก็ให้ความรู้สึกว่า โดยทั่วไปผู้เขียนรู้แค่ว่าต้องใส่คีย์เวิร์ด await หน้าเมธอดคอล async แต่ไม่ได้คิดต่อมากนักเรื่องลำดับการทำงานของ asynchronous execution เลยออกมาเป็นโค้ดแบบนี้
เวลามี await หลายตัว บางตัวผลลัพธ์ถูกใช้ทันทีในบรรทัดถัดไป ก็เลยรับค่าผลลัพธ์จากการ await ของอ็อบเจ็กต์ Task<T> ตรงนั้นไปเลย ส่วนบางตัวกว่าจะถูกใช้ก็ค่อนข้างท้าย ๆ ก็รับมาแค่ Task<T> ก่อนแล้วค่อย await ทีหลัง การเขียนโค้ดโดยคำนึงถึง async flow แบบนี้เป็นงานที่ต้องใช้ความคิดพอสมควรอยู่แล้ว

อย่างน้อยผมเองก็เขียนเมธอดที่ประกาศเป็น asynchronous โดยคำนึงถึง flow ของการประมวลผลแบบนี้อยู่ แต่บางครั้งเวลาไปดูโค้ดเดิมของคนที่ลาออกไปแล้วซึ่งต้องดูแลต่อ ก็มีบางทีที่ให้ความรู้สึกว่า “จริง ๆ ฉันแค่อยากเขียนโค้ด synchronous แบบง่าย ๆ แต่เมธอดที่ต้องใช้ระหว่างทางดันมีแต่แบบ asynchronous ก็เลยเขียนออกมาเป็นแบบนี้แหละ”

 
skageektp 2025-11-17

ถ้าข้อ 1 เป็นอิสระเสมอ การทำแบบนั้นก็ดีอยู่หรอกครับ
แต่ถ้าแก้โค้ดไปแล้วมันไม่เป็นอิสระอีกต่อไป ก็ดูเหมือนจะมีความยุ่งยากตรงที่ต้องไล่ตรวจและแก้ไขทุกจุดที่เรียกใช้ฟังก์ชันนั้นทั้งหมดด้วย
ถ้าเป็นงานที่ใช้เวลาไม่นานมาก การ await แบบอนุกรมอาจจะดีกว่าในแง่การดูแลโค้ดก็ได้

 
euphcat 2025-11-17

ดูเหมือนว่าควรจะเข้าหาด้วยแนวคิดว่า "เนื่องจาก overhead ของมัลติเธรดเป็นภาระ จึงแก้ปัญหาการประมวลผลแบบขนานด้วยการแยก single thread ออกเป็นส่วนย่อยแทน" เพราะแบบนั้น โดยพื้นฐานแล้วก็ดูเหมือนว่าจะต้องใส่ใจมากกว่ามัลติเธรดเสียอีกในบางกรณีครับ

 
kunggom 2025-11-17

ก็จริงครับ
ดูเหมือนว่าโค้ด async ที่ทำได้อย่างถูกต้องนั้น โดยเนื้อแท้แล้วเป็นโค้ดที่ต้องใส่ใจอย่างมากครับ