การเปลี่ยนจาก JavaScript มาเป็น Python ให้ความรู้สึกเหมือนการย้ายไปอยู่ในเมืองที่มีป้ายจราจรเหมือนเดิม แต่มีกฎจราจรที่ต่างออกไป ไวยากรณ์ดูเป็นมิตรและคุ้นเคย คุณเห็น async และ await อยู่ในไวยากรณ์พอดี จึงสันนิษฐานว่าโมเดลความคิด (mental model) จะสามารถนำมาใช้ต่อกันได้ทันที แต่มันไม่ใช่แบบนั้น รูปแบบความเคยชินอย่างหนึ่งจาก JavaScript จะทำให้ประสิทธิภาพของ Python ตกต่ำลงอย่างเงียบๆ โดยที่โปรแกรมไม่พัง ไม่มีการบันทึก error และไม่ปรากฏให้เห็นในการทำ code review แบบผ่านๆ
วิธีที่ JavaScript สอนให้คุณ "เริ่มแล้วลืม"
ใน JavaScript การเรียกฟังก์ชัน async จะเป็นแบบ eager ทันทีที่คุณเรียกใช้งาน engine จะสร้าง Promise ขึ้นมาและงานจะเริ่มขึ้นทันที event loop ก็เริ่มทำงานทันที นั่นคือเหตุผลที่นักพัฒนา JavaScript มักจะเขียนโค้ดในลักษณะนี้:
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
คำขอเครือข่ายทั้งสองกำลังทำงานอยู่ก่อนที่จะถึง await ตัวแรกเสียอีก await ตัวแรกจะระงับการทำงานของฟังก์ชันปัจจุบันจนกว่า fetchUser จะเสร็จสิ้น แต่ fetchOrders ได้เริ่มทำงานไปแล้วในพื้นหลังตั้งแต่บรรทัดก่อนหน้า กว่าที่คุณจะต้องการตัวแปร orders คำขอที่สองก็อาจจะเสร็จสิ้นไปแล้ว รูปแบบนี้ดูเป็นธรรมชาติมากใน JavaScript จนนักพัฒนาหลายคนไม่แม้แต่จะคิดว่ามันเป็นเทคนิคการทำ concurrency แต่มันคือวิธีที่ async ทำงาน
ความประหลาดใจใน Python: Coroutine ที่เย็นชา
Python ใช้ข้อตกลง (contract) ที่ต่างออกไป เมื่อคุณเรียกฟังก์ชัน async def ใน Python คุณไม่ได้เริ่มทำงานใดๆ เลย แต่คุณจะได้ coroutine object กลับมา ให้คิดซะว่ามันเหมือนสูตรอาหารที่เขียนไว้บนกระดาษ มีรายการวัตถุดิบ มีขั้นตอนที่ชัดเจน แต่ยังไม่มีอะไรอยู่ในเตาอบเลย จนกว่าจะมีบางอย่างสั่งให้ coroutine นั้นทำงานผ่าน event loop อย่างชัดเจน มันก็จะยังคงนิ่งเฉยอยู่แบบนั้น
นี่คือกับดัก วิศวกร JavaScript ที่ต้องการข้อมูลผู้ใช้และรายการสั่งซื้อของเขาอาจจะเขียนโค้ดใน Python แบบนี้:
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
มันดูเหมือนทำงานแบบ concurrent มันให้ความรู้สึกเหมือน concurrent แต่มันทำงานแบบ sequential (เรียงลำดับ) โดยสิ้นเชิง
บรรทัดแรกกำหนด coroutine ที่ยังไม่ทำงานให้กับ user_coro บรรทัดที่สองกำหนด coroutine อีกตัวที่ยังไม่ทำงานให้กับ orders_coro เมื่อการทำงานมาถึง await user_coro ในที่สุด Python จึงเริ่มงานแรกและรันจนเสร็จสิ้น หลังจาก fetch_user เสร็จสิ้นแล้วเท่านั้น ตัวแปลคำสั่ง (interpreter) จึงจะไปถึง await orders_coro และเริ่มงานที่สอง เวลาในการทำงานทั้งหมดของคุณคือผลรวมของทั้งสอง I/O operations ไม่ใช่ตัวที่นานที่สุด คุณไม่ได้รันพวกมันแบบ parallel แต่คุณรันพวกมันทีละอย่างต่อกันโดยมีขั้นตอนที่เพิ่มขึ้นมาโดยไม่จำเป็น
ทำไมบั๊กนี้ถึงมองไม่เห็น
นี่คือประเภทของปัญหาประสิทธิภาพที่ลดลง (performance regression) ซึ่งอาจหลุดรอดไปได้นานหลายเดือน โค้ดนี้เป็น Python ที่ถูกต้อง ผ่านการตรวจสอบ type checker และคืนผลลัพธ์ที่ถูกต้อง แต่มันแค่ทำงานด้วยความเร็วเพียงครึ่งเดียว หรือแย่กว่านั้น เนื่องจากไม่มี stack trace และไม่มีคำเตือน ทีมวิศวกรจึงมักจะไปมองหาสาเหตุจากที่อื่นก่อน พวกเขาอาจจะเพิ่ม Redis cache, อัปเกรดระดับฐานข้อมูล หรือเปลี่ยนภูมิภาคที่โฮสต์ แต่ตัวการที่แท้จริงคือความเข้าใจที่คลาดเคลื่อนอย่างแนบเนียนเกี่ยวกับสิ่งที่ await ทำจริงๆ
3 วิธีที่จะทำให้ Python ทำงานแบบ concurrent ได้จริงๆ
เพื่อแก้ไขปัญหานี้ คุณต้องบอกให้ event loop ของ Python จัดตารางงานทันที คุณต้องการสิ่งที่ทำงานได้จริงมากกว่าแค่ coroutine เปล่าๆ คุณต้องการ Task
1. asyncio.create_task
การแปลงรูปแบบจาก JavaScript ที่ตรงที่สุดคือการหุ้ม coroutine ของคุณไว้ใน Task โดย Task จะถูกจัดตารางใน event loop ทันทีที่คุณสร้างมันขึ้นมา มันคือสิ่งที่เทียบเท่ากับ JavaScript Promise ที่กำลังทำงานอยู่ใน Python
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
ตอนนี้ทั้ง fetch_user และ fetch_orders กำลังทำงานอยู่ก่อนที่จะถึง await ตัวแรก เมื่อคุณไปถึง await user_task คุณจะหยุดรอจนกว่า Task นั้นจะเสร็จสิ้นเท่านั้น แต่ Task อื่นจะยังคงทำงานต่อไป หาก fetch_orders เสร็จก่อน ผลลัพธ์ของมันก็จะรออยู่ใน orders_task จนกว่าคุณจะเรียกใช้มัน
อย่างไรก็ตาม โปรดระวัง หากคุณสร้าง Task ขึ้นมาแล้วไม่เคย await มันเลย Python จะแจ้ง error เกี่ยวกับ destroyed pending task คุณยังคงต้องเก็บรวบรวมผลลัพธ์ของคุณด้วย
2. asyncio.gather
หากคุณมี coroutine หลายตัวที่ต้องทำงานให้เสร็จทั้งหมดก่อนจะไปขั้นตอนถัดไป asyncio.gather จะจัดการส่วน boilerplate ให้คุณเอง มันจะจัดตารางแต่ละ coroutine เป็น Task ภายในและรอ (await) พวกมันไปพร้อมกัน
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
วิธีนี้กระชับและอ่านง่าย มันโดดเด่นเมื่อการทำงานเหล่านั้นเป็นอิสระต่อกัน และคุณต้องการโค้ดเพียงบรรทัดเดียวที่สื่อความหมายว่า "รันทั้งหมดนี้ แล้วส่งผลลัพธ์ทั้งหมดมาให้ฉัน" นอกจากนี้มันยังรักษาลำดับของอาร์กิวเมนต์ใน list หรือ tuple ที่ส่งคืนมาด้วย แม้ว่างานที่อยู่เบื้องหลังจะเสร็จสิ้นในลำดับที่ต่างกันก็ตาม
3. asyncio.TaskGroup
Python 3.11 ได้แนะนำ TaskGroup ซึ่งนำโครงสร้างแบบ structured concurrency เข้ามาสู่ standard library แทนที่จะสร้าง task ด้วยตัวเอง คุณจะใช้ context manager ที่ช่วยให้มั่นใจว่าทุก task ที่ถูกสร้างขึ้นจะทำงานจนเสร็จสิ้นอย่างถูกต้อง หาก task หนึ่งเกิด exception ขึ้น task อื่นๆ จะถูกยกเลิกโดยอัตโนมัติ
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
รูปแบบนี้ยอดเยี่ยมมากสำหรับ workflow ที่ซับซ้อน มันช่วยลดความเสี่ยงในการทิ้ง Task ให้ค้างอยู่โดยไม่มีการจัดการ (orphaning a Task) และช่วยจัดกลุ่มวงจรชีวิต (lifecycle) ของการทำงานที่เกี่ยวข้องกันไว้ภายใต้โครงสร้างทางตรรกะเดียวกัน หาก codebase ของคุณรันบน Python 3.11 หรือใหม่กว่า นี่มักจะเป็นสถาปัตยกรรมที่สะอาดที่สุดสำหรับการทำ fan-out concurrency
โมเดลทางความคิด: await หมายถึง "รันสิ่งนี้เดี๋ยวนี้"
บทเรียนสำคัญคือเรื่องของภาษา ใน JavaScript คุณสามารถอ่าน await ว่า "ในระหว่างนี้" ได้ คุณเริ่มทำงานบางอย่าง ทำอย่างอื่นไปพลาง และหยุดรอเฉพาะเมื่อคุณต้องการค่าที่ได้ ใน Python await หมายถึง "ขับเคลื่อน coroutine นี้ไปยังจุดพัก (suspension point) ถัดไปหรือจนกว่าจะเสร็จสิ้น" หาก coroutine ยังไม่ถูกจัดตารางเวลา (scheduled) ตัว await นี่แหละจะเป็นตัวจัดตารางให้ นั่นคือเหตุผลที่คุณไม่สามารถเริ่ม coroutine เปล่าๆ สองตัวแล้วค่อยมา await พวกมันในภายหลังได้ เพราะคุณไม่ได้มอบหมายงานอะไรให้ event loop ทำในระหว่างนั้นเลย
ให้คิดว่า Python coroutines เหมือนกับ generator functions การเรียก generator ไม่ได้เป็นการวนลูป (iterate) มัน คุณต้องวนลูปผ่านมัน, เรียก next(), หรือส่งมันให้กับ consumer ระบบ async ก็ทำงานในลักษณะเดียวกัน asyncio.create_task คือ consumer ที่บอกว่า "เอาสิ่งนี้ใส่ลงใน event loop เดี๋ยวนี้" ส่วน await ที่ตามมาก็เป็นเพียงการรอสัญญาณว่าทำงานเสร็จสิ้นแล้ว
นิสัยที่เป็นรูปธรรมอย่างหนึ่งที่ช่วยได้คือ: เมื่อใดก็ตามที่คุณกำหนดค่าการเรียก async function ให้กับตัวแปรโดยไม่มี await ให้ถามตัวเองว่าคุณได้จัดตารางเวลา (scheduled) ให้มันหรือยัง หากฝั่งขวาไม่ได้ถูกห่อหุ้มด้วย create_task, gather, หรือ TaskGroup มันก็ไม่ได้กำลังทำงานอยู่ มันเป็นเพียงแค่ "สูตรอาหาร" ที่วางทิ้งไว้บนเคาน์เตอร์เท่านั้น
สรุปประเด็นสำคัญ
async runtime ของ Python นั้นทรงพลัง แต่ต้องการความชัดเจนในเจตนา ภาษาจะไม่เริ่มทำงานเบื้องหลัง (background work) เพียงเพราะคุณเรียกฟังก์ชัน หากคุณมาจาก JavaScript ให้ตรวจสอบทุกจุดที่คุณเก็บ coroutine ไว้ในตัวแปรแล้วค่อย await ในภายหลัง หากคุณไม่ได้เปลี่ยนมันให้เป็น Task ก่อน สิ่งที่คุณเขียนก็คือโค้ดแบบลำดับ (sequential code) ที่แต่งตัวด้วยชุด async เท่านั้น เริ่มต้นการทำงานด้วย Task แล้วค่อยรอผลลัพธ์ นั่นคือวิธีที่คุณจะเปลี่ยน Python async จากคอขวดที่เงียบเชียบให้กลายเป็นเครื่องมือ concurrency ที่แท้จริง
