แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C++ — ตัดอุบัติเหตุด้วยโครงสร้างด้วย RAII และ jthread

· · Windows, มัลติเธรด, C++, Visual Studio, แอปธุรกิจ, การตรวจสอบบั๊ก, การออกแบบ

«ดีไซน์ที่ใช้ได้ใน 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 ทำงานขณะเธรดยัง joinable std::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 แตกที่ระดับรหัสเครื่องเป็นสามขั้น — อ่าน บวก เขียนกลับ หากสองเธรดเข้าสามขั้นนั้นพร้อมกัน การบวกของเธรดหนึ่งถูกเขียนทับและหายไปโดยการเขียนกลับของอีกฝ่าย ผลเปลี่ยนจากรอบสู่รอบ และผลที่คุณได้คาดเดาไม่ได้

เธรด Bตัวแปรร่วม countเธรด Aเธรด Bตัวแปรร่วม countเธรด Acount = 10เพิ่มสองครั้งเกิดแล้วแต่ count = 11 — การบวกของเธรด A หายไปอ่าน (10)อ่าน (10)บวกในท้องถิ่น (11)บวกในท้องถิ่น (11)เขียนกลับ (11)เขียนกลับ (11)

ภาพ 1: เงื่อนไขแข่งขันคลาสสิกที่การเพิ่มบนตัวนับร่วมหายไป หากเธรดอื่นแทรกกลางสามขั้นของ ++count การเขียนกลับครั้งหลังทับอีกฝ่าย

เดดล็อก คือสถานะที่สองเธรดแต่ละฝ่ายรอกล็อกที่อีกฝ่ายถืออยู่ จึงไม่มีใครเดินต่อได้ เธรด A ถือกล็อก 1 และรอกล็อก 2 เธรด B ถือกล็อก 2 และรอกล็อก 1 — แค่นั้นพอให้ทั้งคู่หยุดตลอดกาล

รอได้กล็อก 2รอได้กล็อก 1เธรด Aถือกล็อก 1เธรด Bถือกล็อก 2

ภาพ 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

std::threadยังไม่ถูก join และยังไม่ถูก detachstd::threadถูก join แล้วstd::jthread - C++20เธรดถูกเริ่มแล้วเกิดอะไรขึ้นเมื่อขอบเขตจบ?std::terminateโพรเซสตายทันทีjoin อย่างปลอดภัยrequest_stop + join อัตโนมัติปลอดภัยแม้มีข้อยกเว้น

ภาพ 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_;
};
คำขอหยุดฝั่งที่หยุด- ดีสตรักเตอร์ของ jthread หรือ request_stopstop_tokenลูปคำนวณ:สำรวจ stop_requested()เธรดที่รอ:condition_variable_any::wait(lock, st, pred)ตื่นทันทีทำความสะอาดแล้วคืนเองjoin ทำให้นัดพบเสร็จตอนนี้เท่านั้นที่เรียกว่าหยุดแล้ว

ภาพ 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++ ซ้อนบนหลักร่วมของทุกภาษา: อย่าสร้างเธรดโดยตรง ลดสถานะที่เปลี่ยนแปลงร่วมกันให้เหลือน้อย ความสอดคล้องหนึ่งต่อหนึ่งระหว่างล็อกกับข้อมูล และการหยุดแบบร่วมมือ

  1. มีการใช้ std::thread เปล่าหรือไม่ (เป็น jthread ได้ไหม? join รับประกันแม้บนเส้นทางข้อยกเว้นหรือไม่?)
  2. ไม่ได้ใช้ detach() หรือไม่?
  3. การจับแลมบ์ดาชัดเจนหรือไม่ และตัวแปรที่จับตามเรเฟอเรนซ์มีอายุยืนกว่าเธรดหรือไม่?
  4. คุณพูดได้อย่างมั่นใจหรือไม่ว่าไม่มีการเข้าถึงที่เปลี่ยนแปลงร่วมกันโดยไม่ซิงโครไนซ์แม้จุดเดียว (= พฤติกรรมไม่กำหนด)?
  5. ไม่มีการเขียน lock() / unlock() ด้วยมือ และล็อกหลายตัวถูกหยิบพร้อมกันด้วย scoped_lock หรือไม่?
  6. ทุก condition_variable::wait ถูกใช้พร้อมเพรดิเคตหรือไม่?
  7. ไม่ได้ใช้ volatile เป็นแฟล็กร่วม (ใช้ std::atomic แทน) หรือไม่?
  8. เส้นทางหยุดถูกออกแบบรอบ stop_token (หรือแฟล็กอะตอมิกบวกการแจ้ง) โดยการ join เสร็จยืนยันการนัดพบหรือไม่?
  9. ไม่ได้ทิ้ง future จาก std::async หรือไม่?
  10. DllMain ปราศจากการเริ่ม ซิงโครไนซ์ หรือ join เธรดหรือไม่?

