3.3 Concurrency และมัลติเธรด

เฟส 3 · คอมพิวเตอร์ทำงานยังไง · เวลาเรียน: 40–70 h

thread, data race และ lock, atomic และ memory model และทำไมเกมรันงานผ่าน job system เล็ก ๆ บน thread pool แทนที่จะสร้าง thread ดิบเยอะ ๆ

ตอนนี้โปรแกรม C++ ของคุณทำงานทีละอย่าง: รันบรรทัดหนึ่ง แล้วบรรทัดถัดไป เป็นเส้นทางยาวเส้นเดียวจาก main ไปจนจบ เส้นทางเดี่ยว ๆ นี้เรียกว่า thread บทนี้ว่าด้วยการรัน มากกว่าหนึ่ง เส้นทางแบบนี้พร้อมกัน ซึ่งเป็นวิธีที่เอนจินเกมจริง ๆ ใช้พลังประมวลผลทั้งหมดที่คอมพิวเตอร์สมัยใหม่มี แทนที่จะปล่อยให้ core ส่วนใหญ่ว่างเปล่า

ทุกหัวข้อข้างล่างนี้เป็นรูปแบบเดียวกับที่คุณคุ้นเคย: ตัวอย่างโค้ดสั้น ๆ ที่รันได้จริง, output จริง, แล้วอธิบายแบบง่าย ๆ ว่าเกิดอะไรขึ้น thread เพิ่มมุมใหม่ที่ต้องพูดถึงก่อนเลยคือ: บางโปรแกรมในบทนี้จะพิมพ์ผลลัพธ์ ไม่เหมือนเดิม ทุกครั้งที่รัน — ตั้งใจให้เป็นแบบนั้น เพราะความไม่แน่นอนนี้แหละคือปัญหาหลักที่บทนี้จะสอนให้คุณแก้

1. Thread คืออะไร และทำไมเกมถึงอยากได้หลาย 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) แทนที่จะรันทีละอย่างได้

ONE thread, ONE core -- everything happens back-to-back: |--physics--|--render---|--audio--|---AI----| total: 16 ms FOUR threads, FOUR cores -- independent work runs side by side: core 1: |--physics--| core 2: |--render---| core 3: |--audio--| core 4: |---AI----| total: about 6 ms (limited by the slowest lane)

นี่คือแรงจูงใจทั้งหมดของบทนี้: thread ที่มากขึ้น ถ้าใช้ถูกวิธี หมายความว่า CPU ทำงานที่มีประโยชน์จริง ๆ ได้มากขึ้นในเวลาเดียวกัน ซึ่งแปลว่าเฟรมสั้นลง คำว่า "ใช้ถูกวิธี" มีความหมายเยอะมากในประโยคนั้น — ที่เหลือของบทนี้ว่าด้วยข้อผิดพลาดที่เกิดขึ้นเมื่อใช้ thread แบบไม่ระวัง และเครื่องมือที่ C++ ให้มาเพื่อหลีกเลี่ยงมัน

2. เริ่ม thread: std::thread

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 นั้นเสร็จแล้วหรือยัง

Common mistake ปล่อยให้ object std::thread ถูกทำลายทั้งที่ยังอยู่ในสถานะ joinable — คือยังไม่เคยเรียก join() หรือ detach() เลย C++ ถือว่านี่เป็นข้อผิดพลาดร้ายแรงพอที่จะเรียก std::terminate() ซึ่งทำให้โปรแกรมทั้งโปรแกรม crash ทันที ทุก std::thread ที่คุณสร้างต้อง join หรือ detach ก่อนที่มันจะหลุดออกจาก scope

3. อันตรายหลัก: data race

นี่คือปัญหาที่ทำให้ 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) ในลำดับที่แย่ได้:

counter starts at 5 Thread A Thread B counter in memory load counter (reads 5) 5 load counter (reads 5) 5 add 1 (register = 6) 5 add 1 (register = 6) 5 store 6 6 store 6 6 Two increments happened. counter went from 5 to 6 -- ONE increment is LOST, because Thread B read the value 5 before Thread A's store 6 ever landed.

เอาการอัปเดตที่หายไปนี้คูณด้วยหนึ่งล้านรอบบนสอง thread ที่แข่งกันอยู่ คุณจะได้ตัวเลขสุดท้ายที่น้อยกว่า 2,000,000 อยู่มาก — และขาดไปไม่เท่ากันทุกครั้งที่รัน เพราะลำดับการสลับแทรกที่แน่นอนขึ้นอยู่กับ OS scheduling ซึ่งไม่มีทางเหมือนเดิมทุกครั้ง

4. ทำไมนี่ถึงเป็น undefined behavior ไม่ใช่แค่ "ตัวเลขผิด"

คุณเคยเจอ 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 อื่น ๆ ที่คุณรู้จักแล้วว่าต้องหลีกเลี่ยง: แก้มัน อย่าทนกับมัน

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

5. แก้ด้วย std::mutex

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 ไว้แล้วปลุกทีหลัง ซึ่งช้ากว่าการบวกเลขธรรมดามาก ความถูกต้องมาก่อน — ค่อยกังวลเรื่องราคานี้ทีหลังเมื่อโปรแกรมถูกต้องแล้วจริง ๆ

6. std::lock_guard: อย่าลืม unlock

การเรียก 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() เองในโค้ดจริงแทบทุกกรณี

Tip รูปแบบ "constructor ยึด, destructor ปล่อย" นี้ควรจำชื่อไว้: มันเรียกว่า RAII (Resource Acquisition Is Initialization) คุณจะเห็นรูปแบบเดียวกันนี้ถูกใช้กับอะไรอีกมากมายนอกจาก mutex — file handle, heap memory, GPU resource — ที่ไหนก็ตามที่มีบางอย่างต้องถูกปล่อยไม่ว่าฟังก์ชันจะออกทางไหนก็ตาม

7. Atomic: std::atomic สำหรับตัวนับแบบ lock-free

สำหรับกรณีง่าย ๆ อย่างตัวนับเดียว 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 สำหรับอะไรก็ตามที่แตะมากกว่าหนึ่งตัวแปร หรือมากกว่าหนึ่งขั้นตอน ที่ต้องสอดคล้องกันตลอด

8. Deadlock: สอง lock ลำดับผิด

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 ที่ไล่ดูลำดับการสลับแทรกที่โชคร้ายซึ่งทำให้เกิดปัญหานี้:

time -> threadFunc1 (on Thread 1) threadFunc2 (on Thread 2) locks mA ....................... locks mB ........................ wants mB -- but Thread 2 holds it wants mA -- but Thread 1 holds it WAITS ........................... WAITS ........................... (forever: neither will ever release the lock the other one needs)

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 ในลำดับตรงข้ามกันก็ตาม

Common mistake สมมติว่า deadlock จะแสดงตัวออกมาทันทีตอน testing บ่อยครั้งมันไม่แสดง — มันต้องการจังหวะเวลาที่โชคร้ายเฉพาะเจาะจงของทั้งสอง thread เพื่อชนกันในลำดับที่แย่พร้อมกัน ซึ่งอาจเกิดขึ้นน้อยมาก โค้ดที่มี bug เรื่องลำดับ lock อาจผ่าน testing ไปได้หลายสัปดาห์แล้วค่อยค้างตอน production ครั้งแรกที่จังหวะเวลามันเข้าล็อกพอดี อย่าพึ่ง "มันทำงานตอนที่ฉันลอง" สำหรับเรื่องลำดับ lock — บังคับให้ลำดับสม่ำเสมอ หรือใช้ std::scoped_lock เป็นกฎตายตัว ไม่ใช่ความหวัง

9. ทำไมเอนจินไม่สร้าง thread ทีละงาน: thread pool

เมื่อมีเครื่องมือทั้งหมดนี้ ไอเดียที่ล่อใจคือ: สำหรับทุกชิ้นงาน — อัปเดตศัตรูตัวนี้, โหลด 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 -- created ONCE, reused every frame +---------------------------+ | shared queue of jobs | | [job][job][job][job]... | +---------------------------+ | | | | v v v v worker worker worker worker (one per CPU core, roughly) 1 2 3 4 each worker: pull the next job off the queue, run it, pull the next one, repeat no new OS thread is ever created for an individual job

10. แบ่งงานออกเป็น task: job system

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 กี่ตัว" (น้อยและคงที่) ออกจาก "มีงานกี่ชิ้น" (มากเท่าไหร่ก็ได้)

Tip ทั้ง Unity และ Unreal ต่างก็มี job system ของตัวเองที่สร้างจากไอเดียนี้เป๊ะ ๆ — C# Job System ของ Unity และ Task Graph ของ Unreal เมื่อคุณเรียนสองตัวนี้ทีหลัง ภาพของ queue-ของ-job-เล็ก ๆ-ทับ-บน-worker-pool-คงที่ จากหัวข้อนี้คือโมเดลความคิดที่อยู่เบื้องหลังมัน

11. thread ที่ต้องไม่มีวัน block: audio

มี thread หนึ่งในเอนจินจริง ๆ ที่เล่นตามกฎที่เข้มงวดกว่า thread อื่นทั้งหมด: audio thread (หรือ audio callback) ระบบปฏิบัติการเรียกมันตามตารางที่แน่นและตายตัว เพื่อส่งมอบก้อนตัวอย่างเสียงชิ้นเล็ก ๆ ชิ้นถัดไป — มักแค่ไม่กี่มิลลิวินาที พลาด deadline นั้นแม้แค่ครั้งเดียว ลำโพงก็จะขาดข้อมูล: ผู้เล่นจะได้ยินเสียงคลิก, ป็อป, หรือแตกพร่า ซึ่งสังเกตเห็นได้ชัดกว่าเฟรมกราฟิกที่หลุดไปหนึ่งเฟรมมาก และมันเกิดขึ้นทันที ไม่มีที่ว่างให้ "ตามให้ทันในเฟรมถัดไป"

เพราะ deadline นี้ audio thread ต้อง ไม่มีวัน block — มันต้องไม่ทำอะไรก็ตามที่อาจทำให้มันรอเป็นเวลาที่คาดเดาไม่ได้:

เพราะ audio thread ใช้ mutex ไม่ได้ เอนจินจริง ๆ จึงพึ่งเครื่องมือ lock-free จากบทนี้โดยตรง — std::atomic — บวกกับเทคนิคอย่าง double buffering: thread หนึ่งเขียนข้อมูลเสียงใหม่เข้า buffer "หลัง" ในขณะที่ audio thread อ่าน buffer "หน้า" ที่เสร็จสมบูรณ์แล้วอย่างปลอดภัย และทั้งสอง thread สลับกันว่า buffer ไหนเป็นตัวปัจจุบันโดยการพลิก pointer หรือ index ตัวเดียวแบบ atomic ไม่ใช่การล็อกอะไรเลย

game/audio-mixing thread audio callback thread (strict deadline) writes into buffer B ------ reads from buffer A --------- to speakers (atomic swap of a "which buffer is current" index) no lock ever taken by the audio callback thread

บทเรียนทั่วไปนี้ไม่ได้จำกัดแค่เรื่องเสียง: โค้ดที่มี deadline แบบ real-time เข้มงวดควรเลี่ยง mutex บน critical path ของมัน แล้วพึ่ง atomic กับการจัดวางข้อมูลอย่างระมัดระวังแทน โค้ด gameplay ส่วนใหญ่ของคุณจะไม่ต้องระวังระดับนี้เลย — แต่การรู้ว่ามันมีอยู่ และทำไม คือสิ่งที่ทำให้คุณจำได้ว่า thread หนึ่งหรือสองตัวในเอนจินที่คำแนะนำปกติ "ใช้ mutex ก็พอ" จากหัวข้อ 5 ใช้ไม่ได้

12. Glossary

13. แบบฝึกหัด

Exercise 1 โปรแกรมนี้สร้างสอง thread ที่ต่างลบ 1 ออกจากตัวแปร health ที่ใช้ร่วมกัน 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";
}
Show answer

มันจะ ไม่ พิมพ์ 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
}
Exercise 2 สอง thread รันฟังก์ชันเหล่านี้ ซึ่งล็อก mutex สองตัวเดียวกันในลำดับตรงข้ามกัน โปรแกรมนี้ deadlock ทุกครั้งที่รัน หรือแค่บางครั้ง? อธิบายเหตุผลในหนึ่งหรือสองประโยค แล้วให้วิธีแก้แบบบรรทัดเดียว
// 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);
    // ...
}
Show answer

แค่ บางครั้ง เท่านั้น 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); /* ... */ }
Exercise 3 เกมของคุณต้องอัปเดต particle 4,000 ตัวทุกเฟรม และเครื่องเป้าหมายมี 8 CPU core อธิบายสั้น ๆ ว่า job system จะจัดการเรื่องนี้ยังไง และทำไมการสร้าง std::thread เดี่ยว ๆ 4,000 ตัว (ตัวละหนึ่ง particle) ถึงเป็นวิธีที่แย่
Show answer

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 และการทดสอบภายใต้ความกดดัน ไม่ใช่การเดา

← กลับไปหน้ารวมบท