ก้าวแรก HPC #09: Thread Library เก่าแต่เก๋า

เกริ่นนำ

เราเริ่มแหย่ขาเข้าสู่โลกแห่งการประมวลผลแบบขนาน โดยผ่านเรียนรู้จากการใช้งาน Apache Spark ซึ่งเป็นโมเดลการเขียนโปรแกรมแบบ MapReduce ที่ได้รับความนิยมอย่างสูงในยุคปัจจุบัน แต่โจทย์เราอาจจะไม่เหมาะสมกับเครื่องมือ เลยทำให้ได้รับผลตอบสนองที่สอบตกทุกวิชา มาวันนี้เราจะย้อนกลับไปโมเดลดั่งเดิม โมเดลที่ไม่ค่อยมีใครเขียนกันแล้วเพราะยุ่งยาก ส่วนมากก็ไปใช้ library ที่ขี่บนโมเดลนี้อีกที โมเดลนี้ก็คือ Fork-Join นั่นเอง ผมว่าถ้าเรียนรู้อย่างเป็นระบบแล้ว ก็ไม่ได้ยุ่งยากอะไร เปิดใจให้กว้างแล้วมาเรียนรู้ด้วยกันครับ

เรียนรู้ศัพท์การประมวลผลแบบขนานผ่านมุมมองประวัติศาสตร์

ใครเคยเขียนโปรแกรมทำงานบนโมโครคอนโทรลเลอร์ขนาดเล็กจะเข้าใจดีว่า เมื่อเราเขียนโปรแกรมเสร็จ คอมไพล์ แล้วโหลดโปรแกรมจากคอมพิวเตอร์ไปเก็บไว้ใน flash ของไมโครคอนโทรลเลอร์แล้วก็บูทใหม่ เท่านี้โปรแกรมก็จะเริ่มทำงาน ไม่เห็นจำเป็นต้องมี OS แต่อย่างใด  โปรแกรมที่รันนั้นเป็นโปรแกรมเดียว ไม่สามารถทำงานพร้อมกันหลายโปรแกรมได้ (ยกเว้นเขียนแบบพิสดาร) แต่ถ้าอยากทำงานหลายงานจริงๆ ก็สามารถใช้ RT-OS (Real Time OS) ซึ่งเป็น OS ขนาดจิ๋วมากๆ มีภาคสำคัญเพียงภาคเดียวก็คือตัว Scheduler ว่าแต่ว่า ท่านเคยสงสัยหรือไม่ว่า CPU มีเพียงตัวเดียวแท้ๆ แต่ทำไมทำหลายงานได้ เรื่องในใครๆ ก็รู้มันก็สลับเวลากันทำ แบ่งเวลาทำ time-sharing คำถามของผมไม่ใช่เชิงทฤษฏีอย่างข้อสอบวิชา OS แต่สิ่งที่ผมกำลังถามท่านก็คือ ที่ว่ามันแบ่งเวลากัน จริงๆ แล้วมันทำกันอย่างไร

ก่อนตอบต้องรู้จัก Interrupt ก่อน

มองง่ายๆ แบบผม interrupt ก็คือข้อมูลชุดหนึ่งที่เป็นตัวแปรระดับบิทธรรมดา ตัวแปรแต่ละตัวก็แล้วแต่ว่าได้รับการออกแบบให้ผูกกับอุปกรณ์ตัวไหนอย่างไร เช่น mouse เป็นต้น ถ้า mouse เคลื่อนที่ ก็จะมีการมาเซ็ทตัวแปรด้งกล่าวให้กลายเป็น 1  ปุ่มแต่ละปุ่มของ mouse ก็เช่นกัน ถ้ามีการกด ก็จะทำให้บิทใดบิทหนึ่งของ interrupt ที่กำหนดไว้ล่วงหน้า ที่กล่าวมาข้างต้นนี้ เราเรียกมันว่าเป็นต้นทาง หรือ interrupt source ซึ่งอาจจะเป็นอุปกรณ์ภายนอกที่เชื่อมต่ออยู่กับ CPU หรือวงจรอิเล็กทรอนิกส์ภายใน CPU เองเช่นเมื่อ CPU ทำงานแล้วเกิดมีใครเขียนโปรแกรมผิดพลาดหารด้วยศูนย์ แบบนี้ก็จะเกิด interrupt เช่นกัน ว่ากันไปแล้วแม้ว่าโปรแกรมทำงานถูกต้อง แต่ถ้าเราอยากให้เกิด interrupt ก็สามารถ ใช้คำสั่งภาษาเครื่องบางคำสั่งที่จัดเตรียมเอาไว้เฉพาะ ทำให้เกิด interrupt ได้เช่นกัน

คราวนี้เรามาดูการทำงานของ interrupt กัน เมื่อ CPU ทำงานคำสั่งภาษาเครื่องเสร็จ 1 คำสั่ง ก็จะมาตรวจดูชุดข้อมูลของ interrupt ดูว่ามีบิทใดบางที่เป็น 1  ถ้าไม่มีก็ไปทำคำสั่งต่อไปตามปกติที่ program counter (PC) ชี้อยู่ แต่ถ้ามี เราจะเรียกการที่กำหนดค่าเป็น 1 นั้นว่า interrupt request (IRQ) หรือการร้องขอ interrupt  เป็นไปได้ว่ามีการร้องขอหลายๆ ตัวเกิดขึ้นระหว่างทำคำสั่งภาษาเครื่องหนึ่งคำสั่งที่ผ่านมา ซึ่ง CPU จะมีกลไกเป็นของตนเองที่จะเลือกว่าตัวใดสำคัญที่สุด และจะตอบสนองต่อ interrupt ตัวนั้น

การตอนสนองต่อ interrupt นั้น CPU จะเก็บสถานะปัจจุบันเอาไว้ จากนั้นก็กระโดดไปยัง Interrupt Service Routine หรือ ISR ซึ่งเป็นฟังก์ชันที่ลงทะเบียนไว้ตอนเริ่มรันโปรแกรม เป็นการผูก interrupt เข้ากับฟังก์ชันแบบหนึ่งต่อหนึ่ง โปรแกรมก็จะทำในส่วนของ ISR จนจบ เมื่อจบแล้ว CPU ก็จะโหลดเอาสถานะที่เก็บไว้เดิม เอามาใส่กลับไป โปรแกรมปกติก็ทำงานต่อไปได้ เหมือนไม่มีอะไรเกิดขึ้น  อาจจะมองได้ว่า interrupt เป็นการเรียกใช้งานฟังก์ชันวิธีหนึ่งก็คงไม่ผิดนัก

การเกิดขึ้นของ interrupt นั้น เราสามารถประยุกต์ให้ใช้กลไกนี้ทำงาน (ดูเหมือนว่า) หลายงานพร้อมๆ กันได้บน CPU ตัวเดียวได้ เราเรียกการทำงานหลายๆ ที่เกิดขึ้นราวกับว่าพร้อมกันแบบนี้ว่า multitasking ซึ่งมีสองแบบใหญ่ๆ คือ  cooperative multitasking และ preemptive multitasking ซึ่งจะประยุกต์ดังกล่าวมีรายละเอียดดังนี้ครับ

Cooperative Multitasking

ใน BIOS ของเครื่อง IBM PC นั้น ก็ใช้หลักการ Interrupt เพื่อเรียกใช้ฟังก์ชันของ BIOS ยกตัวอย่างเช่น

คำสั่งแรกเป็นการกำหนดให้ register ah มีค่า 0eh  หรือ 14 นั้นเอง ในที่นี้เป็นการเลือกฟังก์ชันที่ใช้แสดงตัวหนังสือที่ตำแหน่ง cursor ปัจจุบันแบบ text mode  และ al จะเก็บตัวหนังสือที่ต้องการพิมพ์ เรียก int 10h เพื่อเรียก ISR  ของ BIOS การพิมพ์ตัวหนังสือบนจอหนึ่งตัวบนจอภาพนั้นปกติแล้วเป็นหน้าที่ของ OS แต่ในกรณีนี้ BIOS ทำในส่วนของ text mode แล้ว OS ก็ไม่ต้องทำ แต่ถ้าเป็น grahics อย่าง Windows OS ก็ต้องจัดการให้เราครับ

ที่ผมต้องการแสดงให้เห็นก็คือ interrupt ที่เห็นในโปรแกรมนี้นั้นเป็น software interrupt อยากเขียนเมื่อไหร่ก็เขียน ไม่จำเป็นต้องเกิดจาก hardware เสมอไป ดังนั้นในอดีตสำหรับ Windows รุ่นเก่าๆ ตั้งแต่ Windows 1 ไปจนถึง Windows 3.1  ใครไปเปิดใน youtube ดูจะเห็นว่ามันทำหลายงานพร้อมๆ กันได้ โดยใช้ CPU เป็นคอร์เดียว ทำได้อย่างไร เรามาเรียนรู้กันครับ

หลักการที่เขาทำก็เป็นอย่างนี้ครับ คือคอมไพเลอร์ของเขาจะเป็นคอมไพเลอร์พิเศษ เมื่อเวลาคอมไพล์โปรแกรมแล้วจะแอบแทรก int 03h (ถ้าผมจำไม่ผิด มันหลายสิบปีแล้ว จำไม่ค่อยได้) เอาไว้ทุกส่วนของโปรแกรมเลย นั่นคือเมื่อเรารันโปรแกรม โปรแกรมก็จะรันตามปกติ จนเจอ int 03h มันก็จะไปเรียก ISR ของ OS ซึ่งพิจารณาดูว่าควรให้โปรแกรมปัจจุบันทำงานหรือไม่ ถ้าเห็นควรก็กลับไปทำต่อ แต่ถ้าไม่เห็นควร ก็คือมันทำงานนานเกินไปแล้ว ก็จะสลับโปรแกรมออก เอาโปรแกรมที่อยู่ในคิวมาทำแทน สลับกันอย่างนี้ไปเรื่อยๆ กลายเป็นดูเหมือนว่าโปรแกรมหลายตัวทำงานพร้อมกัน

เราเลยเรียกวิธีนี้ว่า cooperative หมายถึงโปรแกรมที่ทำงานอยู่ต้องให้ความร่วมมือในการหยุดงานตัวเองแล้วส่งกลับ OS โดยใช้ int 03h แต่ถ้าเกิดเราไปใช้คอมไพเลอร์นอกคอก ไม่มีการแทรก int 03h  (ส่วนมากเป็นโปรแกรมบน MS-DOS) งานอื่นก็หยุดหมดครับ ทำอยู่งานเดียว เพราะมันไม่ยอมคืนงานให้แก่ OS

Preemptive Multitasking

ทุกวันนี้คอมไพเลอร์ไม่มีตัวไหนแล้วนะครับที่แอบแทรก int 03h ไว้ในโปรแกรมอีก แล้วมันทำงานพร้อมกันได้อย่างไร ความลับมันอยู่ที่ IC ตัวหนึ่งครับ ชื่อว่า Intel 8253 เป็น IC 24 ขา อยู่ใน mainboard คอมพิวเตอร์รุ่นเก่าๆ  เป็น IC ที่เราสามารถมากำหนดเวลาได้ว่าเมื่อจับเวลาครบเวลาแล้วให้ส่งสัญญาณมาบอก เวลานั้นอยู่ระหว่าง 1/18 วินาทีไปจนถึง 1/500000 วินาที สายสัญญาณของ IC ต่อเข้ากับ ขา interrupt ของ CPU นั่นหมายความว่าเมื่อครบเวลาเช่น 1/1000 คือทุกๆ 1 msecs IC จะส่งสัญญาณมา และ CPU ก็เกิด interrupt เพื่อหยุดโปแกรมที่ทำค้างอยู่ และกลับไปยัง ISR ที่ OS จัดเตรียมเอาไว้ เพื่อให้ OS ตัดสินใจว่าจะส่งไม้ต่อให้งานไหนไปทำ

