Multi-thread #2 : Classic problem

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

class Runner
{
	public int num = 0;
	public int Even() {
		num++; 	num++;
		return num;
	}
	public void Show10() {
		for (int i=0; i<10; i++) {
			Console.WriteLine(Even());
		}
	}
}

จาก Class นี้ก็คงเห็นได้ว่า class นี้ไม่มีอะไรซับซ้อน มันมีเพียง 2 methods คือ Even() และ Show10() เรามาลองดูความหมายของ ทั้ง 2 methods กัน

method แรก เป็น Method ที่เอาไว้สร้างเลขคู่ ซึ่ง num ตอนแรกนั้นเป็น 0 ดังนั้น เมื่อใครก็ตามเรียกใช้ฟังก์ชันนี้ มันจะได้ 2, 4, 6, .. ตามลำดับ จะได้เลขอะไร ผมคงไม่ทราบ มันขึ้นอยู่กับค่า num ล่าสุดว่าเป็นค่าอะไร แต่สิ่งที่ผมทราบก็คือ ยังไงก็ตาม มันต้อง return เป็นเลขคู่เสมอ ดังนั้นผมจึงตั้งชื่อ method นี้ว่า Even() ครับ

method ที่ 2 ก็ไม่มีอะไรเป็นพิเศษ ใครเรียกเข้ามา มันก็จะ วน 10 รอบ แต่ละรอบจะเรียก method Even() ทำให้มันพิมพ์เลขคู่ ที่มีค่าสูงขึ้นเรื่อยๆ 10 ครั้งก็หยุดครับ

คราวนี้เรามาดู Multi-thread กัน

using System;
using System.Threading;

class Start
{
	public static void Main()
	{
		Runner r = new Runner();
		Thread t1 = new Thread(new ThreadStart(r.Show10));
		Thread t2 = new Thread(new ThreadStart(r.Show10));		
		t1.Start();
		t2.Start();
	}
}

class ตัวนี้เป็นจุดเริ่มต้นของโปรแกรมเพราะ มี Main() method ดูๆ ไป มันก็ไม่มีอะไรซับซ้อนเท่าไหร่ มันก็เพียงแค่สร้าง Thread ออกมาอีก 2 Threads ชี้ไปที่ r.Show10  จากนั้นเรียก Method Start() ของแต่ละตัวเพื่อเริ่มรัน thread นั้น ก็ดูไม่น่าจะมีอะไร

คราวนี้เรามาลองดูควบกันทั้งสองส่วนครับ เราต้องเรียนรู้เพิ่มขึ้นแล้วครับ เพราะ thread ทั้ง 2 ตัว ไปเรียก method เดียวกันเสียนี่ เราต้องมาดูตัวแปรใน class Runner กันก่อนครับ เพื่อไม่ให้สับสน

ทั้ง 2 threads เรียกใช้ Method Show10() ซึ่งใน Method นี้ มีตัวแปรที่ใช้ในการวน loop คือ i นั่นเอง เราถือว่าตัวแปรของทั้ง 2 threads เป็นตัวแปร เป็นคนละตัวกันครับ แต่ตัวแปร num ที่อยู่นอก method นั้น ทั้ง 2 threads ต้องใช้ร่วมกันนะครับ กล่าวโดยสรุปแล้ว ตัวแปรที่ share กันนั้น ต้องเป็นตัวแปรที่ระดับ class scope ครับ

ในเมื่อ Thread 2 threads ใช้ตัวแปร num ร่วมกัน ดังนั้น ผลลัพธ์ที่คุณเห็น มันจะไม่ได้เป็นเลขคู่ 2, 4, .. , 20 สลับกัน 2 ชุดนะครับ แต่ว่ามันจะเป็น 2, 4, ..., 40 เพียงชุดเดียวกันครับ

ผลลัพธ์มันอาจจะได้ดังนี้ครับ

DOS Prompt

C:\CS> classic
2
4
6
8
10
12
14
16
18
20
22
24
26
28
30
32
34
36
38
40
C:\CS>_

 

โปรแกรมนี้ก็ดูปกติ ทุกอย่างเป็นไปดังคาด แต่ถ้าผมบอกว่าโปรแกรมนี้มีปัญหา คุณดูออกไหมครับว่าตรงไหน? ถ้ายังดูไม่ออก ไม่เป็นไร ผมแทรก Code เจ้าปัญหา เข้าไประหว่างการบวกเลข ใน method Even() จาก

num++;   num++;

มาเป็น

num++;    Thread.Sleep(1); num++;

แล้วลองรันใหม่ดูครับ คุณจะปัญหาครับ ลองรันหลายๆ ครับดูนะครับ มันเกิดผลลัพธ์ได้หลายแบบ นี่คือตัวอย่างผลลัพธ์แบบหนึ่งครับ

DOS Prompt

C:\CS> classic
2
4
7
9
11
12
15
17
19
21
23
25
27
29
31
33
35
37
39
40
C:\CS>_