C++ มัลติเธรดคืองานที่เดินชิดขอบเหวของพฤติกรรมไม่กำหนด แต่พลิกแล้วหมายความว่าการเดินตาม RAII และแบบแผนของไลบรารีมาตรฐานอย่างตรงไปตรงมา ได้ระยะจริงจากขอบนั้น jthread scoped_lock wait รูปเพรดิเคต atomic — การเลือกค่าเริ่มต้นที่ถูกในเครื่องมือเหล่านี้ คือใน C++ การปฏิบัติหลักการออกแบบนั้นเอง

บทความที่เกี่ยวข้อง

ด้านให้คำปรึกษาที่เกี่ยวข้อง

KomuraSoft LLC รับรีวิวการออกแบบมัลติเธรดสำหรับแอปและ DLL ที่เขียนด้วย C++ การสอบสวนสาเหตุราก (วิเคราะห์ดัมพ์) ของบั๊กเงื่อนไขแข่งขันอย่าง «แครชเป็นครั้งคราว» หรือ «เพี้ยนเฉพาะบิลด์รีลีส» และการให้คำปรึกษาเรื่องการย้ายโค้ดเธรดเดิมไป C++ สมัยใหม่

ลิงก์อ้างอิง

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. ว่าด้วย CP.1 (สมมติว่าโค้ดของคุณจะรันเป็นส่วนของโปรแกรมมัลติเธรด) และ CP.2 (เลี่ยง data race) ที่ถูกวางเป็นกฎเปิดของบท concurrency และ parallelism ว่าด้วยการที่ไม่มีหลักประกันใดเหลือเมื่อมี data race และว่าด้วยกฎออกแบบของโค้ดพร้อมกัน — ขอบเขตที่ถือล็อก การใช้ RAII เป็นต้น — ที่ถูกจัดระบบที่นั่น  2

  2. cppreference.com, std::jthread. ว่าด้วย jthread ของ C++20 ที่ต่างจาก std::thread เพราะดีสตรักเตอร์เรียก request_stop() แล้วจึง join โดยอัตโนมัติ ว่าด้วยการรับ std::stop_token เป็นอาร์กิวเมนต์นำของฟังก์ชันเธรด และว่าด้วยสิ่งนี้ที่รับประกันทั้ง join และคำขอหยุดแม้เมื่อมีข้อยกเว้น  2

  3. 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

  4. Microsoft Learn, scoped_lock Class. ว่าด้วย scoped_lock ของ C++17 ที่ได้มิวเท็กซ์หนึ่งตัวหรือมากกว่าตอนสร้างและปล่อยในดีสตรักเตอร์ ว่าด้วยหลายมิวเท็กซ์เมื่อส่งพร้อมกันจะได้ด้วยอัลกอริทึมเลี่ยงเดดล็อกเทียบเท่า std::lock ว่าด้วยการปล่อยอย่างน่าเชื่อถือแม้มีข้อยกเว้น และว่าด้วย lock_guard/unique_lock ที่เป็นทางเลือกเมื่อมีมิวเท็กซ์เดียว  2 3

  5. Microsoft Learn, <atomic>. ว่าด้วยการดำเนินการอะตอมิกที่แบ่งไม่ได้ ดังนั้นเธรดอื่นสังเกตได้เฉพาะสถานะก่อนหรือหลังการดำเนินการ ว่าด้วยการตั้งข้อกำหนดลำดับต่อการมองเห็นของการดำเนินการอะตอมิกอื่นตามอาร์กิวเมนต์ memory_order และการกดการออปติไมซ์ของคอมไพเลอร์ที่จะละเมิด ว่าด้วย atomic_flag ที่ไร้ล็อกเสมอ และว่าด้วยเฮดเดอร์นี้ที่ถูกบล็อกภายใต้ /clr:pure  2

  6. Microsoft Learn, <condition_variable>. ว่าด้วยการรอตัวแปรเงื่อนไขที่ต้องมีมิวเท็กซ์ โดยล็อกถูกปล่อยระหว่างรอ ว่าด้วยการปลุกลวงที่มีอยู่ — ตื่นโดยไม่มีการแจ้ง — ดังนั้นฝั่งที่ควรรีตรวจเงื่อนไขเมื่อกลับ และรูปเพรดิเคต wait(lock, pred) รันลูปนั้นแทนคุณ และว่าด้วย condition_variable_any ที่รวมกับชนิดมิวเท็กซ์ใดก็ได้  2

  7. Microsoft Learn, <future>. ว่าด้วยดีสตรักเตอร์ของ future และ shared_future ที่โดยหลักไม่บล็อก โดยข้อยกเว้นเดียวคือ future (หรือ shared_future ตัวสุดท้าย) ที่ผูกกับงานที่ปล่อยด้วย std::async จะบล็อกจนสถานะร่วมพร้อมหากดีสตรักเตอร์ทำงานขณะงานยังไม่จบ — พฤติกรรมที่มาตรฐานระบุชัด  2

  8. Microsoft Learn, About Synchronization. ว่าด้วยแนวทางเลือกปริมิทีฟซิงโครไนซ์ของ Win32: std::mutex / std::shared_mutex และ RAII ถูกแนะนำสำหรับโค้ด C++ ที่ให้ความสำคัญกับการพกพา วัตถุซิงโครไนซ์ Win32 ใช้เมื่อต้องการ API รอของ Win32 หรือการซิงโครไนซ์ข้ามโพรเซส ค่าเริ่มต้นของโค้ดภายในโพรเซสใหม่คือล็อก SRW โดย CRITICAL_SECTION สงวนไว้เมื่อต้องได้ซ้ำแบบเรียกซ้ำ และการใช้ Mutex เพื่อซิงโครไนซ์ภายในโพรเซสเป็น «ความผิดทั่วไป» เพราะมีการสลับเข้าเคอร์เนลเสมอ  2 3

  9. cppreference.com, std::thread::~thread. ว่าด้วยดีสตรักเตอร์ของ std::thread ที่เรียก std::terminate หากถูกเรียกขณะเธรดยัง joinable (ยังไม่ถูก join และยังไม่ถูก detach) — คือการตัดสินใจ join หรือ detach ต้องปิดก่อนอ็อบเจกต์เธรดถูกทำลาย โดยไม่มีข้อยกเว้น 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. ว่าด้วยความเป็นขนานที่ควรแสดงที่ระดับสูงที่สุดเท่าที่ได้ (ลูปนอก) ว่าด้วยค่าโสหุ้ยการจัดตาราง fork/join ที่อาจแซงกำไรของการรันขนานในลูปขนานที่งานแต่ละรอบเล็กหรือไม่สมดุล และว่าด้วยแนวโน้มนั้นที่แข็งขึ้นเมื่อจำนวนโปรเซสเซอร์เพิ่ม 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. ว่าด้วยไลบรารีอัลกอริทึมขนานของ C++17 ที่ครบ ขณะที่ «ครบ» ไม่ได้หมายความว่าทุกอัลกอริทึมถูกขนานในทุกกรณี และนโยบายอิมพลีเมนต์ที่ขนานอัลกอริทึมสำคัญที่สุดขณะยังให้ลายเซ็นนโยบายการรันแก่อัลกอริทึมที่ไม่ได้ถูกขนาน 

  12. 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) 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. ว่าด้วย DllMain ที่ถูกเรียกขณะถือ loader lock ซึ่งใส่ข้อจำกัดร้ายต่อ API ที่เรียกได้ ว่าด้วยการซิงโครไนซ์กับเธรดอื่นใน DllMain ที่เดดล็อกได้ ว่าด้วยการเรียก LoadLibrary หรือรอเธรดจบเป็นการกระทำต้องห้ามทั่วไป ว่าด้วยการเริ่มต้นที่ควรเลื่อนให้ไกลที่สุดแล้วออกนอก DllMain และว่าด้วยการนิยามลำดับชั้นล็อกโดยวาง loader lock ไว้บนสุด 

  14. 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 ว่าทำไมข้อกำห...

หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ

บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้

คำถามที่พบบ่อย

คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้

ควรเลือก 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 ทุกที่ที่ต้องรับประกันการรันพร้อมกัน

โปรไฟล์ผู้เขียน

หน้าแนะนำผู้เขียนบทความ

Go Komura

ผู้แทนของ KomuraSoft LLC

เชี่ยวชาญด้านการพัฒนาซอฟต์แวร์ Windows ที่ปรึกษาเทคนิค และการตรวจสอบบั๊ก โดยเฉพาะโปรเจกต์ที่มีระบบเดิมและบั๊กที่ทำซ้ำได้ยาก

ลิงก์สาธารณะ

กลับไปยังบล็อก