การทำเช่นนี้จะเห็นได้ชัดว่า โปรแกรมที่เราเขียนเป็นโปรแกรมธรรมดา ไม่ต้องแทรกอะไรพิเศษ OS มีวิธีหยุดการทำงานกลางครันตามที่อธิบายข้างต้น เราเรียกวิธีนี้ว่า preemptive หรือแปลเป็นไทยว่าเข้ายึดครอง (ไม่สนว่าโปรแกรมจะยินยอมหรือไม่ ยึดลูกเดียว)

Timesharing : Round-robin vs Priority scheduler

เรายังปักหลักอยู่บนฐานที่เรามี CPU ตัวเดียว ไม่ได้มีหลายตัวหรือหลายคอร์หรือหลาย CPU ดังในยุคปัจจุบัน UNIX เองเกิดขึ้นมาด้วยความต้องการทำงานหลายงานพร้อมกันบน CPU ตัวเดียว ในยุคที่เกิด UNIX นั้น CPU แบบ IC เดี่ยวอย่าง Intel นี้ยังไม่เกิดด้วยซ้ำไป  หลาย CPU หลายหน่วยประมวลผลก็มี แต่เป็นเครื่องพิเศษ Supercomputer อะไรทำนองนั้น ซึ่งเรายังไม่ได้สนใจเพราะไกลเกินไป

ในยุค UNIX นั้น โปรแกรมต่างๆ สามารถทำงานราวกับว่าทำงานพร้อมๆ กัน แม้ว่าจะมี CPU เพียงตัวเดียวก็ตาม ผู้ใช้หลายคนสามารถใช้เครื่อง terminal เพื่อติดต่อเข้ามารันงานบน UNIX พร้อมๆ กันได้ จึงมีการนิยามศัพท์คำว่า concurrency ขึ้นมา หมายถึงหลายๆ งานทำงานอยู่ในช่วงเวลาเดียวกัน จะเป็น หนึ่งคนหลายงานหรือหลายคนหลายงานก็สุดแล้วแต่ ซึ่ง CPU อาจจะมีตัวเดียวหรือหลายตัว มีการทำงานแบบ time-sharing หรือจัดแบ่งเวลาเพื่อตอบสนองงานหลายตัวนั้น ซึ่งการแบ่งเวลานี้อาจจะเป็นแบบ round-robin คือแบ่งแบบยุติธรรมเท่าๆ กันหมดหรือ Priority Scheduler คือแบ่งเวลาตามลำดับความสำคัญ สำคัญมากก็จะได้ทำก่อน

Concurrency vs Parallel

งานในเว็ปนี้ไม่ใช่งานแบบ concurrency ครับ งานของเราเป็นงานประมวลผลแบบขนานหรือ parallel มันต่างกันสองประเด็นใหญ่ก็คือ การประมวลผลแบบขนานนั้น จะมีหลายงาน โดยที่งานนั้นอาจเป็นโปรแกรมเดียวกัน หรืออาจไม่ใช่ก็ได้ แต่หัวใจอยู่ที่ ทั้งหมดต้องสอดประสานทำงานเพื่อจุดมุ่งหมายเดียวกัน ให้งานเดียวนั้นสำเร็จเร็วที่สุด ถ้าเรามีงานสองงาน อย่างเช่นงานพิมพ์เอกสาร และฟังเพลง งานสองงานนี้เป็นคนละงานกันไม่เกี่ยวข้องกันเลยแบบนี้เป็น concurrency แต่ที่ผ่านมาเราใช้ Apache Spark สั่งให้บอร์ดทั้งสามบอร์ดหกคอร์ ทำงานช่วยกันหาคำตอบ Bernoulli Number เพียงงานเดียว แบบนี้ถือว่าเป็น parallel ครับ

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

Program vs Process vs Thread vs Fiber

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

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

เพื่อความง่ายผมขออ้างอิงที่ภาษาตระกูล C เป็นหลัก จะได้ตัดความวุ่นวายทิ้งไป เรามาเริ่มกันที่คำว่า program กันก่อน โปรแกรมในที่นี้ คือโปรแกรมที่คอมไพล์แล้วพร้อมรัน เก็บอยู่ในรูปภาษาเครื่องอยู่บนสื่อสำรองโดยมากก็จะเป็น hard disk นั่นเอง

เมื่อเราสั่งรันโปรแกรม  โปรแกรมก็จะถูกโหลดเก็บเอาไว้ในหน่วยความจำ RAM เมื่อโปรแกรมอยู่ในหน่วยความจำแล้ว เราไม่เรียกมันว่าโปรแกรมอีกต่อไป เราเปลี่ยนชื่อมันเป็น process ซึ่งโปรเซสนั้น ก็ไม่มีอะไรมากไปกว่าการกินเนื้อที่ของหน่วยความจำ ทำงานอะไรก็ยังไม่ได้ จากนั้น OS จะสร้าง thread มาตัวหนึ่งเพื่อเอาไว้เป็นตัวรันโปรแกรม ซึ่งเทร็ดนี้มีองค์ประกอบสองส่วนก็คือ register ต่างๆ รวมทั้ง program counter ซึ่งเก็บว่าคำสั่งที่กำลังทำงานปัจจุบันคือคำสั่งตำแหน่งใด และส่วนที่สองก็คือหน่วยความจำของตัวเองส่วนหนึ่งขนาดไม่ใหญ่นักนั่นคือ stack นั่นเอง เอาไว้เก็บตัวแปร local/parameter ตลอดจนตำแหน่งการเรียกฟังก์ชันที่ซ้อนเป็นชั้นๆ ซึ่งผมเคยคุยในรายละเอียดเรื่องนี้เอาไว้แล้วที่  Functional Programming #2 : ฟังก์ชันพิสุทธิ์และการเรียกตัวเอง

วิธีการที่ CPU เพียงตัวเดียวทำหลายๆ งานได้นั้นทำได้โดยการสลับเทร็ดเข้ามาทำงาน เรียกว่า context switching ปัจจุบันเป็นแบบ preemptive แทบทั้งหมด การสลับนั้นทำได้ไวมาก ก็แค่เก็บ registers ทุกตัวของเทร็ดปัจจุบันเอาไว้ จากนั้นก็ load registers ของเทร็ดที่ต้องการสลับเข้ามา เท่านี้เทร็ดที่ถูกสลับเข้ามาก็สามารถทำงานที่ค้างอยู่ก่อนได้แล้ว

เวลาสลับงานแบบ preemptive ต้อง save/load registers แม้ว่ากินเวลาน้อยมาก แต่ทำบ่อยๆ วินาทีละเป็นร้อยเป็นพันครั้ง ก็เสียเวลาพอสมควร จึงมีการคิดค้น fiber ซึ่งเป็นลูกผสมระหว่าง preemptive และ cooperative เพื่อให้ได้ประสิทธิภาพที่ดีขึ้น หลักการทำงานก็คือการงานหลายๆ งานบรรจุรวมเอาไว้ในเทร็ดเดียวและสร้างตัว scheduler ขนาดเล็กอยู่ในเทร็ดเพื่อจัดการงายแต่ละงานในเทร็ดนั้น ซึ่งแต่ละงานเราเรียกว่า fiber เอง แน่นอนครับ fiber แต่ละตัวจะทำงานแบบ cooperative เมื่อทำงานไประยะหนึ่งแล้วจะ yield เพื่อกลับไปที่ scheduler ทำแบบนี้ทำได้เร็วกว่าแบบ preemptive พอสมควรครับ แต่มีข้อสังเกตก็คือ ถ้าทุกงานอัดอยู่ใน thread เดียว ต่อให้มีหลาย CPU ก็ไม่ช่วยอะไร ทุกอย่างก็อยู่ในตัวประมวลผลเดียวอยู่ดี ดังนั้นการใช้งาน fiber ลักษณะนี้ ต้องไม่ใช่โปรแกรมที่ไม่รู้จักกัน แต่หากเป็นโปรแกรมที่ออกแบบมาทำงานร่วมกันอยู่แล้ว หรือโดยมากมักเป็นโปรแกรมตัวเดียวกันแต่เปิดออกมาหลายๆ fiber เช่น SQL Server ก็ใช้ fiber ในการรับ user หลายๆ คนที่ติดต่อเข้ามาเป็นต้น  ในบทความชุดนี้เราจะไม่ลงไปถึง fiber แค่ทำความรู้จักไว้เท่านั้นครับ

กำเนิดการเขียนโปรแกรมแบบขนาน

บน UNIX นั้นอยากให้โปรแกรมสองตัวทำงานแบบ concurrency ก็รันมันทั้งสองตัว ทั้งสองตัวก็จะสลับกันทำงานถ้าสลับกันเร็วๆ อย่างเช่น 1000 ครั้งในหนึ่งวินาที ก็จะดูเหมือนว่าโปรแกรมทั้งสองนั้นทำงานไปพร้อมๆ กัน แต่ถ้าผมอยากได้โปรแกรมตัวเดียวกันสองตัวทำงานเวลาเดียวกันจะทำอย่างไร ท่านอาจสงสัยว่า งานอะไรที่ใช้หน่วยประมวลผลเดียวแต่ทำงานเหมือนกันสองงาน ก็งานจำพวก server ไงครับ เอาพริตตี้มารอรับลูกค้าก่อน พอมีลูกค้ามาติดต่อ ก็จะเปิดโปรแกรมอีกหน่วยไปตกลงซื้อขายกันกัน ส่วนพริตตี้ก็จะว่างไปรอรับลูกค้าคนต่อไป ลองดูโปรแกรมภาษา C ตัวนี้ครับ

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

fork1

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

ลองดูคำสั่งแปลกปลอมที่มือใหม่ไม่เคยเห็นจากภาษา C ปกติ นั่นคือบรรทัดที่ 8 มีการเรียกใช้ฟังก์ชัน fork() พูดให้งงเล่นก็คือฟังก์ชัน fork() เรียกครั้งเดียว จะส่งค่ากลับสองหน ครั้งแรกได้ 0 ครั้งที่สองได้เลยอะไรก็ไม่รู้ รันแต่ละครั้งไม่เหมือนกัน ดังนั้นมันจึงเข้า if ทั้งสองเงื่อนไขเลยพิมพ์ค่าทั้งสองค่า

ย่อหน้าข้างบน เขียนเอาไว้หลอกเด็กครับ ของจริงนั้นการทำงานเป็นดังรูปข้างล่างครับ เมื่อเรียกคำสั่ง fork() นั้น จะเกิดการทำงานขนานใหญ่ ถ้าเป็นอมีบาก็เกิดการแบ่งตัว จากหนึ่งเป็นสอง ในกรณีนี้ จะมีการคัดลอกหน่วยความจำโปรเซสทั้งตัวออกมากลายเป็นอีกก้อนหนึ่ง จากนั้นก็สร้างเทร็ดใหม่หนึ่งตัว คัดลอกข้อมูลของเทร็ดเดิมแล้วปรับแก้ไขตัวอ้างอิงให้เข้ากับพื้นที่หน่วยความจำใหม่ ถ้าเรามีหน่วยประมวลผลเพียงตัวเดียว OS จะแบ่งเวลาทำงานให้แก่เทร็ดทั้งสองตัว แต่ถ้ามีหน่วยประมวลผลหลายตัว OS ก็อาจจะแยกเทร็ดทั้งสองตัวไปยังหน่วยประมวลผลคนละตัว ทำให้ทำงานเป็นแบบขนานพร้อมกันได้

fork

การทำงานของโปรเซสทั้งสองตัวเริ่มต้นการทำงานที่ตำแหน่งเดียวกันคือกลับมาจาก fork() แต่แตกต่างกันตรงที่ค่าที่ได้จาก fork() ไม่เหมือนกัน ถ้า โปรเซสใดที่ได้ค่าเป็นตัวเลขเป็น 0 คือโปรเซสที่เกิดใหม่ แต่ถ้าได้ค่าเป็นค่าตัวเลขอื่นนั้นคือโปรเซสเดิม และค่าตัวเลขที่ได้นั้นคือตัวเลขประจำโปรเซส (process id : PID) ของโปรเซสที่เกิดใหม่นั่นเอง

