Multiprogramming, Multiprocessing และ Multithreading

หลังจากที่เรามาเล่าเรื่อง malloc() มีคนอยากให้มาเล่าเรื่อง pthread เพื่อให้สามารถยัด Content ที่ละเอียด และเข้าใจง่ายในเวลาที่ไม่นานเกินไป เลยจะมาเล่าพื้นฐานที่สำคัญของคำ 3 คำคือ Multiprogramming, Multitasking, Multiprocessing และ Multithreading

คอมพิวเตอร์สมัยก่อนทำงานได้ทีละอย่าง

ในระบบคอมพิวเตอร์ ปัจจุบันส่วนใหญ่ เราทำงานอยู่บนโปรแกรมที่เรียกว่า ระบบปฏิบัติการ หรือ Operating System (OS) เช่น macOS, Linux และ Windows มันมีหลากหลายหน้าที่มาก ๆ เป็นเหมือนกาวที่เชื่อมระหว่าง Hardware และ Application แต่ หน้าที่ที่เราจะมาพูดถึงกันในบทความนี้คือ การจัดการงาน (Scheduling)

OS เป็นคนสั่งงานให้กับ Hardware ต่าง ๆ เช่น การสั่งให้ CPU คิดเลข แล้วเอาผลลัพธ์กลับมา โดยงานพวกนี้ในภาษาอังกฤษ เราเรียกตรงตัวเลยคือ Job

ภายใน OS มันมีงานหลายงานที่จำเป็นต้องทำ ตั้งแต่การทำ Housekeeping จนไปถึงการทำงานของโปรแกรมต่าง ๆ ที่ผู้ใช้เรียกขึ้นมา ทำให้มันจำเป็นต้องมีสิ่งที่ทำหน้าที่เหมือนที่แปะใบ Order ว่า ตอนนี้มีงานที่เหลืออยู่คืออันนี้ ๆ นะ เราเรียกมันว่า Job Pool 

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

งานส่วนใหญ่ ถูกแบ่งออกเป็น 2 ประเภทด้วยกันคือ งานที่เน้นการคำนวณเน้นการทำงานอยู่บน CPU เป็นหลัก เราเรียกว่า CPU-Bound Job (บางคนเรียกว่า CPU Intensive Job) และงานที่ต้องอาศัยการเข้าถึงหน่วยความจำเป็นจำนวนมาก เราเรียกว่า IO-Bound Job งานพวกนี้มันมาเรื่อย ๆ ทำให้การจัดเรียง หรือ Schedule งานทั้งสองประเภทนี้จึงท้าทายเป็นอย่างมาก

ตัวอย่างเช่น หากเรามี 2 งาน เรียก J1 และ J2 ถ้าเราทำงานแบบง่ายที่สุดคือ First Come First Serve (FCFS) หรือบ้าน ๆ คือ ใครมาก่อนทำก่อน ตัว J1 ที่มาก่อนก็จะได้เริ่มทำงานก่อน แต่ปัญหาเกิดคือ J1 บอกว่า อ่อ เราต้องไปโหลดข้อมูลจาก RAM รอเราแปบนึงนะ ซึ่งเวลาที่ J1 เรียก RAM ตัว CPU ก็ต้องรอจน J1 ได้ข้อมูลมา รันจนเสร็จ แล้วถึงจะให้ J2 เริ่มทำงาน เราจะเห็นว่ามีช่วงเวลาที่ CPU มันว่างงาน ทำให้การทำงานมันไม่มีประสิทธิภาพเท่าไหร่

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

Multiprogramming

ด้วยปัญหาที่เราได้กล่าวไป คือถ้าเราทำ FCFS แล้วมัน IO Bound Job มาต่อกันเยอะ ๆ บอกเลยว่า ตุยแน่นอน รอกันรากงอกก็ไม่เสร็จ เราจะต้องแก้ปัญหาด้วยวิธีการบางอย่างละ

Concept ของการทำ Multiprogramming เลยเกิดขึ้นมา โดยการที่เมื่อ CPU ว่างงาน มันจะเป็นคนขยันเข้าไปหางานใหม่จากใน Job Pool มาทำต่อ เช่น ระหว่างที่มีอีกงานกำลังรอ IO หรือ Network อยู่ตัว CPU จะทำงานใหม่แบบนี้ไปเรื่อย ๆ เพื่อลดเวลาที่ CPU ว่างงานลง ส่งผลให้ประสิทธิภาพในการทำงานสูงขึ้น

