รู้ลึกการทำงานแบบ Asynchronous กับ Event Loop

Nuttavut Thongjor

ก่อนที่เราจะเริ่มบทความนี้กัน ผมมีโค๊ดตัวอย่างให้ทดสอบกันครับ เริ่มจาก...

JavaScript
1const fn1 = () => {
2 setTimeout(() => {
3 console.log('fn1')
4 }, 2000)
5}
6
7const fn2 = () => {
8 fn1()
9 setTimeout(() => {
10 console.log('fn2')
11 }, 1000)
12}
13
14const fn3 = () => {
15 fn2()
16}
17
18fn3()

คุณคิดว่าเมื่อ Run โปรแกรมนี้แล้วผลลัพธ์จะออกมาเป็นเช่นไร? หากคำตอบของคุณคือ fn2 และ fn1 ตามลำดับแล้ว ยินดีด้วยครับ คุณเข้าใจบทความนี้โดยไม่ต้องอ่านไปครึ่งหนึ่งแล้ว แต่ช้าก่อน... ยังเหลืออีกครึ่งเป็นโจทย์สำหรับคุณ...

JavaScript
1for (var i = 0; i < 3; i++) {
2 setTimeout(() => {
3 console.log(i)
4 }, 100)
5}

โค๊ดข้างบนเป็นเพียงการวนลูปง่ายๆ แต่คำตอบอาจไม่ง่ายเช่นที่คิด แล้วคำตอบของคุณเป็นอะไรครับ? หากเป็นการพิมพ์เลข 3 ออกหน้าจอสามครั้งแล้วหละก็ ยินดีด้วยคุณเข้าใจเรื่อง Closure กับ Loop และไม่มีความจำเป็นใดๆต้องอ่านบทความนี้อีก ติดตามอ่านบทความอื่นของ Babel Coder ได้เลยครับ (ยิ้ม)

ใครที่ตอบผิดทั้งสองข้อหรือข้อใดข้อหนึ่งไม่ต้องเสียใจไปครับ บทความนี้ผมจะนำเสนอความหมายของ Asynchronous คำอธิบายของ Event Loop และความเข้าใจใน Closure ในบริบทของ Asynchonous code

Asynchronous Programming

เพื่อไม่ให้เป็นการเสียเวลา เรามาเริ่มกันที่ Asynchronous กันก่อน ผมจะสมมติสถานการณ์ในโลกความจริงให้ฟังตัวอย่างหนึ่ง ผู้อ่านลองจินตนาการตามนะครับ วันที่อากาศร้อนอบอ้าว คุณเดินผ่านร้านส้มตำหน้าปากซอย คุณไม่ได้ทานมันนานแล้ว น้ำลายสออยากลิ้มรสของเผ็ดๆมาก คุณเดินเข้าไปสั่งส้มตำกับแม่ค้าในจังหวะที่ไม่มีลูกค้าแม้แต่คนนึง แม่ค้าฟาดสากกระทบครกเพื่อตำส้มตำให้คุณ ในจังหวะนั้นเองคุณพบว่า ลูกค้าคนอื่นๆไม่สามารถสั่งส้มตำได้แต่ต้องต่อคิวรอให้ป้าแม่ค้าตำเสร็จก่อนถึงจะรับรายการต่อไป วันถัดมาคุณยังคงถวิลหาส้มตำร้านนั้นอยู่แต่พบว่าลูกค้าต่อคิวเยอะมาก คุณไม่อยากรอคิวจึงเดินไปสั่งร้านฝั่งตรงข้าม ร้านใหม่นี้เป็นสิ่งที่คุณประทับใจ คุณไม่จำเป็นต้องรอให้แม่ค้าสาวสวยตำครกก่อนหน้าเสร็จก่อนถึงสั่งอาหารได้ คุณเพียงจดรายการอาหารให้แม่ค้า หลังจากนั้นคุณก็ไปเดินชิลซักที่ เมื่อเสร็จแม่ค้าสวยอึ๋มจะโทรตามคุณเอง