สังเกตว่าในบรรทัดที่ 14 นั้นผม getchar() เอาไว้ คือไม่ยอมจบโปรแกรม เพื่อว่าผมจะได้เข้า shell อีกตัวหนึ่งไปแอบดู ดังนี้ครับ

fork1_ps

คำสั่ง ps (process snapshot) คือการดูสถานะโปรเซสต่างๆ ที่กำลังทำงานในระบบ ผมกรองให้เหลือแต่ fork1 ให้สังเกตเลข 1224 ให้ดีครับ ตัวเลขนี้คือขนาดของโปรเซสในหน่วยความจำนั่นเอง ในที่นี้มีขนาด 1224KB หรือประมาณ 1MB นั่นเอง ที่น่าสนใจก็คือ fork1 มีสองตัวครับ เกิดจากการ fork() ตรงตามทฤษฎีครับ

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

ภาษา C นั้นแม้ว่าเกิดมาคู่ก้บ UNIX ก็ตาม แต่ก็ใช้ว่าผูกติดกันเสียทีเดียว library ของภาษา C ที่เรียกว่า standard library นั้น สามารถใช้ได้กับทุก OS แม้แต่การจัดการแฟ้มที่เป็นเรื่องเฉพาะของ OS ก็ยังรวบยอดอยู่ใน standard library แต่เรื่องของการแตกงานประมวลผลนั้น ใน standard library ไม่มีนะครับ แต่ไปอยู่กับ library ของ OS แทน ดังนั้นคำสั่ง fork() นั้นใช้งานได้เฉพาะบน UNIX เท่านั้นครับ ถ้าเป็น OS ตระกูลอื่นก็จะมีวิธีของตนเองไม่เหมือนกัน ไปคนละทิศละทาง วุ่นวายมากครับ

Posix Threads : Pthreads

library ในการจัดการตัวประมวลผลหลายตัวที่เป็นมาตรฐาน UNIX นั้นมีปัญหาหลายอย่าง โดยเฉพาะอย่างยิ่งการกินหน่วยความจำเป็นเท่าตัวเมื่อเกิดการ fork() เปลืองเนื้อที่มาก fork 100 ครั้ง ก็กินหน่วยความจำไป ขนาดของโปรเซส x 100 และปัญหาที่ไม่ยิ่งหย่อนกันก็คือ ความครบถ้วนของ library ซึ่งยังต้องมีอะไรอีกมากมาย แต่ library มาตรฐานนั้นไม่มี

นั่นจึงเป็นที่มาของ library ตัวใหม่ที่ใช้ทดแทน library มาตรฐานตัวเดิม ผมทันตอนที่เกิด thread library ของ Sun ใหม่ๆ ใช้งานบนเครื่อง Sparc 5 ไม่แน่ใจว่าเป็น thread library ตัวแรกหรือไม่ ตอนนั้นรู้แค่ว่าเป็น library ที่ประมวลผลหลายตัวประมวลผลอีกแบบเท่านั้น วิธีเขียนไม่เหมือนกัน ไม่ได้ใช้สั่ง fork() แต่ในเวลาต่อมาอีกนานพอสมควรจึงรู้ว่ามันคนละเรื่องเลยครับ เพื่อความเข้าใจดูภาพการทำงานครับ

threads

จากภาพจะเห็นได้ครับว่าแตกต่างกับ fork() อย่างมากครับ เพราะไม่มีการคัดลอกโปรแกรมมาอีกชุดหนึ่ง จะคัดลอกทำไมหละครับก็มันเป็นโค๊ดโปรแกรมไม่มีการเปลี่ยนแปลงอยู่แล้วเหมือนเดิมตลอด ส่วนตัวชี้ PC เทร็ดทั้งสองนั้นก็จะชี้ที่หน่วยความจำของโปรเซสเดียวกัน ประหยัดหน่วยความจำ ดังนั้นในยุคปัจจุบัน แทบจะไม่มีใครใช้การ fork() แล้วครับ

อยากจะให้สังเกตเรื่องตัวแปรให้ดีครับ การแตกเทร็ดนั้นไม่ได้คัดลอกหน่วยความจำ global มาอีกชุดนะครับ  ดังนั้นตัวแปรที่เป็น global จะใช้ร่วมกัน นั่นหมายความว่าถ้าเทร็ดใดเปลี่ยนค่าตัวแปร global อีกเทร็ดก็จะเห็นการเปลี่ยนแปลงเช่นเดียวกัน ตรงข้ามกับการ fork ครับนั่นของใครของมันเลย พารามิเตอร์ที่ส่งเข้ามาตอนเริ่มเทร็ดก็น่าสนใจ ถ้า call by value มา ก็เป็นของใครของมัน แต่ถ้า call by reference ก็จะอ้างอิงตัวเดียวกันครับ

UNIX นั้นร้อยพ่อพันแม่ครับ มีความแตกต่างในความเหมือน ดังนั้นเวลาใช้งานจึงเกิดสะดุดบ่อยครั้งเมื่อทำงานข้ามสายพันธุ์ (แต่ในวันนี้ Linux ซึ่งถือว่าเป็น UNIX สายพันธุ์หนึ่ง ครองส่วนแบ่งการตลาดของ UNIX เกือบหมด เลยทำให้ปัญหาดังกล่าวลดลง) ด้วยปัญหานี้ทำให้หน่วยงานมาตรฐาน IEEE นั้นกระโดดเข้ามาสร้างมาตรฐานสำหรับ UNIX โดยการแสวงจุดร่วมสงวนจุดต่าง กลายเป็นมาตรฐานอย่างต่ำที่ UNIX ทุกค่ายต้องรองรับเหมือนๆ กัน   ดังนั้นถ้าเราใช้งานเฉพาะในขอบเขตมาตรฐานนี้ จะสามารถใช้งานกับ UNIX ตระกูลไหนก็ได้ OS อื่นด้วยก็ได้ครับ แม้แต่ Windows ก็รองรับมาตรฐานนี้ตั้งแต่ยุคปี 2000 จนเพิ่งมาตัดออกตอน Windows 8 นี้เอง  มาตรฐานดังกล่าวชื่อว่า Portable Operating System Interface หรือ POSIX

thread library ก็เป็นส่วนหนึ่งของ POSIX ด้วย ถ้าผมจำไม่ผิด POSIX เอา thread library ของ Sun มาปรับปรุงแก้ไขแล้วตั้งชื่อใหม่ว่า Pthreads ทุกวันนี้ก็ยังใช้อย่างกว้างขวางครับ ในเว็ปนี้ก็ใช้งานเป็นหลัก แต่ว่าเราไม่ได้ใช้กับภาษา C แต่หากเป็น C++ นั่นเอง นั่นก็ทำให้เราสามารถใช้ thread library ตัวเดียวกันนี้กับ UNIX ยี่ห้อต่างๆ โดยการเขียนโปรแกรมในส่วน thread library ที่เหมือนกัน ซึ่งดีมากครับ แต่ที่ไม่ดีก็คือ OS อื่นใช้ไม่ได้

Modern C++ thread library

มีคนไม่น้อยครับนึกว่าภาษา C++ นั้นเลยจุดสูงสุดไปแล้ว และกำลังเสื่อมถอยลงทุกวัน เพราะก่อนหน้าปี 2011 ผ่านไปถึง 8 ปี ที่ไม่มีมาตรฐานข่าวอะไรให้หวือหวาเลย ภาษาอื่น อย่างเช่น Java, C# ก็มากลบกระแสของ C++ จนเกือบหมด แต่เมื่อ C++11 กำเนิดขึ้นทุกอย่างก็เปลี่ยนไป อะไรที่เคยขัดหูขัดตาใน C++ ก็ได้รับการขัดเกลาใหม่  ทำให้ภาษา C++ กลับกลายเป็นดาราอีกครั้ง จนคนทั่วไปเรียก C++11 ขึ้นไปนี้ว่า modern C++

C++ ตั้งแต่เก่าก่อนก็มี library ขนาดใหญ่เป็นของตนเองเรียกว่า standard template library : STL  ซึ่งถ้าไม่ชอบและอยากใช้ standard library ของ C ร่วมด้วยก็ยังได้ แต่ STL นั้นกว่าจะบรรจุความสามารถใดๆ หรือแก้ไขส่วนใดส่วนหนึ่งก็มีความยุ่งยากพอสมควรครับ เพราะต้องผ่านการพิจารณาจากคณะกรรมการมาตรฐาน ดังนั้นจึงมียอดฝีมือกลุ่มหนึ่งออกไปสร้าง library เสริมต่างหาก เพราะไม่ยากข้องแวะกับมาตรฐานที่วุ่นวายนั้น หนึ่งในนั้นก็คือ Boost library

หลายสิ่งที่ขาดใน STL หรือที่ STL ทำไม่ดี Boost ก็ใส่เข้ามาครับ นั่นทำให้ C++ ยังคงทันสมัยอยู่ ที่น่าสนใจก็คือ  C++11 ก็ได้นำเอา library หลายภาคส่วนของ Boost นั้นมาปรับปรุงแก้ใขและใส่เพิ่มเข้าไปใน STL ทำให้ STL ในยุคปัจจุบันนี้เก่งขึ้นกว่าเดิมมากนัก

Thread library ก็เป็นส่วนหนึ่งครับที่ STL คัดลอกเอามาจาก Boost เรื่องนี้เปลี่ยนแปลงวงการกันเลยทีเดียว ทำไมหรือครับ ก็ thread library เมื่อมันอยู่ใน STL หมายความว่าคอมไพเลอร์ทุกค่ายทุก OS ต้องรองรับให้ได้ นั่นคือโปรแกรมที่เราเขียนทำงานหลายคอร์นั้นต่อไป จะมีความ portable ทำได้โดยไม่ต้องแก้เมื่อทำงานต่าง OS นั่นทำให้ C++ ก้าวขึ้นไปอีกก้าวหนึ่ง เรามาดูหน้าตาการเขียนโปรแกรมเลยดีกว่าครับ

ในโปรแกรมเรา #include <thread> จะได้ใช้งาน thread library ได้ คำสั่ง thread f1 {f, 1}; ในบรรทัดที่ 16   เป็นการสร้างเทร็ดใหม่โดยใช้โปรเซสร่วมกับของเดิมเพื่อรันฟังก์ชัน f โดยส่งพารามิเตอร์ 1 เข้าไปในฟังก์ชัน เมื่อรวมแล้วก็เป็น f(1) นั้นเอง ในทำนองเดียวกันในบรรทัดที่ 17 ก็สร้างอีกเทร็ดหนึ่งเพื่อทำงาน f(2)  ณ บรรทัดที่ 18 โปรเซสของเราจะมี 3 เทร็ด คือเทร็ด main(), f(1) และ f(2) เมื่อรันโปรแกรมจะได้ผลลัพธ์ดังนี้

cpp_thread_basic_run

สังเกตว่าเรา link -lpthread  หมายความว่า C++ thread library ของ GCC นั้นขี่อยู่ Pthreads ของ POSIX อีกที นั่นหมายความว่า ใน STL เป็นเพียงมาตรฐาน ไม่ใช่วิธีทำ ดังนั้นในแต่คอมไพเลอร์ ก็มีอิสระในการที่จะ implement ฟังก์ชันแต่ละตัวใน STL เอง สุดแล้วแต่ว่าใครจะเขียนอย่างไร ขอให้สุดท้ายผ่าน testsuite มาตรฐานก็พอ ซึ่งคอมไพเลอร์แต่ละตัวก็ไปผูกกับ OS ที่ไม่เหมือนกัน ไปเรียก system call แต่ละ OS ไม่เหมือนกันได้

