ตอนนี้โปรแกรม C++ ของคุณทำงานทีละอย่าง: รันบรรทัดหนึ่ง แล้วบรรทัดถัดไป เป็นเส้นทางยาวเส้นเดียวจาก main ไปจนจบ เส้นทางเดี่ยว ๆ นี้เรียกว่า thread บทนี้ว่าด้วยการรัน มากกว่าหนึ่ง เส้นทางแบบนี้พร้อมกัน ซึ่งเป็นวิธีที่เอนจินเกมจริง ๆ ใช้พลังประมวลผลทั้งหมดที่คอมพิวเตอร์สมัยใหม่มี แทนที่จะปล่อยให้ core ส่วนใหญ่ว่างเปล่า
ทุกหัวข้อข้างล่างนี้เป็นรูปแบบเดียวกับที่คุณคุ้นเคย: ตัวอย่างโค้ดสั้น ๆ ที่รันได้จริง, output จริง, แล้วอธิบายแบบง่าย ๆ ว่าเกิดอะไรขึ้น thread เพิ่มมุมใหม่ที่ต้องพูดถึงก่อนเลยคือ: บางโปรแกรมในบทนี้จะพิมพ์ผลลัพธ์ ไม่เหมือนเดิม ทุกครั้งที่รัน — ตั้งใจให้เป็นแบบนั้น เพราะความไม่แน่นอนนี้แหละคือปัญหาหลักที่บทนี้จะสอนให้คุณแก้
thread คือลำดับคำสั่งชุดหนึ่งที่ CPU รันทีละคำสั่งต่อกันไป ทุกโปรแกรม C++ เริ่มต้นด้วย thread เดียวเสมอ — มันเริ่มที่ main แล้วรันไล่ลงมาทีละบรรทัด เหมือนทุกโปรแกรมที่คุณเคยเขียนมา process (โปรแกรมที่กำลังรันอยู่) สามารถสร้าง thread เพิ่ม ได้ และแต่ละ thread ใหม่จะรันลำดับคำสั่งของตัวเองอย่างอิสระ พร้อม ๆ กับ thread อื่น
core ของ CPU คือฮาร์ดแวร์จริง ๆ ที่รันคำสั่งของ thread หนึ่งได้ในเวลาหนึ่ง PC เกม, console, หรือมือถือสมัยใหม่แทบไม่มีเครื่องไหนมีแค่ 1 core แล้ว — 6, 8, หรือ 16 core คือเรื่องปกติในตอนนี้ ถ้าเกมทั้งเกมของคุณรันบน thread เดียว มันจะใช้ core ได้แค่ อันเดียว เท่านั้น core ที่เหลืออีก 5, 7, หรือ 15 core ก็นั่งว่าง ๆ ในขณะที่ thread เดียวของคุณพยายามทำทุกอย่างที่เกมต้องทำในเฟรมนั้นให้เสร็จ
และในหนึ่งเฟรมมีงานแยกกันเยอะมาก: จำลอง physics, สร้างลิสต์คำสั่งวาดภาพให้ GPU, mix และ stream เสียง, รันการตัดสินใจของ AI ให้ศัตรูทุกตัว, stream asset ใหม่เข้ามาจากดิสก์ งานพวกนี้ส่วนใหญ่ไม่ได้ขึ้นต่อกันในแต่ละช่วงเวลา จึงรัน แบบขนาน (parallel — เกิดขึ้นพร้อมกันจริง ๆ บนคนละ core) แทนที่จะรันทีละอย่างได้
นี่คือแรงจูงใจทั้งหมดของบทนี้: thread ที่มากขึ้น ถ้าใช้ถูกวิธี หมายความว่า CPU ทำงานที่มีประโยชน์จริง ๆ ได้มากขึ้นในเวลาเดียวกัน ซึ่งแปลว่าเฟรมสั้นลง คำว่า "ใช้ถูกวิธี" มีความหมายเยอะมากในประโยคนั้น — ที่เหลือของบทนี้ว่าด้วยข้อผิดพลาดที่เกิดขึ้นเมื่อใช้ thread แบบไม่ระวัง และเครื่องมือที่ C++ ให้มาเพื่อหลีกเลี่ยงมัน
standard library ของ C++ ให้ std::thread มา (จาก header <thread>) คุณส่งฟังก์ชันให้มัน แล้วมันจะเริ่มรันฟังก์ชันนั้นบน thread ใหม่ ทันที — constructor ไม่รอ
#include <thread>
#include <iostream>
void sayHello() {
std::cout << "hello from a worker thread\n";
}
int main() {
std::cout << "main thread starts\n";
std::thread t(sayHello); // starts running sayHello() right now, on a new thread
std::cout << "main thread keeps going\n";
t.join(); // wait here until t finishes
std::cout << "main thread done\n";
}
Output (การรันครั้งหนึ่งที่เป็นไปได้ — สองบรรทัดกลางสลับกันได้):
main thread starts
main thread keeps going
hello from a worker thread
main thread done
มีสองบรรทัดที่รันอย่างอิสระจริง ๆ: main พิมพ์ "main thread keeps going" และ thread ใหม่พิมพ์ "hello from a worker thread" ระบบปฏิบัติการจะให้ตัวไหนรันก่อน ไม่มีการรับประกัน — รันโปรแกรมนี้หลาย ๆ ครั้ง บางทีคุณอาจเห็น "hello from a worker thread" พิมพ์ก่อน "main thread keeps going" ก็ได้ นั่นไม่ใช่ bug มันแปลว่าทั้งสอง thread รันพร้อมกันจริง ๆ และ OS scheduler เลือกลำดับของมันเอง
t.join() ทำให้ thread ที่ เรียก (ในที่นี้คือ main) หยุดรอจนกว่า t จะทำงานเสร็จ ถ้าไม่มี join (หรือทางเลือกอีกทางคือ t.detach() ซึ่งปล่อยให้ thread รันอิสระเต็มที่โดยไม่ต้องรอ) โปรแกรมจะไม่มีวิธีที่ชัดเจนในการรู้ว่า thread นั้นเสร็จแล้วหรือยัง
std::thread ถูกทำลายทั้งที่ยังอยู่ในสถานะ joinable — คือยังไม่เคยเรียก join() หรือ detach() เลย C++ ถือว่านี่เป็นข้อผิดพลาดร้ายแรงพอที่จะเรียก std::terminate() ซึ่งทำให้โปรแกรมทั้งโปรแกรม crash ทันที ทุก std::thread ที่คุณสร้างต้อง join หรือ detach ก่อนที่มันจะหลุดออกจาก scopeนี่คือปัญหาที่ทำให้ multithreading ยาก data race เกิดขึ้นเมื่อสอง thread ขึ้นไปอ่านและเขียนตัวแปร เดียวกัน พร้อมกัน โดยไม่มีการประสานงานกันเลย และอย่างน้อยหนึ่งการเข้าถึงนั้นเป็นการเขียน มาดูมันเกิดขึ้นจริง ๆ กัน
#include <thread>
#include <iostream>
int counter = 0; // shared by every thread that touches it -- that is the danger
void addOneMillion() {
for (int i = 0; i < 1000000; i++) {
counter++; // looks like one step. it is NOT.
}
}
int main() {
std::thread t1(addOneMillion);
std::thread t2(addOneMillion);
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n";
}
สอง thread ต่างก็บวก 1 ให้ counter หนึ่งล้านครั้ง ดังนั้นเราคาดว่า counter จะจบที่ 2,000,000 พอดี แต่ output จริง ๆ คือ:
counter = 1417253
ผิด — และแย่กว่านั้นคือ ผิดไม่เหมือนกัน ทุกครั้ง รันโปรแกรมนี้อีกครั้ง คุณอาจได้ 1583920 หรือ 1998447 หรือ (นาน ๆ ที) ค่าที่ถูกต้องคือ 2000000 ตัวเลขต่างกันแทบทุกครั้งที่รัน บนทุกเครื่อง ความไม่แน่นอนแบบนี้คือลายเซ็นของ data race
เหตุผลคือ: counter++ ในซอร์สโค้ดดูเหมือนเป็นขั้นตอนเดียว แต่จริง ๆ แล้ว CPU ทำมันเป็นสามขั้นตอนแยกกัน — load ค่าปัจจุบันเข้า register, add 1 เข้าไป, แล้ว store ผลลัพธ์กลับเข้า memory เมื่อสอง thread รันสามขั้นตอนนี้โดยไม่มีการประสานงานกัน ขั้นตอนของมันจะ สลับแทรกกัน (interleave) ในลำดับที่แย่ได้:
เอาการอัปเดตที่หายไปนี้คูณด้วยหนึ่งล้านรอบบนสอง thread ที่แข่งกันอยู่ คุณจะได้ตัวเลขสุดท้ายที่น้อยกว่า 2,000,000 อยู่มาก — และขาดไปไม่เท่ากันทุกครั้งที่รัน เพราะลำดับการสลับแทรกที่แน่นอนขึ้นอยู่กับ OS scheduling ซึ่งไม่มีทางเหมือนเดิมทุกครั้ง
คุณเคยเจอ undefined behavior (UB) มาแล้ว — โค้ดที่มาตรฐาน C++ ไม่รับประกันอะไรเลย เช่นการอ่านเลยขอบ array หรือใช้ pointer หลัง delete data race ก็เป็น UB เหมือนกัน มาตรฐานบอกว่า: ถ้าสอง thread เข้าถึง memory เดียวกันโดยไม่มีการ synchronize และอย่างน้อยหนึ่งอันเป็นการเขียน พฤติกรรมของโปรแกรมนั้นไม่มีนิยาม
เรื่องนี้สำคัญด้วยเหตุผลที่ลึกกว่า "อาจได้ตัวเลขผิด" เพราะมาตรฐานไม่รับประกันอะไรเลย compiler จึง ได้รับอนุญาตให้สมมติว่าโปรแกรมของคุณไม่มี data race เวลามัน optimize โค้ด มันอาจสลับลำดับคำสั่ง, เก็บค่าไว้ใน register แทนที่จะอ่าน memory ใหม่ทุกครั้ง หรือเปลี่ยนแปลงอื่น ๆ ที่ปลอดภัยสมบูรณ์สำหรับโค้ดที่ไม่มี race แต่กลับสร้างผลลัพธ์ที่แปลกประหลาดจริง ๆ — ไม่ใช่แค่นับผิด แต่อาจเป็น infinite loop, crash, หรือค่าที่ไม่มี thread ไหนเคยเขียนเลย — เมื่อมี race เกิดขึ้นจริง data race ไม่ใช่ "น่าจะโอเค แค่เพี้ยนนิดหน่อย" ให้ปฏิบัติกับมันเหมือน UB อื่น ๆ ที่คุณรู้จักแล้วว่าต้องหลีกเลี่ยง: แก้มัน อย่าทนกับมัน
mutex (ย่อจาก "mutual exclusion" จาก header <mutex>) คือ lock ที่มีแค่ thread เดียวถือมันได้ในเวลาหนึ่ง เรียก m.lock() เพื่อยึดมัน ถ้า thread อื่นถือมันอยู่แล้ว thread ของคุณจะ รอ จนกว่ามันจะว่าง เรียก m.unlock() เพื่อปล่อยมัน โค้ดระหว่าง lock() กับ unlock() เรียกว่า critical section — ช่วงโค้ดที่รับประกันว่ามีแค่ thread เดียวรันได้ในเวลาหนึ่ง
#include <thread>
#include <mutex>
#include <iostream>
int counter = 0;
std::mutex m;
void addOneMillion() {
for (int i = 0; i < 1000000; i++) {
m.lock();
counter++; // only one thread can be inside here at a time
m.unlock();
}
}
int main() {
std::thread t1(addOneMillion);
std::thread t2(addOneMillion);
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n";
}
Output — ทุกครั้งที่รัน:
counter = 2000000
mutex บังคับให้สามขั้นตอน (load, add, store) ทำเสร็จสมบูรณ์สำหรับ thread หนึ่งก่อนที่อีก thread จะเริ่ม load ของตัวเองได้ ไม่มีการสลับแทรก ไม่มีการอัปเดตหาย ผลรวมถูกต้องเสมอ ราคาที่ต้องจ่ายคือ: mutex ไม่ได้ฟรี การ lock กับ unlock โดยเฉพาะเมื่อหลาย thread แย่งอันเดียวกัน อาจทำให้ OS ต้องพัก thread ไว้แล้วปลุกทีหลัง ซึ่งช้ากว่าการบวกเลขธรรมดามาก ความถูกต้องมาก่อน — ค่อยกังวลเรื่องราคานี้ทีหลังเมื่อโปรแกรมถูกต้องแล้วจริง ๆ
การเรียก lock() กับ unlock() เองมีความเสี่ยง ถ้ามี exception ถูก throw ระหว่างสองอันนี้ หรือมี bug เพิ่ม return ก่อนเวลาเข้าไปใน critical section unlock() จะไม่ถูกรันเลย — mutex จะยังล็อกอยู่ ตลอดไป และทุก thread อื่นที่เรียก lock() กับมันทีหลังจะรอตลอดไปเช่นกัน นี่คือ bug ที่เกิดขึ้นจริงและพบบ่อย
std::lock_guard แก้ปัญหานี้แบบเดียวกับที่ C++ ใช้แก้ปัญหา "เก็บกวาดหรือยัง" เสมอ: object เล็ก ๆ ที่ constructor จัดการตั้งค่า และ destructor จัดการเก็บกวาด โดยอัตโนมัติ ไม่ว่า scope รอบข้างจะออกทางไหนก็ตาม constructor ของมันเรียก lock(); เมื่อมันหลุดออกจาก scope — ออกแบบปกติ, return ก่อนเวลา, หรือ exception — destructor ของมันจะเรียก unlock() ให้เองอัตโนมัติ
void addOneMillion() {
for (int i = 0; i < 1000000; i++) {
std::lock_guard<std::mutex> guard(m); // locks m right now
counter++;
} // guard is destroyed here -> m.unlock() happens automatically
}
output เหมือนกับหัวข้อ 5 เป๊ะ — ยังคงเป็น counter = 2000000 ทุกครั้ง — แต่เวอร์ชันนี้ไม่มีทางลืม unlock ได้เลย ให้เลือกใช้ std::lock_guard (หรือ std::scoped_lock ซึ่งหัวข้อ 8 จะพูดถึง) แทนการเรียก lock()/unlock() เองในโค้ดจริงแทบทุกกรณี
สำหรับกรณีง่าย ๆ อย่างตัวนับเดียว C++ มีเครื่องมือที่เบากว่าให้: std::atomic<T> (จาก header <atomic>) การกระทำบนตัวแปร atomic — increment, add, read, write — รับประกันว่าเกิดขึ้นเป็นขั้นตอนเดียวที่แบ่งแยกไม่ได้ (indivisible, ไม่มีทางถูกขัดจังหวะกลางคัน) ไม่มี thread ไหนจะเห็นมัน "ทำครึ่งเดียว" ได้เลย ไม่ต้องมี lock(), ไม่ต้องมี unlock(), ไม่ต้องมี mutex object เลยด้วยซ้ำ
#include <thread>
#include <atomic>
#include <iostream>
std::atomic<int> counter{0};
void addOneMillion() {
for (int i = 0; i < 1000000; i++) {
counter++; // atomic increment -- safe with no mutex
}
}
int main() {
std::thread t1(addOneMillion);
std::thread t2(addOneMillion);
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n";
}
Output — ถูกต้องทุกครั้ง:
counter = 2000000
ภายใน std::atomic ใช้คำสั่ง CPU พิเศษ (เรียกกว้าง ๆ ว่าคำสั่งสไตล์ compare-and-swap) ที่ฮาร์ดแวร์เองรับประกันว่าไม่มีทางถูกขัดจังหวะกลางคันได้ สำหรับตัวนับหรือ flag เดี่ยว ๆ วิธีนี้มักจะเร็วกว่า mutex อย่างเห็นได้ชัด เพราะมันไม่ต้องให้ OS พัก thread เลย
อย่างไรก็ตาม atomic ป้องกันได้แค่ตัวแปร เดียว ต่อการกระทำหนึ่งครั้ง ถ้าคุณต้องอัปเดตตัวแปรที่เกี่ยวข้องกันสองตัวพร้อมกันและให้มันสอดคล้องกันตลอด — เช่น x กับ y ของตำแหน่งที่ต้องไม่มีทางถูกอ่านแบบ "ขยับไปครึ่งเดียว" — การทำให้แต่ละตัวเป็น atomic แยกกัน ไม่ ช่วยอะไร thread อื่นยังอาจอ่าน x ใหม่คู่กับ y เก่าได้อยู่ดี สถานการณ์แบบนี้ต้องใช้ mutex ครอบทั้งคู่ ให้การอัปเดตทั้งหมดเป็น critical section เดียว กฎง่าย ๆ คือ: atomic สำหรับค่าง่าย ๆ ตัวเดียว; mutex สำหรับอะไรก็ตามที่แตะมากกว่าหนึ่งตัวแปร หรือมากกว่าหนึ่งขั้นตอน ที่ต้องสอดคล้องกันตลอด
mutex แก้ data race ได้ แต่มันสร้างรูปแบบความล้มเหลวใหม่ของตัวเองขึ้นมา: deadlock deadlock คือเมื่อสอง (หรือมากกว่า) thread ต่างถือ lock ที่อีกฝ่ายต้องการอยู่ และทั้งคู่รอกันตลอดไป ไม่มี crash ไม่มีข้อความ error — โปรแกรมแค่ค้างเฉย ๆ
#include <thread>
#include <mutex>
std::mutex mA, mB;
void threadFunc1() {
std::lock_guard<std::mutex> lockA(mA); // grabs mA first
std::lock_guard<std::mutex> lockB(mB); // then wants mB
// ... use both resources ...
}
void threadFunc2() {
std::lock_guard<std::mutex> lockB(mB); // grabs mB first
std::lock_guard<std::mutex> lockA(mA); // then wants mA
// ... use both resources ...
}
โปรแกรมนี้ไม่มี output ให้ดู — มันแค่ค้างตลอดไปได้เฉย ๆ นี่คือ trace ที่ไล่ดูลำดับการสลับแทรกที่โชคร้ายซึ่งทำให้เกิดปัญหานี้:
Thread 1 จะไม่ปล่อย mA จนกว่าจะได้ mB Thread 2 จะไม่ปล่อย mB จนกว่าจะได้ mA ทั้งคู่ไม่มีทางเกิดขึ้นเลย นี่เรียกว่า circular wait และมันคือสาเหตุคลาสสิกของ deadlock: สอง thread ล็อก mutex สองตัวเดียวกัน ใน ลำดับตรงข้ามกัน
มีสองวิธีแก้ วิธีแรกคือวินัยง่าย ๆ: ล็อก mutex ในลำดับเดียวกันเสมอทั่วทั้ง codebase — เช่น ล็อก mA ก่อน mB เสมอ ไม่มีทางกลับกัน ไม่ว่าฟังก์ชันหรือ thread ไหนจะเป็นคนล็อก แค่นี้ก็ทำให้ circular wait เป็นไปไม่ได้แล้ว
วิธีที่สอง ปล่อยให้ library จัดการลำดับให้คุณด้วย std::scoped_lock (C++17) ซึ่งล็อก mutex หลายตัวพร้อมกันได้อย่างปลอดภัย ไม่ว่าคุณจะเรียงลำดับมันยังไงในแต่ละจุดที่เรียกใช้:
void threadFunc1() {
std::scoped_lock lock(mA, mB); // locks both together -- deadlock-safe
// ... use both resources ...
}
void threadFunc2() {
std::scoped_lock lock(mB, mA); // different order here, and it is still fine
// ... use both resources ...
}
std::scoped_lock ใช้ algorithm ภายในที่หลีกเลี่ยงการถือ mutex ตัวหนึ่งค้างไว้ในขณะที่รอตัวอื่นอยู่ ดังนั้นเวอร์ชันนี้ deadlock ไม่ได้เลย แม้ว่าทั้งสองฟังก์ชันจะเรียง mutex ในลำดับตรงข้ามกันก็ตาม
std::scoped_lock เป็นกฎตายตัว ไม่ใช่ความหวังเมื่อมีเครื่องมือทั้งหมดนี้ ไอเดียที่ล่อใจคือ: สำหรับทุกชิ้นงาน — อัปเดตศัตรูตัวนี้, โหลด asset นั้น, คำนวณการตัดสินใจของ AI นี้ — ก็แค่สร้าง std::thread ใหม่ขึ้นมา เอนจินจริง ๆ แทบไม่เคยทำแบบนี้ ด้วยเหตุผลหนักแน่นสองข้อ
ข้อแรก การสร้าง OS thread ไม่ได้ฟรี ระบบปฏิบัติการต้องจอง stack ให้มัน (มักจะประมาณหนึ่งเมกะไบต์โดย default), ตั้งค่าข้อมูล scheduling ให้มัน, และลงทะเบียนมันกับ kernel ทำแบบนี้หลายพันครั้งต่อเฟรม — เช่นหนึ่งครั้งต่อศัตรูหนึ่งตัว — อาจกินเวลามากกว่างานจริง ๆ ที่ต้องทำ
ข้อสอง CPU มี core จำกัด ถ้าคุณสร้าง 500 thread บนเครื่องที่มี 8 core มีแค่ 8 thread เท่านั้นที่รันพร้อมกันได้จริงในเวลาหนึ่ง ที่เหลือแค่นั่งรอคิว และ OS ต้องคอยสลับว่าตัวไหนจะได้ตาเล่น (context switch) ซึ่งเองก็กินเวลาและทำร้าย cache performance ได้ (จำเรื่อง cache locality จากบท data structure ได้ไหม — การกระโดดไป-มาระหว่าง working set ของหลาย thread ทำลาย cache แบบเดียวกับการกระโดดไป-มาระหว่าง memory ที่กระจัดกระจาย)
วิธีแก้ที่เอนจินใช้คือ thread pool: สร้าง worker thread จำนวนคงที่และน้อย ครั้งเดียว ตอนเริ่มโปรแกรม — ปกติประมาณจำนวน CPU core — แล้วใช้ thread ชุดเดิมนี้ตลอดอายุการทำงานของโปรแกรม ป้อนงานให้มันแทนที่จะสร้าง thread ใหม่ทุกงาน
thread pool ต้องมีอะไรมาป้อนให้มัน สิ่งนั้นคือ job (เรียกอีกอย่างว่า task): หน่วยงานเล็ก ๆ ที่จบในตัวเอง — ปกติเป็นฟังก์ชันบวกกับข้อมูลที่มันต้องใช้ — เล็กพอที่จะแจกจ่ายและทำให้เสร็จได้หลายชิ้นอย่างรวดเร็ว job system คือกลไกที่เก็บ queue ของ job และให้ worker pool ดึงมันมารันได้
ไอเดียในทางปฏิบัติ: แทนที่ "อัปเดตศัตรูทั้ง 10,000 ตัว" จะเป็นงานก้อนยักษ์ก้อนเดียวที่รันโดย thread เดียว คุณแบ่งมันเป็น job สัก 10 job ๆ ละ 1,000 ตัว แล้วดัน 10 job นี้เข้า queue thread ไหนว่างก่อนก็คว้า job มา รัน แล้วคว้าอันถัดไป — ทำให้ core ทุกตัวยุ่งอยู่กับงานก้อนใหญ่เดียวกันพร้อมกัน โดยไม่มีใครต้องสร้าง thread ใหม่เลย job system หลายตัวยังรองรับ dependency ด้วย — "อย่าเริ่ม job C จนกว่า job A กับ B จะเสร็จทั้งคู่" — คุณจึงอธิบายอะไรแบบ "physics ต้องเสร็จก่อนที่ render job list จะถูกสร้าง" ได้เป็นกราฟเล็ก ๆ ของ job แทนที่จะต้อง join thread เฉพาะเจาะจงเอง
นี่คือ job queue เล็ก ๆ แบบง่ายเพื่อให้เห็นรูปทรงของมัน มันแนะนำเครื่องมือใหม่หนึ่งตัวคือ std::condition_variable ซึ่งให้ thread หลับได้อย่างมีประสิทธิภาพจนกว่า thread อื่นจะปลุกมัน แทนที่จะเสียเวลา CPU คอยเช็ค "มีงานหรือยัง?" ในลูปซ้ำ ๆ (เรียกว่า busy-waiting)
#include <thread>
#include <mutex>
#include <queue>
#include <functional>
#include <condition_variable>
#include <vector>
#include <iostream>
std::mutex qm;
std::condition_variable cv;
std::queue<std::function<void()>> jobs;
bool stop = false;
void worker() {
while (true) {
std::function<void()> job;
{
std::unique_lock<std::mutex> lock(qm);
cv.wait(lock, [] { return !jobs.empty() || stop; }); // sleep until told otherwise
if (stop && jobs.empty()) return;
job = jobs.front();
jobs.pop();
}
job(); // run OUTSIDE the lock, so other workers can keep grabbing jobs meanwhile
}
}
void pushJob(std::function<void()> j) {
{
std::lock_guard<std::mutex> lock(qm);
jobs.push(j);
}
cv.notify_one(); // wake one sleeping worker
}
int main() {
std::vector<std::thread> pool;
for (int i = 0; i < 4; i++) pool.emplace_back(worker); // 4 workers, created ONCE
for (int i = 0; i < 8; i++)
pushJob([i] { std::cout << "job " << i << " done\n"; });
// (real code would wait for all 8 jobs to finish here before shutting down)
{
std::lock_guard<std::mutex> lock(qm);
stop = true;
}
cv.notify_all();
for (auto& t : pool) t.join();
}
Output: แปดบรรทัด "job 0 done" ถึง "job 7 done" — แต่ไม่จำเป็นต้องเรียงลำดับแบบนั้น เพราะ 4 worker ดึงจาก queue เดียวกันพร้อมกัน การรันครั้งหนึ่งที่เป็นไปได้:
job 0 done
job 2 done
job 1 done
job 3 done
job 5 done
job 4 done
job 6 done
job 7 done
ไอเดียเดียวกับ output ที่ไม่เรียงลำดับในหัวข้อ 2 แค่มี thread มากขึ้น ส่วนสำคัญไม่ใช่ลำดับ — แต่คือมี object std::thread แค่ 4 ตัวเท่านั้นที่ถูกสร้างขึ้นเลย ไม่ว่าคุณจะดัน job เข้าไป 8 job หรือ 8,000 job นั่นคือประเด็นทั้งหมดของการแยก "มี OS thread กี่ตัว" (น้อยและคงที่) ออกจาก "มีงานกี่ชิ้น" (มากเท่าไหร่ก็ได้)
มี thread หนึ่งในเอนจินจริง ๆ ที่เล่นตามกฎที่เข้มงวดกว่า thread อื่นทั้งหมด: audio thread (หรือ audio callback) ระบบปฏิบัติการเรียกมันตามตารางที่แน่นและตายตัว เพื่อส่งมอบก้อนตัวอย่างเสียงชิ้นเล็ก ๆ ชิ้นถัดไป — มักแค่ไม่กี่มิลลิวินาที พลาด deadline นั้นแม้แค่ครั้งเดียว ลำโพงก็จะขาดข้อมูล: ผู้เล่นจะได้ยินเสียงคลิก, ป็อป, หรือแตกพร่า ซึ่งสังเกตเห็นได้ชัดกว่าเฟรมกราฟิกที่หลุดไปหนึ่งเฟรมมาก และมันเกิดขึ้นทันที ไม่มีที่ว่างให้ "ตามให้ทันในเฟรมถัดไป"
เพราะ deadline นี้ audio thread ต้อง ไม่มีวัน block — มันต้องไม่ทำอะไรก็ตามที่อาจทำให้มันรอเป็นเวลาที่คาดเดาไม่ได้:
lock() บน mutex ที่ thread อื่นอาจถืออยู่ ถ้า thread ที่ priority ต่ำกว่าถือ mutex นั้นอยู่ และ OS ดันหยุด thread นั้นไว้ชั่วคราว (ด้วยเรื่องอื่นที่ไม่เกี่ยวกัน) audio thread อาจติดค้างรออยู่หลัง thread ที่ตัวมันเองก็ไม่ได้รันอยู่ด้วยซ้ำ — ปัญหานี้เรียกว่า priority inversionnew หรือ malloc memory allocator มักใช้ mutex ภายในของตัวเอง และแม้ไม่มี mutex การจองหน่วยความจำก็ยังกินเวลาที่คาดเดาไม่ได้อยู่ดีเพราะ audio thread ใช้ mutex ไม่ได้ เอนจินจริง ๆ จึงพึ่งเครื่องมือ lock-free จากบทนี้โดยตรง — std::atomic — บวกกับเทคนิคอย่าง double buffering: thread หนึ่งเขียนข้อมูลเสียงใหม่เข้า buffer "หลัง" ในขณะที่ audio thread อ่าน buffer "หน้า" ที่เสร็จสมบูรณ์แล้วอย่างปลอดภัย และทั้งสอง thread สลับกันว่า buffer ไหนเป็นตัวปัจจุบันโดยการพลิก pointer หรือ index ตัวเดียวแบบ atomic ไม่ใช่การล็อกอะไรเลย
บทเรียนทั่วไปนี้ไม่ได้จำกัดแค่เรื่องเสียง: โค้ดที่มี deadline แบบ real-time เข้มงวดควรเลี่ยง mutex บน critical path ของมัน แล้วพึ่ง atomic กับการจัดวางข้อมูลอย่างระมัดระวังแทน โค้ด gameplay ส่วนใหญ่ของคุณจะไม่ต้องระวังระดับนี้เลย — แต่การรู้ว่ามันมีอยู่ และทำไม คือสิ่งที่ทำให้คุณจำได้ว่า thread หนึ่งหรือสองตัวในเอนจินที่คำแนะนำปกติ "ใช้ mutex ก็พอ" จากหัวข้อ 5 ใช้ไม่ได้
lock()/unlock() ทำเครื่องหมาย critical sectionhealth ที่ใช้ร่วมกัน 500,000 ครั้ง เริ่มจาก 1,000,000 ทายเป็นคำพูดว่าค่าที่พิมพ์ออกมาจะเป็นยังไงเมื่อโปรแกรมรัน (ไม่ต้องบอกตัวเลขแม่นยำ — บอกว่ามันจะเป็น 0 แน่นอนหรือไม่ และทำไม) แล้วเขียน health ใหม่ให้โปรแกรมถูกต้องทุกครั้ง โดยเปลี่ยนน้อยที่สุดเท่าที่จะทำได้
#include <thread>
#include <iostream>
int health = 1000000;
void damage() {
for (int i = 0; i < 500000; i++) {
health--;
}
}
int main() {
std::thread t1(damage);
std::thread t2(damage);
t1.join();
t2.join();
std::cout << health << "\n";
}
มันจะ ไม่ พิมพ์ 0 อย่างแน่นอน health-- คือ data race: สอง thread ทำ read-modify-write กับ int ตัวเดียวกันโดยไม่ประสานงานกัน ดังนั้นการลบบางครั้งจะหายไป แบบเดียวกับที่การบวกหายไปในหัวข้อ 3 — thread หนึ่งอ่านค่าเก่าที่อีก thread เพิ่งเปลี่ยนไปแล้ว แล้วเขียนทับค่าใหม่นั้นด้วยการลบจากค่าเก่าที่ค้างอยู่ ตัวเลขที่พิมพ์ออกมาจะเป็น ค่าที่มากกว่า 0 และไม่เท่ากันในแต่ละครั้งที่รัน
วิธีแก้ที่เปลี่ยนน้อยที่สุดคือทำให้ health เป็น std::atomic<int> เพราะนี่เป็นตัวนับง่าย ๆ ตัวเดียว (หัวข้อ 7) — ไม่ต้องใช้ mutex เลย:
#include <thread>
#include <atomic>
#include <iostream>
std::atomic<int> health{1000000};
void damage() {
for (int i = 0; i < 500000; i++) {
health--; // atomic decrement
}
}
int main() {
std::thread t1(damage);
std::thread t2(damage);
t1.join();
t2.join();
std::cout << health << "\n"; // always 0
}
// std::mutex assetsMutex, sceneMutex; declared elsewhere
void loadThread() {
std::lock_guard<std::mutex> l1(assetsMutex);
std::lock_guard<std::mutex> l2(sceneMutex);
// ...
}
void unloadThread() {
std::lock_guard<std::mutex> l1(sceneMutex);
std::lock_guard<std::mutex> l2(assetsMutex);
// ...
}
แค่ บางครั้ง เท่านั้น deadlock ต้องการจังหวะเวลาที่โชคร้ายเฉพาะเจาะจง: ทั้งสอง thread ต้องคว้า mutex ตัวแรก ของตัวเองได้ก่อนที่ตัวใดตัวหนึ่งจะไปแตะตัวที่สอง — เช่น loadThread ล็อก assetsMutex และ unloadThread ล็อก sceneMutex ก่อนที่ตัวไหนจะทำงานต่อไปได้ ถ้า thread หนึ่งดันทำงานเสร็จสมบูรณ์ก่อนที่อีกตัวจะเริ่มด้วยซ้ำ ก็ไม่มีความขัดแย้งเลย และโปรแกรมรันได้ปกติ นี่แหละคือสิ่งที่ทำให้ bug เรื่องลำดับ lock อันตราย: มันผ่าน testing ได้หลายครั้ง แล้วค่อยค้างตอน production ครั้งแรกที่จังหวะเวลามันเข้าล็อกพอดี (คำเตือนในหัวข้อ 8)
วิธีแก้บรรทัดเดียว — แทนที่ lock_guard ทั้งสองคู่ด้วย std::scoped_lock ตัวเดียวที่ล็อก mutex ทั้งสองพร้อมกัน:
void loadThread() { std::scoped_lock lock(assetsMutex, sceneMutex); /* ... */ }
void unloadThread() { std::scoped_lock lock(sceneMutex, assetsMutex); /* ... */ }
std::thread เดี่ยว ๆ 4,000 ตัว (ตัวละหนึ่ง particle) ถึงเป็นวิธีที่แย่job system จะใช้ thread pool ประมาณ 8 worker thread (ให้ตรงกับจำนวน core, หัวข้อ 9) สร้างครั้งเดียวตอนเริ่มโปรแกรม particle ทั้ง 4,000 ตัวถูกแบ่งเป็น job จำนวนหยิบมือ — เช่น 8 job ๆ ละ 500 particle — และแต่ละ job ถูกดันเข้า queue ที่ใช้ร่วมกัน (หัวข้อ 10) แต่ละ worker คว้า job มาหนึ่งอันและอัปเดต particle 500 ตัวของตัวเองในลูปธรรมดาที่วนต่อเนื่องบน memory ที่ต่อกัน (contiguous) ซึ่งเป็นมิตรกับ cache ด้วย (เหตุผลเดียวกับที่ std::vector ชนะ std::list ในบท data structure ก่อนหน้านี้)
การสร้าง std::thread ดิบ ๆ 4,000 ตัวจะแย่กว่าใน 2 ทาง: มีแค่ 8 ตัวเท่านั้นที่รันพร้อมกันได้จริงในเวลาหนึ่ง เพราะมีแค่ 8 core ที่เหลือก็แค่รอคิวขณะที่ OS คอย context-switch ไป-มาระหว่างมันซ้ำ ๆ — เสีย overhead ไปฟรี ๆ โดยไม่ได้ parallelism เพิ่มขึ้นเลย และค่าใช้จ่ายในการสร้างและทำลาย OS thread 4,000 ตัว (การจอง stack, การจัดการของ kernel) ทุกเฟรมนั้น มีแนวโน้มสูงมากที่จะกินเวลานานกว่าการอัปเดต particle จริง ๆ เสียอีก
นั่นคือชุดเครื่องมือหลัก: thread รันโค้ดอย่างอิสระ, data race คือสิ่งที่เกิดขึ้นเมื่อสอง thread แตะ memory เดียวกันโดยไม่ประสานงานกัน และ std::mutex/std::lock_guard/std::atomic คือเครื่องมือที่เพิ่มการประสานงานที่ขาดหายไปนั้น — แต่ละอันเหมาะกับสถานการณ์ต่างกัน deadlock คืออันตรายที่มาพร้อมกับ lock เอง หลีกเลี่ยงได้ด้วยลำดับ lock ที่สม่ำเสมอ หรือ std::scoped_lock และรูปแบบที่เอนจินจริง ๆ ใช้ใน production ไม่ใช่ "หนึ่ง thread ต่อหนึ่งงาน" แต่เป็น thread pool ขนาดเล็กคงที่ที่ป้อนงานโดย job system โดยมี thread หนึ่งตัว — audio — ที่ปฏิเสธการใช้ lock ไปเลยเพราะมันรอไม่ได้ พกโมเดลความคิดนี้ติดตัวไปด้วย: ระบุว่าอะไรถูกใช้ร่วมกัน ปกป้องมันด้วยเครื่องมือที่เบาที่สุดที่ยังถูกต้อง และอย่าเดาเรื่องจังหวะเวลาเด็ดขาด — race condition กับ deadlock สมควรได้รับ profiler และการทดสอบภายใต้ความกดดัน ไม่ใช่การเดา