ยกตัวอย่างจากเคสเมื่อครู่คือ เรามี J1 และ J2 ระหว่างที่ J1 กำลังรอ IO แทนที่ OS จะปล่อยให้ CPU ว่าง มันจะไปเอา J2 มาทำต่อเลย หาก J2 บอกว่า อ่อ เราต้องไปเอาของจาก RAM หรือ Network เหมือนกัน มันก็จะไปหางานต่อไปทำไปเรื่อย ๆ

ซึ่งการที่เครื่องสลับงานไปทำอีกงานหนึ่ง เราเรียกว่าการทำ Context Switching สิ่งที่มันทำคือ มันจะต้องเอาพวกข้อมูลการทำงานของงานที่กำลังทำเข้าไปเก็บใน Memory ไม่ว่าจะเป็น Register หรือ Primary Memory อะไรก็ตาม ทำให้การทำ Context Switching ทุกครั้ง และมันมีราคาที่ต้องจ่ายเป็น Overhead ในการทำงาน อาจจะเป็นเวลา และ Bandwidth ภายใน CPU และ Memory ที่สูงขึ้น

นั่นแปลว่า Ultimate Goal คือต้องหาวิธี Schedule ที่ลดการทำ Context Switching และลดเวลาที่ CPU ว่างให้ได้มากที่สุด โดยทำได้หลายวิธีมาก ๆ เช่น Priority-Based เพื่อให้เราสามารถจัดเอางานที่สำคัญ ๆ ขึ้นมาทำก่อนได้ หรือความสำคัญเท่ากันหมดเลย ก็อาจจะเป็นการทำ Round-Robin คือ การสลับไปสลับมาทำไปเรื่อย ๆ

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

Multiprocessing

ปัจจุบันเทคโนโลยี CPU เราไปไกลมากกว่าเมื่อก่อนเยอะมากแล้ว ตอนนี้ CPU 1 ตัว เราสามารถยัด Processor ได้มากกว่า 1 ตัว เช่นใน CPU ระดับบ้านตอนนี้เราอาจจะไปกันถึง 4-6 Core และใน CPU ระดับ Server และ Workstation เราไปกันถึง 192 Core ต่อ CPU กันแล้ว เสมือนกับว่า จากเดิมเรามีคนทำงานอยู่คนเดียว ตอนนี้กลายเป็นว่า เราสามารถยัดคนมากกว่า 1 เข้าไปทำงานพร้อม ๆ กันได้

ถ้าเรายังเขียนโปรแกรมแบบเดิม ๆ เราก็จะไม่สามารถใช้งานหลาย ๆ Core หรือ Processor ได้เลยด้วยซ้ำ ทำให้ มันเกิด Concept ที่เรียกว่า Multiprocessing ขึ้นมา โดยตอนนี้ เรามีหลาย ๆ Processor เข้ามาทำงาน เช่น เป็น Dual Core CPU นั่นคือ เสมือนว่า เรามีคน 2 คน มาช่วยกันทำงาน เราให้เป็น P1 และ P2 

เมื่อเราใช้ Multiprocessing Concept เข้ามาช่วย ทำให้เราสามารถจ่ายงานมากกว่า  1 งานเข้าไปใน CPU ได้ ในกรณีตัวอย่าง Dual Core คือ เราสามารถทำงานพร้อม ๆ กันได้ 2 ทำให้ในทางทฤษฏี เราน่าจะทำงานได้เร็วขึ้นเป็น 2 เท่า หรือถ้าเป็น Quad Core ก็ควรจะทำงานได้เร็วขึ้นเป็น 4 เท่า

แต่เรื่องนี้เราต้องบอกว่า มันเป็นจริงได้ในทางทฤษฏีเท่านั้น เนื่องจากการทำงานแบบ Multiprocessing บ้างก็เรียกว่า Parallel Processing มันมี Overhead ในการทำงานด้วยเช่นกัน เช่นสมมุติว่า เรามีโจทย์บวกเลข ตั้งแต่ 1-100 เรามี 2 Core เราอาจจะแบ่งงานให้ Core ที่ 1 บวกตั้งแต่ 1-49 และ Core ที่ 2 บวกตั้งแต่ 50-100 แต่สุดท้าย แล้วมันจะต้องเอาผลรวมจากทั้ง 2 Core มาบวกรวมกันเป็นผลลัพธ์อันเดียว นั่นแปลว่า มันจะต้องมีกระบวนการในการที่ทั้ง 2 Core คุยกันได้ และ เสียเวลาในการบวกขั้นตอนสุดท้ายด้วย

ตอนที่เราเรียนเรื่องนี้ จุดที่เราคิดว่า ยากมาก ๆ คือ การทำให้ทั้งสอง Process ที่ทำงานอยู่กันคนละ Processor คุยกันได้ เราเรียกกระบวนการนี้ว่า Inter-Process Communication (IPC) โดยมันจะมีวิธีการที่เราสามารถทำได้คือ Shared Memory, Mapped Memory Pipe และ Socket