เรากลับมาดูบรรทัดที่ 10 this_thread::yield(); คำสั่งนี้เป็นการบอกว่าการทำงานในรอบนี้เสร็จสิ้นแล้ว ส่งงานกลับไปยัง OS จะได้ให้ thread อื่นทำงานต่อ นั่นคือการทำงานแบบ cooperative นั่นเอง ซึ่งถ้าเราใช้หน่วยประมวลผลเพียงตัวเดียวจำเป็นต้องทำครับ มิฉะนั้น การวนแค่เพียงสิบรอบอาจกินเวลาน้อยเกินไปเกินกว่าที่ OS จะสลับงานออก เราก็อาจจะเห็นเทร็ดแรก ทำงาน 10 รอบ เสร็จแล้วต่อด้วยอีกเทร็ดค่อยทำงานอีกสิบรอบไม่มีสลับกัน แต่เหตุการณ์นี้ไม่เกิดถ้าเรามีหลายตัวประมวลผล มันจะทำงานพร้อมกันไปเลย yield() ไปก็ไม่น่าจะมีผลแต่งต่าง ผลลัพธ์จึงเป็นดังที่เห็นครับ

ผมหยุดโปรแกรมบรรทัดที่ 19 เอาไว้ เพื่อแอบดูครับ ว่ามันสร้างกี่โปรเซสกันแน่ ผลลัพธ์เป็นดังนี้ครับ

cpp_thread_basic_ps

ชัดเจนนะครับว่าถึงจะทำงานกัน 3 เทร็ดแต่ก็ใช้โปรเซสเพียงตัวเดียว แต่ที่น่าตกใจคือขนาดของโปรเซส 19272KB ซึ่งใหญ่กว่าภาษา C ซึ่งใช้เพียง 1224KB เท่านั้น เรื่องนี้ปกติครับ คำตอบเรื่องนี้อยู่ที่ STL ครับ ซึ่งกินเนื้อที่เริ่มต้นค่อนข้างเยอะ นั่นเป็นเหตุผลว่า ในงาน embedded ขนาดเล็กที่ใช้ภาษา C++ เราไม่ใช้ STL แต่หากไปใช้ standard library ของ C แทน ก็จะเล็กลงได้ครับ

ในงานจริงบรรทัดที่ 19 ไม่มีนะครับ ผมใช้หยุดโปรแกรมเพื่อแอบดูเท่านั้น ถ้าไม่มีบรรทัดที่ 19 การทำงานก็จะไหลไปถึงบรรทัดที่ 20, 21 คือ เป็นคำสั่ง join() การ join() เป็นการสั่งให้เทร็ดตัวเองหลับไปครับ โปรเซสนี้จะหลุดออกจากตัว scheduler ของ OS  รอจนกระทั่ง f1 ทำงานเสร็จ ก็จะมาปลุก thread main() ให้ทำงานต่อ โดยบรรจุ thread main() เข้าสู่ scheduler ซึ่ง thread main() ก็จะไป f2.join() หลับเหมือนเดิมอีก  แต่ในกรณีถ้า f2 เกิดเสร็จก่อนคำสั่ง f2.join() ก็ไม่มีปัญหา ก็แค่ข้ามคำสั่งไป ดังนั้นการทำงานจึงเป็นรูปนี้ครับ (ท่านเคยเห็นมาแล้วในบทก่อนๆ)

thread1

 

ในงานการประมวลผลแบบขนาน เราประยุกต์ดังนี้ครับ เริ่มทำงานที่เทร็ด main() ตัวเดียวก่อน และเมื่อมีงานที่ต้องการการประมวลผลแบบขนาน ก็จะแตกออก โดยใช้คำสั่ง thread f1 {f, 1}; เป็นต้น แต่เราใช้คำว่า thread กันหลายความหมายกลัวจะงง คนส่วนมากก็เลยกลับไปใช้คำเดิมก็คือการ fork แต่ให้รู้ไว้นะครับ ว่าเรา fork เทร็ด ไม่ใช่การ fork โปรเซส จากนั้นก็ให้เทร็ดทุกตัวทำงานพร้อมกัน แต่สาระอยู่ตรงนี้ครับ ไม่ใช่ต่างคนต่างไป เทร็ด main() จะรอเทร็ดทุกเทร็ดให้ทำงานเสร็จหมดเสียก่อน ด้วยคำสั่ง join() แล้วค่อยทำงานต่อ โดยเหลือเพียงเทร็ด main() เพียงตัวเดียว ถ้าจะ fork อีกก็ใช้วิธีเดียวกันจนจบโปรแกรม จบที่เทร็ด main() เพียงตัวเดียว เราเรียกโมเดลการทำงานแบบนี้ว่า fork-join model

คราวนี้เรามาถึงปัญหาที่สำคัญที่สำคัญ คือเราจะ fork กี่ thread จึงจะเหมาะสม คำตอบก็คือถ้าเป็นงานประมวลผลแบบขนาน ก็ให้เท่ากับจำนวนคอร์นั่นเองครับ ถ้า fork มากกว่าจำนวนคอร์ไม่มีประโยชน์นะครับ เพราะมันจะเกิด context switching เสียเวลาโดยเปล่าประโยชน์ จำนวนคอร์ก็อาจจะไม่ถูกนัก ถ้าเรามี Hyperthreading ก็ต้องคูณ 2 รายละเอียดเรื่องนี้เอาไว้ในบทความชุดหน้าครับ

คำถามต่อมาคือ เราจะรู้ได้อย่างไรว่า OS จะกระจายเทร็ดเราไปยังแต่ละคอร์ไม่ไปกระจุกกันอยู่ที่คอร์เดียว เราต้องไม่ลืมว่าก่อนจะรันโปรแกรมของเรานั้น มีโปรเซสหลายสิบตัวกระจายกันทำงานอยู่ในแต่ละคอร์ ดังนั้น OS อาจจะรวบงานสองเทร็ดของเราไปคอร์เดียวกันก็ได้ ซึ่งถ้าทำอย่างงั้น งานของเราจะเสร็จช้าลงมาก คำตอบของคำถามก็คือเราก็ต้องเชื่อใจ OS ของเรา ว่ามันเก่งพอที่จะรู้ว่าจะกระจายคอร์อย่างไรจึงจะมีประสิทธิภาพที่สุด (แต่ท่านก็คงเดาได้นะครับ คำว่า “ที่สุด” นั้นไม่มีหรอกครับ)

ท่านอาจสงสัยว่าเรามีวิธีหรือไม่ที่จะทำการกำหนดเทร็ดของเราไปยังแต่ละคอร์เอง OS ไม่ต้องยุ่งฉันทำเองอะไรทำนองนั้น มีครับ มีแน่นอน เราเรียกการทำเช่นนี้ว่า processor affinity ซึ่งแต่ละ OS ก็จะมีวิธีการเป็นของตนเองต้องไปศึกษาเพิ่ม โดยมากเรามักไม่ทำกัน เรามักก็จะบอกกันว่าให้ทำใจยอมรับการตัดสินใจของ OS เชื่อมั่นในตัวเขา เขาเก่งขึ้นมีพัฒนาการจากรุ่นสู่รุ่นอยู่แล้ว

library thread ของ Python

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

Python นั้นตั้งแต่ปางบรรพ์ทำงานเป็นแบบ interpreter ซึ่งตัว Interpreter นั้นเขียนด้วยภาษา C เรารู้จักกันในชื่อ CPython ปัญหาของ CPython ก็คือมันเป็นโปรแกรมทำงานเพียงเทร็ดเดียว ดังนั้นถ้านำเอาไปประมวลผลแบบ multithread มั่วเละแน่นอน ดังนั้นในตัว CPython จึงสร้าง global interpreter lock หรือ GIL ขึ้นมา ทำหน้าที่เป็นควบคุมให้เทร็ดเดียวเท่านั้นทำงานในเวลาใดเวลาหนึ่ง ดังนั้นจึงไม่มีทางที่ เทร็ดสองเทร็ดจะทำงานพร้อมกันในโปรเซสเดียวเมื่อใช้ CPython ได้เลย ว่ากันเชิงเทคนิคแล้ว GIL ก็คือ  mutex ตัวหนึ่งนั่นเอง ซึ่งรายละเอียดเกี่ยวกับ mutex สามารถดูได้จากหัวข้อข้างล่างครับ

library ตัวแรกของ Python ชื่อว่า thread library ผมไม่อยากพูดอะไรมากเกี่ยวกับตัวนี้ มันเก่ามากของก็ไม่ครบ แทบจะหาเหตุผลในการใช้งานไม่ได้เลย ขอข้ามไปเลยก็แล้วกันครับ ไปตัวที่สองกันเลยครับ ชื่อว่า threading ซึ่งขี่อยู่บน thread library ของ Python อีกที การใช้งานคล้ายๆ กับ thread library ของ C++11 สามารถแตกเทร็ดไปตัวประมวลผลหลายตัวได้ โดยใช้โปรเซสร่วมกันเพียงตัวเดียว แต่ผลลัพธ์ไม่เร็วไปกว่าเทร็ดเดียวครับ เพราะเจอ GIL บังคับ ไม่ยอมให้สองเทร็ดทำงานในเวลาเดียวกัน ต้องสลับกันทำ ตัวหนึ่งทำตัวที่เหลือก็ต้องหยุด

ถามอีกว่า แล้ว Python ออก library ไร้สาระอย่างนี้มาเพื่ออะไร กระจายเทร็ดได้ แต่ทำได้เพียงแค่หนึ่งเทร็ดในหนึ่งเวลา หาสาระไม่เจอ คำตอบก็คือ มันอาจจะเร็วได้ครับ ถ้าเทร็ดบางเทร็ดที่ถูกสลับออกนั้นอยู่ในสถานะการรอ เราก็ไม่จำเป็นต้องรอ สลับไปทำอีกเทร็ดเลย ที่ว่ารอนั้นจะเห็นชัดเจนก็คือ I/O ครับ เรียกข้อมูลใน harddisk นั้นช้ามากกว่าข้อมูลจะมา ก็เลยต้องรอ ถ้าเราเอาเวลาที่รอนั้นสลับไปทำเทร็ดอื่น ก็จะได้งาน ดังนั้น threading library จึงเหมาะกับงานที่ใช้ I/O ค่อนข้างมากนั่นเอง ผมชัดเริ่มตะหงิดๆ แล้วว่า Apache Spark นั้นใช้ library ตัวนี้ทำงานรึเปล่า เพราะในงาน Big Compute ที่คำนวณอย่างเดียว ไม่ใช้ I/O  เลย library ตัวนี้จะไร้ประสิทธิภาพโดยสิ้นเชิง แต่ถ้าใช้กับงาน I/O เยอะๆ ประสิทธิภาพก็อยู่ในเกณฑ์ดี ซึ่งผลลัพธ์ที่ได้ในบทก่อนหน้ามันฟ้องครับ

แต่ก็ไม่ใช่ว่า threading นั้นจะไม่ดีไปเสียหมด ถ้าเราใช้งาน Jython หรือ IronPython ควรใช้ library ตัวนี้ครับ เพราะตัวทำงานจริงๆ แล้วไม่ใช่ interpreter ภาษา C อย่าง CPython แต่หากเป็น runtime อย่าง JVM และ CLR  ซึ่ง runtime ทั้งสองตัวไม่มีปัญหาเรื่องการทำงานแบบ multithreading อยู่แล้ว จึงเรียกศักยภาพของ CPU มาได้เต็มที่เลย  runtime ทั้งสองนี้ไม่มี GIL มาให้ยุ่งยากลำบากใจ  แต่ก็เลือกเอานะครับ CPython ได้ Python 3 แต่ Jython และ IronPython นั้นได้เพียงแค่ Python 2 เท่านั้น

แต่ใครใช้ CPython ก็พอมีทางออกครับ ก็คือใช้ library ตัวที่สามนั่นคือ multiprocessing ซึ่งเป็นความพยายามในการเอาชนะ GIL โดยใน interpreter ใช้คำสั่ง fork() เพื่อแตก interpreter ออกมาอีกตัวหนึ่ง เป็นคนละโปรเซส เหมือนกับเรารันโปรแกรมสองครั้งในคนละ shell โปรแกรมจะทำงานพร้อมกัน กลายเป็นเรามี GIL สองตัวอิสระต่อกัน วิธีนี้ก็แน่นอนครับสิ้นเปลืองหน่วยความจำ ดูย้อนยุคไปหน่อย แต่ก็ดีกว่าทำไม่ได้นะครับ

