JavaScript #06: ในมิติ Asynchronous Programming

เกริ่นนำ

บทความนี้ผมขอนำเสนอเรื่องราวเกี่ยวกับ asynchronous programming ซึ่งในตอนแรกผมตั้งใจจะเขียนเรื่อง modular programming แต่จนใจที่เครื่องไม้เครื่องมืออย่าง node.js และ io.js รวมถึง browser ต่างๆ ส่วนมากยังไม่รองรับ ถ้าจะดันทุรังเขียน มันก็คงมีแต่โค้ดที่รู้ไว้ใช่ว่า ทำงานจริงไม่ได้ ผมเลยขอข้ามไปก่อนอย่างไม่มีกำหนด เอาไว้เมื่อใดที่เครื่องมือต่างๆ ส่วนใหญ่เขาบรรจุมาแล้ว ผมค่อยเขียนให้ท่านทั้งหลายอ่านกันอีกที

ตั้งแต่แรกที่ผมเขียนบทความชุดนี้มา ผมหลีกเลี่ยงไม่กล่าวถึงสิ่งอื่นใด จับจุดเฉพาะตัวภาษา ECMAScript แต่อย่างเดียวเท่านั้น แต่เนื้อหาในบทนี้คงต้องขอเป็นข้อยกเว้น เพราะเนื้อหามีความสัมพันธ์ใกล้ชิดกับการทำงานภายในของทั้ง browser และ node ถ้าไม่อธิบายในส่วนนี้ ก็จะไม่เข้าใจ

ขอนอกเรื่องเล็กน้อยครับ วันที่ผมเขียนบทความนี้ ES6 เพิ่งได้รับการประกาศใช้เป็นทางการ และได้เปลี่ยนชื่อใหม่เป็น ECMAScript 2015 ดังนั้นถ้าจะเรียก ES6 ก็จะดูเชย แต่ก็ทำอะไรไม่ได้แล้ว เพราะใช้คำว่า ES6 มาโดยตลอด ผมก็คงต้องใช้คำว่า ES6 ต่อไปเพื่อความเป็นเอกภาพ

สำหรับผู้ที่สนใจ สามารถศึกษาสเปคของ ECMAScript 2015 ได้ที่ http://www.ecma-international.org/ecma-262/6.0

Asynchronous programming คืออะไรหนอ

Asynchronous ถ้าจะให้ง่ายก็ควรทำความเข้าใจ synchronous เสียก่อน แล้วก็กลับหลังหัน 180 องศาก็จะกลายเป็น asynchronous โดยอัตโนมัติ ดังนั้นผมจะเริ่มที่คำว่า synchronous กันเลย

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

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

ถ้าเติมคำว่าโปรแกรมมิ่งเข้าไปเป็น synchronous programming  มันก็คือโปรแกรมที่เราเคยเขียนแบบภาษา C พื้นฐานทั่วไปนั่นเอง ทำงานไล่มาเป็นขั้น จะวนรอบจะเรียกฟังก์ชัน มีเงื่อนไขอะไรก็แล้วแต่ ส่วน asynchronous programming ก็พวกที่เป็น event-based programming อย่างที่เราคุ้นเคยในการเขียนโปรแกรมในยุคปัจจุบันนั่นเอง

จำได้ว่าช่วงที่กำลังเขียนบทความ FP  มีคนเขียนถามผมอยู่ข้อหนึ่ง ผมจำไม่ได้แม่นนักว่าถามว่าอะไรแน่ (#สุพจน์เป็นคนขี้ลืม) หาหัวขั้วไม่เจอแล้ว แต่พอจำได้คร่าวๆ ทำนองว่า ใน ECMAScript การเขียนฟังก์ชันซ้อนเป็นพารามิเตอร์ของอีกฟังก์ชันอีกตัวหนึ่งนั้นเป็น asynchronous programming  หรือเป็น non-blocking ใช่หรือไม่ วันนี้ฤกษ์ดีฝนตก เราจะมาหาคำตอบกัน

Gotcha!

ตามธรรมเนียมครับ เรามาเล่น gotcha! กัน วันนี้มี gotcha! ให้เล่นแค่ตัวเดียวนะครับ ไม่ใช่ไม่มี gotcha! อื่นให้เล่น แต่หากโค้ดทั้งบทนี้เป็น gotcha! เกือบทั้งหมด มันจะเยอะเกิน เอาแค่ตัวเดียวดีกว่าครับ ทำไม่ให้มันผิดธรรมเนียมก็แค่นั้น

อธิบายกันก่อนครับ  การ require() มองง่ายๆ ก็คือการ include เอา library มาใช้นั่นเอง node นั้นพยายามทำตัวเองให้เบาที่สุด ถ้าอยากได้อะไรก็ไป require() ข้างนอกมา กรณีนี้ก็เช่นกันครับ ผมอยากได้ library ที่จัดการแฟ้มข้อมูล ผมก็ไป require มา จากนั้นก็เรียก method readFile() ของมันเพื่อไปอ่านข้อมูล ซึ่งมันจะมาเรียก callback ของเราเมื่อทำงานเสร็จ สมมุติว่า ในแฟ้ม 1.txt นั้นมีข้อมูลเพียง 3 ตัวอักษร ‘111’ ถามว่าโปรแกรมนี้แสดงผลอะไรครับ ตอบได้ไหม ผมขอเฉลยเลยก็แล้วกัน

gotcha!  ทำไมถึงได้ค่า undefined หนอ น่าจะเป็น ‘111’ ไม่ใช่หรือ คนที่เก่ง debug ก็รีบปรับโค้ดทันใด เพื่อขอแอบดูค่า ดังนี้

ค่า 111 ก็มานี่ ทำไม str ข้างนอกถึงเป็น undefined

แต่…. เอะ! ทำไมบรรทัด debug ถึงแสดงหลังบรรทัด ‘done’ ที่เป็นบรรทัดสุดท้าย gotcha! gotcha!

เรื่องนี้มีเงื่อนงำครับ มือใหม่ที่ใช้ node ไม่น้อยก็เคยตกม้าตายกับโจทย์นี้  การทำความเข้าใจ gotcha! ตัวนี้ต้องเข้าการทำงานภายในของ browser หรือ node เสียก่อน จึงจะคลาย gotcha! ตัวนี้ได้

การทำงานของ browser

เมื่อเราอยู่ใน browser แล้วทำการ request หน้า web ซักหน้า  web browser ก็จะไปติดต่อ network เพื่อดึงหน้า HTML นั้นมา เมื่อได้หน้าดังกล่าวมาแล้ว ECMAScript engine  (V8,  Rhino, SpiderMonkey เป็นต้น) จะรันโปรแกรมหลักตัวหนึ่งที่อยู่ในระบบ ผมขอเรียกว่าโปรแกรมระบบก็แล้วกัน การทำงานของ ECMAScript engine นั้นเป็นแบบ Virtual Machine เหมือน Python หรือ Java แต่จะทำงานจะเป็นแบบ interpreter หรือ compiler หรือเป็นลูกผสมอย่างก็ก็ขึ้นอยู่กับ ECMAScript engine แต่ละตัว ซึ่งการทำงานของโปรแกรมดังกล่าวเป็นดังรูป

promise1

ผมขออธิบายการทำงานเฉพาะส่วนที่น่าสนใจอย่างย่นย่อดังนี้

การ parse HTML

โปรแกรมในส่วนนี้จะทำหน้าที่อ่านหน้า HTML และนำมา parse ให้เป็นโครงสร้างข้อมูลแบบลำดับชั้นที่เรียกว่า DOM (Document Object Model) ซึ่งก็คือ object ในแบบ ECMAScript ที่เราคุ้นเคยอยู่แล้ว และ ECMAScript จะเข้าถึงข้อมูลชุดนี้ได้โดยตรงตลอดการทำงานของโปรแกรมระบบ และโครงสร้างนี้จะผูกพันกับภาคแสดงผลตลอดเวลา ถ้ามีการเปลี่ยนแปลงอะไรก็ตามในภาคแสดงผล ก็จะส่งผลให้ DOM เปลี่ยนตาม และในทางกลับกันก็เช่นกัน ถ้าเปลี่ยนแปลงอะไรใน DOM ก็จะส่งผลให้ทำให้ภาคแสดงผลเปลี่ยนตามเช่นเดียวกัน

เมื่อ parse ไปพบแฟ้มอื่นๆ เช่นรูปภาพ ก็จะขอดึงรูปภาพนั้นมา เอามาปรับปรุง DOM ดังนั้นภาคแสดงผลก็เปลี่ยนตามไปด้วยเช่นกัน

รัน user code

เมื่อโปรแกรมระบบสร้าง DOM เสร็จเรียบร้อยแล้ว ก็จะมาถึงขั้นตอนการเรียกโค้ดของเรารัน คำว่าโค้ดของเราในที่นี้หมายรวมถึงพวก library ต่างๆ เช่น jQuery หรือ Bootstrap เป็นต้น ซึ่งโค้ดเหล่านี้จะ inline อยู่ในหน้า HTML หรือจะอยู่ในแฟ้มแยกต่างหากก็ตาม ทั้งหมดจะยำรวมกันโดยนำมาต่อกันตามลำดับก่อนหลังตั้งแต่ในเวลาที่เรา parse HTML ในขั้นตอนที่แล้ว กลายเป็นแฟ้มเพียงแฟ้มเดียว ที่ในรูปผมเขียนว่า ‘user code’ จากนั้นโปรแกรมระบบก็จะมา call ใช้งานโปรแกรมดังกล่าว และเมื่อโปรแกรมนั้นทำงานเสร็จ ก็ส่งคืนการควบคุมเข้าสู่โปรแกรมระบบเช่นเดิม

มีข้อน่าสังเกตอยู่สามประการคือ ประการแรก ในส่วน user code จะทำงานโดยใช้เธรดเดียว อันนี้หมายถึงเธรดที่เป็นโค้ดของเรานะครับ ถ้าโค้ดของเราไปเรียกฟังก์ชันของระบบที่ไม่ใช่ของเรา มันอาจจะทำงานแยกอีกเทธดหนึ่งได้ ขึ้นอยู่กับ ECMAScript engine แต่ละตัว

และสืบเนื่องมาจากการทำงานแบบเทรดเดียว ทำให้เกิดประการที่สอง นั่นคือ user code ต้องทำงานจนจบ ยิ่งเร็วยิ่งดี จะมาวนรอบเล่นๆ แบบไม่รู้จบไม่ได้ มิฉะนั้นหน้าเว็บนั้นจะแฮงค์ เพราะไม่ส่งคืนการทำงานเข้าสู่โค้ดระบบ การทำงานก็จะไปไม่ถึงส่วนของ event loop

ประการที่สามเมื่อ user codeทำงานจนจบแล้ว แต่ก็ไม่ใช่จบสิ้นการทำงานนะครับ แค่ส่งการควบคุมกลับสู่โปรแกรมระบบเท่านั้น โปรแกรมระบบยังคงต้องทำงานต่อ ในส่วนของ event loop ดังที่จะได้อธิบายต่อไป

Event loop

event loop ก็คือการวนรอบรอเหตุการณ์ต่างๆ ให้เกิดขึ้น เช่นการเลื่อนเมาส์ การกดปุ่ม การตั้งเวลา และอื่นๆ อีกมากของหน้าเว็บ ซึ่งรายการ event ต่างๆ ที่มี ท่านทราบดีกว่าผมอย่างแน่นอน เมื่อมีเหตุการณ์เกิดขึ้น ก็จะไปเรียกใช้ฟังก์ชันที่ลงทะเบียนไว้นั่นเอง ผมลืมกล่าวไป การลงทะเบียนอาจจะลงในหน้า HMTL หรือในโปรแกรม ECMAScript ก็ได้

ถ้าพิจารณาการทำงานฟังก์ชันที่ตอบสนองกับเหตุการณ์ พบว่าฟังก์ชันเหล่านั้นทำงานเป็นแบบ asynchronous เพราะไม่ยอมทำงานในช่วง user code แต่หากมาทำในขั้น event loop นี้ แถมเวลาในการทำงานก็ยังไม่แน่นอนอีกต่างหาก ขึ้นอยู่กับการเกิดของเหตุการณ์ภายนอก

event loop มีการใช้งานอย่างกว้างขวางมาก โปรแกรมแบบ visual ที่เราคุ้นเคยก็ใช้เช่นกัน การที่เรามี form เปล่า แล้วลากเอาปุ่มไปใส่ จากนั้น double-click เพื่อเขียน messagebox แสดง popup ว่า Hello, World นั้น หาใช่ว่าโปรแกรมทั้งหมดจะมีแค่บรรทัดเดียว นั่นเทียบได้กับ user code ที่ผมกล่าวถึง หลังฉากมันก็มีโปรแกรมระบบทำนองนี้เหมือนกันและทำงานแบบ event loop ในขั้นตอนสุดท้ายเหมือนกันอีกด้วย

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

  • งานที่ขึ้นอยู่กับปัจจัยภายนอก เช่นการกดปุ่มเป็นต้น
  • งานที่ตั้งเวลา เมื่อถือเวลาที่ระบุ ก็จะทำงาน เช่นอีกสามวินาทีเป็นต้น
  • งานที่ทำทันทีถ้าว่าง โดยไม่มีเงื่อนไข

ท่านที่คิดลึกหน่อย อาจจะสงสัยว่าการวนรอบที่วนแล้วตรวจว่ามีเหตุการณ์เข้ามาหรือไม่ ถ้ามีก็ส่งต่อนั้น มันจะไม่กินกำลังเครื่องคอมพิวเตอร์ของเราหมดเลยหรืออย่างไร ผมขอตอบอย่างนี้ครับ วิธีที่ท่านกล่าวมานั้น เราเรียกเป็นภาษาคอมพิวเตอร์ว่า busy-waiting หรือ spinning ซึ่งแน่นอนครับ CPU ทำงานหนัก และไม่ได้งานอีกต่างหาก เหมือนหายใจทิ้งไปวันๆ

วิธีการที่นิยมกันก็คือใช้ความสามารถของ OS ที่ตั้งเวลาปลุก มีไม่น้อยที่ตั้งเวลาปลุกทุกๆ 1ms หรือ 1 วินาทีปลุก 1,000 ครั้ง อย่าทำเป็นเล่นไปนะครับ เครื่องคอมพิวเตอร์ในยุคปัจจุบันนี่ใน 1ms สามารถทำงานได้เป็นหลักร้อยล้านคำสั่งภาษาเครื่องได้อย่างสบายๆ ถ้ากลไกในการตรวจสอบว่ามีเหตุการณ์เข้ามาหรือไม่ ใช้เพียงหลักพันคำสั่งภาษาเครื่องต่อครั้ง ดังนั้นเราจะเหลือกำลัง CPU เพื่อเอาไปใช้งานอื่นได้อีกมากครับ

การทำงานของ node

จาก browser เรามาดูการทำงานของ node ดูบ้าง node นั้นลอกเอาแนวคิดของการทำงานหลังฉากของ browser ไปเต็มๆ แทบจะเหมือนกันอย่างกะแกะ แต่มีข้อแตกต่างบางประการดังนี้

การ parse HTML

node นั้นทำงานบนฝั่ง server ไม่มี HTML ดังนั้นในขั้นตอนการสร้าง DOM และความสัมพันธ์ระหว่าง DOM และ ภาคแสดงผล จึงไม่มี ขั้นตอนนี้ตัดทิ้งไปเลยครับ

การรัน user code

โปรแกรมที่เราเขียนในบทความทั้งชุดนี้ก็คือในส่วนของ user code นั่นเอง ไม่ได้แตกต่างอะไรกับ browser เลย

ประเภทของ event

browser มี event จำพวกการติดต่อผู้ใช้ mouse keyboard และอื่นๆ ที่ผูกพันกับ DOM ซึ่ง node ไม่มี แต่สิ่งที่ node มีเหนือ browser ก็คือ ความสามารถติดต่อกับ I/O  ดังนั้น node จึงมี event เกี่ยวกับแฟ้ม หรือจำพวก network เป็นต้น พูดง่ายๆ ก็คือมี event เหมือนกัน แต่เป็น event คนละประเภทนั่นเอง แต่ก็มี event บางตัวที่เหมือนกัน เช่นพวกตั้งเวลาอย่างเช่น setTimout()  setInterval() เป็นต้น

การสิ้นสุดการทำงาน

โดยปกติ event loop ของ browser มักทำงานไปเรื่อยๆ ไม่รู้จบ ซึ่งต่างจาก node คือ สำหรับ node แล้วถ้า event queue ว่าง ไม่มีอะไรเหลือ การจะวน event loop ต่อไปเรื่อยๆ ก็เสียทรัพยากรเปล่าๆ มิสู้คืนงานให้ shell จะดีกว่า จึงหยุดการทำงาน ลองดูตัวอย่างนี้ครับ

เทียบกับ

ใน func1() นั้นมีการลงทะเบียนตัวเองคือ setTimeout() เพื่อเก็บเอาไว้ใน event queue โดยใช้ setTimeout() กำหนดเวลา 3 วินาที เมื่อโปรแกรมทำงานถึงบรรทัดสุดท้าย แสดงผล Done เรียบร้อยแล้วก็จะกลับเข้าสู่โปรแกรมระบบมาทำ event loop ซึ่งอาจจะตื่นมาทุกๆ 1ms ดูคิวว่ามีอะไรเหลือให้ทำบ้าง ในที่นี่มีคิวเดียว แต่ยังไม่ถึงเวลาทำ มันก็จะหลับต่อไป และตื่นๆ ขึ้นมาเรื่อยๆ ทุกๆ 1ms จนกระทั่งถึงวินาทีที่ 3 เงื่อนไขสุกงอม ปลุก setTimeout() ให้ตื่นขึ้นมา และ setTimeout() มันจึงเไปเรียก func1() มาทำอีกที เมื่อทำเสร็จ มันก็จะถอดตัวเองออกจาก event queue และเมื่อไม่มีของเหลือในคิวแล้ว ความสิ้นสุดของโปรแกรมจึงมาเยือนโปรแกรมนี้ กลับสู่ shell ครับ

ผิดกับ func2() ที่ลงทะเบียนโดยใช้ setInterval()  หมายความว่าทำงานทุกๆ ช่วงเวลาที่กำหนด ในที่นี้ ทุกๆ 3 วินาที ดังนั้นมันต่างจาก func1() ตรงที่ ไม่มีการถอดออกจากคิว ทุกๆ 3 วินาทีจะแสดง world  ถ้าไม่ทำอะไร มันก็จะรันไปเรื่อยๆ ไม่รู้จบ

จากตัวอย่างจะเห็นว่า ‘done’ ไม่ใช้การทำงานเป็นบรรทัดสุดท้าย ยังสามารถมีพวก callback ทำงานทีหลังจากนี้ได้อีก

เฉลย Gotcha!

จาก Gotcha! เรามาดูบรรทัดที่ 3 กันครับ ในบรรทัดนี้ มีการเรียก readFile() ซึ่งถามว่าการปั่น harddisk เพื่ออ่านแฟ้มนั้นเกิดขึ้นในบรรทัดที่ 4 แล้วหรือยัง คำตอบคือยังครับ มันแค่ลงทะเบียนตัวมันลงไปที่ event queue เท่านั้น และลงทะเบียน callback function ของเรา ไว้กับตัวมันเอง ดังนั้น จึงไม่มีการทำงานเกิดขึ้น

ในบรรทัดก่อนสุดท้าย มีการพิมพ์ค่า str มันจะได้ค่า ‘undefined’ เสมอ เพราะ readFile() ยังไม่ได้เริ่มงาน จากนั้นการทำงานถึงบรรทัดสุดท้าย แสดง ‘done’

เมื่อโปรแกรมของเราทำงานเสร็จแล้ว ก็กลับเข้าสู่โปรแกรมระบบ ซึ่งจะทำงานในส่วนของ event loop ต่อไป  ซึ่งตอนนี้มีของอยู่ใน queue ค้างอยู่ตัวหนึ่งคือ readFile() ดังนั้น event loop จึงไปเรียก readFile() ขึ้นมาทำ จนดึงข้อมูลจนเสร็จ

เมื่อ readFile() ดึงข้อมูลเสร็จแล้ว มันยังไม่ส่งคืนการทำงานกลับสู่ event loop ในทันที เพราะเราลงทะเบียน callback เอาไว้ ดังนั้นมันจึงมาเรียก callback ของเราเพื่อทำงาน และส่งผลลัพธ์ที่ไปดีงมาได้ผ่าน parameter data เข้ามา

ดังนั้นใน callback เราจึงเห็นข้อมูล การแสดงผลการ ‘debug’ จึงทำได้อย่างถูกต้อง แต่น่าเสียดายที่ไม่มีใครอ่านค่า str ได้อีกแล้วเพราะโปรแกรมหลักสิ้นสุดการทำงานไปแล้ว

และเมื่อ callback ทำงานเสร็จ ก็ส่งการควบคุมคืน readFile() ตัวของ readFile() ก็ไม่เหลืออะไรทำ ก็ส่งคืนไปยัง event loop และ event loop ก็ไม่มีอะไรเหลือใน queue โปรแกรมจึงหยุดทำงาน

callback function ใช่เป็น asynchronous ไหม

มาถึงคำตอบของคำถามที่มีคนถามผมว่า “ฟังก์ชันมีระดับ (ฟังก์ชันซ้อนฟังก์ชัน) เป็น asynchronous หรือเป็น non-blocking ใช่หรือไม่” ผมขอคุยเฉพาะประเด็น synchronous/asynchronous ก่อน ส่วน blocking/non-block ขอคุยกันในหัวข้อต่อไป

คำถามคือ callback เป็น asynchronous ไหม ซึ่งถ้าเอาคำตอบจริงๆ ก็คงบอกได้ว่าอาจเป็นหรือไม่เป็นขึ้นอยู่กับกรณี ถ้า callback ทำงานทันที ทำงานในส่วน user code มันก็จะเป็น synchronous แต่ถ้าเป็นการลงทะเบียนเฉยๆ เพื่อเอาไว้ทำที่หลัง ทำหลังบรรทัดสุดท้ายของ user code มันก็จะเป็น asynchronous

เพื่อความเข้าใจ เรามาชำแหละ callback ดูกันทีละแบบกัน ซึ่งเป็นแบบ synchronous 1 แบบ กับ แบบ asynchronous 3 แบบที่ผมกล่าวเอาไว้แล้วข้างต้น

synchronous

จะได้คำตอบ

เห็นชัดเจนนะครับว่า forEach() จะต้องทำงานจนเสร็จก่อน จึงจะ done ได้ นั่นคือการทำงานเป็นแบบ synchronous นั่นเอง ถ้าเป็นฟังก์ชันของคนอื่นที่มาเป็น library ให้เราใช้ มันจะเป็น synchronous หรือ asynchronous นั้น ก็ต้องศึกษาคู่มือกันให้ดีครับ ของมันไม่แน่ แต่ถ้าเป็นฟังก์ชันมีระดับที่เราเขียนเอง ถ้าไม่มีลูกเล่นอะไรเป็นพิเศษ callback ที่ส่งมา ก็จะเป็นแบบ synchronous  โดยธรรมชาติครับ

asynchronous: แบบตั้งเวลา

ในบทนี้ผมกล่าวไปแล้วข้างต้น นั่นคือ setTimeout() และ setInterval() จะไม่ขอฉายซ้ำ

asynchronous: ทำงานทันที

คำว่าทันทีในที่นี้คงไม่ใช่ทันที ณ เวลานิยาม callback เพราะมันก็จะกลายเป็น synchronous ไปเสีย แต่คำว่าทันทีในที่นี้ ก็คือการทำงานเมื่อ event loop เริ่มทำงาน คิดออกไหมครับว่าทำได้อย่างไร ทำอย่างนี้ครับ

จะได้ผลลัพธ์เป็น

หลักการก็ง่ายนิดเดียวครับ ก็ตั้งเวลา แต่ให้เวลาเป็น 0 ดังนั้นมันจึงทำงานทันทีเมื่อ event loop เริ่มทำงาน

asynchronous: รอเหตุการณ์

อันนี้ทำงานบน node นะครับ  เป็นการดักจับเหตุการณ์เมื่อแฟ้มที่เราดักมีการเปลี่ยนแปลง ลองดูบรรทัดที่สอง จะเป็นการลงทะเบียน callback และ ในบรรทัดที่ 6 จะเป็นการถอด callback ออกจาก queue  หัวใจของการทำงานก็อยู่ที่ watchFile() เป็น asynchronous มันลงทะเบียนตัวเองลงใน event queue เพื่อให้ event loop เรียกเมื่อแฟ้มที่มันเฝ้าจับตา มีการเปลี่ยนแปลง

ถ้าท่านลองรันโปรแกรมตัวนี้ แล้วแก้แฟ้ม hello.txt ที่อยู่ใน directory เดียวกันนี้ เมื่อแก้แล้วจะเกิด event loop จะไปเรียกใช้ watchFile()  ซึ่ง watchFile() ก็จะไปเรียก callback ของเราอีกที ซึ่งผมให้แสดงเวลาที่แก้ล่าสุด ซึ่งเมื่อบันทึกแฟ้มหนึ่งครั้ง มันจะแสดงหนึ่งบรรทัด แต่อย่าคิดว่ามันจะแสดงผลลัพธ์ทันทีนะครับ อันนี้มันขึ้นอยู่กับกลไกภายใน ว่ามันจะตรวจจับเมื่อใด คงไม่ใช่ทุก 1ms เป็นแน่

และเมื่อครบ 30 วินาที ผมก็จะถอดแฟ้มนี้ออกจาก watchFile() ซึ่งทำให้ watchFile() ไม่เหลืองานที่จะทำ มันจึงปลดตัวเองออกจาก event queue และเมื่อไม่เหลืองานให้ทำ โปรแกรมก็จะหยุดทำงานครับ

blocking / non-blocking

มีคำที่คู่กับ synchronous/asynchronous อีกคำหนึ่งก็คือ blocking/non-blocking  มันเป็นคนละเรื่องแต่คู่กัน เช่นคนนิโกรคู่กับผิวดำเป็นต้น เพื่อจะอธิบายสองคำนี้ ผมขอให้ดูตัวอย่างนี้ครับ

อันนี้ก็เป็นโปรแกรมธรรมดา compute() สมมุติว่าใช้ 10 นาทีในการทำงาน โค้ดข้างล่างก็จะยังทำงานไม่ได้ จะทำได้อย่างไรใช่ไหมครับ ถ้าเกิดโค้ดข้างล่างไม่อยากรอ เกิดอยากใช้ตัวแปร result เลย  จะเอาค่า result ที่ไหนมาให้ใช่ไหมครับ เหมือนล้อหลังวิ่งแซงล้อหน้า กลายเป็นปัญหา race condition ไป

แบบข้างบนนี้แหละครับ เราเรียกมันว่า blocking  ซึ่ง compute() จะ block การทำงานจนกระทั่งมันหาคำตอบเสร็จ จึงจะไปบรรทัดต่อไปได้ แต่ถ้า

ตัวอย่างข้างต้นจะเห็นว่ามันรันบรรทัดแรกแล้ว บรรทัดแรกจะเสร็จทันที ไม่ต้องรอ 10 นาที ไปทำงานบรรทัดที่ 2 ต่อได้เลย หลังจากโปรแกรมทำครบทุกส่วนแล้ว ค่อยมาคำนวณใช้เวลา 10 นาที แต่ก็อย่างที่คุยกันแล้วว่า ในบรรทัดที่ 2 และต่อมา //จะทำอะไรก็ทำ จะไม่สามารถเอาค่าคำตอบของ compute() มาใช้ได้ ก็เพราะ compute() ยังไม่เริ่มทำงานด้วยซ้ำไป แต่การที่ไม่ต้องรอให้ทำเสร็จเสียก่อนจึงจะไปบรรทัดต่อไปแบบนี้แหละครับเรียกว่า non-blocking

ขอย้ำอีกครั้ง ปัญหาเรื่องการนำเอาค่าผลลัพธ์ไปใช้ต่อ ตอนนี้มันอยู่ใต้ดิน (event loop) ไปแล้ว จะเอามันขึ้นมาบนดิน (user code) ไม่ได้ เพราะ user code จบไปแล้ว ดังนั้น การใช้งานผลลัพธ์ของ compute() ต้องอยู่ในส่วนของ event loop ถ้าจะเอาง่าย ก็อยู่ในตัว compute() นั่นเอง

คราวนี้อยากให้ดู C++11 บ้าง เขามีแนวคิดที่น่าสนใจ แปลงหวยใต้ดินให้เป็นหวยบนดิน  แต่ผมคงไม่เอา syntax ของ C++ มาแสดงนะครับ ปวดหัวเปล่าๆ ผมขอเก็บเอาไว้ตอนที่พูดถึง C++ จะดีกว่า เอาเป็นว่าเขียนแบบ pseudo code ที่หน้าตาเหมือน ECMAScript น่าจะเข้าใจง่ายกว่า ดังนี้

มีการเรียกใช้ promise()  คร่อม compute อีกทีหนึ่ง การทำงานภายในจะเกิดการจะแตกเธรดออกมาอีกเธรด แล้วเรียกใช้งาน compute() ให้ทำงานทันที แยกอิสระ ซึ่งอีกประมาณ 10 นาทีเสร็จ ดังนั้น promise() จะเป็น non-blocking นะครับ เพราะส่งคืนการทำงานทันที ซึ่งจะส่งผลลัพธ์ออกมาเป็น object ตัวหนึ่ง ที่เรียกว่า future ที่เปรียบเสมือนตั๋ว ที่เราจะขึ้นเงินได้ภายหลัง เราสามารถเอาค่าผลลัพธ์ออกมาได้โดยใช้ future.value ในบรรทัดที่ 3

คราวนี้เรามาลองจำลองสถานการณ์ดูนะครับ ถ้าในบรรทัดที่ 2 ทำอะไรก็ทำนี่ เป็นงานอีกงานหนึ่งที่ซับซ้อนไม่น้อย  สมมุติว่าใช้เวลาไป 15 นาที กว่าจะถึงบรรทัดที่ 3 ซึ่งแน่นอนถ้าเป็นกรณีนี้ ไม่เป็นปัญหาแต่อย่างใด compute() นั้นทำเสร็จไปก่อนแล้ว เราจึงสามารถนำค่ากลับไปบนดินเอาไปแสดงผลได้

แต่กลับกัน ถ้าเกิดในบรรทัดที่ 2 ทำอะไรก็ทำ เกิดใช้เวลาเพียงแค่ 7 นาทีก็เสร็จ ดังนั้นพองานมาถึงบรรทัดที่ 3 future ยังไม่สามารถให้ค่าได้ ระบบจะน็อกไหม ไม่น็อกนะครับ มันเพียงเปลี่ยนสถานะของ future กลับกลายมาเป็น blocking รอค้างไปยอมไปทำต่อที่บรรทัดที่ 4 ต้อง block อยู่ประมาณ 3 นาที เมื่อ compute() ทำงานเสร็จจึงส่งค่ากลับมาที่เธรดหลัก จึงจะทำงานต่อไปได้

จะเห็นว่าวิธีนี้ไม่เลวเลย ไม่ต้องเสียเวลารอ สามารถทำงานสองงานพร้อมกันได้ แต่… ECMAScript ทำแบบนี้ไม่ได้เพราะมีเธรดเดียว  วิธีนี้เรียกชื่อตรงๆ ว่า futures and promises มึคนไปถาม Bjarne Stroustrup ผู้คิดค้น C++ ว่าอะไรเข้าฝันถึงได้ตั้งชื่อเทคโนโลยีออกแนวหวานแหวววาเลนไทน์แบบนี้ มีทั้งสัญญาและอนาคต ซึ่งทาง Stroupstrup ก็ตอบอย่างชัดเจนทำนองว่า ผมไม่ขอรับผิดหรือรับชอบต่อการตั้งชื่อนี้เพราะผมไม่ได้เป็นคนตั้ง  #จบปะ เขาตั้งกันมาเมินนานแล้วครับ ก่อนผมเกิดอีก

futures and promises นั้นไม่ใช่มีเฉพาะใน C++ นะครับ มีในหลายภาษามากมาย C++ เองเสียอีกที่มาช้ากว่าภาษาอื่น เพราะ C++ ในตอนแรกมองว่าการทำ multithreading นั้นเป็นเรื่องของ OS ของใครของมัน แต่ละ OS ต้องทำ library มารองรับเอง ซึ่งผลลัพธ์คือความเสียความ portable ข้าม OS สุดท้ายทนไม่ไหวก็เลยต้องใส่เข้าไป

แปลง asynchonous เป็น กึ่ง synchronous

เห็น futures and promises ของ C++ แล้วดูน่าใช้ดีสามารถทำ asynchronous แล้วดึงข้อมูลกลับมาใช้บนดินเป็นแบบ synchronous ได้อีกต่างหาก ในส่วนของ ECMAScript ผมก็ได้แสดงให้เห็นใน gotcha! แล้วว่า ถ้าเป็น asynchronous ไปแล้วมันก็ไปลับเลย มันกลับมาที่โปรแกรมหลักไม่ได้ แล้วคำถามมันก็จบลงเหมือนทุกครั้งว่า พอมีวิธีไหมที่จะทำให้ได้ผลลัพธ์คล้ายๆ กับที่ C++ ทำได้

และถามแบบนี้ทุกครั้ง ผมก็จะตอบว่ามีครับ ทุกครั้งไป ครั้งนี้ก็เช่นกันครับ แต่อาจตอบไม่เต็มปากเท่าไหร่ ใช้วิธีตกแต่ง asynchronous ให้เหมือนกับ synchronous ดังนี้

ได้ผลลัพธ์เป็น

ผมคงไม่ต้องอธิบายนะครับ ลองไล่โค้ดดู ไม่ยากครับ

ES6: Promise

ES6 มี Promise แต่ไม่มี future มันจึงเป็นเพียงสัญญาที่ไร้อนาคต อืมๆ ออกแนวหลังข่าวเกินไปแล้ว

Promise ของ ECMAScript นั้นมีมานานแล้ว อยู่ในรูปของ library มีดังๆ หลายตัวเลยเช่น RSVP, when, Q หรือแม้แต่ jquery ก็มี promise เช่นกัน ทาง AngularJS สร้าง Q ขนาดเล็กอยู่ในตัวเอง ส่วน ES6 นั้นได้แนวคิดของ RSVP ซึ่งสอดคล้องกับมาตรฐาน Promise/A+ ซึ่งเป็นมาตรฐานกลางของการสร้าง Promise ของ ECMAScript

จะว่าไปแล้ว library เกือบทุกตัวก็ใช้มาตรฐาน Promise/A+ เกือบทั้งหมดอยู่แล้ว ดังนั้นการเปลี่ยนจาก library ตัวหนึ่งไปใช้อีกตัวหนึ่ง จึงแทบไม่ต้องศึกษาอะไรใหม่ หรือพูดอีกอย่างก็คือ แนวคิด  Promise ของ ES6 ที่ท่านเรียนรู้ในวันนี้ เรียนครั้งเดียวใช้ได้ในทุก library ต่างกันเพียงแค่เรื่องปลีกย่อยและประสิทธิภาพเท่านั้น

ขอให้ท่านลองนึกตามผมดูนะครับ เปรียบเทียบกับ C++ ข้างต้น C++ นั้นจะทำ compute() ในเธรดหนึ่ง พร้อมๆ กับงานที่เป็นหน้าฉากอีกเธรด แต่ ECMAScript ทำอย่างนั้นไม่ได้ คำถามก็คือ แล้วมันจะมันจะมี promise ไปเพื่ออะไร  ผมยังไม่ตอบท่านในตอนนี้ดีกว่า ท่านดูโค้ดไปเรื่อยๆ แล้วจะทราบคำตอบได้เอง

compute() จะ blocking ทำงานไป 10 นาที เมื่อเสร็จแล้ว จึงไปบรรทัดต่อไป เอาผลลัพธ์ไปแสดง ซึ่งว่าไปแล้ว ถ้าเขียนโดยไม่มี Promise() คร่อม

มีอะไรต่างกันไหม ตอบได้เลยว่าไม่ต่าง! เหมือนกันทุกประการ แล้วจะเอาบล็อก Promise คร่อมหาพระแสงของ้าวอันใด  ใจเย็นๆ ค่อยๆ ตามผมมา ผมขอปรับเป็นโค้ดที่เอาไปลองรันได้จริงจะได้เห็นภาพดังนี้

พอจะเดาผลลัพธ์ได้ไหมครับ ผมอยากใส่ gotcha! เสียจริงๆ ผลลัพธ์จะได้ดังนี้ครับ

งงดีไหมครับ มีจุดน่า gotcha! อยู่หลายจุด แต่ไม่ต้องกังวลครับ ผมจะค่อยๆ อธิบายให้ฟังกัน  ผมเริ่มที่ Math.sin() กันเสียก่อน อันนี้ผมขอใช้เป็นตัวแทน compute() ขอให้จินตนาการว่ามันเป็นงานอะไรซักอย่างที่กินเวลามาก ไม่ว่าจะเป็นคำนวณหรือ I/O ใดๆ ก็ตาม ตกลงกันตามนี้นะครับ ผ่านไปแล้วหนึ่งเปราะ

ในบรรทัดแรก new Promise() มันจะลงทะเบียนตัวเองลงไปใน event queue จากนั้นก็ค่อยเข้ามาทำงานใน callback ซึ่งใน callback ก็ทำงาน Math.sin() ทำงานเป็นแบบ synchonous

หลังจากรอมาเป็นเวลานาน (สมมุติ) Math.sin() ก็ทำงานเสร็จ ให้ผลลัพธ์เป็น result จากนั้นก็ส่งเข้าไปยัง resolve function ซึ่ง resolve ฟังก์ชันนี้ จะนำเอาผลลัพธ์ของเราไปเก็บเอาไว้ พร้อมกับกำหนดสถานะของ promise ว่า fulfilled หรือสำเร็จนั่นเอง  จากนั้นก็ทำบรรทัดต่อมา จึงแสดงผลเป็น ‘in promise’ เป็นบรรทัดแรก

จากนั้นเรามาลองดูบล็อก B มีการลงทะเบียน callback ลงไปยัง myPromise ประเภท then  ลงทะเบียนเอาไว้เฉยๆ การทำงานจึงเป็นแบบ asynchronous

การทำงานก็จะลงมาถึงบล็อก C  ก็จะเช่นกัน มีการลงทะเบียน callback ประเภท catch ลงไปยัง myPromise การทำงานจึงเป็นแบบ asynchronous เช่นกัน

การทำงานมาถึงบรรทัดสุดท้าย จึงแสดง ‘done’ ออกไปเป็นบรรทัดสุดท้าย

หลังจากนี้ event loop ก็เริ่มทำงาน พบว่ามี myPromise ค้างอยู่ จึงเรียกมาทำงาน ซึ่งสถานะของ myPromise เรากำหนดไปแล้วว่า fulfilled เนื่องจากเราใช้ฟังก์ชัน resolve() ในการบันทึกค่า ดังนั้นมันจึงมาทำในส่วนของ callback ของ then() ซึ่งแสดงผลลัพธ์เป็นตัวเลขทศนิยมนั่นเอง

การทำงานทำหมดแสดงเป็นภาพบนกระดานดำได้ดังนี้ครับ

promise2

Chaining

ES6 เหนื่อกว่า futures and promises ของ C++14 ตรงที่สามารถทำ chaining ได้ ซึ่ง function chaining นั้นเป็นพื้นฐานของ FP อยู่แล้ว ถ้าใครจำไม่ได้ ลองดูโค้ดนี้ครับ จะร้องอ๋อทันที

การ chaining ก็คือการร้อยฟังก์ชันต่อเนื่องการในลักษณะนี้นั่นเอง เสร็จจะฟังก์ชันหนึ่งเอาผลลัพธ์ไปทำต่ออีกฟังก์ชันที่อยู่ด้านขวา

สิ่งที่เราจะ chain ได้ก็คือในส่วนของ then() ครับ เราสามารถเชื่อม then กี่ชั้นก็ได้ ลองดูตัวอย่างนี้ครับ

ได้ผลลัพธ์ดังนี้

จะเห็นว่า ‘done’ นั้นแสดงผลเป็นบรรทัดแรก มาถึงตรงนี้แล้วคงเข้าใจแล้วนะครับว่าทำไม ซึ่งก่อนจะมาถึง ‘done’ นั้น ก็จะมีการลงทะเบียน callback ของ then เป็นแบบ queue นะครับ ลงทะเบียนก่อนทำก่อน

เมื่อ ‘done’ แล้ว ใน event queue ก็มี myPromise เช่นเดิม ก็ต้องถูกเรียกขึ้นมาทำงาน สถานะเป็น fulfilled เพราะมีการ resolve() มา ดังนั้นจึงดึง callback ออกมาจาก queue ของ myPromise เพื่อทำงาน เมื่อทำงานเสร็จ เก็บผลลัพธ์ที่ได้จาก callback เอามาเพื่อป้อนให้แก่ callback ที่อยู่ใน queue ถัดไป ทำเช่นนี้ไปเรื่อยๆ จน queue ว่าง ก็จะจบการทำงาน

สังเกตได้ว่าในขั้นตอนสุดท้ายส่งค่า val+3  นั่นคือ 7 ออกไป ในเมื่อไม่มี .then() มารับต่อ ก็หายไปตามสายลมเท่านั้นครับ เพราะเท่าที่ผมตรวจสอบมา Promise ของ ES6 ยังไม่รองรับ finally ที่จะเป็นปราการด่านสุดท้ายในการรับค่า

บางคนอาจจะขัดใจว่าค่าที่ส่งข้ามระหว่าง callback มีได้เพียงพารามิเตอร์ตัวเดียว ทางแก้ก็ง่ายๆ ครับ ส่งมันเป็น object เลย เป็นพารามิเตอร์ตัวเดียวเหมือนกัน แต่ภายในใส่จักรวาลได้ทั้งจักรวาล

การทำงานขั้นต้นเขียนเป็นกระดานดำให้เข้าใจง่ายขึ้นดังนี้

promise3

ในบล็อก Promise() เป็น synchronous แต่ในบล็อก then() แต่ละตัวเป็น asynchronous

การจัดการข้อผิดพลาด

ข้อผิดผลาดนั้นไม่ได้เกิดขึ้นจากได้จากในบล็อก Promise เพียงอย่างเดียว แต่หากเกิดใน then ก็ได้ ถ้าพิจารณาให้ดีจะพบว่าในส่วนของ Promise นั้นเขาเตรียมเครื่องมือรองรับเอาไว้แล้วนั่นคือ reject() function เราสามารถส่งอะไรออกไปก็ได้ มันก็จะส่งงานต่อไปยัง callback ของ catch  แล้วในส่วนของ then หละทำอย่างไร

ในส่วนของ then ไม่มี reject เราใช้ throw ตามปกติได้เลยครับ ใน Promise ก็ใช้ throw ได้  จะ throw อะไรก็ได้ โดยมากนิยมเป็นตัวเลข string หรือไม่ก็ Error object เอาที่สบายใจเลยครับ ซึ่ง reject() ก็รองรับทั้งหมดเช่นกัน   ความแตกต่างระหว่าง throw และ reject() ในส่วนของ Promise ก็มีเพียงแค่ว่า  throw นั่นหลุดทันที แต่ reject() เพียงแค่ลงทะเบียนค่าไว้เฉยๆ ดังนั้นการทำงานในบล็อก Promise จึงยังทำงานได้ต่อไปจนหมดฟังก์ชัน

ถ้างานซับซ้อนมากๆ มี then หลายๆ ตัว การรวบเอาการจัดการ error เอาไว้ที่ส่วนกลางใน catch() อาจจะดูสับสน ทางแก้ก็คือเราสามารถดัก error ได้ในแต่ละthen ดังโครงสร้างนี้ครับ

callback ตัวแรก จะทำงานเมื่อมีการส่งค่ากลับออกมาจากขั้นตอนก่อนหน้า แต่ถ้าขั้นตอนก่อนหน้า throw มา ก็จะเข้า callback ตัวที่ 2 แต่ปัญหาของวิธีนี้ก็คือท่านไม่สามารถหยุดการ chain ได้ เพราะ callback ทุกตัวไปอยู่ในคิวหมดแล้ว ดังนั้นอย่างไรเสียขั้นต่อไปก็ยังคงต้องถูกเรียกมาทำงาน และทำงานได้ตามปกติด้วย เพียงแต่ค่าที่ส่งออกมานั้นจะเป็น undefined ซึ่งท่านได้รับมา แสดงว่าขั้นตอนก่อนหน้ามีข้อผิดพลาด เราก็ต้อง return undefined ไปยังขั้นตอนต่อไปเรื่อยๆ  ยุ่งเหมือนกัน

ถ้าเราใช้ reject() ตัว Promise จะมีสถานะเป็น rejected

Promise ออกแบบมาแบบนี้เพื่อ…?

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

มาถึงหัวใจของเรื่องนี้แล้ว เขาออกแบบมาเพื่ออะไร น่าสนใจนะครับ ว่าโครงสร้างแปลกนี้มันจะประยุกต์ใช้อะไรได้ เอาแบบง่ายๆ ก่อนครับ ที่เห็นก็คือถ้าเรามีงานทำต่อเนื่องหลายส่วน แทนที่มันจะบล็อกการทำงานจนเสร็จ ค่อยไปทำในส่วนอื่น เราสามารถจัดโค้ดส่วนต้นให้เป็นแบบ synchronous ก่อน จากนั้นไปทำงานอื่น แล้วค่อยทะยอยๆ ทำส่วนที่เหลือทีละขั้นแบบ asynchronous ได้ ถือว่ายืดหยุ่นดี

แต่หัวใจของ Promise จริงๆ อยู่ที่การจัดการ callback Hell หรือ pyramid of doom ดังที่ผมเคยกล่าวไว้ในบทความ FP ก่อนหน้า ลองดูตัวอย่างนี้ครับ

โปรแกรมนี้อ่านแฟ้ม 3 แฟ้มแล้วนำมาต่อกันเพื่อแสดงผล จะเห็นว่ามัน callback แบบ asynchronous เวลาเขียนโปรแกรมต้องซ้อนลงไปเป็นชั้นๆ  callback hell นั่นเอง ในงานปกติที่เราเขียนกันดัว ECMAScript การซ้อนกันลักษณะนี้ 5-6 ชั้น ถือเป็นเรื่องปกติ เลี่ยงไม่ได้ อ่านยาก แก้ไขยาก ซึ่ง Promise ออกแบบมาเพื่อแก้ไขปัญหานี้โดยเฉพาะเลยครับ โดยที่จะซ้อนเป็นชั้นๆ ลึกลงไป แต่หากใช้การ chaining ทำไล่ขั้นตอนลงมาที่ละชั้น ไม่ลึกเข้าไป เรามาลองดูกันในหัวข้อต่อไปดู ว่าจะทำกันอย่างไร

สแกนกรรม

ผมจะแก้โค้ดข้างบนให้กลายเป็น Promise  แต่ก่อนอื่น ผมขอสมมุติกรรมไว้ก่อนว่าเราจำเป็นต้องใช้ readFile() ที่เป็นลักษณะ callback จริงๆ แล้ว มันยังมี readFileSync() ที่เป็นแบบ synchronous ที่ทำ blocking รอจนอ่านจบแล้วให้ผลลัพธ์เลย แบบนี้ไม่ยุ่งยากแน่นอน การเขียนก็เหมือนกับภาษาทั่วไปเช่น C เป็นต้น แต่ผมขอสมมุติว่ามันไม่มี readFileSync() ก็แล้วกัน จำเป็นต้องใช้ readFile() เท่านั้น ที่ต้องสมมุติเช่นนี้ก็เพราะมี library บางส่วนที่มีแต่แบบ asynchronous มาให้เพียงแบบเดียว

ผมสร้างแฟ้มทดสอบ 1.txt, 2.txt, 3.txt  ภายในแฟ้มีตัวอักษรในแต่ละแฟ้ม 3 ตัวคือ 111, 222, 333 ตามลำดับ เพื่อใช้เป็นตัวอย่างในการทดสอบ

เราจะเอา Promise มาแก้ได้อย่างไร ลองดูนะครับ ผมเริ่มที่ชั้นแรกก่อน

ผมสร้าง Promise ขึ้นมา มันจะลงทะเบียนใน event queue และ จะทำสิ่งที่อยู่ในบล็อก นั่นคือ readFile() ซึ่ง readFile() ก็จะลงทะเบียนตัวเองใน event queue เช่นกัน และ ก็แสดง ‘done’

ขั้นตอนต่อมาก็มาทำ Promise ที่เข้าไปใน event queue ก่อน แต่ Promise อยู่ในสถานะ pending คือกำลังรอคำตอบอยู่ ทำงานต่อไม่ได้ event loop จึงสั่งตัวต่อไปให้ทำ นั่นคือ readFile() ซึ่งทำงานจนเสร็จ readFile() ก็ไปเรียก callback ของตัวเองมาทำ ซึ่งมีการกำหนดสถานะของ Promise เป็น fulfilled โดยใช้ resolve() และส่งผลลัพธ์ออกมาด้วย

จากนั้น Promise ที่อยู่ในก็ถูกเรียกอีกครั้ง คราวนี้สถานะเป็น fulfilled จึงไปดึงเอา callback ที่อยู่ใน then queue ออกมา ซึ่งไม่มี ก็จบงานไป หยุดโปรแกรม

ถ้าใน Promise ยังไม่ reslove() หรือ reject() จะมีสถานะเป็น pending

คราวนี้ผมลองขยายงานโดยเติม then ดู ดังนี้

เมื่อ readFile() ทำงานเสร็จมันก็เรียก call

ต่อเนื่องเลยนะครับ คราวนี้มี callback ของ then ผมคงไม่อธิบายต่อ น่าจะไล่เองได้ จะได้ผลลัพธ์ดังข้างบน ยังไม่จบครับ แฟ้มเรามี 3 แฟ้ม ผมก็เลยเพิ่ม then() ไปอีกตัว ดังนี้

ลองดูผลลัพธ์นะครับ น่าตกใจไม่น้อย

เกิดอะไรขึ้น Promise มันเพี้ยนหรือ ไม่ใช่นะครับ เรามาลองดูปัญหากัน ตอนที่ callback ของ .then() ตัวแรกทำงานนั้น มันไปเรียก readFile() แฟ้มที่ 2 ถามว่า readFile() ทำงานรึยัง คำตอบคือยัง ฟังก์ชันนี้ต้องจบสิ้นลงก่อน จึงจะทำงานได้ (จำได้ไหมครับ เรามีเธรดเดียว) ดูดีๆ นะครับ แม้ว่าภายใน มี return result แต่ การ return นั้นเป็น return ของ callback ของ readFile() ไม่ใช่ callback ของ then() นั่นหมายความว่า callback ของ then() ไม่มีการ return ซึ่งการ ไม่ return ก็คือการ return เป็น undefined นั่นเอง

หลังจากนั้น readFile() ของ 2.txt ก็ทำงาน ทำเสร็จแล้วก็เรียก callback เพื่อส่งค่ากลับออกมา แต่ส่งออกมาให้ใครหละครับ  ไม่มี!  ค่า return จึงหายไปในอากาศ ไม่มีใครได้รับ

then() ตัวที่ 2 แม้ว่าจะไม่ส่งค่าอะไรกลับแต่ก็ถือว่าสำเร็จดังที่กล่าวไว้ข้างต้น ดังนั้นมันจึงสะกิด .then() ตัวที่ 3 ขึ้นมาทำ โดยรับ data เป็น undefined เมื่อเอาไปทำต่อจึงได้ผลลัพธ์ดังบรรทัดที่ 4 นั่นเอง

กรรมคือผลของการกระทำ

แก้กรรม

เขียนแบบข้างบนทำไม่ได้ ปัญหามันอยู่ที่ then() นั้นต้องการผลลัพธ์เพื่อส่งไปยังขั้นตอนต่อไป จะไป callback เพื่อหาค่าภายหลังไม่ได้ ดังนั้นต้องเปลี่ยนวิธีเขียน ต้องเรียนรู้ syntax เพิ่มเติมด้วย เดี๋ยวดูโค้ดก่อน แล้วค่อยมาคุยกัน

เมื่อรันแล้วจะได้

มาเรียน syntax กันก่อนครับ

ถ้าเรามี Promise() หลายๆ ตัวที่มีเฉพาะส่วนแรกนั่นคือส่วน synchronous นะครับ เราสามารถเอาไปใส่ไว้ใน array จากนั้นก็สามารถใช้ Promise.all() เพราะไล่ทำ promise แต่ละตัว และเมื่อทำเสร็จแล้วก็รวบผลลัพธ์มาเป็น array เราจะได้ผลลัพธ์ 1 item ใน array ต่อหนึ่ง Promise และที่สำคัญก็คือ เราใช้ .then() ตัวเดียวเพื่อประมวลผลงานทั้งหมด นั่นหมายความว่าก่อนเข้า .then() นั้น Promise() ทุกตัวเสร็จก่อนแล้ว

โปรแกรมก็น่าจะตรงไปตรงมานะครับ ไม่น่าจะมีอะไรเข้าใจยาก ใช้หลักการเหมือนฟังก์ชัน map() นั่นเอง และ callback ของ then() ทำหน้าที่เป็นตัว reduce()

สรุปจุดอ่อนของ Promise

ข้อดีพูดไปแล้ว คราวนี้มาดูข้อเสียหรือจุดอ่อนกันบ้าง ที่แน่ๆ เลย ก็คือ

ข้อแรกเกิดข้อผิดพลาดใน then มันยังไหลลงมาข้างล่าง หยุดไม่ได้

ข้อสองจำนวนของการ .chain .then() นั้นเปลี่ยนแปลงไม่ได้ อย่างเช่น ถ้าผมมีแฟ้มขนาด 10 mb  ก็อยากจะ then() ในแต่ละ then() ทำงานทีละ 1 mb แต่ถ้า 15mb ก็อยากให้เป็น .then() 15 ชั้นเป็นต้น แบบนี้ทำไม่ได้ ต้องเลี่ยงไปใช้ Promise.all() ซึ่งก็ไม่ได้เหมือนกันเสียทีเดียว

ข้อที่สามนี่เป็นจุดอ่อนที่น่าคิดที่สุด เราต้องย้อนถามตัวเองว่าทำไมเราต้องแตก chaining ออกมาเป็นส่วนย่อยๆ ทำไมไม่รวมกันทำในทีเดียว การที่เราต้องการแตกออกเป็นส่วนย่อยๆ โดยสามัญสำนึกแล้ว ก็เพื่อว่าจะได้มีช่องว่างของเวลาเพื่อจะแทรกงานอื่นเข้าไปด้วยกันได้ เหมือนเป็น multitasking ว่าแต่ว่าทำได้จริงหรือ เรามาดูกัน

ผลลัพธ์ได้ดังนี้ครับ

ใน Promise() และแต่ละ .then() ผมใช้วิธีหน่วงเวลา 2 วินาที ดังนั้นเวลาที่ใช้ทั้งหมด 6 วินาที แต่ทำไม timeout ที่บอกว่าแค่ 1 วินาทีนั้น ทำไมจึงไม่ทำงานต่อจาก ‘done’ แต่มารั้งท้ายเลย ฝากไว้ให้คิดเล่นๆ ครับ แต่ที่แน่ๆ มันไม่ได้ทำ multitasking ได้อย่างเราคาดหวังครับ

ES6: generator

มาถึงอีกความสามารถหนึ่งครับ คือ generator ภาษาอื่นๆ เขาก็มีกันหมดแล้ว ES6 ก็ขอมีกับเขาบ้าง ถ้าใครยังไม่รู้จัก generator ผมก็ขออธิบายคร่าวๆ ให้เข้าใจกันครับ

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

ได้ผลลัพธ์ดังนี้ครับ

การทำให้ฟังก์ชันเป็น generator ก็คือการเติม * เข้าไปท้ายฟังก์ชันนั่นคือ  function*  และใช้ yield แทน return ส่วนฝั่งเรียกใช้ ก็เรียกใช้ตามปกตินะครับ แต่การเรียกใช้นั้น ไม่ได้เป็นการรันโปรแกรม แต่หากส่งออกมาเป็น generator object ในที่นี้ผมให้ชื่อ gen ซึ่งถ้าเราอยากให้ดึงค่าตัวถัดไปก็ใช้ gen.next()

ผลลัพธ์ที่ได้จาก next() แต่ละครั้ง จะเป็น object ดังนี้

ถ้าค่าไม่มีแล้ว done จะเป็น true และ value จะเป็น undefined

การทำงานภายใน

ECMAScript ฝืนกฏแรงโน้มถ่วงอีกแล้ว ฟังก์ชันทำงานครึ่งๆ กลางๆ แล้วส่งค่าออกมา แล้ว stack หละจะทำอย่างไร ฟังก์ชันนั้นยังคงค้างอยู่บน stack หรือไม่ คำตอบคือ ไม่ค้างครับ ค้างไม่ได้ ถ้าค้างแล้วระบบมั่วเละแน่นอน

เรื่องนี้จริงๆ แล้วเข้าใจไม่ยากครับ ตัว gen นั้นเป็น Generator Object ภายในมีตัวแปรสองตัวคือ  [[GeneratorState]] และ [[GeneratorContext]]    ตัวของ [[GeneratorState]] นั้นจะเก็บสถานะของการทำงาน ซึ่งมีสถานะดังนี้ suspendedStart, suspendedYield, executing และ completed  ดังนั้นถ้าเราเรียก .next() เมื่อ [[GeneratorState]] เป็น completed จะได้ผลลัพธ์ { value: undefined, done: true} เสมอ

ส่วน [[GeneratorContext]] จะเก็บตำแหน่งที่ทำค้างอยู่ ส่วน frame ของข้อมูลก็ยังคงอยู่เช่นกัน ไม่ได้ไม่ได้หายไปไหน เพียงแต่ว่าไม่ได้อยู่บน stack เท่านั้น และเมื่อเราสั่ง .next() โดยที่ [[GeneratorState]] อยู่ในสถานะ suspendedStart หรือ suspendedYield มันก็จะเอา frame ดังกล่าวไปบรรจุไว้บน stack แล้วสั่งทำงาน หัวใจหลักๆ ของการทำงานก็มีเพียงแค่นี้ครับ

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

โดยหลักใหญ่ของ generator นั้นจะทำงานเป็นแบบ synchronous ดังนั้นเพื่อให้ผลลัพธ์แบบ asynchronous เราก็นิยมผสมผสาน generator เข้ากับ Promise ซึ่งเนื้อหาในบทความนี้นำเสนอเพียงพื้นฐานการใช้งานจึงขอข้ามไปครับ

กลยุทธ์: กาแฟขมขนมหวาน

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

เมื่อรันแล้วจะได้ผลลัพธ์ดังนี้ครับ

สลับกันเห็นๆ เลยครับ ซึ่งเราผิดหวังจาก Promise คิดว่ามันน่าจะได้ได้ดีแต่กลับยังไปไม่สุด ก็มาใช้กลยุทธ์นี้แทนก็น่าจะพอได้อยู่นะครับ แต่สิ่งที่ไม่ค่อยดีก็คือมันเป็น synchronous ดังนั้นงานที่ทำนั้นยังไปไม่ถึง event loop ทำให้งาน asynchronous ต่างๆ ยังไม่สามารถทำงานได้จนกว่างานนี้เสร็จสิ้น ก็คงต้องดูใน ES7 ต่อไปว่าจะมีอะไรเพิ่มเติมกว่านี้ไหม

เดี๋ยวจะไปเข้าใจว่า Promise ทำ multitasking ไม่ได้ จริงๆ แล้วทำได้นะครับ จะทำได้ง่ายด้วย ถ้าใช้ Promise.all() ผมฝากเป็นการบ้านก็แล้วกันว่าทำได้อย่างไร บอกใบ้ให้ว่าก็คล้ายกลยุทธ์ข้างบนนั่นเอง

ทิ้งท้าย

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

 

 

 

 

 

[Total: 9    Average: 4.6/5]

You may also like...

1 Response

  1. rchatsiri says:

    ชอบฮ่ะ เข้าใจง่ายดี ;D

Leave a Reply

Your email address will not be published. Required fields are marked *