สถานการณ์สมมติทั้งสองนี้คุณคงมองความแตกต่างออกใช่ไหมครับ สถานการณ์แรกที่ทุกอย่างทำเป็นขั้นตอนไม่สามารถลัดได้ งานที่1ต้องเสร็จก่อนจึงจะเริ่มงานที่2ได้ เราเรียกรูปแบบนี้ว่าเป็น Synchronous ซึ่งจะต่างจาก Asynchonous แบบสถานการณ์ที่สองที่ไม่ต้องรอให้งาน1เสร็จก่อนถึงจะเริ่มงาน2 นั่นคือในขณะที่แม่ค้าตำส้มตำไป คุณยังสามารถสั่งอาหารได้ ไม่ต้องรอกัน เมื่ออาหารเสร็จแม่ค้าจะโทรตามคุณมารับอาหาร เรียกเป็นภาษาโปรแกรมว่า Callback แปลเป็นไทยสวยๆได้ว่า เสร็จแล้วเรียกเค้านะเบบี๋ เราจะกล่าวถึง Callback กันภายหลังครับ ตรงนี้ขอให้แยก Synchronous ออกจาก Asynchronous ได้ก่อน

การติดต่อกับ I/O หรือ Network ไม่ต่างอะไรกับรอป้าตำส้มตำ เป็นเรื่องที่ใช้เวลาทั้งคู่ ตัวอย่างเช่น คุณเขียนโปรแกรมเพื่อดึงข้อมูลจาก Google ที่ใช้เวลานานเนื่องจากคุณต้องรอให้ Google ตอบกลับคำร้องของคุณ ถ้าอินเตอร์เน็ตสัญญาณไม่ดีก็อาจจะรอแล้วรอเล่าจน Timeout ไป ฉะนั้น Synchronous Programming จะทำให้คุณต้องรอผลลัพธ์ที่ยาวนานนี้ โดยไม่สามารถทำงานอย่างอื่นได้ มันไม่ดีสำหรับโปรแกรมของคุณแน่ๆ พิจารณาตัวอย่างล่างครับเป็นภาษา Ruby บรรทัดที่3จะต้องรอผลลัพธ์จากบรรทัดที่2

Ruby
1uri = URI('http://www.google.co.th/search?q=async')
2response = Net::HTTP.get_response(uri)
3puts response # รอ~~~ เพื่อทำงาน
4puts 123

Call Me Baby

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

JavaScript
1http.get('http://www.google.co.th/search?q=async', (response) => {
2 console.log(response)
3})
4
5console.log(123)

เมื่อเราสั่ง Run โปรแกรมนี้พบว่าโปรแกรมพิมพ์ 123 ออกหน้าจอทันทีโดยไม่รอให้ Google ตอบผลลัพธ์การค้นหามาก่อน เรายัด Anonymous Function (ฟังก์ชันไร้ชื่อ) คือ (response) => { ... } ใส่เข้าไปเป็นพารามิเตอร์ตัวที่สองของ http.get เจ้าฟังก์ชันนี้หละครับคือ Callback โดยโค๊ดที่อยู่ภายในฟังก์ชันจะทำงานเมื่อ Google ตอบกลับมา โดยผลลัพธ์ของการค้นหาคือตัวแปร response

Call Stack

JavaScript นั้นเป็น single-threaded หมายความว่าตัวมันเองเป็นญาติกับแม้ค้าส้มตำร้านแรก ต้องรอให้โค๊ดบรรทัดบนทำงานเสร็จก่อนจึงจะทำโค๊ดบรรทัดถัดไปได้ แต่ถึงอย่างนั้น JavaScript ก็ยังคงสนับสนุนการเขียนโปรแกรมแบบ Asynchronous ด้วยเช่นกัน เขาทำได้อย่างไร เราจะศึกษาและทำความเข้าใจในเรื่องของ Call Stack และ Event Loop

พิจารณาโค๊ดแบบ Synchronous ต่อไปนี้ครับ คุณคิดว่ามีอะไรเกิดขึ้่นใน Memory ของเราบ้างและอะไรคือผลลัพธ์จากการทำงานด้วยคำสั่งชุดนี้?