สรุปก็คือสำหรับงาน Big Compute แล้ว ถ้าใช้ Jython หรือ IronPython ควรใช้ threading library สามารถทำงานหลายเทร็ดโดยใช้โปรเซสตัวเดียวกัน แต่ถ้าใช้ CPython ควรใช้ multiprocessing library สามารถทำงานหลายเทร็ดแต่ต้องเป็นหนึ่งเทร็ดต่อหนึ่งโปรเซส

ในบทความนี้ผมจะใช้ library ทั้งสองตัว นั่นคือ threading ทำงานบน Jython และ multiprocessing ทำงานบน CPython ทั้งหมดทำงานบน Banana Pi เหมือนเดิมครับ การติดตั้ง Jython ลงบน *buntu ทำได้โดย ไป download .jar มาจาก http://www.jython.org/downloads.html เป็นรุ่น 2.7 นะครับ จากนั้นลงโปรแกรมโดย

แนะนำให้ลงโปรแกรมที่ $home/jython ครับ ถึงตรงนี้ เรามาดูโปรแกรมตัวอย่างเดียวกับ C++ ข้างบน แปลงเป็น Python ทั้งเวอร์ชัน threading และ multiprocessing กันครับ

 และ

 

ผมคงไม่ต้องรันให้ดูนะครับ ผลลัพธ์เหมือนกับ C++ ข้างบน ตัวโปรแกรมทั้งสองก็คล้ายกับภาษา C++ มาแทบเหมือนกันบรรทัดต่อบรรทัดเลย จะต่างกันเล็กน้อยก็ตรงเมื่อสร้างเทร็ดแล้ว มันจะยังไม่รันทันที เราต้องสั่ง .start() จึงจะเริ่ม fork และผมใช้การ sleep(0.1)  เพื่อหลับไป 0.1 วินาที จะได้เห็นการสลับงานครับ

Race Condition

“With great power comes great responsibility” วาทะเด็ดจากหนังไอ้แมงมุม “อำนาจที่ล้นฟ้านำมาซึ่งความรับผิดที่หนักหนาดุจกัน” เช่นเดียวกันกับเรื่องเทร็ด เราสามารถ fork ได้ทำงานได้พร้อมๆ กันดึงกำลังเครื่องมาใช้ได้เต็มที่ แต่ก็ไม่ใช่ของฟรีนะครับ ค่าตอบแทนของมันก็หนักหนาพอสมควร ยกตัวอย่างง่ายๆ ถ้าเรามีเครื่องพิมพ์ตัวหนึ่ง เกิดมีเทร็ดอยู่สองเทร็ดสั่งพิมพ์งานพร้อมกัน คงไม่ดีแน่ถ้าหน้ากระดาษของเราหน้าเดียวกันได้ผลงานของเทร็ดทั้งสองเทร็ด คงดูไม่จืด ปัญหานี้เราเรียกว่า “race condition” หรือการแก่งแย่งชิงดีชิงเด่นนั่นเอง ในประวัติศาสตร์ก็เป็นประจักษ์พยานมาแล้วทุกยุคทุกสมัยว่าการแก่งแย่งชิงดีชิงเด่นนั้นนำพาความวิบัติมาเยือนอยู่เสมอ

เพื่อความเข้าใจในเรื่อง race condition ให้ชัดเจนขึ้น เรามาลองดูตัวอย่างโปรแกรมภาษา C ดังนี้

แบบนี้เห็นยังไม่ชัด ผมลองขยายเป็นภาษา assembly ช่างมันครับมันจะเป็น CPU อะไรก็ช่าง น่าจะเข้าใจได้โดยไม่ยาก ดังนี้ครับ

ถ้า balance มีค่า 1000 เมื่อเสร็จคำสั่งนี้ จะเหลือ 900 เรื่องนี้ไม่ต้องสงสัยเลยครับถ้าทำงานแค่เทร็ดเดียว แต่ถ้ามีหลายเทร็ด สมมตินะครับ ถ้าเทร็ดนี้ทำงานเสร็จบรรทัดที่ 4 ตอนนี้ ax เป็น 900 พร้อมที่จะบันทึกกลับไปยัง balance ในบรรทัดที่ 5 แต่บังเอิญ OS สลับงานตัวนี้ออก ไปทำงานงานอีกงาน ซึ่งงานอีกงานก็เจ้ากรรม มีการปรับปรุงค่าของ balance ด้วย ไม่ว่าเทร็ดนั้นจะปรับปรุงค่า balance เป็นเท่าไหร่ก็ตาม เมื่อสลับกลับมาแล้ว  ax ยังคงมีค่า 900 จึงบันทึก 900 ลงไปใน balance เท่านี้ความถูกต้องก็หายจ้อยครับ กลายเป็นระบบที่ไม่เหลือความเสถียรครับ race condition น่ากลัวมากครับ ยิ่งถ้าเราใช้ตัวประมวลผลหลายตัวก็ยิ่งแล้วใหญ่ครับ โอกาสเกิดปัญหาลักษณะนี้ยิ่งพบบ่อยขึ้นครับ

critical region

การแก้ปัญหาเรื่อง race condition นั้น เราต้องมีความเข้าใจเกี่ยวกับ critical region เสียก่อน critical region นั้นคือส่วนของโปรแกรมที่ถ้ามีเทร็ดหลายเทร็ดทำงานพร้อมกันจะเกิดปัญหา ส่วนของโปรแกรมข้างต้นก็ใช่ การพิมพ์ออกเครื่องพิมพ์ก็ใช่ การเข้าห้องน้ำก็ได้ ต้องเข้าทีละคนจะมาเข้าพร้อมกันไม่ได้ ดังนั้นเราจึงจำเป็นต้องมีกลไกบางประการเพื่อบังคับให้ มีเพียงเทร็ดเดียวในหนึ่งเวลาที่เข้าสู่ critical region ได้ ซึ่งกลไกนี้ควบคุมโดยผู้เขียนโปรแกรม ถ้าควบคุมมากเกินไปอย่าง GIL ที่มองทั้งโปรแกรมเป็น critical region แบบนี้ประสิทธิภาพก็หายหมด ดังนั้นการต้องจัดการ critical region ให้เหมาะสมจึงจะมีประสิทธิภาพ

อะไรบ้างที่ควรอยู่ใน critical region

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

เรามาดูเรื่องเทร็ดกันก่อนครับ ภาษาที่ทำหลายเทร็ดได้ในภาษายุคใหม่ๆ การเริ่มต้นเทร็ดใหม่จะเริ่มต้นที่ฟังก์ชันหรือ class ใดก็ได้ที่เราระบุ ทั้ง C++ และ Python ก็ใช้หลักการนี้ ซึ่งสิ่งที่เราต้องกังวลเกี่ยวกับ critical region นั้นมีเพียงตัวแปรสองส่วนเท่านั้น นั่นคือตัวแปรที่เป็น global และตัวแปรที่ส่งเป็นพารามิเตอร์เข้าสู่ฟังก์ชันเริ่มต้นเทร็ด เท่านั้น ส่วนตัวแปรที่เป็น local นั้นเป็นของใครของมันครับ ถือว่าเป็นคนละตัว ไม่ต้องห่วงว่าจะเกิดปัญหาครับ

แต่ถ้าเราใช้ library ที่ทำการ fork โปรเซสออกมา แบบนี้ค่อนข้างหายห่วงครับ เพราะตัวแปรเป็นคนละตัวไม่เป็น critical region แต่การเขียนโปรแกรมแบบ fork นั้นจะนำพาปัญหาใหม่ที่ตรงข้ามกันครับ คือเราต้องหาวิธีทำอย่างไรจึงจะแชร์ตัวแปรกันได้ เพราะมันแยกกันโดยอิสระไปแล้ว

โปรแกรมภาษา C++ ที่มีปัญหา race condition

คราวนี้เรามาลองดู race condition ในตัวโปรแกรมภาษา C++ บ้าง ลองดูโปรแกรมนี้ครับ

โปรแกรมนี้เป็นโปรแกรมบวกเลขตั้งแต่ 1 ถึง MAX แบบอ้อมๆ โดยการกระจายตัวเลขลำดับตั้งแต่ 1, 2, 3, … ไปยัง deque  และกระจายเทร็ดเพื่อระดมดึงตัวเลขออกมาสะสมค่า เมื่อแต่ละเทร็ดสะสมค่าเสร็จเรียบร้อย ก็เอาผลลัพธ์ที่ได้จากแต่ละเทร็ดเอามาบวกกันอีกที ก็จะได้คำตอบ ซึ่งก็คือ \frac{MAX(MAX+1)}{2} 

ตัวฟังก์ชันที่กระจายเทร็ดในที่นี้ชื่อว่า worker ซึ่งเป็นคำยอดนิยมครับ ดูปุ๊บรู้เลยว่าเป็นฟังก์ชันที่ทำงานหลายเทร็ด ตัวแปรที่ส่งมาเป็นพารามิเตอร์ก็คือ d, threadNo และ sumWorkers ถ้ามีการอ่านหรือแก้ไข ก็เสี่ยงทั้งนั้นครับ และตัวแปรภายนอกก็มี MAX, nthreads แต่ก็ปลอดภัยเพราะผม const เอาไว้ แก้ไม่ได้อีก  ถ้าใน worker มีการสร้างตัวแปร local ขึ้นมา อันนี้ไม่มีปัญหา ไม่ได้อยู่ในกลุ่มเสี่ยง  

พิจารณาการทำงานของ deque ตัว deque เองก็คือ stack + queue นั่นเอง นั่นคือ สามารถเพิ่มข้อมูลเข้าไปได้ทั้งหน้าและหลังการเอาข้อมูลออกก็ได้ทั้งสองทางเช่นกัน ถือว่าเป็นโครงสร้างข้อมูลที่อยู่ใน STL ที่ไม่มีอะไรเป็นพิเศษ แต่ตัวแปรตัวนี้เป็นตัวเสี่ยงตัวเดียวเลยครับ เสี่ยงยังไงก็ว่ากันอีกที่ ส่วน sumWorkers ดูเหมือนว่าจะเสี่ยงแต่ไม่เสี่ยงครับ เพราะการเขียนนั้นเขียนลงใน vector คนละ element กันครับเทร็ด 0 ก็เขียนลงช่อง 0 เทร็ด 1 ก็เขียนลงช่อง 1 ไม่มีความเสี่ยง

คราวนี้ผมจะเปิดเทร็ดมาเท่ากับจำนวนคอร์ของ CPU เพื่อแข่งกันดึงข้อมูลจาก deque ผมก็ตอบไม่ได้ว่าเทร็ดใดจะดึงไปได้เท่าไหร่ แต่เมื่อเอามารวมกันทั้งหมด ก็ต้องได้ค่าเท่าเดิมตามสูตรข้างต้น แต่จะได้ตามนั้นหรือไม่ ผมลองรันโปรแกรมดูครับ

cpp_thread_race

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

ผมพยายามดัก try-catch แล้วก็ดักไม่อยู่ จริงๆ ถ้าเกิดข้อผิดพลาดแบบนี้ library น่าจะส่ง เป็น exception ออกมาให้ดักได้ แต่คนเขียน library ใช้คำสั่ง abort() ซึ่งเป็นการส่ง signal SIGABRT  ไปต่อไม่ได้เลยครับ ที่ต้องการดักเพราะผมต้องการดูผลลัพธ์ มีอะไรบ้างอย่างที่น่าสนใจ งานนี้คงต้องพึ่ง Python แล้วครับ

มาดู race condition ฉบับ python บ้าง

ไม่พูดพล่ามทำเพลง มาดูโปรแกรมกันเลยครับ

รันโปรแกรมโดยใช้ jython บน Windows ได้ผลลัพธ์ดังนี้ครับ