Shared Memory และ Mapped Memory ให้เราคิดภาพง่าย ๆ ว่า เราจะใช้งาน Memory ก้อนนี้ด้วยกัน Process A อาจจะเขียนข้อมูลเอาไว้ และค่อยให้ Process B เข้ามาอ่าน ไปมาได้สองทางก็ย่อมได้ ส่วน Mapped Memory ลักษณะเดียวกัน ต่างกันแค่เรามีการ Mapped Disk Block เอาไว้ แล้วอาจจะเข้าถึงด้วย Pointer ก็ได้ ดังนั้นเมื่อเกิดปัญหาอะไรขึ้นมา ข้อมูลเก่าก็ยังคงอยู่ สามารถ Resume State ได้ ถ้าเราเขียนให้มันรองรับอะนะ

Pipe ให้เราคิดภาพง่าย ๆ เหมือนกับท่อที่เราเชื่อมต่อทั้งสอง Process เข้าด้วยกัน แต่ท่อนี้ มันสามารถวิ่งไปได้ในทิศทางเดียว (Unidirectional) เท่านั้น เช่น ถ้าเรามี Pipe จาก Process A ไป B เราจะสามารถส่งข้อมูลจาก Process A ไป B ได้เท่านั้น ไม่สามารถส่งจาก B กลับไป A ได้

ยกตัวอย่างโปรแกรมสำหรับบวกเลขเมื่อครู่ เรามี Process เป็น Process เริ่มต้น เราเรียกว่าเป็น Parent Process ละกัน โดยใน Process นี้เราจะให้มันบวกเลขตั้งแต่ 1-49 และ รอผลจาก อีก Process แล้วเอามาบวกกันกับผลลัพธ์ที่ได้จาก Process ตัวเอง

ในอีก Process เราจะต้อง Fork หรือสร้างขึ้นมา เราจะให้มันบวกเลขตั้งแต่ 51-100 เราจะเรียก Process ที่ถูกสร้างขึ้นมาจาก Parent Process ว่า Child Process และเราบอกว่า เมื่อ Process นี้บวกเลขเสร็จมันต้องส่งกลับไปให้ Parent Process  ทำให้ถ้าเราใช้ Pipe ในการส่งข้อมูล เราจำเป็นต้องสร้าง Pipe จาก Child Process ไปที่ Parent Process นั่นเอง

ถ้าใครเคยใช้ Command Line อาจจะเคยได้ยินอะไรที่เรียกว่า Pipe ที่เราใช้เครื่องหมาย Vertical Bar (|) คั่นระหว่างคำสั่ง เพื่อเอา Stdout ของคำสั่งแรกส่งไปให้คำสั่งต่อ ๆ ไป

ถ้าอ่านแล้วยังไม่ค่อยเข้าใจ แนะนำให้ไปหาอ่านเรื่อง Process Lifecycle ก่อนเด้อ น่าจะช่วยได้เยอะ

แต่ Socket ต่างออกไป มันเป็นกระบวนการสื่อสารระหว่าง Process ที่ซับซ้อนกว่าการใช้ Pipe มาก ๆ มันเป็นเหมือนตัวต่อที่เชื่อมต่อ Process เข้าด้วยกัน สิ่งสำคัญที่ต่างกับ Pipe คือ Socket สามารถเชื่อมต่อแบบสองทาง (Bidirectional) ได้ ทำให้มันเหมาะกับ การทำงานที่เราจำเป็นต้องให้ทั้งสอง Process มีการพูดคุยไปกลับกันตลอดเวลา

อ่านมาแล้ว ทำไมเหมือนเราเชียร์การใช้ Socket จัง มันดูเป็น All-in-one solution สำหรับการทำ IPC มาก ๆ แต่เอาเข้าจริง การ Implement Process ถือว่ามีความยุ่งยากมากกว่า Pipe เยอะมาก และ เรายังต้องจัดการการคุยกันไปมา และ ถ้ามันต้องคุยกัน มันไม่ใช่ว่า มันจะคุยกันเมื่อไหร่ก็ได้ คนเขียนโปรแกรมจะต้องเขียนมาให้มันรอเผื่ออีกฝั่งคุยด้วย ทำให้การใช้ CPU ไม่มีประสิทธิภาพลงไปอีก ดังนั้นสิ่งสำคัญของ Engineer ที่ทำงานพวกนี้คือ การ คุยกันให้น้อย และเร็วที่สุด แต่ได้ผลลัพธ์ที่ต้องการให้ได้