JavaScript
1const fn1 = () => {
2 console.log('fn1')
3}
4
5const fn2 = () => {
6 fn1()
7 console.log('fn2')
8}
9
10fn2()

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

  • เริ่มทำงานในบรรทัดที่10
  • เรียก fn2 ในบรรทัดที่5
  • JavaScript ยัด fn2 ลงใน call stack เป็นสิ่งแรก
  • พบว่าเมื่อโค๊ดใน fn2 ทำงานจะเรียก fn1 อีกที ดังนั้น fn1 จึงโดนยัดลงไปใน call stack เป็นสิ่งที่สอง

ก่อนที่จะอธิบายการทำงานต่อไป ขอให้เราจินตนาการ call stack เหมือนการคว่ำจานซ้อนกัน จานใบสุดท้ายที่เราคว่ำก็คือจานบนสุด ฉะนั้นแล้วจานใบแรกที่คว่ำอยู่ล่างสุดจะต้องเป็นใบสุดท้ายที่เราหยิบออก call stack ก็เช่นกัน ตอนนี้เรายัดทั้ง fn2 และ fn1 ลงใน call stack แล้ว ไม่มีอะไรให้เรายัดลงไปอีก JavaScript จึงเริ่มประมวลผลตามลำดับด้วยการ...

  • หยิบจานใบบนสุดคือ fn1 ออกจาก call stack ดึงออกมาทำงานนั่นคือพิมพ์ fn1 ออกหน้าจอ
  • เสร็จแล้วจึงหยิบจานใบถัดไปคือ fn2 ที่มีการทำงานคือพิมพ์ fn2 ออกหน้าจอ

เมื่อ call stack โล่งก็จบการทำงาน ถ้าอ่านแล้วไม่เข้าใจลองดูรูปข้างล่างครับ

Call Stack

แล้วถ้ามี Callback ด้วยละ?

คราวนี้ลองมาดู Call Stack เมื่อต้องปะทะ Callback ในโค๊ดแบบ Asynchronous กันบ้าง

JavaScript
1const fn1 = () => {
2 console.log('fn1')
3}
4
5const fn2 = () => {
6 setTimeout(fn1, 100)
7 console.log('fn2')
8}
9
10fn2()

โปรแกรมนี้ยังคงเริ่มบรรทัดที่10เช่นกันซึ่งจะไปปลุกฟังก์ชัน fn2 ในบรรทัดที่5แล้วจับยัดใส่ Call Stack บรรทัดถัดมาคือหมายเลข6 เราพบฟังก์ชัน setTimeout จึงจับยัดลง Call Stack ต่อจาก fn2

ถึงตอนนี้ Call Stack และ Event Table ของเราจะมีหน้าตาเป็นแบบนี้

Async Call Stack

ตอนนี้ Call Stack ของเราก็เสร็จสมบูรณ์แล้ว ได้เวลาดึงจานทีละใบขึ้นมาทำงานแล้ว! เริ่มจากดึง setTimeout ที่อยู่บนสุดออกมาทำงาน

เนื่องจาก setTimeout เป็น Asynchronous ไม่ได้ทำงานทันที แต่จะเรียก Callback คือ fn1 ขึ้นมาทำงานภายหลัง100มิลลิวินาทีผ่านไป ด้วยเหตุนี้เราจึงไม่ยัด f1 ลง Call Stack แต่จะใส่ไว้ในสิ่งที่เรียกว่า Event Table พร้อมกำกับไว้ด้วยว่า จะปลุก f1 มาทำงานหลัง 100มิลลิวินาทีผ่านไปดังรูปด้านล่าง

Event Table

ต่อมา JavaScript Engine จะพิจารณาของใน Call Stack ตัวถัดไปคือ fn2 เป็นผลให้พิมพ์ fn2 ออกทางหน้าจอ เมื่อจบแล้วจึงหยิบ main ขึ้นมาทำจบการทำงานของ Call Stack แต่เพียงนี้

ถึงเวลาของ Event Loop แล้ว