jython_threading_race

โปรแกรมนี้ผมเปลี่ยนการเขียนโปรแกรมมาเป็นแบบ functional programming ซึ่งไม่น่าจะเข้าใจยากนะครับ ลองอ่านดู ถ้าสงสัยก็เขียนมาถามได้ครับ มีข้อสังเกตคือผมยก d ขึ้นให้กลายเป็น global มิฉะนั้นตัวชี้ d จะเป็น local ซึ่งอิสระต่อกัน ทำให้คำสั่งในบรรทัดที่ 12 นั้น ไม่มีการเปลี่ยนค่า d แต่กระนั้นสิ่งที่เห็นจากการรันโปรแกรมสามครั้ง คำตอบไม่ตรงกันสักครั้ง แถมยังมากกว่าความเป็นจริงเกือบเท่าตัว ที่เห็นน้อยกว่าความเป็นจริงเป็นเพราะว่าบางครั้งเมื่อรันไปแล้ว มีการแย่ง d กันเหมือนใน C++ แล้วก็ตายไปทำให้ผลลัพธ์ในเทร็ดนั้นไม่ถูกนำมารวม แต่โดยทั่วๆ ไปแล้ว ผลลัพธ์ได้เกือบเป็นสองเท่าของคำตอบจริง ซึ่งการแย่งกันแบบนี้ทำให้เกิดปัญหามากครับ

คราวนี้ลองมาดู multiprocessing library กันบ้าง ตามโปรแกรมนี้เลยครับ

 เมื่อรันโดยใช้ Python3 บน Banana Pi ได้ผลลัพธ์ดังนี้ครับ

python_multiprocessing_race

การเขียนโปรแกรมโดยใช้ multiprocessing library นั้นก็ดังที่เคยกล่าวไว้ครับว่ามีปัญหาอีกแบบก็คือ ตัวแปรนั้นอิสระต่อกันทั้งหมด เมื่อเราสร้าง list d ขึ้นมา ก็ถือว่าเป็น d คนละตัวกันอิสระต่อกัน ดังนั้นถ้าเขียนโปรแกรมล้อลอจิกเดิม ก็จะกลายเป็นการทำงานอิสระของ 2 lists ดังนั้นผลรวมจึงเป็นสองเท่าเสมอ ซึ่งไม่ใช่คำตอบที่เราต้องการ ดังนั้นใน multiprocessing library นั้นจึงเตรียมโครงสร้างข้อมูล Queue() เอาไว้ให้ ดังในบรรทัดที่ 19 อะไรก็ตามที่เขียนอยู่ใน Queue นั้นจะถือว่าเป็นของกลาง ทุกเทร็ดเห็นเป็นตัวเดียวกันหมด และในแต่ละคำสั่งนั้นไม่มีอะไรแทรกได้ ไม่เหมือนกันตัวอย่างที่ใช้ threading มีการแทรกตลอดผลลัพธ์จึงได้เป็นสองเท่า ดังนั้นท่านจะเห็นได้ว่าคำตอบเกือบถูกต้อง แต่ก็มีปัญหาอยู่สองประเด็นครับคือ

ประเด็นแรก แม้ว่าในแต่ละคำสั่งที่ใช้จัดการ queue จะแทรกไม่ได้นั้นเป็นเรื่องที่ดี แต่ยังดีไม่พอครับ แทรกในคำสั่งไม่ได้ ก็แทรกระหว่างสองคำสั่งเลย เพราะในกรณีนี้ เราต้องตรวจสอบว่าว่างหรือไม่เสียก่อน นั่นก็เป็นการเรียกคำสั่ง .empty() ของ Queue มาใช้แล้ว ถ้าไม่ว่างก็ดึงข้อมูล นั้นก็คือคำสั่ง .get() ซึ่งเป็นคำสั่งที่สอง ถ้าทำคำสั่งแรกเสร็จ ก็ถูกสลับไปอีกเทร็ดหนึ่ง ซึ่งมีการเปลี่ยนแปลงค่า Queue แบบนี้ถึงน๊อกอย่างที่เห็ฯ เราต้องหาวิธีทำอย่างไรที่ไม่ให้เกิดการแทรกได้

ประเด็นที่สอง สังเกตนะครับว่าการแบ่งการทำงานของเทร็ดนั้นมีเพียงเทร็ดเดียวครับที่รับงานส่วนใหญ่ 99% ไป ผมไม่แน่ใจว่าเป็นเพราะอะไร ให้ไว้เป็นข้อสังเกตเฉยๆ ครับ 

Thread Synchonization

ในหัวข้อที่แล้วเราได้เรียนรู้ถึงความน่ากลัวของ race condition เรียบร้อยแล้ว มาหัวข้อนี้เรามาเรียนรู้ถึงวิธีทางแก้กัน เราก็มาทบทวน critical region กันในอีกมิติหนึ่งเสียก่อน เอาให้แน่นครับ

นักวิทยาศาสตร์คอมพิวเตอร์วิเคราะห์ตัวโปรแกรมที่เขียนๆ กันสรุปออกมาเป็นสองส่วน  ส่วนแรกคือส่วนที่ทำงานพร้อมกันหลายเทร็ดได้ ไม่มีปัญหา ซึ่งถือว่าเกือบทั้งโปรแกรมจะเป็นลักษณะนี้ครับ กับอีกส่วนคือ ถ้าทำพร้อมกันแล้วเกิดเรื่อง อย่างเช่นการปรับปรุงค่า balance เป็นต้น ถ้าแข่งกันปรับปรุงค่า หรือแม้แต่อ่านค่าระหว่างที่เทร็ดอื่นกำลังปรับปรุงก็จะอาจจะได้ค่าที่ผิดเพี้ยนไป เราเรียกส่วนนี้ว่าเป็นส่วนวิกฤตหรือ critical region นั่นเอง ใครมาจากสายฐานข้อมูล จะคุ้นเคยเป็นอย่างดีกับการแย่งบันทึกข้อมูลรายการเดียวกัน ทางออกของงานฐานข้อมูลพื้นฐานก็คือการสร้าง transaction  นั่นก็คือส่วนที่เป็น critical region นั่นเอง ดังนั้นเราต้องหาวิธีให้มีเพียงเทร็ดเดียวเท่านั้นที่สามารถเข้าสู่ critical region ได้ในเวลาใดเวลาหนึ่ง หรือเราอาจเรียกว่า mutual exclusion ก็ได้

ถ้าเปรียบไปแล้วมือใครยาวสาวได้สาวเอา รถยนต์ใครใคร่ขับทางไหนก็ขับได้ แบบนี้รถติดแน่นอนครับ เพราะต่างคนต่างไม่ยอมกัน ถาเทียบกับเรื่องจราจรแล้ว ทางแก้ไขก็คือเราต้องจัดระเบียบการจราจรใช่ไหมครับ เทร็ดก็เช่นเดียวกันเราก็ต้องจัดจราจรให้มัน การจัดนี้เรียกเป็นศัพท์ว่า thread synchronization 

มาทำความเข้าใจตรงกันก่อนครับว่าในส่วนของ critical region นั้น จะมีเพียงเทร็ดเดียวในหนึ่งเวลาเท่านั้นที่เข้าไปทำงานได้ คำว่า critical region นี้บางทีอาจจะสับสน มันไม่ได้หมายความถึงพื้นที่ใดพื้นที่หนึ่งแต่เพียงพื้นที่เดียว จากตัวอย่างข้างบน โค๊ดตรงไหนก็ตามที่มีการปรับปรุงค่า balance ถือว่าอยู่ใน critical region เดียวกัน เข้าใจตรงกันนะครับ

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

จากโปรแกรม เราตรวจดูว่า lock นั้นถ้ายังคงเป็น 1 คือยังมีคนล็อคอยู่ ก็วนรอจนให้กลายเป็น 0 เราก็จะเข้า critical region ได้ เมื่อเข้าได้แล้วก็ล็อคโดยกำหนดให้ lock=1  จากนั้นค่อยทำใน critical region เมื่อเสร็จแล้วก็ปลดล็อคโดยการ lock=0 เป็นอันเสร็จพิธี การวนรอการล็อคแบบนี้เราเรียกว่า spinlock ซึ่งเทร็ดยังคงทำงานวนรอต่อเนื่องครับ กินเวลาการประมวลผลตลอด แถมยังทำงานไม่ถูกต้องอีกต่ ก็ lock เป็นตัวแปรเหมือนกัน balance ในเมื่อ balance มีปัญหา ตัว lock ก็ย่อมก็มีเช่นกันครับ

ปัญหานี้อยู่ที่การเปรียบเทียบกับการกำหนดค่าแยกคำสั่งกัน ทำให้ถูกแทรกโดยเทร็ดอื่นได้ ถามว่าแก้ได้อย่างไร บอกได้เลยครับถ้าในระดับภาษาเครื่องทำไม่ได้ ยังแยกเป็นสองคำสั่ง ก็หมดหวัง preemptive นั้นสามารถเกิดได้ในทุกคำสั่งภาษาเครื่อง CPU ของ Intel ในรุ่นแรกๆ  ไม่มีปัญหาก็เพราะทำงานเทร็ดเดียวตลอด จนกระทั่ง OS เริ่มพัฒนาให้ทำงานหลายงานแบบ concurrent ได้ ก็พบปัญหาอย่างที่กล่าวครับก็เลยจำเป็นต้องปรับปรุง ซึ่ง CPU รุ่นแรกที่แก้ไขปัญหานี้ได้ก็คือ 80286 มีการเพิ่มคำสั่ง tsl (Test and Set Lock) ดังนี้

คำสั่ง tsl นี้ เมื่อสั่งแล้ว จะทำให้ตัวแปร lock กลายเป็น 1 เสมอ คือล็อค แต่ถ้าเป็น 1 ก่อนหน้ามาใช้คำสั่งนี้ หมายความว่ามีการล็อคอยู่ก่อนหน้า  ax จะเป็น 1 มิฉะนั้นจะเป็น 0 เราก็เลยต้องวนจนกว่า ax เป็น 0 ถ้าเป็น spinlock แต่เราอาจไปทำงานอื่นก็ได้ถ้าล็อคไม่สำเร็จ แล้วค่อยมาลองล็อคใหม่ ด้วยกลไกนี้ ไม่ว่าจะถูกแทรกที่คำสั่งใดก็ตาม ก็จะไม่ผิดเพี้ยน ทำให้เราสามารถใช้ preemptive multitasking แทน cooperative multitasking ในยุคนั้น

แต่ไม่ว่า OS จะพัฒนาขึ้นมากมายตลอดเวลาหลายสิบปี แต่ก็ยังเอาชนะ race condition ด้วยตัวเองไม่ได้ ภาระนี้ก็ยังคงต้องตกอยู่กับผู้เขียนโปรแกรมอยู่ดี OS ต่างๆ จึงนำเอาคำสั่งลักษณะ tsl ที่ CPU สร้างขึ้นมานั้นมากสร้างเป็นตัวแปรควบคุมพิเศษที่ชื่อว่า semaphore ถ้าแปลตามรูปศัพท์ก็แปลตัวว่าอาณัติสัญญาณ โดยเฉพาะอย่างยิ่งรถไฟ อาจจะเป็นธงสีเขียวสีแดง เพื่อบอกว่าให้ไปต่อหรือหยุด หรือจะเป็นป้ายไฟก็สุดแล้วแต่ ผู้คิดค้นคำนี้คือ Dijkstra ผมคิดว่าที่มานาจะได้มาจากรถไฟดังที่กล่าวไว้ เมื่อย้ายมาสู่โลกของ OS ก็หมายความว่า semaphore คือตัวแปรที่ควบคุมการทำงานของเทร็ดนั่นเอง จะมีผู้ที่ล็อคได้เพียงหนึ่งเทร็ดในหนึ่งเวลา ไม่มีการหลุดทำให้พลาดหลุดให้สองเทร็ดเข้าไปทำงานใน critical region เป็นเด็ดขาด ก็อาศัย tsl ดังที่กล่าวไว้นั่นเอง ตัวแปร lock ที่แสดงให้ดูจึงถือว่าเป็น semaphore ตัวหนึ่ง

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