Multithreading

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

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

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

หนึ่งในสิ่งที่ Thread มันต่างจาก Process คือ Thread ไม่มีสิทธิของ Memory ของตัวเอง กล่าวคือ เวลามันจำเป็นต้องเก็บ หรือ เรียกข้อมูล มันไม่มีที่เก็บข้อมูลเป็นของตัวเองในแต่ละ Thread มันจำเป็นต้องกลับไปใช้ของ Process ที่เป็นเหมือนส่วนกลางของ Thread เอง นั่นนำมาซึ่งทั้งข้อดี และ ข้อเสีย

ข้อดีคือ หากเรามีข้อมูลบางอย่างที่ต้องใช้ร่วมกันในแต่ละ Thread เราไม่จำเป็นต้อง Copy ออกเป็นหลาย ๆ ชุดซ้ำกัน ลดการใช้ Memory อันมีค่าของเราได้ แต่กลับกัน เมื่อมันใช้ข้อมูลชุดเดียวกันในการทำงาน นี่แหละปัญหา เพราะสมมุติว่า เราจะเขียนโปรแกรมบวกเลข เราแบ่งเป็น 2 Thread คือ T1 และ T2 เริ่มต้น อาจจะเป็น T1 เข้ามาอ่านค่าเดิมจาก Memory สมมุติว่าเป็น 2 แล้ว เอาไปประมวลผลด้วยการบวก 1 เข้าไป ระหว่างนั้น T2 เข้ามาอ่านเหมือนกัน ข้อมูลที่มันได้ไปคือ 2 มันก็เอาไปบวก 1 ระหว่างที่ T2 กำลังบวกเลข T1 คำนวณเสร็จแล้ว เลยเขียน 3 กลับไป และ T2 ก็เสร็จตามมา เลยเขียน 3 ลงไปเช่นกัน อ้าว แทนที่จะบวกแล้วได้ 4 กลายเป็น 3 ซะงั้น นี่แหละ ปัญหา เราเรียกอาการแบบนี้ว่า Race Condition

ทำให้ในการทำงานแบบ Multithread ที่เราใช้ Memory ร่วมกัน เวลามันคุยกันให้ลดโอกาสการเกิด Race Condition ให้มากที่สุด เราจะใช้ Synchoronisation Mechanism เช่น Locks และ Semaphores (จริง ๆ มี Mutex ด้วย แต่ไม่อธิบายละกัน ยาววว)

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

Semaphores ซับซ้อนกว่านั้นหน่อยคือ เราต้องกำหนดว่า ส่วนนี้เราจะให้เข้าถึงได้จากกี่ Thread เช่นเราตั้งไว้ 3 Thread สมมุติว่า มี 3 Thread กำลังเข้าถึงอยู่ แล้วมี Thread ที่ 4 บอกว่าต้องการใช้ มันจะต้องรอจนกว่า ใครสักคนใน 3 Threads ที่ใช้อยู่จะส่งสัญญาณแปะมือบอกว่า ชั้นออกแล้ว นายเข้าได้ สำหรับคนที่เรียน อย่าสับสนระหว่าง Binary Semaphore และ Mutex เด้อสู มันค่อนข้างมีรายละเอียดที่แตกต่างกันพอสมควร คนเข้าใจผิดเยอะมาก รวมถึงกรูตอนเรียนปี 2 ด้วยค่าาาา

สรุป

คำทั้งสามคำที่เรายกมาในวันนี้เป็น Concept สำคัญที่ถ้าใครเรียน Computer Science น่าจะได้ผ่านหูผ่านตามาไม่มากก็น้อยแน่นอน มันเกิดจากการที่เครื่องคอมพิวเตอร์ถูกพัฒนาให้มีความสามารถในการทำงานที่หลากหลายมากขึ้น วิธีการออกแบบโปรแกรม และ OS ต้องตามให้ทันกับเทคโนโลยีที่เปลี่ยนไป ในทุก ๆ วันนี้ในระบบคอมพิวเตอร์ เราก็ยังคงใช้ Concept เหล่านี้แหละในการออกแบบและเขียนโปรแกรมออกมาใช้งานจริง บทความนี้น่าจะทำให้พอเข้าใน Concept เบื้องต้นได้ดีมากขึ้น และถ้าต้องการลึกกว่านี้ สามารถเอา Keyword ที่เราเล่าไปหาอ่านเพิ่มได้ แนะนำไปลองดูพวก Textbook ในวิชา Operating System มีเล่าอย่างละเอียดยัน Lifecycle และหลักการทำงานของแต่ละกลไกอย่างละเอียดยิบ