ในช่วงเวลาใดเวลาหนึ่งที่ 100 มิลลิวินาทีผ่านไป JavaScript Engine จะเคลื่อน fn1 ออกจาก Event Table แล้วใส่ลงไปที่ Event Queue เพื่อรอทำงาน ถามว่าทำไมถึงไม่ทำทันที? นั่นเป็นเพราะมันชื่อ อีเวนต์คิว เลยต้องรอคิวทำงาน โดยสิ่งที่มันรอคือ รอให้ของทุกชิ้นใน Call Stack ทำงานหมดก่อน จากนั้นของใน Event Queue ซึ่งก็คือ fn1 จึงจะเคลื่อนไปอยู่ใน Call Stack เริ่มการทำงานถัดไป

ตรงจุดนี้อยากให้ทุกคนสังเกตอยู่สองอย่างครับ คือ

  • การทำงานของ Event Table ที่ต้องรอครบ 100 มิลลิวินาทีนั้น ทำไปพร้อมๆกับการทำงานของ Call Stack โดยไม่ได้รอคอยซึ่งกันและกัน
  • ลักษณะการทำงานที่เคลื่อน Callback ออกจาก Event Queue ไปสู่ Call Stack และนำ Event ไปลงทะเบียนใน Event Table นี้เป็นการทำงานซ้ำไปซ้ำมา (มีการทำงานแบบวน loop) เราจึงเรียกการทำงานลักษณะนี้ว่า Event Loop

Event Loop

ถ้ายังจำกันได้ผมบอกไว้ว่า JavaScript เป็น Single Thread แล้วทำไมมันถึงทำงานใน Call Stack แถมยังรอเวลาใน SetTimeout พร้อมๆกันไปได้ อย่างนี้มันก็ไม่ใช่ Thread เดียวแล้วซิ?

ใช่แล้วครับ ตัว Event Loop นั้นเป็น Single Thread แต่มันย้ายงานอะไรที่ใช้เวลานานๆ เช่น รอคอย Response จาก Google ที่เป็น Asynchronous code ไปให้ Non-blocking Worker ซึ่งเป็น Threadpool ทำงาน แน่นอนว่าเมื่อส่วนนี้ไม่ได้ทำด้วย Thread เดียว เราจึงได้เห็นความสามารถต่างๆตามที่ได้อธิบายไว้ครับ

Event Loop

ออกทะเลแล้ว ว่ายน้ำเข้าฝั่งกัน! อึ๊บๆ

เอาหละ หลังจากเข้าใจคอนเซ็บกันแล้ว ย้อนมาดูปัญหาตั้งต้นของเราซักนิดนึง

JavaScript
1const fn1 = () => {
2 setTimeout(() => {
3 console.log('fn1')
4 }, 2000)
5}
6
7const fn2 = () => {
8 fn1()
9 setTimeout(() => {
10 console.log('fn2')
11 }, 1000)
12}
13
14const fn3 = () => {
15 fn2()
16}
17
18fn3()

ปัญหาแรกนี้ค่อนข้างตรงตัว หยิบจานกันคนละใบสองใบ เอาหละเริ่มวางได้

โปรแกรมนี้เริ่มการทำงานที่บรรทัด18 โดยลำดับของ Call Stack จากล่างขึ้นบนเป็น fn2, fn1, setTimeout(บรรทัดที่ 2) และ setTimeout(บรรทัดที่ 9) โดย setTimeout ทั้งสองส่ง Anonymous Function ไปไว้ที่ Event Table ผลลัพธ์ที่ได้จะพิมพ์ fn2 และ fn1 ออกหน้าจอตามลำดับ เนื่องจาก setTimeout ใน fn2 จะทำงานภายหลังเวลาผ่านไป 1วินาที (1000 มิลลิวินาที) ซึ่งไวกว่า setTimeout ใน fn1