เมื่อเราใช้ mutex ในการล็อค และมีอีกเทร็ดมาพยายามล็อคอีก ซึ่งแน่นอนไม่สำเร็จ เมื่อไม่สำเร็จ OS จะสั่งพักงานเทร็ดที่สองทันที ถอดออกจากรายการเทร็ดที่มีสิทธิในการได้รับเวลาจาก CPU เพื่อทำงาน รอจนกระทั่งเทร็ดแรกปล่อยล็อค OS ก็จะไปปลุกเทร็ดที่สองมาแล้วล็อคให้เสร็จสรรพ ดังนั้นการล็อคโดยใช้ mutex จึงไม่เกิด spinlock ให้เสียทรัพยากรแต่อย่างใด

การใช้ mutex กับ C++ และ python

จำโปรแกรมที่ core dump ได้ไหมครับ ผมจะเอามาปรับปรุงใหม่ใส่ mutex เข้าไป ดังนี้ครับ

 ผมต้อง #include <mutex> จึงจะใช้งานได้ ตัว mutex เองมีมาใน Boost นานแล้วครับ ถ้าเราใช้ mutex m;  เราสามารถใช้ m.lock() และ m.unlock() ได้  ถ้าเราใช้ m.lock() แต่ไม่สำเร็จ OS จะถอดเทร็ดนั้นออกจาก schedule จนกว่าเทร็ดที่ล็อคอยู่จะปล่อย การใช้งาน mutex ก็ง่ายๆ แค่นี้ครับ แต่สำหรับ modern C++ แล้ว แค่นี้ไม่เพียงพอครับ ปัญหามีครับ ปัญหาแรกก็คือ เมื่อเราล็อคแล้ว เราอาจจะลืมปล่อย ทำให้ล็อคค้าง เรื่องนี้ไม่สู้จะเป็นปัญหามากมายนะ เพราะโปรแกรมจะทำงานไม่ถูกต้อง หาบั๊กไม่นานก็เจอ แต่ปัญหาที่ใหญ่กว่านั้นก็คือ ถ้าโปรแกรมเกิด exception ระหว่างที่เราล็อค mutex ค้างอยู่ การทำงานหลุดขึ้นไปชั้นบนกว่าที่มีการดัก exception โปรแกรมยังทำงานได้ต่อ แต่ล็อคค้าง ทางแก้ต้องใช้ finally try-catch-finally มาปลดล็อค แต่ถ้าอะไรก็ต้องมาดัก try-catch-finally  ก็คงวุ่นวายน่าดู

ผู้คิดค้นภาษา C++ คือ Stroustrup เล็งเห็นปัญหานี้ครับ ประกอบกับได้แนวคิดการสร้าง object ที่เอาแต่สร้างไม่จำเป็นต้องทำลายทิ้ง เมื่อตัวแปรหมดสโคป object ตัวนั้นมันก็ตายเอง ซึ่งแนวคิดนี้ผู้นำมาเผยแพร่เป็นเจ้าแรกๆ ก็คือ Java ดังที่ผมเคยกล่าวมา Storoustrup จึงสร้างแนวคิดใหม่ที่ไม่จำเป็นต้องอาศัย Garbage Collector แต่สามารถทำลายตัวล็อคอัตโนมัติเมื่อหมดสโคป เรียกว่า Resource Acquistion Is Initilization (RAII) 

แนวคิดของ RAII เข้าใจได้ง่ายมาก แต่เป็นวิธีการที่มีประสิทธิภาพ ก็คือการนำเอา object อีกชนิดหนึ่งมาห่อหุ้มตัวแปรเป้าหมายไว้ ในที่นี้ตัวหุ้มคือ unique_lock และตัวที่ถูกหุ้มก็คือ mutex นั่นเอง เมื่อตัวหุ้มเกิดขึ้นมาแล้วมันก็จะทำงานทันที ในที่นี้คือ ไปเรียก mutex ให้ .lock()  แล้วถ้าเกิด exception ขึ้นมา จะทำให้ unique_lock หมดสโคป ก่อน object ทุกตัวตาย อย่างไรก็เรียก destructor ก่อนอยู่ แล้ว ใน destructor นี้เองมันจะไปเรียก mutex ให้ .unlock() 

จากโปรแกรม ผมสร้าง mutex ใน main() จากนั้นก็ส่งไปยัง worker ซึ่ง worker จะทำงานหลายเทร็ดพร้อมๆ กัน สังเกตว่าใน worker() สร้าง unique_lock เอาไว้หุ้ม mutex ในบรรทัดที่ 16  ดังนั้นจึงถือว่าบรรทัดที่ 17 ถึง 19 นั้นเป็น critical region บรรทัดที่ 20 นั้นผมอาจจะไม่เขียนก็ได้ ถ้าไม่เขียน เมื่อเทร็ดทำงานถึงบรรทัดที่22 ตัว lck จะได้ ก่อนตายมันจะปลดล็อคเอง จากนั้นก็ไปวน loop while เพื่อสร้าง lck ตัวใหม่ แต่ที่ผมสั่ง unlock เอง ก็เพราะการล็อคยิ่งแคบยิ่งดี ถ้าท่านเอา unique_lock ไปคร่อม loop while ทั้งหมด แบบนี้ก็ทำงานถูกต้องครับ แต่ 2 เทร็ดทำงานไม่ได้ขนานกันนะครับ เทร็ดแรกเสร็จก่อนแล้วค่อยต่ออีกเทร็ด กลายเป็นโปรแกรมตัวประมวลผลเดียวไป

ผลลัพธ์นั้นถูกต้องแล้วนะครับ ไม่จำเป็นต้องแสดงให้ดู ท่านสามารถทดลองเองได้ครับ คราวนี้เรามาดูทางฝั่งของ Python บ้าง ดูโปรแกรมที่ใช้ threading library กันเลยครับ

เมื่อใช้ jython ทำงานบน Windows ได้ผลลัพธ์ดังนี้ครับ

jython_theading_sync

เห็นได้ชัดว่าคำตอบถูกต้องทุกครั้งและการแบ่งงานให้ทั้งสองเทร็ดทำนั้น ค่อนข้างแบ่งได้ดี วิธีการทำก็คือ เราสร้าง mutex ขึ้นมาก่อน ในที่นี้คือ lock1 แล้วก็ส่งไปให้กลับ worker ซึ่ง แต่ละเทร็ดก็ได้รับ mutex ตัวเดียวกันไปควบคุม การควบคุมนั้น ใช้คำสั่ง with ซึ่งเป็น RAII สำหรับ Python จะสังเกตว่าเมื่อใช้คำสั่ง with นั้น จะมีการเรียก .acquire() อัตโนมัติซึ่งเป็นการล็อค และเมื่อจบคำสั่ง with จะมีการเรียก .release() อัตโนมัติเช่นกันซึ่งเป็นการปลดล็อค จึงเป็น RAII ที่สมบูรณ์แบบ เราจะล็อคคร่อมการตรวจสอบว่าว่างหรือไม่ และการดึงข้อมูล และรีบปลดออก เพราะ sumWorkers นั้นไม่จำเป็นต้องล็อค เพราะแต่ละเทร็ดใช้ค่าคนละ item กัน ไม่เกิด race condition

คราวนี้มาลองดู multiprocessing library ดูบ้าง

 ทำงานบน Banana Pi ได้ผลลัพธ์ดังนี้ครับ

python_multiprocessing_sync

เห็นได้ว่าทำงานได้อย่างถูกต้องเช่นกัน การแบ่งงานก็ถือว่าใช้ได้ ผมคงไม่อธิบายคำสั่งนะครับ เพราะคล้ายกับ threading 

การใช้ with ก็มีประโยชน์อีกอย่างครับ คือการเปิด/ปิด lock/unlock หรืออะไรที่เป็นคู่ๆ แบบนี้เราไม่ต้องจำชื่อครับ ใช้ with มันเรียกให้ถูกต้องเอง อย่างตัวอย่างข้างต้น mutex ของ library ทั้งสองตัวนี้ ตัวหนึ่งใช้ lock()/unlock() อีกตัวใช้ acquire()/release() ถ้าใช้ with ผมก็ไม่ต้องจำ ทำให้การเปลี่ยนจาก library ตัวหนึ่งเป็นอีกตัวหนึ่ง ไม่ต้องแก้ไขมากครับ

Thread Synchronization กับโปรแกรม Bernoulli ครั้งที่ 1

ผมนำเอาแนวคิด thread synchronization มาลองใช้กับโปรแกรม bernoulli ดังโปรแกรม HPCThai/introHPC/09/bern_thread_split.cpp ซึ่งผมตัดส่วนของโปรแกรมเฉพาะในส่วนการกระจายหน่วยประมวลผลให้ท่านดูดังนี้ครับ

โปรแกรมนี้จะแตกเทร็ดตามจำนวนคอร์ของ CPU ดังบรรทัดที่ 219 ผมใช้ วิธีการแบ่ง primeList ออกเป็นส่วนตามจำนวนคอร์เช่น ถ้าใน primeList มี 100 ตัวและเรามี 4 คอร์ จะทำให้เทร็ดแรกได้งานจาก primeList[0:24] เทร็ดที่สองจะได้ primeList[25:49] เป็นเช่นนี้ทั้ง 4 คอร์ แต่ละเทร็ดจะได้ primeList ไปอ่าน อ่านอย่างเดียวจึงไม่ถือว่าอยู่ใน critical region ตัวที่อยู่ใน critical region ก็คือผลลัพธ์ rp นั่นเอง เป็น vector ที่ใช้ร่วมกัน ทุกคนเพิ่มค่าต่อท้ายกันหมด การต่อท้ายจึงเป็น hotspot จุดร้อน ดังนั้นเราต้องใช้ mutex มาครอบในส่วนนี้ ดังบรรทัดที่ 207-209 ส่วนตัวแปรอื่นที่สร้างขึ้นมาก็เป็นตัวแปร local จะเรียกใช้ computeBkModP() ก็ไม่ใช่ปัญหาอยู่นอก critical region งานจึงมีเพียงแค่นี้ การแบบเป็นส่วนๆ แบบนี้ผมเชื่อว่าเป็นวิธีเดียวกันกับที่ Apache Spark ใช้ เรามาลองรันกันดูเลยครับ

cpp_bern_thread_split

เห็นเวลาของ user ไหมครับ กินเวลา 50 วินาทีเทียบกับที่ผมเคยทดสอบเอาไว้ในชื่อของ bern_mod_v2 ในท้ายของบทความ ก้าวแรก HPC #06: อัลกอริทึมประสิทธิภาพสูงและการปรับปรุงประสิทธิภาพ  ตอนนั้นทำงานคอร์เดียวใช้เวลา 49.440s จึงถือว่าเป็นค่าระดับเดียวกัน หมายความว่าเป็นผลรวมเวลาของทั้ง 2 คอร์ แต่สิ่งที่เห็นชัดคือ real นั้นคือเวลาที่ใช้จริง เพราะคอร์ทั้งสองทำงานเวลาเดียวกัน ทำให้เร็วขึ้นเหลือเพียง 39 วินาที ซึ่งถือว่าดีในระดับหนึ่ง แต่ถ้าจะให้ดีมากมันต้องแบ่งครึ่งแล้วเหลือแค่ประมาณ 25 วินาที จึงจะถือว่าเยี่ยม