จะเห็นได้ว่า ที่ว่า Method Even() จะให้ค่าเป็นเลขคู่เสมอ มันก็ไม่จริงแล้วครับ อะไรทำให้มันเป็นเช่นนั้น หัวใจของเรื่องนี้ มันอยู่ที่การแทรกการทำงานครับ มันเป็นไปได้ที่เมื่อ Thread หนึ่งทำงานเสร็จคำสั่งหนึ่งแล้วถูกสลับไปทำ Thread อื่นๆ อย่างตัวอย่างนี้ เมื่อ thread แรก บวกค่าเพิ่มไปหนึ่ง สมมุติจาก 4 เป็น 5 จากนั้นมันก็พักไป 1 millisecond (1 ใน 1000 ส่วนของวินาที) ซึ่งมันก็นานพอที่ทำให้ thread อื่นแทรกการทำงาน บวกค่า 2 ค่า ทำให้การเป็น 7 ทำให้มันแสดงค่าเป็นเลขคี่ได้ครับ

โปรแกรมนี้ถ้าคุณทดลองหัดเขียน ผมว่าโอกาสที่มันจะถูกแทรกคงมีน้อย แต่ถ้าคุณเปิดงานหลายงาน โดยเฉพาะอย่างยิ่งงานที่ทำงานเกี่ยวกับ I/O เยอะๆ โอกาสที่ถูกแทรกก็มีมากครับ

เห็นแล้วใช่ไหมครับว่า โปรแกรมง่ายๆ ที่ทำงานบน Single Thread แล้ว ไม่มีวันผิดพลาด พอมาทำเป็น Multi-thread แล้ว มันสามารถเกิดปัญหาได้อย่างง่ายดาย ดังนั้นนักวิทยาศาสตร์คอมพิวเตอร์ผู้ออกแบบ OS เขาก็นิยามศัพท์มาคำหนึ่งครับ ตั้งชื่อว่า Critical Region หรือแปลเป็นไทยให้มันแย่หน่อยว่า พื้นที่วิกฤต ซึ่งก็หมายถึง บริเวณของ Code ใดๆ ที่สามารถทำงานได้เพียงแค่ Thread เดียวใน 1 เวลา ยกตัวอย่างเช่น ถ้าหลาย Thread มีการเรียกใช้ Printer มันคงไม่ดีแน่ที่ Thread 2 Threads สามารถพิมพ์ได้พร้อมกัน ทำให้ผลลัพธ์ เป็นกระดาษออกมา มีเนื้อหาของทั้ง 2 Threads (สมมุติว่าเราไม่มี spooler นะครับ) หรืออีกตัวอย่างหนึ่งก็คือ เวลาที่คุณเขียน Log file คุณก็คงไม่อยากให้ 2 Threads เขียน Log file เดียวกัน พร้อมๆ กัน ผลลัพธ์มันก็จะได้ ข้อมูลที่สลับไปมา

ทางออกของปัญหานี้ นักวิทยาศาสตร์คอมพิวเตอร์นี้คิดทางแก้ไว้เยอะครับ เช่นพวก Semaphore เป็นต้น ผมเชื่อว่าหลายคนคงเคยต้องท่องเพื่อสอบมาก่อนแล้ว ผมคงไม่เสียเวลาอธิบายครับ เรามาดูวิธี C# กันเลยดีกว่า C# ทำอย่างนี้ครับ

lock(this) {
     num++;    Thread.Sleep(1); num++;
}

เอาบล็อก lock(this) คร่อมครับ เพื่อบ่งบองพื้นที่ที่เป็น Critical Region เมื่อคร่อมแล้ว code ที่อยู่ในบล็อกจะเข้าได้เพียงแค่ Thread เดียวในหนึ่งเวลาครับ คุณลองรันดูได้ครับ ว่าผลลัพธ์มันได้เลขคู่หมดหรือยัง หรือคุณจะลองยืดเวลา ของการ Sleep() ให้ยาวนานขึ้นก็ได้ ยังไงก็ไม่หลุดครับ

ที่จริงแล้ว ผมว่าเรื่องนี้ไม่ใช่เรื่องใหม่สำหรับคุณนะครับ เราใช้แนวคิดของ Critical Region อยู่แล้วในชีวิตประจำวัน เพียงแค่คุณสังเกตหรือเปล่าเท่านั้นแหละครับ งานที่ว่านั้นก็คืองาน Database นั่นเอง ในงาน Database เวลาคุณเขียนอะไรกลับไปที่ Database นั้น คุณก็ต้องสร้าง Critical Region เหมือนกัน ซึ่งภาษาทางด้าน Database เรียกว่า Transaction นั่นเองครับ หลักการมันก็ทำนองเดียวกันนั่นแหละครับ

ตั้งแต่ต้นมา คุณจะเห็นได้ว่า Method Thread.Sleep() เป็นเครื่องที่สำคัญตัวหนึ่งที่ใช้ทดสอบความถูกต้องของ Multi-thread ครับ