ทำไมโปรแกรมเมอร์ต้องรู้จัก Asynchronous Programming
เวลาเราเขียนโปรแกรมทั่วไป คอมพิวเตอร์จะทำตามคำสั่งจากบรรทัดบนลงล่างทีละขั้นตอน ซึ่งเราเรียกการทำงานแบบนี้ว่า Synchronous (การทำงานแบบตามลำดับที่ต้องรอให้งานเก่าเสร็จก่อนถึงจะเริ่มงานใหม่ได้) เหมือนกับการต่อคิวซื้อกาแฟที่ทุกคนต้องรอให้คนข้างหน้าได้รับเครื่องดื่มก่อนถึงจะสั่งได้ ซึ่งถ้าคิวแรกสั่งเมนูที่ทำยาก พนักงานก็จะยืนทำเมนูนั้นคนเดียวจนกว่าจะเสร็จ ทำให้คิวข้างหลังเสียเวลาไปเปล่าๆ
แต่ในโลกของ Node.js (สภาพแวดล้อมที่ช่วยให้รันภาษา JavaScript บนเครื่องคอมพิวเตอร์ได้) เรามักต้องทำงานที่ใช้เวลานาน เช่น การดึงข้อมูลจากฐานข้อมูล หรือการอ่านไฟล์ขนาดใหญ่ ถ้าเราใช้การทำงานแบบตามลำดับ โปรแกรมจะค้างจนกว่างานนั้นจะเสร็จ ซึ่งส่งผลเสียต่อ User Experience (ประสบการณ์ที่ผู้ใช้งานได้รับ) อย่างมาก
เราจึงต้องใช้ Asynchronous Programming (การจัดการงานแบบไม่รอให้งานเดิมเสร็จก่อน) เพื่อให้โปรแกรมทำงานอื่นต่อไปได้ในระหว่างที่รอกระบวนการที่ใช้เวลานาน นี่คือทักษะสำคัญที่โปรแกรมเมอร์ทุกคนต้องมี เพราะมันช่วยให้แอปพลิเคชันของเราตอบสนองได้รวดเร็วและรองรับผู้ใช้งานได้พร้อมกันหลายคนครับ
รู้จักกับ Callback Functions
Callback Functions (ฟังก์ชันที่ถูกส่งเข้าไปเป็นอาร์กิวเมนต์เพื่อให้ทำงานหลังจากงานหลักเสร็จสิ้น) คือวิธีพื้นฐานที่สุดในการจัดการงานที่ไม่รู้ว่าจะเสร็จเมื่อไหร่ ลองนึกภาพว่าคุณสั่งอาหารแล้วพนักงานให้บัตรคิวมาแทนที่จะยืนรอหน้าเคาน์เตอร์ เมื่ออาหารเสร็จพนักงานจะเรียกเลขคิวของคุณ นั่นแหละคือหลักการของ Callback
ในภาษา JavaScript เราสามารถส่งฟังก์ชันเข้าไปในฟังก์ชันอื่นได้ เพื่อบอกว่า "เมื่อทำงานนี้เสร็จแล้ว ให้เรียกฟังก์ชันนี้ต่อด้วยนะ" วิธีนี้ช่วยให้เราไม่ต้องหยุดโปรแกรมเพื่อรอผลลัพธ์ แต่เราต้องระวังให้ดี เพราะถ้าเขียนซ้อนกันเยอะเกินไป โค้ดจะอ่านยากมากจนกลายเป็นปัญหาที่เรียกว่า Callback Hell (สภาวะที่โค้ดซ้อนกันเป็นชั้นๆ จนดูเหมือนพีระมิด)
ลองดูตัวอย่างการใช้ Callback ในการอ่านไฟล์กันครับ
const fs = require('fs'); // ดึงโมดูลสำหรับจัดการไฟล์
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) {
console.log('เกิดข้อผิดพลาด:', err);
return;
}
console.log('ข้อมูลที่อ่านได้:', data);
});
ในโค้ดนี้ fs.readFile คือฟังก์ชันที่สั่งอ่านไฟล์ โดยเราส่งฟังก์ชันลูกศร (err, data) => { ... } เข้าไปเป็นพารามิเตอร์ตัวสุดท้าย บรรทัดแรกคือการนำเข้า fs (File System) เพื่อใช้คำสั่งอ่านไฟล์ บรรทัดที่สองคือการเรียกคำสั่งโดยระบุชื่อไฟล์และรูปแบบตัวอักษร ส่วนฟังก์ชันข้างในจะทำงานทันทีที่อ่านไฟล์เสร็จ โดยมี err ไว้เก็บกรณีเกิดข้อผิดพลาด และ data ไว้เก็บเนื้อหาในไฟล์
ผลลัพธ์ที่ควรเห็น: หากไฟล์ data.txt มีข้อความว่า "Hello World" หน้าจอจะแสดงผลว่า ข้อมูลที่อ่านได้: Hello World
ทำความเข้าใจปัญหา Callback Hell
Callback Hell (ปัญหาโค้ดซ้อนกันลึกจนอ่านไม่ออก) เกิดขึ้นเมื่อเราต้องทำงานหลายอย่างที่ต้องทำต่อกันเป็นทอดๆ เช่น ต้องอ่านไฟล์ก่อน แล้วค่อยส่งข้อมูลนั้นไปเขียนไฟล์ใหม่ แล้วค่อยส่งไปบันทึกในฐานข้อมูล ถ้าใช้ Callback แบบเดิม เราจะเขียนฟังก์ชันซ้อนกันไปเรื่อยๆ จนโค้ดเอียงไปทางขวาเหมือนบันได
สถานการณ์นี้ทำให้การจัดการข้อผิดพลาดทำได้ยากมาก เพราะเราต้องเช็ค if (err) ในทุกๆ ชั้นที่ซ้อนกันอยู่ ถ้าลืมเช็คตรงไหนไปสักจุด โปรแกรมอาจจะพังโดยที่เราไม่รู้สาเหตุเลยว่าเกิดอะไรขึ้นที่บรรทัดไหน การแก้ไขโค้ดที่ซ้อนกันแบบนี้ในอนาคตจึงเป็นเรื่องที่เหนื่อยมากสำหรับโปรแกรมเมอร์
ให้ลองจินตนาการว่าคุณต้องทำภารกิจ 3 อย่างที่ต้องรอผลลัพธ์จากกันและกัน โค้ดที่ไม่มีการจัดการที่ดีจะดูยุ่งเหยิงและไม่มีระเบียบแบบนี้ครับ
// ตัวอย่างแบบที่ควรหลีกเลี่ยง (Callback Hell)
doStep1((err, result1) => {
doStep2(result1, (err, result2) => {
doStep3(result2, (err, result3) => {
console.log('ผลลัพธ์สุดท้าย:', result3);
});
});
});
บรรทัดแรกเริ่มทำงานขั้นตอนที่ 1 เมื่อเสร็จจะเรียก doStep2 ต่อไปเรื่อยๆ จนถึง doStep3 สังเกตว่าวงเล็บและปีกกาจะซ้อนกันลึกเข้าไปเรื่อยๆ ทำให้โค้ดอ่านยากและดูแลรักษายากมาก นี่คือสัญญาณเตือนว่าคุณต้องเริ่มหาวิธีอื่นมาแทนที่ Callback แล้วครับ
ผลลัพธ์ที่ควรเห็น: โปรแกรมจะพิมพ์ข้อความ ผลลัพธ์สุดท้าย: [ค่าที่ได้] ออกมา แต่ถ้ามีขั้นตอนที่ 4 หรือ 5 โค้ดจะเริ่มดูไม่ได้เลย
แก้ปัญหาด้วย Promises
Promises (วัตถุที่ใช้แทนค่าที่อาจจะยังไม่มีในตอนนี้ แต่จะมีในอนาคต) ถูกสร้างมาเพื่อแก้ปัญหา Callback Hell โดยการเปลี่ยนโครงสร้างโค้ดแบบซ้อนกัน ให้กลายเป็นการเขียนแบบเรียงแถวลงมาแทน Promise จะมีสถานะอยู่ 3 อย่างคือ Pending (กำลังรอ), Fulfilled (สำเร็จ), และ Rejected (ล้มเหลว)
เราใช้ .then() เพื่อระบุว่าถ้างานสำเร็จให้ทำอะไรต่อ และใช้ .catch() เพื่อดักจับข้อผิดพลาดที่เกิดขึ้นในทุกขั้นตอน นี่ทำให้โค้ดดูสะอาดขึ้นและจัดการ Error Handling (การจัดการข้อผิดพลาด) ได้รวมศูนย์มากขึ้น ไม่ต้องคอยเช็ค err ในทุกๆ บรรทัดอีกต่อไป
ลองดูตัวอย่างการใช้ Promise แทนที่โค้ดแบบเก่ากันครับ
doStep1()
.then(result1 => doStep2(result1))
.then(result2 => doStep3(result2))
.then(result3 => console.log('ผลลัพธ์:', result3))
.catch(err => console.error('พบข้อผิดพลาด:', err));
ในโค้ดนี้ doStep1() จะส่งคืนค่า Promise ออกมา ทำให้เราสามารถใช้ .then() มาต่อท้ายได้แบบลูกโซ่ (Chain) ถ้าขั้นตอนใดขั้นตอนหนึ่งเกิดข้อผิดพลาด Promise จะข้ามการทำงานใน .then() ไปที่ .catch() ทันที ทำให้เราเขียนโค้ดที่จัดการความผิดพลาดไว้ที่เดียวตอนท้ายได้เลย
ผลลัพธ์ที่ควรเห็น: หน้าจอจะแสดง ผลลัพธ์: [ค่าสุดท้าย] หากทุกอย่างสำเร็จ หรือแสดงข้อความใน catch หากมีขั้นตอนใดขั้นตอนหนึ่งทำงานล้มเหลว
ก้าวสู่ความเชี่ยวชาญด้วย async/await
async/await (รูปแบบการเขียนโค้ดที่ทำให้ Asynchronous ดูเหมือนโค้ดทั่วไปที่ทำงานตามลำดับ) เป็นวิธีที่ได้รับความนิยมที่สุดในปัจจุบัน มันถูกสร้างขึ้นมาครอบบน Promise อีกทีเพื่อให้เราเขียนโค้ดได้อ่านง่ายเหมือนภาษาโปรแกรมแบบปกติ โดยไม่ต้องมี .then() มาคั่นกลางให้ลายตา
คำว่า async จะถูกใส่ไว้หน้าฟังก์ชันเพื่อบอกว่าฟังก์ชันนี้จะมีการทำงานแบบรอผลลัพธ์ ส่วน await จะใช้หน้าคำสั่งที่ต้องรอผลลัพธ์จาก Promise เมื่อโปรแกรมอ่านถึงบรรทัดที่มี await มันจะหยุดรอผลลัพธ์ตรงนั้นก่อนจะไปบรรทัดถัดไป แต่การหยุดนี้จะไม่ทำให้ทั้งแอปพลิเคชันค้างครับ
ลองเปลี่ยนโค้ดจาก Promise มาเป็น async/await ดูครับ
async function runTasks() {
try {
const r1 = await doStep1();
const r2 = await doStep2(r1);
const r3 = await doStep3(r2);
console.log('ผลลัพธ์:', r3);
} catch (err) {
console.error('พบข้อผิดพลาด:', err);
}
}
runTasks();
เราต้องห่อคำสั่ง await ไว้ในบล็อก try...catch เพื่อจัดการข้อผิดพลาดแทน .catch() ของ Promise บรรทัด const r1 = await doStep1() จะรอให้งานเสร็จแล้วเก็บค่าไว้ในตัวแปร r1 ทันที วิธีนี้ทำให้โค้ดของเราดูเป็นธรรมชาติและเรียงลำดับขั้นตอนได้อย่างชัดเจนที่สุด
ผลลัพธ์ที่ควรเห็น: ผลลัพธ์จะเหมือนกับ Promise ทุกประการ แต่โค้ดจะอ่านง่ายและจัดการได้สะดวกกว่ามากในโปรเจกต์ขนาดใหญ่
สรุป: นำไปใช้จริงในการทำงาน
การเข้าใจการทำงานแบบ Asynchronous เป็นด่านสำคัญที่แยกมือใหม่กับโปรแกรมเมอร์ที่ทำงานจริงออกจากกัน ในชีวิตการทำงานจริง คุณจะต้องดึงข้อมูลจาก API (ช่องทางเชื่อมต่อระหว่างแอปฯ) หรือเชื่อมต่อกับฐานข้อมูลอยู่ตลอดเวลา การใช้ async/await จะช่วยให้คุณเขียนโค้ดที่สะอาดและลดโอกาสเกิดบั๊กได้มหาศาล
คำแนะนำสำหรับมือใหม่คือ ให้เริ่มฝึกเปลี่ยนโค้ด Callback เก่าๆ มาเป็น Promise แล้วค่อยอัปเกรดเป็น async/await ในทุกๆ ฟังก์ชันที่ต้องดึงข้อมูลภายนอก อย่าลืมว่าการทำ Error Handling ด้วย try...catch ทุกครั้งเป็นวินัยที่ดีมาก เพราะจะช่วยให้โปรแกรมของคุณไม่ปิดตัวลงทันทีเมื่อเจอข้อมูลผิดพลาด
ตัวอย่างการนำไปใช้จริง: สมมติว่าคุณกำลังสร้างแอปฯ สภาพอากาศ คุณต้องดึงข้อมูลจากเซิร์ฟเวอร์ก่อน แล้วค่อยเอาข้อมูลนั้นไปแสดงผลที่หน้าจอ ให้คุณเขียนฟังก์ชัน fetchWeather ที่ใช้ async แล้ว await ผลลัพธ์จาก API จากนั้นค่อยเรียกฟังก์ชันแสดงผล วิธีนี้จะทำให้แอปฯ ของคุณดูลื่นไหลและเป็นมืออาชีพครับ