ผมไขความลับของมันโดยจับเวลาที่ใช้ของแต่ละเทร็ด เห็นได้ว่า Thread[0] นั้นใช้เวลา 11 วินาที แต่ Thread[1] นั้นใช้เวลากว่า 38วินาทีเลยทีเดียว เดาได้ไหมครับว่าเป็นเพราะอะไร ไม่ยากใช่ไหมครับ นั่นก็เป็นเพราะแต่ละเทร็ดได้งานที่ยากง่ายไม่เท่ากัน ตอนแบ่งค่าจำนวนเฉพาะให้กระจายไปทำนั้น จำนวนเฉพาะยิ่งค่ามากก็มีแนวโน้มยิ่งซับซ้อนขึ้น ดังนั้นจึงใช้เวลามาก Thread[0] ได้งานง่ายจึงเสร็จก่อน แล้วก็รอฆ่าเวลา แต่ Thead[1] ทำงานตายกันไปข้าง อย่างนี้ไม่ยุติธรรม เราจะปรับปรุงในหัวข้อถัดไปครับ

ส่วน Python ขอข้ามไปนะครับ เพราะผลลัพธ์ก็ได้ทำนองเดียวกัน บทความนี้ชักจะยาวเกิน คงต่อย่อลงบ้าง เราไปดูที่การปรับปรุงครั้งที่ 2 เลยดีกว่าครับ

Thread Synchronization กับโปรแกรม Bernoulli ครั้งที่ 2

ในการปรับปรุงครั้งที่ 2 นั้นแทนที่เราจะแบ่งเป็นส่วนๆ ผมกลับไม่แบ่ง ผมจะเอาค่าจำนวนเฉพาะ ไปเก็บไว้ใน queue หรือ list ถ้าเทร็ดใดทำงานเสร็จก็มาขอตัวต่อไป ทำแบบนี้น่าจะมีประสิทธิภาพมากขึ้น หลักการผมได้นำเสนอมาต่อเนื่องแล้ว โดยใช้ mutex เป็นตัวควบคุม ดังโปรแกรมภาษา C++ HPCThai/introHPC/09/bern_thread_sync.cpp โดยตัดเฉพาะส่วนสำคัญมาให้ดูดังนี้

ผลลัพธ์การทำงานจะได้

cpp_bern_thread_sync

ชัดเจนนะครับ user time ยังคงประมาณ 49-50 วินาที และ real ลดลงเหลือ 25 วินาที หมายความว่าเทร็ดทั้งสอง แบ่งงานกันอย่างยุติธรรมมาก ซึ่งผมก็บันทึกเวลาที่ใช้ให้ดูด้วยนั่นคือ Thread[0] ใช้เวลา 24.66 วินาที ส่วน Thread[1] ใช้เวลา 24.85 วินาที ถือว่าเป็นผลสำเร็จครับ

ส่วน Python ผมคงไม่แสดงรายละเอียดนะครับ สามารถไปอ่านโปรแกรมที่ใช้ threading library ได้ที่ HPCThai/introHPC/09/bern_threading_sync.py ซึ่งเมื่อทำงานโดยใช้ jython บน windows แล้วจะได้ ผลลัพธ์

jython_bern_threading_sync

ส่วนถ้าใช้ multiprocessing library ก็ดูจาก HPCThai/introHPC/09/bern_multiprocessing_sync.py ซึ่งเมื่อรันโปรแกรมบน Banana Pi แล้วจะได้

python_bern_multiprocessing_sync

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

การปรับปรุงครั้งที่ 3 Embarrassingly Parallel

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

ถ้าเปรียบการประมวลผลแต่ละคอร์เหมือนกับเด็กอนุบาลหนึ่งคน สมมุติว่าผมจะพาเด็กอนุบาลสองคนไปเที่ยวทัศนศึกษาที่สามจังหวัดภาคใต้ (ไม่มีที่ไปที่อื่นแล้วเหรอ) ขับรถเก๋งไป มีเงื่อนไขอยู่ว่า ถ้าเด็กคนไหนปวดฉี่ เขาจะร้องเรียกมาเลย ต้องรีบจอดให้ทันครับ  มิฉะนั้นฉี่ราดในรถแน่ เด็กก็ยังเป็นเด็กนะครับ ยังอั้นฉี่ไม่เป็น ท่านก็ว่าก็ไม่มีปัญหาอะไรมากมาย ปวดเมื่อไหร่ก็จอดข้างทางก็เท่านั้น

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

เมื่อสูงสุดคืนสู่สามัญ ที่ๆ อันตรายที่สุดเป็นที่ๆ ปลอดภัยที่สุดฉันใด การล็อคที่ดีที่สุดคือการไม่ล็อคนั่นเอง ในงานฐานข้อมูลนั้น ถ้าเราล็อคกันมากๆ ก็อาจจะเกิด deadlock เหมือนรถที่มาจากทางแยกทุกทางอัดกันเข้าไป สุดท้ายเมื่อไม่มีใครยอมใคร ก็จะไม่มีใครได้ไป คากันอยู่กลางสี่แยกนั่นแหละ เราเรียกการล็อคแบบนี้ว่า pessimistic locking หรือล็อคแบบมองโลกในแง่ร้าย แต่ก็มีแนวทางที่คิดมาตามหลังเรียกว่า Optimistic locking หรือมองโลกในแง่ดี วิธีการคือเวลาดึงข้อมูล ก็ดึงเอา timestamp มาด้วย เมื่อใครปรับปรุง timestamp นั้นก็ถูกปรับปรุงด้วย ดังนั้นถ้าเรา update ข้อมูล โดยเอา timestamp มาเป็นเงื่อนไขด้วย ถ้ายังไม่มีใครแก้รายการนั้น เราก็เขียนสำเร็จ แต่ถ้าเกิดข้อผิดพลาด  คือหารายการตามเงื่อนไขไม่เจอ แสดงว่ามีใครชิง update ก่อนหน้าแล้ว เราก็ต้องข้อมูลใหม่ แล้วทำกระบวนที่ทำแล้วใหม่ทั้งหมด จากนั้นก็เอาไปทดลอง update ทำไปจน update สำเร็จ จะเห็นได้ว่า ยุ่งยากขึ้นพอสมควร แต่ไม่มีการล็อคเกิดขึ้น ดังนั้นจึงสามารถรองรับจำนวน transaction ต่อวินาที ได้มากกว่าอย่างมาก

หลักการการประมวลผลแบบขนานโดยไม่ต้องล็อคนั้น เราเรียกว่า embassassingly Parallel แปลตามตัวได้ว่าการประมวลผลขนานแบบน่าละอาย ที่มาของคำนี้ผมยังหาไม่ได้เลยครับว่ามันน่าละอายอย่างไร แต่เดาเอานะครับ ผมว่าน่าจากอาจารย์ตรวจข้อสอบวิชาการประมวลผลแบบขนาน ปรากฏว่าที่สอนไปมากมาย เทคนิคต่างๆ เด็กคนนั้นไม่ได้เอาไปใช้เลย อาจเป็นเพราะไม่ตั้งใจเรียน ก็เลยหาวิธีหลบเลี่ยงทำแบบไม่ต้องมีการล็อคแต่อย่างใด ซึ่งพออาจารย์ท่านนั้นมาเห็นผลงาน ก็เลยสบถออกมาว่า “ช่างน่าละอายจริงๆ” ทำมาแบบนี้ เธอจะให้ฉันให้คะแนนยังไง สงสัยว่าเด็กคนนั้นสอบตกครับตามที่ผมมโน แต่ถ้าเป็นยุคปัจจุบันเด็กคนนั้นได้เต็มอย่างแน่นอนครับ

เอาหละเรามาลองวิเคราะห์กันดูนะครับว่าจุดที่เป็น critical region เราจะแก้อย่างไร ในส่วนของ rp ที่เก็บผลลัพธ์ ถ้าเราใช้ list หรือ queue เพียงตัวเดียว เราก็ต้อง lock เพื่อให้มั่นใจได้ว่า จะไม่มีการเขียนลง list สองตัวพร้อมๆ กัน ทางแก้ง่ายนิดเดียวครับ ก็เขียนมันคนละตัวไปเลย ถ้าเป็น list ก็ส่งเป็น list ของ list ดังนั้นแต่ละตัวก็เขียนได้อย่างอิสระไม่รวบกวนใคร ก็ไม่ต้องล็อคครับ

คราวนี้มาถึงจุดสำคัญ แบ่งงานอย่างไรจึงจะยุติธรรม เราเห็นแล้วว่าแบ่งเป็นส่วนๆ ก็ไม่ยุติธรรม เทร็ดหลังสุดทำงานหนักสุด เราเคยใช้วิธีใครทำเสร็จมาขอไป ซึ่งเป็นวิธีที่น่าจะดีที่สุดแล้ว แต่จุดอ่อนก็คือยังต้องอาศัยการล็อค ทางออกทางหนึ่งที่พอใช้ได้ก็คือ การทำแบบกระโดด  เช่น ถ้าเรามีสองเทร็ด ก็แบ่งไปเลย ถ้าเป็น item เลขคี่ เทร็ดแรกก็ทำไป แต่ถ้าเป็นเลขคู่ เทร็ดที่สองก็ทำ อะไรทำนองนั้น ถ้ามี 10 เทร็ด เทร็ดแรกก็กระโดดทำ 0, 10, 20, 30, .. ส่วนเทร็ดที่สองก็ทำ 1, 11, 21, 31, … เป็นต้น ซึ่งวิธีนี้ยุติธรรมกว่า การแบ่งเป็นกลุ่มๆ แบบที่แล้วมา แต่นั่นหมายความว่า แต่ละเทร็ดต้องมีกำลังพอๆ กันนะครับ และไม่ติดงานอื่น มันถึงจะทำให้ยุติธรรม มิฉะนั้นก็ควรจะไปใช้แบบล็อคครับ

แนวคิดการกระโดดดังกล่าวนี้ก็เป็นการแบ่งข้อมูล หรือ partitioning อย่างหนึ่ง ซึ่งถ้าแบ่งแบบเอากลุ่มที่ติดกันเอาไว้ด้วยกัน เราเรียกว่า block partitioning เราก็ได้พิสูจน์กันไปแล้วว่าไม่ค่อยมีประสิทธิภาพสำหรับโจทย์นี้ ดังนั้นเราจึงไปใช้ cyclic partitionting ก็คือแบบกระโดดนั่นเอง ซึ่งเป็นพื้นฐานหลักของ CUDA ซึ่งผมจะกล่าวในบทความชุดหน้าครับ ขอละเอาไว้ในฐานที่ไม่เข้าใจก่อนครับ เรากลับมาดูโปรแกรมที่ใช้แนวคิดที่ผมบอกว่าที่ HPCThai/introHPC/09/bern_thread_emba.cpp เมื่อรันโปรแกรมแล้วจะได้

cpp_bern_thread_emba

 

จะเห็นได้ว่าผลลัพธ์ก็ยังคงดีอยู่ ทีนี้เรามาลองดู Python กันบ้างกับ threading library ที่  HPCThai/introHPC/09/bern_threading_emba.py เมื่อรันโดยใช้ jython บน Windows จะได้ผลลัพธ์

jython_bern_theading_emba

และสำหรับ multiprocessing lib บน Banana Pi ตามนี้ครับ HPCThai/introHPC/09/bern_multiprocessing_emba.py เมื่อรันแล้วได้ผลลัพธ์ดังนี้ครับ

python_bern_multiprocessing_emba

ผลลัพธ์ก็อยู่ในเกณฑ์ดีมากทั้งหมด แนวโน้มการเขียนประมวลผลแบบขนานก็โน้มเอียงไปแนว embarrassingly ลองพิจารณา Apache Spark ที่ผมเคยเขียน ก้าวแรก HPC #08: Apache Spark กับงาน Big Compute ขอตัดประเด็นเรื่องประสิทธิภาพออกไปก่อน พิจารณาเฉพาะ framework ในการเขียนโปรแรม เราจะเห็นได้ว่าวิธีการเขียนโปรแกรมนั้นเป็นแบบ MapReduce ซึ่งการทำงานภายในเป็นแบบ embarrassingly โดยธรรมชาติอยู่แล้ว และ library ตัวอื่นอีกมามาย ซึ่งผมขอค่อยๆ ทะยอยเขียนให้อ่านกันครับ

ทิ้งท้าย

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

[Total: 4    Average: 5/5]

You may also like...

Leave a Reply

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