ถึงตรงนี้บางคนอาจสงสัยต่อว่า 1วินาทีแล้วทำทันทีไหม? ถ้ายังจำ Event Queue ได้จะพบว่าคำตอบขึ้นอยู่กับ Call Stack หากในขณะนั้น Call Stack โล่งพอดี Callback แรกที่อยู่ใน Event Queue ก็จะทำงานทันที แต่ถ้าไม่ก็ต้องรอจนกว่างานใน Call Stack จะหมด นั่นคือ setTimeout ไม่ได้การันตีเวลาที่แน่นอนเราจึงกล่าวได้อีกอย่างว่า Callback ของ SetTimeout ใน fn2 จะทำงานเมื่อเวลาผ่านไป มากกว่าหรือเท่ากับ 1000มิลลิวินาที

ปัญหาสุดท้ายเป็นเรื่องของ Closure นั่นคือ Anonymous Function ที่ส่งเข้าไปใน setTimeout จะยังไม่ทำงานทันที แต่จะทำงานเมื่อเวลาผ่านไป 100มิลลิวินาที โปรแกรมนี้วนลูปไปสามครั้งแต่ละครั้งทำเพียงแค่นำ setTimeout ไปใส่ไว้ใน Call Stack เมื่อใส่จนครบพบว่าตัวแปร i มีค่าเป็น 3 พอถึงเวลาที่ Callback ต้องทำงานแล้วพบว่าตัวแปร i ที่อ้างอิงอยู่มีค่าเป็น3 จึงได้ผลลัพธ์เป็นการพิมพ์เลข3ออกหน้าจอสามครั้ง

JavaScript
1for (var i = 0; i < 3; i++) {
2 setTimeout(() => {
3 console.log(i)
4 }, 100)
5}

วิธีแก้ปัญหาข้อนี้มีหลายวิธีแต่วิธีที่จะนำเสนอนี้เป็นการแก้ปัญหาที่ง่ายนั่นคือการใช้ let แทน var

JavaScript
1for (let i = 0; i < 3; i++) {
2 setTimeout(() => {
3 console.log(i)
4 }, 100)
5}

การประกาศตัวแปรผ่าน var จะทำให้ตัวแปรดังกล่าวเป็น global variable หรือเป็นตัวแปรแบบ function scope ผิดกับ let ที่ทำให้ตัวแปรมีสถานะเป็น block scope ผู้อ่านที่สนใจสามารถค้นหาคำอธิบายเรื่องนี้ได้จากอินเตอร์เน็ตนะครับ ผมคงไม่ลงลึกในบทความนี้

จบแล้วครับกับบทความนี้ เราได้อะไรบ้างจากการศึกษาเรื่อง Event Loop และ Asynchonous Programming? ผมเชื่อว่าผู้อ่านหลีกเลี่ยงไม่ได้ที่จะเจอสารพัด Callback ใน JavaScript ผมจึงคาดหวังว่าอย่างน้อยบทความนี้จะทำให้คุณเข้าใจมากขึ้นว่าทำไมเราต้องมี Callback นอกจากนี้คุณอาจเคยเห็น process.nexttick ใน Node.js แล้วไม่เข้าใจว่ามันคืออะไร Event Loop ที่คุณพึ่งอ่านไปจะทำให้คุณเข้าใจมันมากขึ้น ไม่เชื่อคุณลองดูครับ แล้วจะร้องอ๋อทันที

เอกสารอ้างอิง

Mozilla Developer Network. Concurrency model and Event Loop. Retrieved April, 11, 2016, from https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop

Aaronontheweb (2011). Intro to Node.JS for .NET Developers. Retrieved April, 11, 2016, from http://www.aaronstannard.com/intro-to-nodejs-for-net-developers/

Erin Swenson-Healey (2013). The JavaScript Event Loop: Explained. Retrieved April, 11, 2016, from http://blog.carbonfive.com/2013/10/27/the-javascript-event-loop-explained/

Willson Mock (2014). What is the JavaScript Event Loop?. Retrieved April, 11, 2016, from http://altitudelabs.com/blog/what-is-the-javascript-event-loop/

สารบัญ

สารบัญ

  • Asynchronous Programming
  • Call Me Baby
  • Call Stack
  • แล้วถ้ามี Callback ด้วยละ?
  • ถึงเวลาของ Event Loop แล้ว
  • ออกทะเลแล้ว ว่ายน้ำเข้าฝั่งกัน! อึ๊บๆ
  • เอกสารอ้างอิง