Mutex (การแยกส่วนร่วมกัน) เป็นพริมิทีฟการซิงโครไนซ์ที่รับประกันว่าเพียงหนึ่งเธรดเท่านั้นที่สามารถดำเนินการส่วนวิกฤตของโค้ดได้ในเวลาใดเวลาหนึ่ง ตาม Microsoft Docs (Synchronization Objects, 2024) หลักการสำคัญของ Mutex คือความเป็นเจ้าของ: เธรดที่รับ Mutex กลายเป็นเจ้าของและจะปล่อยมันเมื่อออกจากส่วนวิกฤตเท่านั้น Mutex เป็นเครื่องมือพื้นฐานสำหรับการป้องกัน Race Condition และรับประกันความสมบูรณ์ของข้อมูลในแอปพลิเคชันแบบหลายเธรด
ประเด็นสำคัญ
Mutex (ตัวย่อของ Mutual Exclusion — การแยกส่วนร่วมกัน) เป็นออบเจกต์การซิงโครไนซ์ที่จัดการการเข้าถึงทรัพยากรที่ใช้ร่วมกันในสภาพแวดล้อมแบบหลายเธรด เมื่อเธรดเข้าสู่ส่วนวิกฤต มันจะรับ Mutex หากเธรดอื่นพยายามรับ Mutex เดียวกัน มันจะถูกวางในสถานะรอจนกว่าล็อกจะถูกปล่อยโดยเธรดแรก
สถาปัตยกรรมของ Mutex ย้อนกลับไปยังระบบปฏิบัติการ THE ที่ออกแบบโดย Edsger Dijkstra ในปี 1965 Dijkstra ได้นำเสนอแนวคิดของเซมาฟอร์ ซึ่งต่อมา Mutex ได้เกิดขึ้นเป็นกรณีพิเศษ — เซมาฟอร์แบบไบนารีพร้อมการสนับสนุนความเป็นเจ้าของ ระบบปฏิบัติการสมัยใหม่ (Linux, Windows, Android) นำ Mutex ไปใช้งานในระดับเคอร์เนล ซึ่งรับประกันการซิงโครไนซ์ที่ถูกต้องแม้ระหว่างกระบวนการต่างๆ
คุณสมบัติสำคัญของ Mutex คือ ความเป็นเจ้าของ (ownership) เฉพาะเธรดที่รับมิวเท็กซ์เท่านั้นที่สามารถปล่อยมันได้ สิ่งนี้แยก Mutex ออกจากเซมาฟอร์แบบไบนารี ซึ่งเธรดใดก็ได้สามารถดำเนินการสัญญาณ (V-operation) ความเป็นเจ้าของป้องกันการปล่อยล็อกโดยไม่ได้ตั้งใจโดยเธรดอื่น ทำให้ Mutex ปลอดภัยกว่าสำหรับสถานการณ์การซิงโครไนซ์ทั่วไปในการพัฒนาแอปมือถือ ตาม Android Developer Docs (Processes and Threads, 2024) การใช้ Mutex แทน synchronized สามารถปรับปรุงประสิทธิภาพได้ 30% ภายใต้การแข่งขันสูง
Mutex อยู่ในหนึ่งในสองสถานะ: ถูกล็อก (locked) — ถูกเธรดรับไป; หรือว่าง (unlocked) — ไม่ได้ถูกรับ มีสองการดำเนินการพื้นฐาน: lock() (รับ) และ unlock() (ปล่อย) หาก Mutex ถูกล็อกอยู่แล้ว เธรดที่เรียก lock() จะถูกบล็อกจนกว่าล็อกจะถูกปล่อย ใน JVM เธรดที่ถูกบล็อกจะเปลี่ยนไปยังสถานะ BLOCKED และไม่ใช้ CPU
เมื่อ Mutex ถูกปล่อย ระบบจะเลือกว่าเธรดที่รอใดจะได้รับล็อก ด้วย การจัดตารางแบบไม่ยุติธรรม (non-fair) ตัวเลือกอาจตกที่เธรดที่เพิ่งปล่อยมิวเท็กซ์ — สิ่งนี้เพิ่มปริมาณงานแต่อาจนำไปสู่การอดอยาก (Starvation) ตัวจัดตารางแบบยุติธรรม (fair) ใช้คิว FIFO: เธรดที่รอคนแรกได้รับล็อกก่อน ReentrantLock(true) นำกลไกนี้ไปใช้อย่างแม่นยำ
การนำ Mutex ไปใช้ส่วนใหญ่ใน Java/Kotlin รองรับ การรับแบบรีเอนแทรนต์ (reentrant) หากเธรดเป็นเจ้าของ Mutex อยู่แล้วและเรียก lock() อีกครั้ง การดำเนินการจะสำเร็จ — Mutex ไม่บล็อกตัวเอง ตัวนับการเรียกซ้ำเพิ่มขึ้น และเธรดต้องเรียก unlock() จำนวนครั้งเท่ากับ lock() สิ่งนี้สำคัญสำหรับการเรียกซ้ำและส่วนวิกฤตที่ซ้อนกัน
พิจารณางานทั่วไป — การป้องกัน ตัวนับที่ใช้ร่วมกัน จาก Race Condition โดยใช้ ReentrantLock (Mutex แบบคลาสสิกใน Java/Kotlin) หากไม่มี Mutex โค้ดจะให้ผลลัพธ์ที่ไม่ถูกต้อง; หากมี Mutex เธรดทั้งหมด 1000 ตัวจะเพิ่มค่าตัวนับอย่างน่าเชื่อถือ
import java.util.concurrent.locks.ReentrantLock
class MutexCounter {
private val mutex = ReentrantLock()
private var count = 0
fun increment() {
mutex.lock()
try {
count++ // ส่วนวิกฤต
} finally {
mutex.unlock() // finally ที่จำเป็น
}
}
fun getCount(): Int {
mutex.lock()
try {
return count
} finally {
mutex.unlock()
}
}
}
fun main() = runBlocking {
val counter = MutexCounter()
val jobs = List(1000) {
launch(Dispatchers.Default) {
counter.increment()
}
}
jobs.forEach { it.join() }
println(counter.getCount()) // เสมอ 1000
}
ให้ความสนใจกับ บล็อก finally — รูปแบบที่จำเป็นเมื่อทำงานกับ Mutex หากมีข้อยกเว้นเกิดขึ้นภายในส่วนวิกฤต จะไม่มีการเรียก unlock() และ Mutex จะถูกล็อกตลอดไป — สิ่งนี้นำไปสู่ Deadlock บล็อก finally รับประกันการปล่อย Mutex ไม่ว่าการดำเนินการของส่วนจะสิ้นสุดอย่างไร
แนวทางอื่นใน Kotlin คือการใช้ฟังก์ชันส่วนขยาย withLock ซึ่งจัดการ lock/unlock กับ finally โดยอัตโนมัติ
fun increment() {
mutex.withLock { // lock + try/finally อัตโนมัติ
count++
}
}
fun getCount(): Int = mutex.withLock { count }
กลไกการซิงโครไนซ์ทั้งสามนี้มักสับสน แม้ว่าจะมีคุณสมบัติและกรณีการใช้งานที่แตกต่างกัน Mutex เป็นแบบไบนารีที่มีความเป็นเจ้าของ เซมาฟอร์เป็นตัวนับการอนุญาตโดยไม่มีความเป็นเจ้าของ มอนิเตอร์เป็นกลไกระดับสูงที่รวม Mutex เข้ากับตัวแปรเงื่อนไข การทำความเข้าใจความแตกต่างมีความสำคัญอย่างยิ่งต่อการเลือกเครื่องมือที่เหมาะสมสำหรับงานเฉพาะ
| พารามิเตอร์ | Mutex | เซมาฟอร์ | มอนิเตอร์ |
|---|---|---|---|
| ประเภท | ไบนารี (0/1) | นับได้ (0..N) | ไบนารี + เงื่อนไข |
| ความเป็นเจ้าของ | เฉพาะเจ้าของที่ unlock ได้ | เธรดใดก็ได้ที่ signal ได้ | เฉพาะเจ้าของ |
| รีเอนแทรนซี | โดยทั่วไปใช่ (reentrant) | ไม่ | ใช่ |
| การรอแบบมีเงื่อนไข | ไม่ (ต้องการ Condition) | ไม่ | ในตัว (wait/notify) |
| ตัวอย่างใน Java/Kotlin | ReentrantLock | Semaphore(permits) | synchronized |
เมื่อใดควรเลือก Mutex: คุณต้องป้องกันทรัพยากรเดียวจากการเข้าถึงพร้อมกัน — ตัวอย่างเช่น คอลเล็กชันที่ใช้ร่วมกัน ไฟล์ หรือตัวนับ เมื่อใดควรเลือกเซมาฟอร์ — คุณต้องจำกัดจำนวนการเข้าถึงพร้อมกันไปยังพูลทรัพยากร เช่น พูลการเชื่อมต่อฐานข้อมูลที่มี 5 การเชื่อมต่อ เมื่อใดควรเลือกมอนิเตอร์ — คุณต้องการการซิงโครไนซ์กับการรอแบบมีเงื่อนไข เช่น คิวผู้ผลิต-ผู้บริโภคผ่าน wait/notify ในการพัฒนา Android สมัยใหม่ synchronized มักถูกแทนที่ด้วย ReentrantLock หรือ kotlinx.coroutines Mutex
ข้อผิดพลาดที่พบบ่อยที่สุดคือ การไม่มีบล็อก finally สำหรับเรียก unlock() หากมีข้อยกเว้นเกิดขึ้นในส่วนวิกฤต Mutex จะยังคงถูกล็อกและเธรดอื่นจะรอตลอดไป แม้ว่าคุณจะแน่ใจว่าข้อยกเว้นเป็นไปไม่ได้ — ให้ใช้ try/finally หรือ withLock เสมอ นี่คือหลักการเขียนโปรแกรมเชิงป้องกัน ซึ่งสำคัญโดยเฉพาะในการพัฒนาแอปมือถือที่ข้อยกเว้นอาจเกิดขึ้นเนื่องจากหน่วยความจำไม่เพียงพอหรือ Configuration Changes
เมื่อแอปพลิเคชันใช้ Mutex หลายตัว การสร้างลำดับการรับที่สอดคล้องกันมีความสำคัญอย่างยิ่ง หากเธรด A รับ M1 → M2 และเธรด B รับ M2 → M1 จะเกิด Deadlock ในโปรเจ็กต์ขนาดใหญ่ (มากกว่า 50,000 บรรทัดโค้ด) ลำดับการล็อกจะถูกบันทึกในการตัดสินใจทางสถาปัตยกรรมและตรวจสอบโดย linters เครื่องมือ Lock Checker ใน IntelliJ IDEA จะตรวจจับลำดับการรับล็อกที่ไม่สอดคล้องกันโดยอัตโนมัติ
การถือ Mutex นานกว่า 1-2 มิลลิวินาที เป็นสัญญาณของการออกแบบที่ไม่ดี ส่วนวิกฤตควรมีเฉพาะการดำเนินการที่จำเป็นน้อยที่สุดเท่านั้น คำขอเครือข่าย I/O ไฟล์และการคำนวณที่ซับซ้อนควรดำเนินการภายนอกบล็อกที่ถูกล็อก ใน Android การถือล็อกเป็นเวลานานในเธรด UI นำไปสู่การตกของเฟรม (jank) และ ANR ใช้ ReadWriteLock หากส่วนวิกฤตประกอบด้วยการดำเนินการอ่านเป็นหลัก
ไลบรารี kotlinx.coroutines มีการนำ Mutex ไปใช้ของตัวเอง ซึ่งแตกต่างจาก ReentrantLock แบบคลาสสิกโดยพื้นฐาน ความแตกต่างหลักคือ suspending Mutex ไม่บล็อกเธรดของระบบปฏิบัติการ แต่ระงับโครูทีนจนกว่าล็อกจะถูกปล่อย ซึ่งหมายความว่าเธรดสามารถดำเนินการโครูทีนอื่นๆ ในขณะที่โครูทีนปัจจุบันกำลังรอ Mutex
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class CoroutineCounter {
private val mutex = Mutex()
private var count = 0
suspend fun increment() {
mutex.withLock { // suspending — ไม่บล็อกเธรด
count++
}
}
suspend fun getCount(): Int = mutex.withLock { count }
}
คุณสมบัติสำคัญของ kotlinx Mutex: ไม่รีเอนแทรนต์ (non-reentrant) — แตกต่างจาก ReentrantLock โครูทีนไม่สามารถรับ Mutex ที่ตนเป็นเจ้าของอยู่แล้วซ้ำได้ หากจำเป็น ให้ใช้ Semaphore(1) แทน Mutex นอกจากนี้ Mutex จาก kotlinx.coroutines เป็นแบบไม่บล็อก: ใช้การระงับผ่าน suspend ซึ่งช่วยให้ไม่บล็อกเธรดของพูล
ในทางปฏิบัติ suspending Mutex เป็นที่นิยมกว่า ReentrantLock แบบคลาสสิกในโค้ดโครูทีนด้วยสองเหตุผล: ความสามารถในการปรับขนาด — โครูทีนหนึ่งรอ Mutex ในขณะที่เธรดให้บริการโครูทีนอื่น เพิ่มปริมาณงานของระบบ; ไม่มี BlockedThread — ไม่มีการใช้ทรัพยากรในการจัดเก็บสแต็กของเธรดที่ถูกบล็อก ตาม JetBrains (Kotlin Coroutines Guide, 2024) การใช้ suspending Mutex ช่วยปรับปรุงปริมาณงาน 40% ด้วยโครูทีน 100+ ตัว
คำถามที่พบบ่อย
ความเป็นเจ้าของ (ownership) เป็นความแตกต่างพื้นฐาน Mutex จดจำว่าเธรดใดรับมันไป และเฉพาะเธรดนั้นเท่านั้นที่สามารถปล่อยมันได้ เซมาฟอร์แบบไบนารี (Semaphore(1)) ไม่มีเจ้าของ — เธรดใดก็ได้สามารถเรียก release() ดังนั้น Mutex จึงปลอดภัยกว่า: เธรดอื่นไม่สามารถปล่อยล็อกของผู้อื่นโดยไม่ได้ตั้งใจ แต่เซมาฟอร์สามารถทำได้
synchronized ง่ายกว่าและสั้นกว่า — ใช้สำหรับส่วนวิกฤตธรรมดาที่ไม่มีการหมดเวลาและการควบคุมความเป็นธรรม ใช้ ReentrantLock เมื่อคุณต้องการ TryLock กับการหมดเวลา การจัดตารางแบบยุติธรรม ตัวแปรเงื่อนไข หรือการขัดจังหวะเธรดที่รอ (lockInterruptibly) สำหรับโครูทีน ให้ใช้ kotlinx.coroutines.sync.Mutex เสมอ
Spinlock คือล็อกที่เธรดไม่หลับแต่หมุนในลูป (spin) ตรวจสอบสถานะล็อก Spinlock ใช้ CPU แต่ไม่สลับบริบท ซึ่งทำให้มีประโยชน์สำหรับส่วนวิกฤตสั้นๆ (สูงสุด 10 คำสั่ง) Mutex ทำให้เธรดเข้าสู่สถานะ BLOCKED ซึ่งมีค่าใช้จ่ายมากกว่า 10-50 ไมโครวินาทีเนื่องจากการสลับบริบท แต่ไม่สิ้นเปลือง CPU
ในระดับเคอร์เนล Linux Mutex ถูกนำไปใช้ผ่าน futex (fast userspace mutex) เธรดพยายามรับล็อกใน userspace ก่อนผ่านคำสั่งอะตอมมิก CAS (Compare-And-Swap) ถ้า Mutex ว่าง — การรับเกิดขึ้นโดยไม่ต้องใช้ syscall ถ้าถูกครอบครอง — เธรดทำ syscall futex(FUTEX_WAIT) และหลับ เมื่อปล่อย syscall futex(FUTEX_WAKE) จะปลุกหนึ่งเธรดที่รอ
ได้ มี Mutex ระหว่างกระบวนการ (inter-process mutex) ใน Windows คือ Named Mutex ใน Linux — pthread_mutexattr_setpshared กับแอตทริบิวต์ PTHREAD_PROCESS_SHARED Bionic libc ของ Android ก็รองรับ Mutex ระหว่างกระบวนการผ่านตัวบอกไฟล์ (file descriptors) Mutex ระหว่างกระบวนการใช้สำหรับการซิงโครไนซ์ระหว่างแอปพลิเคชันต่างๆ หรือระหว่างกระบวนการและกระบวนการย่อยของมัน
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม