«ดีไซน์ที่ใช้ได้ใน C# เริ่มแครชเป็นครั้งคราวเมื่อพอร์ตมา C++» «เราใช้ std::thread พอมีข้อยกเว้นทั้งแอปก็ตายทันทีผ่าน terminate» «เราหยุดด้วยแฟล็ก volatile bool แต่เฉพาะบิลด์รีลีสเท่านั้นมันไม่หยุด» — มัลติเธรดใน C++ พกอันตรายที่ภาษาแมเนจด์ไม่มี: data race คือพฤติกรรมไม่กำหนด (UB) ตามที่เป็น ไม่ใช่แค่คุณอาจอ่านค่าเสียหาย สมมติฐานการออปติไมซ์ของคอมไพเลอร์พัง และคุณจบที่สถานะที่อะไรก็เกิดขึ้นได้ตามตัวอักษร
บทความนี้เป็น ฉบับ C++ ของซีรีส์มัลติเธรดเชิงปฏิบัติ มุ่งนักพัฒนาที่เขียนแอปธุรกิจ ซอฟต์แวร์ควบคุมอุปกรณ์ และ DLL ด้วย C++ สมัยใหม่ (C++17/20) โดยแปลหลักการทั่วไปของการออกแบบมัลติเธรด — อย่าเพิ่มเธรดโดยตรง ลดสถานะที่เปลี่ยนแปลงร่วมกัน ใช้วินัยล็อก ออกแบบวิธีหยุดก่อนอย่างอื่น — ไปยังเครื่องมือ C++ และ Windows พร้อมกับดักเฉพาะของ C++ ทั้งหมดยึดแหล่งปฐมภูมิ ณ สิงหาคม 2026 เขียนให้ยืนได้ด้วยตัวเอง หลักเดียวกันที่ขยายสำหรับภาษาอื่นอยู่ใน «ฉบับ .NET» «ฉบับ C» และ «ฉบับ Java»
1. สรุปก่อนเลย
- ใน C++ data race ไม่ใช่ «คุณอาจอ่านค่าเสียหาย» — มันคือพฤติกรรมไม่กำหนด การไม่เหลือการเข้าถึงที่เปลี่ยนแปลงร่วมกันโดยไม่ซิงโครไนซ์แม้จุดเดียวในโค้ดเป็นข้อกำหนดเด็ดขาด มากกว่าในภาษาอื่น1
- อย่าใช้
std::threadเปล่า หากดีสตรักเตอร์ของstd::threadทำงานขณะเธรดยัง joinablestd::terminateฆ่าโพรเซสทันทีstd::jthreadของ C++20 join ในดีสตรักเตอร์โดยอัตโนมัติ และมีกลไกขอหยุด (stop_token) ในตัว23 - ถือล็อกผ่าน RAII เสมอ เลิกเขียน
mtx.lock()ด้วยมือ ใช้lock_guard/scoped_lockดีสตรักเตอร์ปล่อยล็อกอย่างน่าเชื่อถือแม้มีข้อยกเว้น เมื่อได้ล็อกหลายตัวพร้อมกันscoped_lockจัดการด้วยอัลกอริทึมเลี่ยงเดดล็อก4 volatileไม่ใช่เครื่องมือซิงโครไนซ์ ใช้std::atomicสำหรับแฟล็กและตัวนับที่แชร์ และstd::mutexเพื่อปกป้องหลายตัวแปรพร้อมกันstd::atomicให้ทั้งความเป็นอะตอมิกและลำดับตามmemory_order5- รอแบบนัดพบด้วย
waitรูปเพรดิเคตของcondition_variableตัวแปรเงื่อนไขมีการปลุกลวง (ตื่นโดยไม่ถูกแจ้ง) ดังนั้นการเรียกwaitโดยไม่มีเพรดิเคตคือแหล่งเพาะบั๊ก6 jthread+stop_token(C++20) คือรูปพื้นฐานของการหยุดเธรด ในสภาพแวดล้อมก่อนหน้านั้น สร้างการหยุดแบบร่วมมือด้วยมือด้วยstd::atomic<bool>บวกตัวแปรเงื่อนไข ถือว่าการบังคับจบเธรดไม่มีอยู่ในโลก C++3- รู้ว่าดีสตรักเตอร์ของ
futureบล็อกได้ ก่อนใช้std::asyncทิ้งค่าที่คืนมาแล้วคุณได้ผลเดียวกับการรันแบบอนุกรม7 - วัตถุซิงโครไนซ์ของ Win32 มีที่ยืนเฉพาะสถานการณ์ «ทำงานกับ API รอของ Win32» และ «ข้ามโพรเซส» ที่อื่น การเขียนกับไลบรารีมาตรฐานดีกว่าสำหรับการพกพาและการบำรุงรักษา8
2. ทำไมมัลติเธรดจึงยาก — เงื่อนไขแข่งขัน เดดล็อก และพฤติกรรมไม่กำหนด
ย่อแล้ว ปัญหาที่มัลติเธรดนำเข้ามีสองชนิด โดยไม่ขึ้นกับภาษา
เงื่อนไขแข่งขัน (race condition) คือบั๊กที่ผลขึ้นกับลำดับที่หลายเธรดถึงชิ้นโค้ดหนึ่ง ตัวอย่างคลาสสิกคือตัวนับร่วม: นิพจน์เดียว ++count แตกที่ระดับรหัสเครื่องเป็นสามขั้น — อ่าน บวก เขียนกลับ หากสองเธรดเข้าสามขั้นนั้นพร้อมกัน การบวกของเธรดหนึ่งถูกเขียนทับและหายไปโดยการเขียนกลับของอีกฝ่าย ผลเปลี่ยนจากรอบสู่รอบ และผลที่คุณได้คาดเดาไม่ได้
sequenceDiagram
participant A as เธรด A
participant M as ตัวแปรร่วม count
participant B as เธรด B
Note over M: count = 10
A->>M: อ่าน (10)
B->>M: อ่าน (10)
A->>A: บวกในท้องถิ่น (11)
B->>B: บวกในท้องถิ่น (11)
A->>M: เขียนกลับ (11)
B->>M: เขียนกลับ (11)
Note over M: เพิ่มสองครั้งเกิดแล้ว<br/>แต่ count = 11 — การบวกของเธรด A หายไป
ภาพ 1: เงื่อนไขแข่งขันคลาสสิกที่การเพิ่มบนตัวนับร่วมหายไป หากเธรดอื่นแทรกกลางสามขั้นของ ++count การเขียนกลับครั้งหลังทับอีกฝ่าย
เดดล็อก คือสถานะที่สองเธรดแต่ละฝ่ายรอกล็อกที่อีกฝ่ายถืออยู่ จึงไม่มีใครเดินต่อได้ เธรด A ถือกล็อก 1 และรอกล็อก 2 เธรด B ถือกล็อก 2 และรอกล็อก 1 — แค่นั้นพอให้ทั้งคู่หยุดตลอดกาล
flowchart LR
A["เธรด A<br/>ถือกล็อก 1"] -->|"รอได้กล็อก 2"| B["เธรด B<br/>ถือกล็อก 2"]
B -->|"รอได้กล็อก 1"| A
ภาพ 2: การรอเป็นวงของเดดล็อก ทันทีที่ลูกศรอารอเป็นวง ทุกเธรดในวงนั้นหยุดตลอดกาล
สิ่งที่ทำให้ทั้งสองน่ารำคาญคือการพึ่งจังหวะ การสอดแทรกที่บนเครื่องพัฒนาเกิดครั้งเดียวในหลายหมื่นรัน อาจเกิดทุกวันบนเครื่องลูกค้าที่มีจำนวนคอร์ต่างและจังหวะต่าง «ไม่ทำซ้ำได้เมื่อติดดีบักเกอร์» และ «หายไปเมื่อฉันเพิ่มล็อก» ทั้งคู่เกิดเพราะการสังเกตเองเปลี่ยนจังหวะ — นั่นคือพฤติกรรมคลาสสิกของบั๊กแข่ง นั่นแหละคือเหตุที่ทุกหลักในบทความนี้ชี้ทิศเดียว: ลดจุดที่ต้องซิงโครไนซ์ ก่อนกังวลว่าจะซิงโครไนซ์ให้ถูก
2.1. ใน C++ data race คือพฤติกรรมไม่กำหนดโดยตรง
เหนือนั้น C++ มีชั้นเพิ่มที่ภาษาอื่นไม่มี ตามมาตรฐาน C++ หากหลายเธรดเข้าถึงตำแหน่งหน่วยความจำเดียวกันโดยไม่ซิงโครไนซ์ และอย่างน้อยหนึ่งฝ่ายเขียน นั่นคือ data race และเป็นพฤติกรรมไม่กำหนด บท concurrency ของ C++ Core Guidelines (CP.2, «Avoid data races») ระบุสิ่งนี้เป็นกฎเด็ดขาดข้อแรก1 พฤติกรรมไม่กำหนดไม่ใช่เรื่องอ่อนว่า «คุณอาจอ่านค่าเก่าหรือค่าใหม่» คอมไพเลอร์ออปติไมซ์บนสมมติว่าไม่มี data race ดังนั้นพฤติกรรมที่คุณคาดจากซอร์สไม่ได้เลย — การตรวจเงื่อนไขหายจากลูป การเขียนถูกเรียงใหม่หรือรวม — เกิดขึ้นอย่างชอบธรรม อุบัติเหตุคลาสสิกที่ «แฟล็กหยุด volatile bool ใช้ไม่ได้เฉพาะบิลด์รีลีส» คือกรณีตำราพอดีเรื่องนี้
2.2. RAII คือฐาน
สมมติฐานอีกข้อเฉพาะ C++ คือ ข้อยกเว้นและการจัดการทรัพยากร C++ ไม่มี finally แต่มี RAII (การปล่อยอัตโนมัติผ่านดีสตรักเตอร์) และชุดเครื่องมือมัลติเธรดถูกออกแบบบนสมมติว่าคุณจะใช้มัน «จัดการล็อกผ่านอายุขัยของอ็อบเจกต์» «รับประกันการ join ของเธรดผ่านอายุขัยของอ็อบเจกต์เช่นกัน» — การเดินตามแบบแผนนั้นคือฐานของการเขียน C++ มัลติเธรดอย่างปลอดภัย
3. วิธีเริ่มเธรด — กับดักของ thread และ jthread
3.1. ดีสตรักเตอร์ของ std::thread «ถูกออกแบบให้ก่ออุบัติเหตุ»
std::thread มีกับดักที่รู้จักกันดี หากดีสตรักเตอร์ทำงานขณะเธรดยัง joinable (ยังไม่ถูก join และยังไม่ถูก detach) จะเรียก std::terminate และโพรเซสตายทันที9
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← หากมีข้อยกเว้นที่นี่...
worker.join(); // ← ไม่ถึง join; ดีสตรักเตอร์ของ worker เรียก terminate
}
การทำให้สิ่งนี้ปลอดภัยต่อข้อยกเว้นเคยต้องรับประกัน join ด้วย try/catch — สภาพบิดเบี้ยวในภาษา RAII ที่เธรดอย่างเดียวต้องจัดการด้วยมือ std::jthread ของ C++20 แก้เรื่องนี้ เพราะดีสตรักเตอร์ออกคำขอหยุดแล้วจึง join โดยอัตโนมัติ โค้ดด้านบนจึงปลอดภัยต่อข้อยกเว้นเพียงแค่สลับเป็น std::jthread2 บน MSVC <stop_token> และ jthread พร้อมใช้ตั้งแต่ Visual Studio 2019 16.9 เป็นต้นไป3
flowchart TB
T["เธรดถูกเริ่มแล้ว"] --> Q{"เกิดอะไรขึ้น<br/>เมื่อขอบเขตจบ?"}
Q -->|"std::thread<br/>ยังไม่ถูก join และยังไม่ถูก detach"| X["std::terminate<br/>โพรเซสตายทันที"]
Q -->|"std::thread<br/>ถูก join แล้ว"| OK1["join อย่างปลอดภัย"]
Q -->|"std::jthread - C++20"| OK2["request_stop + join อัตโนมัติ<br/>ปลอดภัยแม้มีข้อยกเว้น"]
ภาพ 3: อายุขัยของอ็อบเจกต์เธรดและวิธีจบ std::thread ถูกกำหนดให้ตายทันทีถ้าลืม join ดังนั้นตั้งแต่ C++20 เป็นต้นไป ให้ jthread เป็นค่าเริ่มต้น
โดยหลักแล้วอย่าใช้ detach() เธรดที่สูญเสียทุกทางที่จะถูก join กลายเป็นสาเหตุคลาสสิกของแครชตอนปิด โดยแข่งกับการทำลายตัวแปรสแตติกและฮีปเมื่อโพรเซสออก
3.2. เครื่องมือ «เหนือระดับเธรด» — async, future และอัลกอริทึมขนาน
หลักของฉบับ .NET ที่ว่า «อย่าสร้างเธรดเอง» แมปไปยังเครื่องมือต่อไปนี้ใน C++
std::async+std::future: สำหรับงานอะซิงโครนัสครั้งเดียวและการรับผล มีความแปลกสำคัญอย่างไรก็ตาม:future(หรือshared_futureตัวสุดท้าย) ที่ผูกกับงานที่ปล่อยผ่านstd::asyncบล็อกจนเสร็จหากดีสตรักเตอร์ทำงานขณะงานยังไม่จบ7 สำหรับงานที่ปล่อยจริงด้วยstd::launch::asyncการทิ้ง future ที่คืนมาเทียบเท่าการรันแบบซิงโครนัส ณ จุดนั้น แย่กว่านั้น หากไม่ระบุนโยบายปล่อย การอิมพลีเมนต์เลือกdeferred(รันแบบขี้เกียจ) เป็นค่าเริ่มต้นได้ ในกรณีนั้นหากไม่มีใครเรียกget()/wait()งานไม่เคยถูกรันเลยและหายไปเงียบ ๆ หากต้องการรับประกันการรันพร้อมกัน ให้ระบุstd::launch::asyncอย่างชัดเจน และให้เจ้าของจัดการอายุขัยของ future- PPL - Parallel Patterns Library -
concurrency::parallel_for/parallel_for_each: ทำงานขนานกับทุกสมาชิกของคอลเล็กชัน อย่างไรก็ตามหากงานในหนึ่งรอบเล็กเกินไป ค่าโสหุ้ย fork/join กินกำไร ดังนั้นโดยหลักให้ขนานที่ลูปนอก10 - อัลกอริทึมขนานของ C++17 -
std::execution::par: บน MSVC อัลกอริทึมหลักถูกขนานแล้ว (ไม่ใช่ทั้งหมด)11 โปรดทราบว่าหากข้อยกเว้นหลุดจากการประมวลผลสมาชิกภายใต้นโยบายการรัน จะเรียกstd::terminateการวางขอบเขตข้อยกเว้นของตนเอง (try/catch) ในคอลแบ็กเป็นความคิดเดียวกับขอบเขตเธรดในหมวด 6
เส้นที่ลากไว้ที่อื่น — «การรอ I/O ไม่ใช่สิ่งที่แก้ด้วยการเพิ่มเธรด» — ยังใช้ได้โดยไม่เปลี่ยน สำหรับโค้ด Windows เนทีฟ I/O แบบ OVERLAPPED และ IOCP คือเครื่องมือที่รับงานนั้น (เรื่องกลไกดู «เชิงลึกของ Windows I/O ตอนที่ 2»)
4. ลดสถานะที่เปลี่ยนแปลงร่วมกัน — แยก ส่งตามค่า const และคิว
การแย่งเกิดเมื่อมีทั้ง «หลายเธรด» และ «ข้อมูลที่เปลี่ยนแปลงร่วมกัน» จำนวนเธรดถูกกำหนดโดยความต้องการ ดังนั้นสิ่งที่ออกแบบตัดได้คือการแชร์ วิธีตกอยู่ในสามตระกูล — แยก ทำให้ไม่เปลี่ยนแปลง และส่งต่อข้อมูล — และนี่คือวิธีเขียนแต่ละอย่างใน C++
แยก ในงานอย่างการรวมขนาน แทนที่ทุกเธรดเขียนเข้าผลรวมร่วม ให้ แต่ละเธรดมีผลรวมย่อยของตนเอง แล้วรวมครั้งเดียวตอนท้าย การเขียนค่าที่แชร์ลดจาก «ทุกรอบ» เป็น «ครั้งต่อเธรด» ตัดทั้งต้นทุนซิงโครไนซ์และหน้าต่างการแย่งเป็นเท่าตัว ขั้นรวมครั้งเดียวทำด้วย std::mutex หรือด้วย fetch_add บน std::atomic ก็ได้ทั้งคู่
ส่งตามค่า หากคุณส่งข้อมูลที่เธรดต้องการให้มันด้วยการคัดลอก (หรือย้าย) ตอนเริ่ม ข้อมูลนั้นเป็นของเธรดนั้นเท่านั้น และไม่ต้องซิงโครไนซ์ การจับแลมบ์ดาตามเรเฟอเรนซ์ ([&]) แล้วแตะตัวแปรที่อายุขัยจบแล้วเป็นอุบัติเหตุทั่วไป ดังนั้นแลมบ์ดาที่ส่งให้เธรดควรใช้ การจับชัดเจน ตามการคัดลอกหรือย้ายเป็นหลัก ทั้งนี้ «คัดลอกแล้วจึงเป็นเอกสิทธิ์» เป็นจริงเมื่อ ค่านั้นเป็นกราฟค่าลึกที่ไม่มีนามแฝงอย่างพอยน์เตอร์หรือ shared_ptr การคัดลอกสตรักที่มีพอยน์เตอร์ดิบยังทิ้งสิ่งที่มันชี้ให้ถูกแชร์อยู่
แชร์แบบ const ข้อมูลที่ถูกอ่านอย่างเดียวปลอดภัยให้อ่านจากกี่เธรดก็ได้พร้อมกัน ค่าคอนฟิก ข้อมูลหลัก อินพุตการคำนวณ และคล้ายกัน แชร์ได้โดยไม่ซิงโครไนซ์หากคุณทำให้เป็นการแชร์ const ที่ไม่ถูกเขียนหลังสร้าง (std::shared_ptr<const Config> เป็นต้น) ข้อควรระวังหนึ่ง: สิ่งที่ shared_ptr<const T> ห้ามคือ การกลายพันธุ์ผ่านแฮนเดิลนั้นเท่านั้น หากนามแฝงที่ไม่ใช่ const ยังอยู่ที่อื่น หรือสมาชิก mutable ถูกเขียน การแย่งยังอยู่ — ดังนั้นออกแบบเรื่องนั้นด้วย จนถึง «เมื่อสร้างเสร็จแล้ว ปล่อยเรเฟอเรนซ์ที่ไม่ใช่ const และอย่าให้ใครเขียนหลังจากนั้น» การตัดสินใจเพียงว่า «เมื่อต้องเปลี่ยน ให้สร้างอ็อบเจกต์ใหม่แล้วสลับเข้า แทนการกลายพันธุ์ในที่» ลบชิ้นสถานะที่เปลี่ยนแปลงที่มิฉะนั้นคุณต้องเฝ้า (เรื่องการจัดการอายุขัยของการสลับเอง ดูข้อควรระวังในหมวด 5.2)
ส่งต่อผ่านคิว นำการไหลของข้อมูลระหว่างเธรดผ่านคิวผู้ผลิต/ผู้บริโภค แทนตัวแปรร่วม มาตรฐาน C++ ไม่มีชนิดช่องทาง ดังนั้นการเขียนคิวเล็กด้วย std::mutex + std::condition_variable คือแบบแผนที่ตั้งตัวแล้ว
template <typename T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
{
if (capacity == 0) // ความจุ 0 คือกับดักที่ทุก Push รอตลอดกาล
throw std::invalid_argument("capacity must be positive");
}
// รอจนมีที่ว่าง (หรือคำขอหยุด) หากเต็ม คืน false หมายถึงคำขอหยุด
bool Push(T item, std::stop_token st)
{
{
std::unique_lock lock(mtx_);
if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
return false; // ถูกปลุกโดยคำขอหยุด
if (st.stop_requested()) // หากที่ว่างกับการหยุดเกิดพร้อมกัน ให้หยุดมาก่อน
return false; // และปฏิเสธการผลักเมื่อเริ่มหยุดแล้ว
queue_.push(std::move(item));
}
not_empty_.notify_one(); // แจ้งนอกกล็อก
return true;
}
// รอคำขอหยุด (stop_token) หรือรายการมาถึง คืน nullopt เมื่อหยุด
std::optional<T> Pop(std::stop_token st)
{
std::optional<T> item;
{
std::unique_lock lock(mtx_);
if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
return std::nullopt; // ถูกปลุกโดยคำขอหยุด
if (st.stop_requested()) // หากรายการกับการหยุดเกิดพร้อมกัน ให้หยุดมาก่อน
return std::nullopt; // และอย่าเริ่มงานใหม่เมื่อเริ่มหยุดแล้ว
item = std::move(queue_.front());
queue_.pop();
}
not_full_.notify_one();
return item;
}
private:
const std::size_t capacity_;
std::mutex mtx_;
std::condition_variable_any not_empty_; // condition_variable_any เพื่อใช้ wait ที่รับรู้ stop_token
std::condition_variable_any not_full_;
std::queue<T> queue_;
};
มีจุดออกแบบสองจุดที่นี่ ประการแรก จำกัดความจุและให้ฝั่งผู้ผลิตรอเมื่อเต็ม คิวที่ไม่มีเพดานกลายเป็นระเบิดเวลาในชุดที่การผลิตแซงการบริโภค: มัน «ยังรันอยู่» แต่หน่วยความจำโตเรื่อย ๆ การให้ Push บล็อกเมื่อเต็มทำหน้าที่เป็นแรงดันย้อนกลับตามธรรมชาติ ส่งโอเวอร์โหลดขึ้นต้นทางเชิงกล ประการที่สอง เพราะตัวแปรเงื่อนไขมีการ ปลุกลวง (ตื่นโดยไม่มีการแจ้ง) ให้เรียก wait พร้อมเพรดิเคตเสมอ รูปเพรดิเคตของ wait รันตรรกะ «วนจนกว่าเงื่อนไขเป็นจริง» ให้คุณภายใน6
5. วินัยล็อก — RAII และ scoped_lock
แม้หลังลดสถานะที่เปลี่ยนแปลงร่วมกัน คุณมักตัดให้เป็นศูนย์ไม่ได้ ใช้การกีดกันกับสิ่งที่ยังแชร์ แต่การล็อกโดยไม่มีวินัยเพียงซ่อนการแย่ง
ก่อนอื่น คิดหน่วยของการล็อกไม่ใช่เป็น «ช่วงโค้ด» แต่เป็น «ข้อมูล» กำหนดมิวเท็กซ์หนึ่งตัวต่อชุดข้อมูลที่เปลี่ยนแปลงที่คุณต้องการปกป้อง (ทำให้เป็นสมาชิก private ไม่เปิดออกนอก) และหยิบมิวเท็กซ์เดียวกันทุกจุดที่แตะข้อมูลนั้น — ตารางความสอดคล้องนี้ที่พังคือสิ่งที่บั๊กแข่งส่วนใหญ่เป็นจริง ๆ และสิ่งเดียวที่คุณทำได้ขณะถือล็อกคืออ่านและเขียนข้อมูลที่มันปกป้อง I/O ไฟล์ การเรียกเครือข่าย และคอลแบ็ก (การเรียกเข้าโค้ดภายนอก) ขณะถือล็อกไม่เพียงยืดเวลาที่คุณถือ — มันเปิดเส้นที่ผู้ถูกเรียกพยายามหยิบล็อกอื่นแล้วเดดล็อก เตรียมนอกกล็อก และในกล็อกอย่าทำอย่างอื่นนอกจากสลับเข้า คือรูปพื้นฐาน
5.1. ห้ามเขียน lock()/unlock() ด้วยมือ
โค้ดที่เรียก lock() / unlock() ของ std::mutex โดยตรงจบด้วยการไม่ปล่อยล็อกเมื่อมีข้อยกเว้นหรือคืนก่อนเวลา มอบการได้และปล่อยล็อกให้แรปเปอร์ RAII เสมอ
| แรปเปอร์ | ใช้เมื่อ |
|---|---|
std::lock_guard |
ถือมิวเท็กซ์เดียวพอดีตลอดขอบเขต — รูปพื้นฐานที่สุด |
std::scoped_lock (C++17) |
ได้ หลายมิวเท็กซ์พร้อมกัน แก้ปัญหาลำดับด้วยอัลกอริทึมเลี่ยงเดดล็อก4 |
std::unique_lock |
เมื่อต้องการปลดและล็อกใหม่กลางทาง หรือต้องส่งให้ condition_variable::wait |
เมื่อมีล็อกสองตัวขึ้นไป ลำดับการได้ที่สลับกันตามเธรดคือแบบเดดล็อกคลาสสิก (การรอเป็นวงในภาพ 2 เกิดพอดีแบบนี้) การแก้คือตั้งกฎว่า «ทุกเธรดได้ล็อกในลำดับเดียวกัน» แต่เมื่อคุณได้ล็อก พร้อมกัน C++ มีคำตอบที่ดีกว่า: ส่งหลายมิวเท็กซ์ให้ std::scoped_lock พร้อมกัน และ ไลบรารีรับประกันลำดับการได้ที่ปราศจากเดดล็อก ให้คุณ4 ในสถานการณ์อย่างการโอนระหว่างสองอ็อบเจกต์ที่คุณต้องการ «ล็อกทั้งคู่» อย่าหยิบทีละตัว — หยิบพร้อมกันเสมอ
void Transfer(Account& from, Account& to, int amount)
{
if (&from == &to) return; // ไม่ทำอะไรกับบัญชีเดียวกัน (ดูหมายเหตุด้านล่าง)
std::scoped_lock lock(from.mtx, to.mtx); // ทั้งคู่พร้อมกัน; ไลบรารีคลี่ลำดับ
from.balance -= amount;
to.balance += amount;
}
การตรวจเอกลักษณ์ด้านบนไม่ใช่ของตกแต่ง หาก Account เดียวกันถูกส่งทั้ง from และ to คุณจะส่งมิวเท็กซ์ที่ไม่เรียกซ้ำตัวเดียวกันให้ scoped_lock สองครั้ง ซึ่งทำให้แฮงหรือพฤติกรรมไม่กำหนด แนบการตัดบัญชีเดียวกันเข้ากับทุกฟังก์ชันที่ «ล็อกทั้งคู่» เสมอ
สำหรับข้อมูลที่ «อ่านบ่อย เขียนน้อย» คุณใช้ std::shared_mutex (C++17) เป็นล็อกอ่าน/เขียนได้12 recursive_mutex คือชนิดที่ออกแบบให้ «เธรดเดียวกันได้ซ้ำแล้วไม่พัง» แต่ดีไซน์ที่ต้องได้ซ้ำแบบเรียกซ้ำมักเป็นสัญญาณว่าขอบเขตความรับผิดชอบของล็อกพร่ามัว — พิจารณาทบทวนโครงสร้างก่อน
5.2. บทบาทที่ถูกต้องของ atomic
std::atomic ให้การดำเนินการอะตอมิกบนตัวแปรเดียว บวกลำดับตาม memory_order5 มันมีที่ยืนในสถานการณ์เดียวกับ Interlocked ในฉบับ .NET: อัปเดตตัวแปรเดียว เช่น ตัวนับหรือแฟล็ก มันรักษาความคงที่ของหลายตัวแปรพร้อมกันไม่ได้ ดังนั้นกรณีนั้นให้กลับไปใช้ std::mutex
การสลับพอยน์เตอร์ดิบ (std::atomic<T*>) มีกับดักของมันเอง แม้การสลับเองจะอะตอมิก ไม่มีใครปกป้องอายุขัยของอ็อบเจกต์เก่าเมื่อถูกแทนที่แล้ว หากผู้อ่านโหลดพอยน์เตอร์เก่าพอดีก่อนผู้เขียนสลับแล้ว delete คุณได้การเข้าถึงหน่วยความจำที่ถูกปล่อยแล้ว หากต้องการดีไซน์ «สลับแล้วแชร์อ็อบเจกต์ที่ไม่เปลี่ยนแปลง» ใน C++ ให้เลือกวิธีที่ มาคู่กับการจัดการอายุขัย — สลับ std::shared_ptr<const T> ที่ปกป้องด้วยล็อก หรือ std::atomic<std::shared_ptr<T>> ของ C++20
และเพื่อย้ำ: volatile ไม่ใช่เครื่องมือซิงโครไนซ์เธรด การเขียนแบบไร้ล็อกที่คุณระบุ memory_order เองเป็นดินแดนผู้เชี่ยวชาญ ต้องการทั้งเหตุผลชอบธรรมที่จะผ่อนจากค่าเริ่มต้น (seq_cst) และวิธีตรวจว่าคุณทำถูก ในแอปธุรกิจ ให้ใช้ค่าเริ่มต้นหรือเขียนด้วย mutex ตั้งแต่ต้น
6. ออกแบบวิธีหยุด — stop_token และการหยุดแบบร่วมมือ
คำถามแรกที่ควรถามเมื่อรีวิวดีไซน์มัลติเธรดคือ «นี่หยุดอย่างไร?» และ C++ ไม่มีวิธีหยุดเธรดจากภายนอกอย่างปลอดภัย (อันตรายของ TerminateThread ของ Win32 ถูกไล่ละเอียดใน ฉบับ C) ดังนั้นวิธีที่เธรดหยุดต้องสร้างด้วยเครื่องมือของ C++ รอบ การหยุดแบบร่วมมือ — ฝั่งที่หยุดออกคำขอเท่านั้น เธรดเองตัดสินว่าเมื่อใดและอย่างไรจะจบ ณ จุดที่ทิ้งสิ่งต่าง ๆ ให้เรียบร้อย และการ join เสร็จคือสิ่งที่นับว่า «หยุดแล้ว»
ใน C++20 std::jthread มีกลไกหยุดในตัว การเรียก request_stop() ยกคำขอหยุดบน std::stop_token ที่ฟังก์ชันเธรดได้รับ และลูปสำรวจมัน wait ของ condition_variable_any รับ stop_token ได้โดยตรง ดังนั้น «เธรดที่รอให้งานมาถึง» ถูกปลุกทันทีด้วยคำขอหยุดได้เช่นกัน (BlockingQueue::Pop ในหมวด 4 มีรูปนี้พอดี)
class Worker {
public:
void Start()
{
if (thread_.joinable()) // ปฏิเสธ Start ซ้ำขณะรันอยู่แล้ว
throw std::logic_error("already running"); // หากกำหนดค่าแทนการปฏิเสธ เธรดใหม่
// จะเริ่มรัน และขณะที่มันรอ
// เธรดเก่าหยุด จะมีสองเวิร์กเกอร์
// รันคู่กัน
thread_ = std::jthread([this](std::stop_token st) {
try {
while (!st.stop_requested()) {
if (auto item = queue_.Pop(st)) { // ตื่นเมื่อมีคำขอหยุดด้วย
try {
Process(*item, st); // ส่ง st เข้างานที่บล็อกภายในได้เช่นกัน
} catch (...) {
ReportError(std::current_exception()); // บันทึกความล้มเหลวชิ้นเดียวแล้วไปต่อ
}
}
}
} catch (...) {
// แนวป้องกันสุดท้ายที่ขอบเขตเธรด (จับความล้มเหลวใน
// Pop หรือการย้ายด้วย) หากข้อยกเว้นหลุดจากที่นี่ std::terminate
// จะล้มทั้งโพรเซส ดังนั้นให้แน่ใจว่า ReportError เองไม่โยน
ReportError(std::current_exception());
}
});
}
// ไม่ต้องมี Stop ชัดเจน:
// ดีสตรักเตอร์ของ Worker -> ดีสตรักเตอร์ของ jthread -> request_stop() + join()
private:
BlockingQueue<WorkItem> queue_{100}; // จำกัดความจุ (หมวด 4)
std::jthread thread_;
};
flowchart TB
OWNER["ฝั่งที่หยุด<br/>- ดีสตรักเตอร์ของ jthread หรือ request_stop"] -->|"คำขอหยุด"| ST["stop_token"]
ST --> P["ลูปคำนวณ:<br/>สำรวจ stop_requested()"]
ST --> W["เธรดที่รอ:<br/>condition_variable_any::wait(lock, st, pred)<br/>ตื่นทันที"]
P --> E["ทำความสะอาดแล้วคืนเอง"]
W --> E
E --> J["join ทำให้นัดพบเสร็จ<br/>ตอนนี้เท่านั้นที่เรียกว่าหยุดแล้ว"]
ภาพ 4: การหยุดแบบร่วมมือของ C++20 ฝั่งที่หยุดออกคำขอเท่านั้น เธรดเองตัดสินว่าจบอย่างไร การ join เสร็จคือสิ่งที่นับว่าหยุดแล้ว
อีกจุดหนึ่ง: try/catch ในเวิร์กเกอร์ละไว้ไม่ได้ สิ่งที่ jthread ทำให้ปลอดภัยต่อข้อยกเว้นคือ การ join และเฉพาะการ join — หากข้อยกเว้นหลุดจากฟังก์ชันเธรด std::terminate ล้มโพรเซส เหมือนกับ std::thread ตัดสินอย่างชัดเจนที่ขอบเขตเธรด ว่าจะจัดการความล้มเหลวของงานชิ้นเดียวอย่างไร (บันทึกแล้วไปต่อ หรือรายงานให้เจ้าของผ่านช่องข้อผิดพลาด)
ด้วยเหตุเดียวกัน สังเกตว่า Process ก็ได้รับ stop_token ด้วย หากการประมวลผลงานชิ้นหนึ่งบล็อกภายใน (รอเครือข่าย การคำนวณยาว เป็นต้น) และจุดนั้นสังเกตคำขอหยุดไม่ได้ การ join โดยนัยของดีสตรักเตอร์จะรอรายการนั้นจบตลอดกาล การหยุดแบบร่วมมือครบได้เมื่อโทเคนถึงทุกจุดที่รอ หากงานรวมการเรียกภายนอกที่ขัดจังหวะไม่ได้ ให้ติดหมดเวลาและใส่เพดานว่าหนึ่งรายการรันได้นานเท่าใด
ในสภาพแวดล้อมก่อน C++17 คุณสร้างรูปเดียวกันด้วยมือด้วยแฟล็กหยุด std::atomic<bool> บวก notify_all ของ condition_variable จุดสำคัญที่นี่คือพับการตรวจแฟล็กหยุดเข้าเพรดิเคตของตัวแปรเงื่อนไข — หากคุณแค่ยกแฟล็กแล้วลืมแจ้ง เธรดที่รอจะไม่ตื่นเลย
7. ข้อกังวลเฉพาะ Windows — ขอบกับ Win32 API
7.1. เลือกระหว่างไลบรารีมาตรฐานกับวัตถุซิงโครไนซ์ของ Win32
เอกสารของ Microsoft แนะนำ std::mutex / std::shared_mutex สำหรับโค้ด C++ ที่ให้ความสำคัญกับการพกพา และวางที่ยืนของวัตถุซิงโครไนซ์ Win32 เป็น «เมื่อต้องการ API รอของ Win32» และ «การซิงโครไนซ์ข้ามโพรเซส»8
| สถานการณ์ | ทางเลือก |
|---|---|
| การกีดกันภายในโพรเซสทั่วไป | std::mutex + RAII (ค่าเริ่มต้น) |
| อ่านมาก เขียนน้อย | std::shared_mutex |
รอหลายวัตถุพร้อมกันด้วย WaitForMultipleObjects |
วัตถุเคอร์เนล Win32 เช่น อีเวนต์และมิวเท็กซ์ |
| การกีดกัน / แจ้งข้ามโพรเซส | มิวเท็กซ์ อีเวนต์ เซมาฟอร์ที่มีชื่อ |
| ล็อกภายในโพรเซสด้วย Win32 API โดยตรง | ล็อก SRW (CRITICAL_SECTION เฉพาะเมื่อต้องเรียกซ้ำ)8 |
สำหรับการออกแบบรูปธรรมของการกีดกันการเข้าถึงหน่วยความจำร่วมข้ามโพรเซส ดู «กับดักของ Shared Memory และแนวปฏิบัติจริง»
7.2. อย่าแตะเธรดใน DllMain
ข้อจำกัดร้ายเมื่อเขียน DLL คือ loader lock DllMain ถูกเรียกขณะถือ loader lock ดังนั้นการดำเนินการข้างในอย่างการซิงโครไนซ์กับเธรดอื่น การรอเธรดจบ หรือการเรียก LoadLibrary ก่อเดดล็อกหรือพฤติกรรมคาดเดาไม่ได้ ย้ายการเริ่มต้นใดที่เริ่มหรือ join เธรดออกนอก DllMain ไปยังฟังก์ชันเริ่มต้นที่ชัดเจน13
7.3. เธรด UI และอพาร์ตเมนต์ COM
แอปเดสก์ท็อป Windows มีข้อจำกัดแข็งที่ใช้โดยไม่ขึ้นกับภาษา: เฉพาะเธรดที่สร้างหน้าต่างหรือคอนโทรล — เธรด UI — เท่านั้นที่แตะมันได้ Windows ส่งข้อความหน้าต่างไปยังคิวข้อความของเธรดที่สร้างหน้าต่างนั้น ดังนั้นการสร้างและจัดการ UI ต้องกระจุกบนเธรดนั้น เมื่อต้องการอัปเดตหน้าจอจากเธรดเวิร์กเกอร์ อย่าแตะโดยตรง — ขอเธรด UI ด้วย PostMessage (อะซิงโครนัส) และจัดการในโพรซีเยอร์หน้าต่างฝั่งเธรด UI การเรียกรูปซิงโครนัส SendMessage ขณะที่เธรด UI รอเวิร์กเกอร์นั้นจบ ก่อเดดล็อกที่แต่ละฝ่ายรออีกฝ่าย ดังนั้นให้รูปอะซิงโครนัสเป็นค่าเริ่มต้นสำหรับการแจ้งจากเวิร์กเกอร์ STA/MTA เมื่อมี COM เกี่ยวข้อง อยู่ใน «พื้นฐาน COM STA/MTA - โมเดลเธรดและวิธีหลีกเลี่ยงแฮง» และโปรดทราบว่าในโค้ด C++/CLI ที่คอมไพล์ด้วย /clr เฮดเดอร์เธรดมาตรฐานอย่าง <thread> และ <mutex> ถูกบล็อก14
8. การตรวจและการดีบัก — เตรียมบนสมมติว่าจะทำซ้ำไม่ได้
คุณพึ่งการทดสอบเพื่อหาบั๊กแข่งไม่ได้ เพราะการทดสอบธรรมดานับรอบที่ «บังเอิญไม่แข่ง» เป็นผ่าน คิดการเตรียมเป็นสามชั้น
แนวป้องกันแรกคือหลักการออกแบบที่ไล่มาจนถึงตรงนี้ ตามที่เป็น ในรีวิว ให้ยืนยันด้วยตาราง: ข้อมูลที่เปลี่ยนแปลงใดถูกแชร์ มิวเท็กซ์ใดปกป้องแต่ละชิ้น ลำดับการได้ล็อกหลายตัวไม่ซ้ำกันหรือไม่ (หรือหยิบพร้อมกันด้วย scoped_lock) และเส้นทางหยุดอยู่ที่ใด ดีไซน์ที่คุณเขียนตารางนี้ไม่ได้ยังไม่จบ ไม่ว่าตอนนี้จะรันดีเพียงใด
ประการที่สอง ทำให้สถานะผิดปกติสังเกตได้แทนการซ่อน ติดหมดเวลาด้วย try_lock_for ของ timed_mutex หรือ wait_for ของ condition_variable กับล็อกใดที่ไม่ควรล้มเหลวในการได้ และบันทึกหมดเวลาเป็นความผิดปกติ — นั่นเปลี่ยนแฮงตลอดกาลเป็นความล้มเหลวที่ตรวจพบได้ บันทึกข้อยกเว้นที่จับที่ try/catch ของขอบเขตเธรด (หมวด 6) เสมอ เมื่อเกิดแฮงหรือแครชในสนาม ให้จับดัมพ์ ตรวจสแต็กทุกเธรด และดูว่าการรอกล็อกของพวกมันเป็นวงหรือไม่ การจัดดัมพ์และล็อกอยู่ใน «ออกแบบแอป Windows ให้เหลือล็อกและดัมป์เมื่อแครช»
ประการที่สาม เขย่าภายใต้ภาระ การรันนานด้วยความเป็นขนานมากกว่าจำนวนคอร์ การสุ่มลำดับการประมวลผล และการฉีดความล่าช้าเทียม คือเทคนิคทดสอบความเค้นเชิงปฏิบัติที่ทำให้ «แจ็กพอต» ของการแข่งเกิดง่ายขึ้นบนเครื่องพัฒนา บั๊กที่หายในบิลด์ดีบักมักทำซ้ำได้ง่ายในบิลด์รีลีสที่ออปติไมซ์ภายใต้ภาระหนัก
9. สรุป — รายการตรวจของ C++
วางการตรวจเฉพาะ C++ ซ้อนบนหลักร่วมของทุกภาษา: อย่าสร้างเธรดโดยตรง ลดสถานะที่เปลี่ยนแปลงร่วมกันให้เหลือน้อย ความสอดคล้องหนึ่งต่อหนึ่งระหว่างล็อกกับข้อมูล และการหยุดแบบร่วมมือ
- มีการใช้
std::threadเปล่าหรือไม่ (เป็นjthreadได้ไหม? join รับประกันแม้บนเส้นทางข้อยกเว้นหรือไม่?) - ไม่ได้ใช้
detach()หรือไม่? - การจับแลมบ์ดาชัดเจนหรือไม่ และตัวแปรที่จับตามเรเฟอเรนซ์มีอายุยืนกว่าเธรดหรือไม่?
- คุณพูดได้อย่างมั่นใจหรือไม่ว่าไม่มีการเข้าถึงที่เปลี่ยนแปลงร่วมกันโดยไม่ซิงโครไนซ์แม้จุดเดียว (= พฤติกรรมไม่กำหนด)?
- ไม่มีการเขียน
lock()/unlock()ด้วยมือ และล็อกหลายตัวถูกหยิบพร้อมกันด้วยscoped_lockหรือไม่? - ทุก
condition_variable::waitถูกใช้พร้อมเพรดิเคตหรือไม่? - ไม่ได้ใช้
volatileเป็นแฟล็กร่วม (ใช้std::atomicแทน) หรือไม่? - เส้นทางหยุดถูกออกแบบรอบ
stop_token(หรือแฟล็กอะตอมิกบวกการแจ้ง) โดยการ join เสร็จยืนยันการนัดพบหรือไม่? - ไม่ได้ทิ้ง
futureจากstd::asyncหรือไม่? DllMainปราศจากการเริ่ม ซิงโครไนซ์ หรือ join เธรดหรือไม่?
C++ มัลติเธรดคืองานที่เดินชิดขอบเหวของพฤติกรรมไม่กำหนด แต่พลิกแล้วหมายความว่าการเดินตาม RAII และแบบแผนของไลบรารีมาตรฐานอย่างตรงไปตรงมา ได้ระยะจริงจากขอบนั้น jthread scoped_lock wait รูปเพรดิเคต atomic — การเลือกค่าเริ่มต้นที่ถูกในเครื่องมือเหล่านี้ คือใน C++ การปฏิบัติหลักการออกแบบนั้นเอง
บทความที่เกี่ยวข้อง
- แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ .NET — สิ่งที่ต้องตัดสินก่อนเพิ่มเธรด
- แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C — เขียนอย่างปลอดภัยแบบ Win32 API
- แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ Java — แบบแผนยุคเธรดเสมือน
- เรียก Native DLL จาก C#: แรปเปอร์ C++/CLI กับ P/Invoke
- กับดักของ Shared Memory และแนวปฏิบัติจริง
- พื้นฐาน COM STA/MTA - โมเดลเธรดและวิธีหลีกเลี่ยงแฮง
- เชิงลึกของ Windows I/O (ตอนที่ 2) — I/O แบบซิงโครนัสและอะซิงโครนัส: ความหมายที่แท้จริงของ OVERLAPPED
ด้านให้คำปรึกษาที่เกี่ยวข้อง
KomuraSoft LLC รับรีวิวการออกแบบมัลติเธรดสำหรับแอปและ DLL ที่เขียนด้วย C++ การสอบสวนสาเหตุราก (วิเคราะห์ดัมพ์) ของบั๊กเงื่อนไขแข่งขันอย่าง «แครชเป็นครั้งคราว» หรือ «เพี้ยนเฉพาะบิลด์รีลีส» และการให้คำปรึกษาเรื่องการย้ายโค้ดเธรดเดิมไป C++ สมัยใหม่
ลิงก์อ้างอิง
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. ว่าด้วย CP.1 (สมมติว่าโค้ดของคุณจะรันเป็นส่วนของโปรแกรมมัลติเธรด) และ CP.2 (เลี่ยง data race) ที่ถูกวางเป็นกฎเปิดของบท concurrency และ parallelism ว่าด้วยการที่ไม่มีหลักประกันใดเหลือเมื่อมี data race และว่าด้วยกฎออกแบบของโค้ดพร้อมกัน — ขอบเขตที่ถือล็อก การใช้ RAII เป็นต้น — ที่ถูกจัดระบบที่นั่น ↩ ↩2
-
cppreference.com, std::jthread. ว่าด้วย jthread ของ C++20 ที่ต่างจาก std::thread เพราะดีสตรักเตอร์เรียก request_stop() แล้วจึง join โดยอัตโนมัติ ว่าด้วยการรับ std::stop_token เป็นอาร์กิวเมนต์นำของฟังก์ชันเธรด และว่าด้วยสิ่งนี้ที่รับประกันทั้ง join และคำขอหยุดแม้เมื่อมีข้อยกเว้น ↩ ↩2
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. ว่าด้วย P0660R10 (<stop_token> และ jthread) และ P1135R6 (ไลบรารีซิงโครไนซ์ C++20) ที่รองรับตั้งแต่ Visual Studio 2019 16.9 และสถานะการรองรับตามรุ่นของฟีเจอร์ไลบรารีมาตรฐาน C++ ↩ ↩2 ↩3
-
Microsoft Learn, scoped_lock Class. ว่าด้วย scoped_lock ของ C++17 ที่ได้มิวเท็กซ์หนึ่งตัวหรือมากกว่าตอนสร้างและปล่อยในดีสตรักเตอร์ ว่าด้วยหลายมิวเท็กซ์เมื่อส่งพร้อมกันจะได้ด้วยอัลกอริทึมเลี่ยงเดดล็อกเทียบเท่า std::lock ว่าด้วยการปล่อยอย่างน่าเชื่อถือแม้มีข้อยกเว้น และว่าด้วย lock_guard/unique_lock ที่เป็นทางเลือกเมื่อมีมิวเท็กซ์เดียว ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. ว่าด้วยการดำเนินการอะตอมิกที่แบ่งไม่ได้ ดังนั้นเธรดอื่นสังเกตได้เฉพาะสถานะก่อนหรือหลังการดำเนินการ ว่าด้วยการตั้งข้อกำหนดลำดับต่อการมองเห็นของการดำเนินการอะตอมิกอื่นตามอาร์กิวเมนต์ memory_order และการกดการออปติไมซ์ของคอมไพเลอร์ที่จะละเมิด ว่าด้วย atomic_flag ที่ไร้ล็อกเสมอ และว่าด้วยเฮดเดอร์นี้ที่ถูกบล็อกภายใต้ /clr:pure ↩ ↩2
-
Microsoft Learn, <condition_variable>. ว่าด้วยการรอตัวแปรเงื่อนไขที่ต้องมีมิวเท็กซ์ โดยล็อกถูกปล่อยระหว่างรอ ว่าด้วยการปลุกลวงที่มีอยู่ — ตื่นโดยไม่มีการแจ้ง — ดังนั้นฝั่งที่ควรรีตรวจเงื่อนไขเมื่อกลับ และรูปเพรดิเคต wait(lock, pred) รันลูปนั้นแทนคุณ และว่าด้วย condition_variable_any ที่รวมกับชนิดมิวเท็กซ์ใดก็ได้ ↩ ↩2
-
Microsoft Learn, <future>. ว่าด้วยดีสตรักเตอร์ของ future และ shared_future ที่โดยหลักไม่บล็อก โดยข้อยกเว้นเดียวคือ future (หรือ shared_future ตัวสุดท้าย) ที่ผูกกับงานที่ปล่อยด้วย std::async จะบล็อกจนสถานะร่วมพร้อมหากดีสตรักเตอร์ทำงานขณะงานยังไม่จบ — พฤติกรรมที่มาตรฐานระบุชัด ↩ ↩2
-
Microsoft Learn, About Synchronization. ว่าด้วยแนวทางเลือกปริมิทีฟซิงโครไนซ์ของ Win32: std::mutex / std::shared_mutex และ RAII ถูกแนะนำสำหรับโค้ด C++ ที่ให้ความสำคัญกับการพกพา วัตถุซิงโครไนซ์ Win32 ใช้เมื่อต้องการ API รอของ Win32 หรือการซิงโครไนซ์ข้ามโพรเซส ค่าเริ่มต้นของโค้ดภายในโพรเซสใหม่คือล็อก SRW โดย CRITICAL_SECTION สงวนไว้เมื่อต้องได้ซ้ำแบบเรียกซ้ำ และการใช้ Mutex เพื่อซิงโครไนซ์ภายในโพรเซสเป็น «ความผิดทั่วไป» เพราะมีการสลับเข้าเคอร์เนลเสมอ ↩ ↩2 ↩3
-
cppreference.com, std::thread::~thread. ว่าด้วยดีสตรักเตอร์ของ std::thread ที่เรียก std::terminate หากถูกเรียกขณะเธรดยัง joinable (ยังไม่ถูก join และยังไม่ถูก detach) — คือการตัดสินใจ join หรือ detach ต้องปิดก่อนอ็อบเจกต์เธรดถูกทำลาย โดยไม่มีข้อยกเว้น ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. ว่าด้วยความเป็นขนานที่ควรแสดงที่ระดับสูงที่สุดเท่าที่ได้ (ลูปนอก) ว่าด้วยค่าโสหุ้ยการจัดตาราง fork/join ที่อาจแซงกำไรของการรันขนานในลูปขนานที่งานแต่ละรอบเล็กหรือไม่สมดุล และว่าด้วยแนวโน้มนั้นที่แข็งขึ้นเมื่อจำนวนโปรเซสเซอร์เพิ่ม ↩
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. ว่าด้วยไลบรารีอัลกอริทึมขนานของ C++17 ที่ครบ ขณะที่ «ครบ» ไม่ได้หมายความว่าทุกอัลกอริทึมถูกขนานในทุกกรณี และนโยบายอิมพลีเมนต์ที่ขนานอัลกอริทึมสำคัญที่สุดขณะยังให้ลายเซ็นนโยบายการรันแก่อัลกอริทึมที่ไม่ได้ถูกขนาน ↩
-
Microsoft Learn, C++ standard library header files. ว่าด้วยเฮดเดอร์มาตรฐานที่เกี่ยวกับมัลติเธรดที่จัดเป็น <atomic> (C++11) <mutex> (C++11) <shared_mutex> (C++14) <condition_variable> (C++11) <future> (C++11) <stop_token> / <semaphore> / <latch> / <barrier> (C++20) และ <thread> (C++11) ↩
-
Microsoft Learn, Dynamic-Link Library Best Practices. ว่าด้วย DllMain ที่ถูกเรียกขณะถือ loader lock ซึ่งใส่ข้อจำกัดร้ายต่อ API ที่เรียกได้ ว่าด้วยการซิงโครไนซ์กับเธรดอื่นใน DllMain ที่เดดล็อกได้ ว่าด้วยการเรียก LoadLibrary หรือรอเธรดจบเป็นการกระทำต้องห้ามทั่วไป ว่าด้วยการเริ่มต้นที่ควรเลื่อนให้ไกลที่สุดแล้วออกนอก DllMain และว่าด้วยการนิยามลำดับชั้นล็อกโดยวาง loader lock ไว้บนสุด ↩
-
Microsoft Learn, <thread>. ว่าด้วยเฮดเดอร์ <thread> ที่นิยามคลาส thread และฟังก์ชันช่วยอย่าง sleep_for ว่าด้วยเฮดเดอร์นี้ที่ถูกบล็อกในโค้ดที่คอมไพล์ด้วย /clr และว่าด้วยแมโคร STDCPP_THREADS ที่ให้คุณตรวจว่ามีการรองรับเธรดหรือไม่ ↩
บทความที่เกี่ยวข้อง
บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง
แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C — เขียนอย่างปลอดภัยแบบ Win32 API
แนวทางที่ลงตัวของมัลติเธรดใน C กับ Win32 คือสร้างเธรดด้วย _beginthreadex, ล็อก SRW และตัวแปรเงื่อนไข, ฟังก์ชัน Interlocked และการออกแบบหย...
แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ Java — แบบแผนยุคเธรดเสมือน
ใน Java แนวปฏิบัติที่ลงตัวของมัลติเธรดคือไม่สร้างเธรดเอง แต่พึ่ง ExecutorService กับเธรดเสมือน บทความนี้เรียบเรียงหลักปฏิบัติ: เลือกระหว่...
Win32 Thread Pool API — คอนเคอร์เรนซีโดยไม่สร้างเธรดเอง ด้วย CreateThreadpoolWork
โค้ดเนทีฟของคุณกระจายการเรียก CreateThread ไปทั่วหรือไม่ บทความนี้อธิบาย Win32 thread pool API ที่ออกแบบใหม่ใน Vista — ออบเจ็กต์สี่ชนิด w...
DllMain และ Loader Lock — เหตุผลจริงที่ถูกบอกให้ "อย่าทำอะไรในการเริ่มต้น DLL"
ทำไมคุณต้องไม่เรียก LoadLibrary หรือซิงโครไนซ์กับเธรดอื่นจาก DllMain บทความนี้อธิบายจากแหล่งปฐมภูมิว่า loader lock ทำให้การแจ้ง DLL ทุกคร...
Spurious Wakeup — ทำไม condition variable จึงตื่น "โดยไม่ถูกแจ้ง" และวิธีรออย่างถูกต้องบน Windows
การรอของ condition variable สามารถกลับได้แม้ไม่มีการแจ้งมาถึง (spurious wakeup) บทความนี้อธิบายจากการอิมพลีเมนต์ของ Windows ว่าทำไมข้อกำห...
หัวข้อที่เกี่ยวข้อง
หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ
หัวข้อเทคนิคของ Windows
ประตูสู่การพัฒนา Windows การตรวจสอบบั๊ก และการใช้ประโยชน์จากสินทรัพย์เดิม
บริการที่เกี่ยวข้องกับหัวข้อนี้
บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้
พัฒนาแอป Windows
แอปธุรกิจ การเชื่อมต่ออุปกรณ์ และเครื่องมือสื่อสาร ตั้งแต่ความต้องการจนถึงการพัฒนา
คำถามที่พบบ่อย
คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้
- ควรเลือก std::mutex หรือ CRITICAL_SECTION / ล็อก SRW ของ Win32 อย่างไร?
- สำหรับโค้ด C++ ทั่วไปที่ให้ความสำคัญกับการพกพา std::mutex / std::shared_mutex คู่กับแรปเปอร์ RAII (lock_guard / scoped_lock) คือทางเลือกแรก หันไปใช้วัตถุซิงโครไนซ์ของ Win32 เมื่อต้องรวมกับการรอแบบ Win32 เช่น WaitForMultipleObjects หรือเมื่อต้องซิงโครไนซ์ข้ามโพรเซสผ่านวัตถุที่มีชื่อ หากใช้ Win32 API โดยตรงภายในโพรเซส ค่าเริ่มต้นของโค้ดใหม่คือล็อก SRW และใช้ CRITICAL_SECTION เฉพาะเมื่อเธรดเดียวกันต้องได้ล็อกซ้ำแบบเรียกซ้ำ การใช้ Mutex ของ Win32 เพื่อกีดกันภายในโพรเซสเป็นความผิดคลาสสิก เพราะมีการสลับเข้าเคอร์เนลเสมอและช้าตามนั้น
- ใช้ detach() ของ std::thread ได้หรือไม่?
- โดยหลักแล้วหลีกเลี่ยง เธรดที่ถูก detach สูญเสียทุกทางที่จะถูก join และคุณเสียการควบคุมว่ามันยังรันอยู่หรือไม่เมื่อโพรเซสออก นี่คืออุบัติเหตุคลาสสิก: เธรดที่ถูก detach รันต่อหลังจากตัวแปรสแตติกหรือฮีปถูกทำลายแล้ว จึงทำให้แครชตอนปิด การรอให้เธรดจบได้เป็นข้อกำหนดพื้นฐานของการออกแบบเธรด ดังนั้นใช้ jthread (ซึ่ง join อัตโนมัติ) หรือถ้าใช้ thread ให้จัดโครงสร้างให้ join เสมอก่อนขอบเขตจบ detach อนุญาตเฉพาะสถานการณ์แคบที่เธรดแบ่งชะตากรรมกับโพรเซสได้ และคุณรับประกันได้ว่ามันไม่แตะสถานะร่วมเลย
- ใน C++ ใช้ volatile เพื่อซิงโครไนซ์ระหว่างเธรดได้หรือไม่?
- ไม่ได้ volatile ของ C++ เป็นตัวระบุสำหรับการอ่านเขียนที่คุณไม่ต้องการให้คอมไพเลอร์ออปติไมซ์ทิ้ง เช่น I/O ที่แมปหน่วยความจำ และไม่รับประกันการมองเห็นหรือลำดับระหว่างเธรด หากหลายเธรดเข้าถึงตัวแปรเดียวกันโดยไม่ซิงโครไนซ์ นั่นคือ data race และเป็นพฤติกรรมไม่กำหนด ใช้ std::atomic สำหรับแฟล็กและตัวนับที่แชร์ระหว่างเธรด และ std::mutex เมื่อต้องปกป้องหลายตัวแปรพร้อมกัน std::atomic ให้ทั้งความเป็นอะตอมิกของการดำเนินการและลำดับตาม memory_order
- std::async ดูสะดวก แต่มีกับดักไหม?
- กับดักที่ใหญ่ที่สุดคือดีสตรักเตอร์ของ future future (หรือ shared_future ตัวสุดท้าย) ที่ผูกกับงานที่ปล่อยด้วย std::async จะบล็อกจนเสร็จหากดีสตรักเตอร์ทำงานขณะงานยังไม่จบ หากคุณทิ้ง future ที่คืนมาโดยไม่ถือไว้ นั่นเทียบเท่าการรันแบบซิงโครนัส ณ จุดนั้น — อุบัติเหตุที่ตั้งใจอะซิงโครนัสแต่กลายเป็นอนุกรม นอกจากนี้เมื่อไม่ระบุนโยบายปล่อย ว่างานจะรันบนเธรดแยกจริงหรือไม่ขึ้นกับดุลยพินิจของการอิมพลีเมนต์ หากใช้ ให้จัดการอายุขัยของ future อย่างชัดเจน และระบุ std::launch::async ทุกที่ที่ต้องรับประกันการรันพร้อมกัน