แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C — เขียนอย่างปลอดภัยแบบ Win32 API

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

«ผมกำลังเขียนโพรเซสประจำเครื่องสำหรับควบคุมอุปกรณ์ด้วย C» «เราต้องเพิ่มเธรดให้แอป C อายุยี่สิบปี» «เราหยุดเธรดด้วย TerminateThread แต่ทั้งโพรเซสค้างเป็นครั้งคราว» — มัลติเธรดใน C คือโลกที่ภาษาช่วยน้อยที่สุด ไม่มีข้อยกเว้น ไม่มี RAII ไม่มีเทมเพลต ความถูกต้องของการซิงโครไนซ์วางทั้งก้อนบน API ที่เลือกและความมีวินัยในการเรียก

บทความนี้คือ ฉบับ C ของซีรีส์มัลติเธรดเชิงปฏิบัติ มุ่งนักพัฒนาที่เขียน C ต่อ Win32 API นำหลักการออกแบบมัลติเธรด — หยุดผลิตเธรดเป็นกอง ลดสถานะเปลี่ยนที่ใช้ร่วม รักษาวินัยล็อก และออกแบบวิธีหยุดก่อนออกแบบวิธีเริ่ม — แล้วแมปลงเครื่องมือ Win32: วิธีสร้างเธรด (_beginthreadex) วิธีเลือกอ็อบเจกต์ซิงโครไนซ์ วิธีออกแบบเส้นทางหยุดโดยไม่ใช้ TerminateThread และข้อจำกัดของ DllMain จัดรอบแหล่งปฐมภูมิ ณ สิงหาคม 2026 เขียนให้อ่านเดี่ยวได้ หลักการเดียวกันที่คลี่สำหรับภาษาอื่นมีใน ฉบับ .NET ฉบับ C++ และ ฉบับ Java

1. สรุปก่อนเลย

  • สร้างเธรดด้วย _beginthreadex ไม่ใช่ CreateThread ถ้าเธรดที่เรียก CRT ถูกสร้างด้วย CreateThread CRT อาจจบโพรเซสเมื่อหน่วยความจำต่ำ12
  • ล็อกปริยายในโพรเซสคือล็อก SRW ใช้ CRITICAL_SECTION เฉพาะเมื่อต้องได้มาแบบเรียกซ้ำ การใช้ Mutex สำหรับการกีดกันในโพรเซสคือ «ความผิดพลาดทั่วไป» ที่มักมีการเปลี่ยนเข้าโหมดเคอร์เนลเสมอ3
  • อัปเดตตัวแปรเดี่ยวด้วยตระกูล Interlocked volatile ไม่รับประกันทั้งความเป็นอะตอมและลำดับ ฟังก์ชัน Interlocked ส่วนใหญ่พกกำแพงหน่วยความจำเต็ม4
  • รอด้วยตัวแปรเงื่อนไข (ตระกูล SleepConditionVariableCS) หรือเหตุการณ์บวกฟังก์ชันรอ ลูปโพลที่ใช้ Sleep เปลืองทั้ง CPU และความตอบสนอง5
  • อย่าใช้ TerminateThread เด็ดขาด เป็นฟังก์ชันอันตรายที่ทำให้ล็อก ฮีป และสถานะ DLL เสียหาย และเป็นเป้าคำเตือนวิเคราะห์โค้ด C6258 ออกแบบการหยุดเป็นการปิดแบบร่วมมือ: เหตุการณ์หยุดบวก WaitForMultipleObjects67
  • ส่งงานสั้นให้พูลเธรด Windows (CreateThreadpoolWork) แทนการทำขนานด้วยเธรดของตัวเอง อย่าจบเธรดพูลด้วย ExitThread / TerminateThread เด็ดขาด89
  • อย่าสร้างเธรด ซิงโครไนซ์ หรือรอเธรดจบใน DllMain ถูกเรียกขณะถือล็อกโหลดเดอร์ จึงเป็นแหล่งเพาะเดดล็อก10
  • <threads.h> ของ C11 ใช้ได้จาก VS 2022 17.8 เป็นต้นไป แต่ <stdatomic.h> ยังทดลอง สำหรับโค้ดเบสเฉพาะ Windows แนว Win32 คือทางเลือกที่เป็นจริง11

2. ทำไมมัลติเธรดยาก? — เรซคอนดิชันและเดดล็อก

ต้มปัญหาที่มัลติเธรดนำเข้าโดยไม่สนภาษา เหลือสองชนิด

เรซคอนดิชัน คือบั๊กที่ผลเปลี่ยนตามลำดับที่หลายเธรดถึงชิ้นโค้ด ตัวอย่างคลาสสิกคือตัวนับที่ใช้ร่วม: นิพจน์เดียว count++ แตกที่ระดับรหัสเครื่องเป็นสามขั้น — อ่าน บวก เขียนกลับ ถ้าสองเธรดเข้าสามขั้นนี้พร้อมกัน การบวกของเธรดหนึ่งถูกเขียนทับและหายเมื่ออีกเธรดเขียนกลับ4 ผลเปลี่ยนจากรันหนึ่งไปอีกรัน และผลใดที่คุณได้คาดไม่ได้

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

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

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

รอการปล่อยล็อก 2รอการปล่อยล็อก 1เธรด Aถือล็อก 1เธรด Bถือล็อก 2

ภาพ 2: การรอเป็นวงของเดดล็อก วินาทีที่ลูกศรรอเป็นวง ทุกเธรดในวงนั้นหยุดตลอดกาล

ทั้งคู่ขึ้นกับจังหวะ: ชุดลำดับการทำงานที่เครื่องพัฒนาเจอครั้งในหลายหมื่นรันอาจเกิดทุกวันบนเครื่องลูกค้าที่จำนวนคอร์และจังหวะต่าง «ไม่ทำซ้ำเมื่อติดดีบักเกอร์» เกิดเพราะการสังเกตเองเปลี่ยนจังหวะ — พฤติกรรมแบบบั๊กรace ดังนั้นทุกหลักในบทความนี้ชี้ทิศเดียว: ลดจุดที่ต้องซิงโครไนซ์ ก่อนกังวลว่าจะซิงโครไนซ์ให้ถูก

2.1. ข้อสมมติเฉพาะ C — ภาษาไม่ปกป้องคุณจากอะไรเลย

ใน C เพราะภาษาไม่มีกลไกบังคับหลักเหล่านี้ จึงต้อง เขียนลงอย่างชัดเป็นวินัย

ประการแรก สร้างการรับประกันการปล่อยลงในโครงสร้าง โดยไม่มีสิ่งเทียบเท่า RAII ของ C++ การปล่อยล็อกและการเรียก CloseHandle บนแฮนเดิลต้องถูกปกป้องด้วยแพทเทิร์น goto cleanup ที่กรองทุกทางออกฟังก์ชันผ่านจุดเดียว หรือด้วยข้อตกลงเขียนโค้ดที่จับคู่ทุกการได้มากับการปล่อย การเพิ่ม return เร็วแล้วรั่วล็อกเป็นอุบัติเหตุคลาสสิกของ C

ประการที่สอง ปฏิบัติต่อเรซข้อมูลแบบเดียวกับ C++ การอ่านหรือเขียนธรรมดาของตัวแปร 32 บิตที่จัดแนวถูกต้องเป็นอะตอมบน Windows แต่ไม่มีอะไรเกินนั้น — ตัวแปร 64 บิตบน Windows 32 บิต ปฏิบัติการเชิงประกอบ หรือความสอดคล้องข้ามหลายตัวแปร — รับประกันเลย12 โค้ดที่ «บังเอิญทำงาน» พังทันทีที่คอมไพเลอร์หรือระดับเพิ่มประสิทธิภาพเปลี่ยน

ประการที่สาม ตัดสินความเป็นเจ้าของ วัฒนธรรมที่ระบุในคอมเมนต์ฟังก์ชันว่า «เธรดใดเขียนบัฟเฟอร์นี้ และตั้งแต่เมื่อใดเป็นของใคร» ให้ผลในมัลติเธรด C ไม่แพ้การเลือกปริมิทีฟซิงโครไนซ์

3. วิธีสร้างเธรด — _beginthreadex และไม่มีอย่างอื่น

3.1. ทำไม CreateThread คือทางเลือกผิด

API เนทีฟของ Win32 คือ CreateThread แต่แนวทางทางการคือ ทุกเธรดที่เรียกฟังก์ชัน CRT (รันไทม์ C) ต้องสร้างด้วย _beginthreadex _beginthreadex เริ่มต้นข้อมูลภายในต่อเธรดที่ CRT ต้องการก่อนเริ่มเธรด ถ้าเธรดที่สร้างด้วย CreateThread เรียกฟังก์ชัน CRT CRT อาจจบโพรเซสในภาวะหน่วยความจำต่ำ12 เพราะ printf malloc และ strtok ล้วนเป็นฟังก์ชัน CRT กฎปฏิบัติคือ «เธรดที่เขียนด้วย C ใช้ _beginthreadex เสมอ»

หลีกเลี่ยง _beginthread (ไม่มี ex) ด้วย มีกับดัก: ถ้าเธรดที่มันสร้างจบเร็ว แฮนเดิลที่คืนอาจใช้ไม่ได้แล้ว — หรือแม้ชี้ไปเธรดอื่น — ส่วน _beginthreadex ที่แฮนเดิลส่งให้ API ซิงโครไนซ์ได้อย่างปลอดภัยคือทางเลือกที่ปลอดภัยกว่า ผู้เรียกปิดแฮนเดิลที่ _beginthreadex คืนด้วย CloseHandle13

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... ลูปรอจากหมวด 5 และ 6 ... */
    return 0;
}

HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* จัดการความล้มเหลว */ }
/* ...หลังคำขอหยุด... */
WaitForSingleObject(hThread, INFINITE);  /* จอยน์ */
CloseHandle(hThread);

3.2. งานสั้นไปที่พูลเธรด Windows

ถ้าอยาก «โยนงานเล็กจำนวนมากใส่สิ่งใด» หรือพบว่า «สร้างแล้วทำลายเธรดอายุสั้นซ้ำแล้วซ้ำเล่า» ใช้ พูลเธรด Windows (API พูลเธรดที่มีจาก Vista เป็นต้นไป) แทนเธรดของตัวเอง สร้างอ็อบเจกต์งานด้วย CreateThreadpoolWork แล้วส่งด้วย SubmitThreadpoolWork เธรดเวิร์กเกอร์ของพูลจะรันคอลแบ็กขนานกัน8 การจัดการจำนวนเธรดปล่อยให้ OS ต้นทุนสร้างและทำลายเธรดหาย นี่คือคำตอบของ C ต่อหลัก «อย่าสร้างเธรดเอง»

วินัยการใช้พูลก็ถูกบันทึกอย่างเป็นทางการ: อย่าจบเธรดพูลด้วย TerminateThread / ExitThread เด็ดขาด คืนสถานะใดที่เปลี่ยนในคอลแบ็ก (TLS ความสำคัญเธรด เป็นต้น) ก่อนกลับ และรักษาแฮนเดิลรอให้มีชีวิตจนกว่าพูลใช้เสร็จ9 หมายเหตุปฏิบัติอีกข้อ: พูลจำกัดแค่ จำนวนเธรดเวิร์กเกอร์ — จำนวนคอลแบ็กที่ยังไม่รันซึ่งเข้าคิวผ่าน SubmitThreadpoolWork กองได้ไม่จำกัด ในโครงที่รันยาวนานซึ่งการส่งแซงการประมวลผลตลอด ให้ใส่ตัวจำกัดทางเข้าอย่างเซมาฟอร์ หรือคิวมีขอบ ฝั่งแอป เพื่อให้ฝั่งส่งรอหรือถูกปฏิเสธเมื่อเต็ม (นี่คือแบ็กเพรสเชอร์ไม่ให้โอเวอร์โหลดกลายเป็นปัญหาหน่วยความจำ หลักเดียวกับการออกแบบคิวในหมวด 5)

4. ลดสถานะเปลี่ยนที่ใช้ร่วมให้ต่ำสุด — แบ่ง อ่านอย่างเดียว และส่งต่อ

เรซเกิดเมื่อทั้ง «หลายเธรด» และ «ข้อมูลเปลี่ยนที่ใช้ร่วม» มีพร้อมกัน ก่อนเลือกปริมิทีฟซิงโครไนซ์ (บทถัดไป) คิดว่าลดการร่วมได้ตั้งแต่ต้นหรือไม่ มีสามตระกูลเทคนิค

แบ่ง สำหรับการรวมขนาน แทนที่แต่ละเธรดเขียนตัวนับร่วม ให้สร้างผลรวมย่อยใน ตัวแปรท้องถิ่นต่อเธรด (หรือบัฟเฟอร์ที่จัดสรรต่อเธรด) แล้วรวมครั้งเดียวตอนท้ายด้วยสิ่งอย่าง InterlockedAdd การเขียนสู่สถานะร่วมลดจาก «ทุกการวน» เป็น «ครั้งต่อเธรด» ทั้งต้นทุนซิงโครไนซ์และหน้าต่างการแย่งหดหลายระดับ วินัยความเป็นเจ้าของจากหมวด 2.1 — «บัฟเฟอร์นี้เป็นของเธรดใด» — กลายเป็นพิมพ์เขียวว่าคุณแบ่งอย่างไร

ทำให้เป็นอ่านอย่างเดียว คอนฟิกและตารางที่สร้างตอนสตาร์ทแล้วไม่แก้ต่อมาปลอดภัยให้อ่านจากเธรดกี่ตัวก็ได้เมื่อเริ่มต้นเสร็จ หรือจบการเริ่มต้นทั้งหมดก่อนเธรดใดเริ่ม หรือถ้าต้องเริ่มต้นแบบขี้เกียจ ใช้การเริ่มต้นครั้งเดียวของ Win32 (InitOnceExecuteOnce) และทำให้ขอบ — «ตั้งแต่เมื่อใดจึงเป็นอ่านอย่างเดียว» — ชัดในโค้ด3

ส่งต่อ แทนให้ทั้งสองฝั่งแตะตัวแปรร่วม นำการไหลของข้อมูลระหว่างเธรดผ่านคิวผู้ผลิต/ผู้บริโภค ใน C การทำให้เกิดคือแพทเทิร์นตัวแปรเงื่อนไขจากหมวด 5 พอดี (บัฟเฟอร์วงมีขอบบวก SleepConditionVariableCS) ซึ่งเป็นตัวอย่างทางการที่ทำมาแล้ว บัฟเฟอร์ที่มีเพดานความจุยังให้แบ็กเพรสเชอร์ตามธรรมชาติ — การผลิตจะรอเมื่อแซงการบริโภค5

5. การเลือกอ็อบเจกต์ซิงโครไนซ์และวินัยล็อก

Win32 มีปริมิทีฟซิงโครไนซ์หลายชนิด และการเลือกผิดทำให้เสียทั้งประสิทธิภาพและความถูกต้อง นี่คือแนวทางทางการสรุปในรูปเดียว3

ใช่กีดกันซึ่งกันและกันจำกัดจำนวนเข้าถึงพร้อมกันแจ้งเหตุการณ์ไม่ - ในโพรเซสใช่ไม่ใช่ไม่ต้องซิงโครไนซ์ข้ามโพรเซสไหม?เพื่ออะไร?Mutex มีชื่อเซมาฟอร์มีชื่อเหตุการณ์มีชื่อต้องได้มาแบบเรียกซ้ำโดยเธรดเดียวกันไหม?CRITICAL_SECTIONโค้ด C++ ที่พกพาได้เป็นลำดับแรกไหม?std::mutex /std::shared_mutexล็อก SRW - ทางเลือกปริยาย

ภาพ 3: วิธีเลือกปริมิทีฟซิงโครไนซ์ Win32 กิ่งแรกคือ «ข้ามโพรเซสหรือไม่» — ประเด็นคืออย่าคว้าอ็อบเจกต์เคอร์เนล (Mutex) เมื่อไม่ข้าม

ปริมิทีฟ ขอบเขต ลักษณะ ที่ใช้
ล็อก SRW ในโพรเซส เร็ว (มักอยู่โหมดผู้ใช้ทั้งก้อน) ขนาดพอยน์เตอร์ ไม่เรียกซ้ำ ปริยายสำหรับโค้ดใหม่ AcquireSRWLockShared ยังอนุญาตการอ่านร่วม
CRITICAL_SECTION ในโพรเซส เร็ว (หมุน แล้วตกลงรอเคอร์เนล) เรียกซ้ำได้ เมื่อเธรดเดียวกันต้องได้มาแบบเรียกซ้ำ
Mutex ในโพรเซส / ข้ามโพรเซส เป็นอ็อบเจกต์เคอร์เนลเสมอ จึงช้ากว่า กีดกันข้ามโพรเซส (มีชื่อ) หรือคู่กับ WaitForMultipleObjects
เซมาฟอร์ ในโพรเซส / ข้ามโพรเซส อ็อบเจกต์เคอร์เนล จำกัดการเข้าถึงพร้อมกันสู่พูลทรัพยากร
เหตุการณ์ ในโพรเซส / ข้ามโพรเซส อ็อบเจกต์เคอร์เนล แจ้งว่า «มีอะไรเกิด» (ไม่สำหรับปกป้องข้อมูล)
ฟังก์ชัน Interlocked ในโพรเซส (ข้ามโพรเซสด้วย ผ่านหน่วยความจำร่วม) ปฏิบัติการอะตอมไร้ล็อก ตัวนับ แฟล็ก สลับพอยน์เตอร์4

เชิงอรรถตารางและผัง อ็อบเจกต์เคอร์เนลอย่างเหตุการณ์ เซมาฟอร์ และมิวเท็กซ์ ใช้ซิงโครไนซ์ในโพรเซสได้ดีเมื่อสร้างไร้ชื่อ (เหตุการณ์หยุดในหมวด 6 คือเหตุการณ์ไร้ชื่อพอดี) อ็อบเจกต์เคอร์เนลไม่ได้แปลว่าข้ามโพรเซสอย่างเดียว และในทางกลับกัน «ไร้ชื่อ» ก็ไม่แปลอย่างเคร่งว่า «ขังในโพรเซสเดียว» — ถ้าให้โพรเซสลูกสืบทอดแฮนเดิล หรือทำสำเนาเข้าโพรเซสอื่นด้วย DuplicateHandle อ็อบเจกต์เคอร์เนลเดียวกันใช้จากหลายโพรเซสได้แม้ไร้ชื่อ วิธีพูดที่แม่นคือการตั้งชื่อเป็นวิธีตัวแทนอย่างหนึ่งให้โพรเซส เปิดอ็อบเจกต์เดิมอีกครั้ง กิ่งในภาพ 3 จับประเด็นการเลือกเป็น «อย่าเลือกอ็อบเจกต์เคอร์เนลสำหรับ ล็อก ในโพรเซส» — สำหรับการแจ้งในโพรเซส (เหตุการณ์) หรือจำกัดพร้อมกัน (เซมาฟอร์) อ็อบเจกต์เคอร์เนลไร้ชื่อยังเป็นคำตอบถูก

ตระกูล Interlocked สอดคล้องกับคลาส Interlocked ในฉบับ .NET และ std::atomic ในฉบับ C++ InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange ทำปฏิบัติการบนตัวแปรเดียวแบบแยกไม่ได้ และเพราะฟังก์ชันเหล่านี้ส่วนใหญ่พกกำแพงหน่วยความจำเต็ม คุณจึงได้การรับประกันลำดับด้วย4 «ไม่เป็นไรเพราะติด volatile» คือความเข้าใจผิด — volatile ไม่รับประกันทั้งความเป็นอะตอมและลำดับ (ดูคำถามที่พบบ่อย) ยังมีเงื่อนไขล่วงหน้าอีกข้อ: การจัดแนว ตัวแปรที่ฟังก์ชัน Interlocked เล็งต้องจัดแนวบนขอบธรรมชาติ (ขอบ 4 ไบต์สำหรับค่า 32 บิต ขอบ 8 ไบต์สำหรับค่า 64 บิต) ถ้าไม่ พฤติกรรมคาดไม่ได้12 อย่าเล็งฟิลด์ในสตรักต์ #pragma pack หรือฟิลด์ในบัฟเฟอร์ที่แมปตรงลงรูปแบบสาย ด้วยฟังก์ชัน Interlocked จำกัดตัวนับและแฟล็กที่ตัวแปรประกาศธรรมดา — ที่คอมไพเลอร์จัดแนวให้ มีคำเตือนเฉพาะรอบ การสลับพอยน์เตอร์ ด้วยสิ่งอย่าง InterlockedExchangePointer: เฉพาะการสลับเองที่แยกไม่ได้ และ ไม่มีใครรับประกันอายุบล็อกเก่าหลังสลับ ถ้าผู้อ่านโหลดพอยน์เตอร์เก่าพอดีตอนผู้เขียนสลับแล้วเรียก free นั่นคือการเข้าถึงหน่วยความจำที่ปล่อยแล้ว การออกแบบที่อัปเดตข้อมูลร่วมด้วยการสลับพอยน์เตอร์ทำงานได้เมื่อคู่กับโปรโตคอลทวงคืน — ล็อก นับอ้างอิง หรือคล้าย (ถ้าลังเล การปกป้องด้วยล็อก SRW คือปริยายที่ปลอดภัย)

สำหรับ การรออะไรสักอย่าง มีตัวแปรเงื่อนไข สร้างอันด้วย InitializeConditionVariable ผู้บริโภคนอนบน SleepConditionVariableCS (คู่กับ CRITICAL_SECTION) ผู้ผลิตปลุกด้วย WakeConditionVariable — นี่คือรูปตัวอย่างทางการของคิวผู้ผลิต/ผู้บริโภคบนบัฟเฟอร์มีขอบพอดี5 วินัยสำคัญ: เมื่อตื่น ให้ตรวจเงื่อนไขอีกครั้งเสมอ (คิวไม่ว่างหรือไม่) ในล็อก และวนกลับไปรอถ้าเท็จ ตัวแปรเงื่อนไขอาจถูกปลุกเท็จโดยไม่มีแจ้งเลย และตอนคุณตื่นผู้บริโภคอื่นอาจหยิบรายการไปก่อนแล้ว — ดังนั้น «ถูกปลุก» ไม่จำเป็นแปลว่า «เงื่อนไขเป็นจริง» นี่คือเครื่องมือของ C สำหรับสร้างรูปเดียวกับแชนเนลในฉบับ .NET หมวด 4.3 และ BlockingQueue ในฉบับ C++ หมวด 4 เมื่อคู่กับล็อก SRW ใช้ SleepConditionVariableSRW

5.1. วินัยล็อก — สามหลักที่ยืนได้ไม่ว่าเลือกปริมิทีฟใด

การเลือกปริมิทีฟถูกอย่างเดียวไม่พอ — ถ้าใช้ไร้วินัย คุณยังกันเรซไม่ได้

  • ตัดสิน หนึ่งต่อหนึ่ง ว่าล็อกใดปกป้องข้อมูลใด กำหนดล็อกพอดีหนึ่งอัน (ล็อก SRW หรือ CRITICAL_SECTION) ให้แต่ละชุดข้อมูลเปลี่ยนที่อยากปกป้อง และถือล็อกนั้นที่ ทุก จุดที่แตะข้อมูลนั้น ใน C โดยเฉพาะ การระบุในคอมเมนต์ส่วนหัว — «สตรักต์นี้ถูกล็อก g_lockFoo ปกป้อง» — ให้ผลดี
  • อย่าทำอะไรช้าหรือภายนอกขณะถือล็อก สิ่งเดียวที่ทำได้ขณะถือล็อกคืออ่านหรือเขียนข้อมูลที่มันปกป้อง ไฟล์ I/O การเรียกเครือข่าย หรือการเรียกคอลแบ็กที่ยังถือล็อกยืดเวลาถือและเสี่ยงให้ผู้ถูกเรียกพยายามถือล็อกอื่น สร้างการรอเป็นวงจากภาพ 2
  • ตรึงลำดับการได้มาสำหรับหลายล็อก ที่ใดที่ถือสองล็อกขึ้นไป ให้เป็นกฎว่าทุกเธรดถือตามลำดับเดียวกัน (ลำดับชั้นล็อก) เอกสารแนวปฏิบัติที่ดีที่สุดของ DLL ระบุชัดว่าการกลับลำดับนั้น (lock order inversion) ให้เดดล็อกที่แก้ยาก และคุณควรนิยามลำดับชั้นแล้วทำตามอย่างสม่ำเสมอ10

6. ออกแบบวิธีหยุด — อย่า TerminateThread เด็ดขาด

6.1. TerminateThread ทำลายอะไร

TerminateThread ลบเธรดเป้าหมาย โดยไม่ให้รันโค้ดโหมดผู้ใช้เลย ผลที่เอกสารทางการลิสต์รุนแรง ถ้าเธรดเป้าหมายถือคริติคอลเซกชัน จะไม่ถูกปล่อยเลย ถ้าอยู่กลางปฏิบัติการฮีป ล็อกฮีปยังถูกถือ (และทุกเธรดถัดไปที่เรียก malloc ค้าง) และถ้ากำลังจัดการสถานะโกลบอลของ DLL สถานะนั้นถูกทิ้งให้เสียหาย ท่าทีทางการคือเป็น «ฟังก์ชันอันตรายที่ควรใช้เฉพาะกรณีสุดขั้วที่สุด» และการวิเคราะห์โค้ดติดเป็นคำเตือน C625867

การพบ TerminateThread ขณะสอบแอปที่ «ค้างทั้งก้อนเป็นครั้งคราว» คือภาพที่พบบ่อยจริงในงาน ถ้าพบ ให้ถือว่าต้องแก้

6.2. แพทเทิร์นที่ถูก: เหตุการณ์หยุดบวก WaitForMultipleObjects

แพทเทิร์นที่ลงตัวสำหรับการปิดแบบร่วมมือใน C คือสร้าง เหตุการณ์หยุดรีเซ็ตด้วยมือ อันเดียว แล้วให้แต่ละเธรดเวิร์กเกอร์รอ «สัญญาณงาน» และ «สัญญาณหยุด» พร้อมกัน เอกสารคำเตือน C6258 เองชี้แพทเทิร์นนี้พอดี — สร้างเหตุการณ์ ให้แต่ละเธรดเฝ้าด้วย WaitForSingleObject และให้เธรดจบเอง — เป็นวิธีจบที่ถูก7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): รีเซ็ตด้วยมือ */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): รีเซ็ตอัตโนมัติ
                        กลับเป็นไม่สัญญาณเองเมื่อถูกได้รับ
                        (เหตุการณ์รีเซ็ตด้วยมือจะให้การรอผ่านตรง
                        หลังถูกสัญญาณครั้งเดียว กลายเป็นลูปยุ่ง
                        ที่หมุนบนคิวว่าง) */

static unsigned __stdcall WorkerMain(void* arg)
{
    HANDLE waits[2] = { hStopEvent, hWorkEvent };
    for (;;) {
        DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
        if (r == WAIT_FAILED) {          /* เช่น แฮนเดิลใช้ไม่ได้ ปล่อยไว้จะหมุนเต็มกำลัง */
            LogLastError();              /* บันทึก GetLastError() แล้วออก */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* ขอหยุด */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* มีงาน */
            /* ส่งเหตุการณ์หยุดเข้า ProcessNextItem ด้วย: ถ้ามันรอใน
               นานสำหรับรายการเดียว และสังเกตหยุดที่นั่นไม่ได้ด้วย
               การปิดกลายเป็นตัวประกันของรายการนั้น */
            while (ProcessNextItem(hStopEvent)) {  /* ประมวลผลหนึ่งรายการจากคิว FALSE ถ้าว่าง */
                /* ตรวจคำขอหยุดตอนเทด้วย ข้ามข้อนี้แล้วคุณ
                   หยุดไม่ได้ตราบที่งานยังกอง (อดอยากการหยุด) */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* ทำความสะอาดเอง */
    return 0;                            /* จบเอง */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* ถ้าคำขอหยุดไม่ถึง */
        LogLastError();                      /* อย่าไปจอยน์ไร้ขอบ */
        return FALSE;
    }
    /* ทุกคนมีคำขอหยุดยกขึ้นพร้อมกันแล้ว */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* ปิดเฉพาะแฮนเดิลที่ยืนยันจอยน์แล้ว */
        } else {
            LogLastError();                  /* WAIT_FAILED: เช่น แฮนเดิลใช้ไม่ได้ */
            ok = FALSE;                      /* อย่ารายงาน «ทุกคนหยุดแล้ว» */
        }
    }
    return ok;   /* ถ้า FALSE อย่าไปปล่อยทรัพยากรร่วม */
}

มีเหตุผลที่ฝั่งหยุดจอยน์ทีละเธรดด้วย WaitForSingleObject WaitForMultipleObjects รอได้มากสุด MAXIMUM_WAIT_OBJECTS (64) แฮนเดิล พร้อมกัน ส่งอาร์เรย์ใหญ่กว่าการรอเองล้มด้วย WAIT_FAILED ทิ้งให้คุณปิดแฮนเดิลโดยเชื่อว่ารอทุกคนทั้งที่จริงไม่รอใคร ถ้าต้องการแค่รอทุกคนจบ ลูปทีละอันไร้เพดานบนคือทางเลือกที่ปลอดภัย

SetEvent(hStopEvent)ฝั่งหยุดเหตุการณ์หยุดรีเซ็ตด้วยมือ - ทุกคนเห็นพร้อมกันเวิร์กเกอร์ 1 - รอหยุดและงานพร้อมกันผ่าน WaitForMultipleObjectsเวิร์กเกอร์ 2 - รอหยุดและงานพร้อมกันผ่าน WaitForMultipleObjectsทำความสะอาดแล้วกลับเองทำความสะอาดแล้วกลับเองฝั่งหยุดรอแฮนเดิลเธรดแล้วจอยน์ตอนนี้ถึงเรียกว่าหยุด

ภาพ 4: แพทเทิร์นเหตุการณ์หยุด การใช้เหตุการณ์รีเซ็ตด้วยมือสำหรับสัญญาณหยุดหมายความว่า SetEvent ครั้งเดียวปลุกทุกเวิร์กเกอร์ที่รอพร้อมกัน แต่ละเธรดตัดสินเองว่าจบอย่างไร และการหยุดถือว่าสมบูรณ์เมื่อจอยน์เสร็จแล้วเท่านั้น

มีสามจุดสำคัญ ทำให้เหตุการณ์หยุดเป็น รีเซ็ตด้วยมือ (เพื่อให้ SetEvent ครั้งเดียวเห็นได้ทุกเวิร์กเกอร์) ใส่เหตุการณ์หยุดไว้หน้า ในอาร์เรย์รอ (เพื่อว่าถ้าทั้งคู่ถูกสัญญาณพร้อมกัน การหยุดได้ลำดับก่อน) และฝั่งหยุดต้อง จอยน์แฮนเดิลเธรดก่อนปิดเสมอ

สองข้อจำกัดขอบเขต ประการแรก แพทเทิร์น «เหตุการณ์บวกเททั้งหมด» นี้สำหรับโครงเวิร์กเกอร์เดียว เรียก SetEvent กี่ครั้งบนเหตุการณ์รีเซ็ตอัตโนมัติก็แสดงได้แค่ «มีสถานะสัญญาณหนึ่ง» (สัญญาณต่อเนื่องรวมกัน) ดังนั้นเมื่อมีหลายเวิร์กเกอร์มีเพียงคนตื่นแล้วประมวลผลทั้งชุดแบบต่อเนื่อง ถ้าหลายเวิร์กเกอร์ใช้คิวร่วม ให้เปลี่ยนสัญญาณงานเป็น เซมาฟอร์ เพิ่มนับด้วย ReleaseSemaphore(hSem, 1, NULL) ทุกครั้งที่ใส่รายการในคิว การรอเซมาฟอร์สำเร็จกินหนึ่งนับ ให้ความสอดคล้องที่ถูก: เวิร์กเกอร์ที่รอตื่นทีละคน พอดีกับจำนวนรายการที่เข้าคิว (การใช้แบบนี้อยู่ในหน้าที่เซมาฟอร์เช่นเดียวกับ «จำกัดการเข้าถึงพร้อมกันสู่พูลทรัพยากร» ในตารางภาพ 3) แต่เมื่อเปลี่ยนเป็นเซมาฟอร์ ให้เปลี่ยนฝั่งผู้บริโภคด้วยให้ การรอสำเร็จหนึ่งครั้งเท่ากับการประมวลผลพอดีหนึ่งรายการจากคิว ปล่อยลูปเททั้งหมดจากตัวอย่างด้านบนไว้ การรอครั้งเดียว — ซึ่งกินใบอนุญาตเดียว — จะเททั้งคิว ทำให้บัญชีเพี้ยน: เวิร์กเกอร์อื่นตื่นสู่คิวว่างบนใบอนุญาตที่เหลือ และ ReleaseSemaphore ของผู้ผลิตเริ่มล้มเพราะเกินนับสูงสุด การรักษาความสอดคล้อง «หนึ่งใบอนุญาตเท่ากับหนึ่งงาน» คือเงื่อนไขล่วงหน้าของแนวเซมาฟอร์ ประการที่สอง พาเส้นทางหยุดผ่านการประมวลผลรายการเดียวด้วย ถ้า ProcessNextItem รอแบบบล็อกนานข้างใน หรือส่งเหตุการณ์หยุดเข้าไปที่นั่นด้วยแล้วรอทั้งคู่พร้อมกัน หรือติดหมดเวลาจำกัด การตรวจเฉพาะระหว่างรายการทิ้งรูที่ «การปิดรอนิรันดร์เพราะรายการหนึ่งไม่จบ» นี่พูดสิ่งเดียวกับ StopAsync ในฉบับ .NET และ jthread บวกจอยน์ในฉบับ C++

เธรดที่รอ I/O บล็อก (ไปป์ ซ็อกเก็ต พอร์ตอนุกรม) กลับมาตรวจเหตุการณ์ไม่ได้ ดังนั้นฝั่ง I/O ต้องมีการออกแบบของตัวเอง — หรือ I/O OVERLAPPED คู่กับเหตุการณ์ที่รอร่วมกัน หรือปลุก I/O ด้วย CancelIoEx (ตัวอย่างสื่อสารอนุกรมที่เป็นรูปธรรม ดู “Serial Communication App Pitfalls - Through Reconnection and Log Design”)

7. DllMain และล็อกโหลดเดอร์ — สนามระเบิดสำหรับผู้เขียน DLL

คอมโพเนนต์ร่วมที่เขียนด้วย C มักกลายเป็น DLL และ DLL มีข้อจำกัดของตัวเอง: ล็อกโหลดเดอร์ โหลดเดอร์ของ OS เรียก DllMain ขณะถือล็อกโหลดเดอร์ ดังนั้นการทำสิ่งใดต่อไปนี้ข้างในกลายเป็นแหล่งเดดล็อกหรือแครช10

  • ซิงโครไนซ์กับเธรดอื่น (ถือล็อก รอเธรดจบ)
  • เรียก LoadLibrary / FreeLibrary โดยตรงหรือโดยอ้อม
  • สร้างเธรด (อันตรายถ้าเกี่ยวข้องกับการซิงโครไนซ์) หรือเรียก ExitThread

«รอใน DllMain ให้เธรดเวิร์กเกอร์จบเมื่อ DLL ถูกถอด» ดูสมเหตุสมผลแต่เป็นเดดล็อกคลาสสิก: เธรดที่กำลังจบพยายามถือล็อกโหลดเดอร์เพื่อส่ง DLL_THREAD_DETACH และสองฝั่งจบด้วยการรอซึ่งกันและกัน DLL ที่มีเธรดของตัวเองควรเปิด ฟังก์ชันเริ่มต้นและปิดที่ชัด — อย่าง MyLib_Init / MyLib_Shutdown — และทำการสตาร์ทกับจอยน์เธรดที่นั่น DllMain ในอุดมคติใกล้สตับว่าง10

8. ทางเลือกเธรด C11 — เราอยู่ตรงไหน

ถ้าอยากเขียน C ที่พกพาได้โดยไม่พึ่ง Win32 ทางเลือกคือ <threads.h> ของ C11 (thrd_create / mtx_lock / cnd_wait) และ <stdatomic.h> ตามตารางความสอดคล้องทางการ การรองรับของ MSVC ยืนดังนี้: <threads.h> รองรับจาก Visual Studio 2022 17.8 (ต้อง /std:c11 และ Windows SDK ที่เข้ากัน) ส่วน <stdatomic.h> ยังทดลอง อยู่ในขั้นที่ต้องการตัวเลือก /experimental:c11atomics11

ถ้าการแชร์โค้ดกับ Linux เป็นข้อกำหนด เธรด C11 (หรือห่อ pthread) มีค่าจริง แต่สำหรับโค้ดเบสเฉพาะ Windows แนว Win32 ที่บทความนี้ครอบคลุมได้เปรียบด้านปริมาณข้อมูลที่มี ประวัติ และความง่ายในการดีบัก ไม่ว่าเลือกอะไร หลักการออกแบบที่ครอบมา — ลดการร่วม ความสอดคล้องระหว่างล็อกกับข้อมูล และการปิดแบบร่วมมือ — ไม่เปลี่ยน

9. การตรวจและดีบัก — เตรียมรับ «ทำซ้ำไม่ได้»

คุณคาดให้การทดสอบจับบั๊กรace ไม่ได้ การทดสอบธรรมดานับรันที่เรซ «บังเอิญไม่ยิง» เป็นผ่าน คิดแนวป้องกันเป็นสามชั้น

แนวแรกคือการออกแบบ ในการรีวิว ยืนยันด้วยตาราง: ข้อมูลเปลี่ยนใดที่ใช้ร่วม ล็อกใดปกป้องแต่ละชิ้น (ความสอดคล้องจากหมวด 5.1) ลำดับการได้มาของล็อกชัดหรือไม่ และเหตุการณ์หยุดถึงทุกเวิร์กเกอร์หรือไม่ การออกแบบที่กรอกตารางนี้ไม่ได้ยังไม่เสร็จ แม้ตอนนี้ทำงาน

ประการที่สอง ทำให้ความผิดปกติสังเกตได้ แทนรออย่างไม่มีเงื่อนไขด้วย INFINITE ให้ติดหมดเวลาที่จุดสำคัญและบันทึกหมดเวลาเมื่อยิง เปลี่ยนแฮงก์ที่จะอยู่ตลอดกาลเป็นความล้มเหลวที่ตรวจพบได้ เมื่อแฮงก์เกิดในสนาม จับดัมพ์ ตรวจสแต็กทุกเธรด และหาวงว่าใครรอกล็อกของใคร การตรวจด้วย Application Verifier ถูกแนะนำอย่างเป็นทางการสำหรับข้อผิดพลาดรอบ DLL10 การสร้างดัมพ์และล็อกอยู่ใน “Designing Windows Apps to Leave Logs and Dumps When They Crash

ประการที่สาม เขย่าด้วยภาระ การทดสอบความเครียด — รันด้วยเธรดมากกว่าจำนวนคอร์ สุ่มลำดับประมวลผล แทรกหน่วงเทียม — เป็นวิธีปฏิบัติที่เพิ่มโอกาสให้คุณโดน «แจ็กพอต» เรซบนเครื่องพัฒนา อย่าลืมทดสอบการทำซ้ำบนบิลด์รีลีสที่เพิ่มประสิทธิภาพภายใต้ภาระหนักด้วย

10. สรุป — รายการตรวจฉบับ C

  1. ทุกเธรดถูกสร้างด้วย _beginthreadex (ไม่มี CreateThread / _beginthread ปน) หรือไม่
  2. คุณจอยน์แฮนเดิลเธรด (WaitForSingleObject) ก่อนเรียก CloseHandle หรือไม่
  3. คุณกำลังผลิตเธรดเองเป็นกองสำหรับงานอายุสั้นหรือไม่ (ส่งให้ API พูลเธรดได้ไหม)
  4. การกีดกันในโพรเซสใช้ล็อก SRW / CRITICAL_SECTION (ไม่ใช้ Mutex ผิด) หรือไม่
  5. ตัวนับและแฟล็กที่ใช้ร่วมถูกอัปเดตด้วยฟังก์ชัน Interlocked แทนการพึ่ง volatile หรือไม่
  6. ยังมีโพล Sleep เหลือไหม (ถูกแทนด้วยตัวแปรเงื่อนไขหรือการรอเหตุการณ์แล้วหรือยัง)
  7. TerminateThread (ฆ่าเธรดอื่นโดยบังคับ) หายไปทุกที่หรือไม่ เวิร์กเกอร์จบด้วย return จากฟังก์ชันเธรดแทนการเรียก ExitThread หรือไม่ (เพื่อให้การทำความสะอาด CRT วิ่งถูกผ่าน _endthreadex)
  8. ทุกเวิร์กเกอร์มีเส้นทางหยุดผ่านเหตุการณ์หยุดบวก WaitForMultipleObjects หรือไม่ และปลุกเธรดที่บล็อกบน I/O ได้ด้วยหรือไม่
  9. การปล่อยล็อกและแฮนเดิลรับประกันบนทุกเส้นทางกลับหรือไม่ (วินัย goto cleanup)
  10. DllMain หลีกเลี่ยงการสร้างเธรด ซิงโครไนซ์ หรือรอเธรดจบหรือไม่

แลกกับการไม่มีความช่วยเหลือจากภาษา คุณภาพมัลติเธรดใน C คือสิ่งที่การเลือก API และวินัยของคุณทำให้เป็น พา _beginthreadex ล็อก SRW ฟังก์ชัน Interlocked และเหตุการณ์หยุดเป็นชุดสี่ชิ้นปริยาย แม้ใน C คุณก็ออกแบบให้ห่างจาก «ค้างเป็นครั้งคราว» ได้

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

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

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

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

  1. Microsoft Learn, CreateThread function ว่าเธรดในไฟล์ปฏิบัติการที่เรียก CRT ต้องจัดการด้วย _beginthreadex / _endthreadex ไม่ใช่ CreateThread / ExitThread และว่า CRT อาจจบโพรเซสในภาวะหน่วยความจำต่ำเมื่อเธรดที่สร้างด้วย CreateThread เรียก CRT  2

  2. Microsoft Learn, Multithreading with C and Win32 ว่าโปรแกรมที่เรียกไลบรารี CRT ต้องเริ่มเธรดด้วย _beginthread / _beginthreadex ไม่ใช่ CreateThread / ExitThread ของ Win32 ว่าตระกูล _beginthread เริ่มต้นตัวแปรต่อเธรดของ CRT และว่า SuspendThread อาจหยุดเธรดขณะเข้าถึงโครงสร้างข้อมูลภายใน CRT ซึ่งนำไปสู่เดดล็อกได้  2

  3. Microsoft Learn, About Synchronization แนวเลือกปริมิทีฟซิงโครไนซ์ Win32: ล็อก SRW เป็นปริยายสำหรับโค้ดใหม่ ขนาดพอยน์เตอร์และมักอยู่โหมดผู้ใช้ CRITICAL_SECTION สำหรับกรณีที่ต้องได้มาแบบเรียกซ้ำ Mutex เป็นอ็อบเจกต์เคอร์เนลเสมอ ใช้ซิงโครไนซ์ข้ามโพรเซสมีชื่อและคู่กับ WaitForMultipleObjects การใช้ Mutex สำหรับซิงโครไนซ์ในโพรเซสคือ «ความผิดพลาดทั่วไป» ที่ช้ากว่ามากภายใต้ปฏิบัติการบ่อย และเซมาฟอร์ใช้จำกัดการเข้าถึงพร้อมกันสู่พูลทรัพยากร เหตุการณ์ใช้แจ้ง  2 3

  4. Microsoft Learn, Interlocked Variable Access ว่าฟังก์ชัน Interlocked ซิงโครไนซ์การเข้าถึงตัวแปรที่ใช้ร่วมหลายเธรดและทำปฏิบัติการแบบแยกไม่ได้ ว่า InterlockedIncrement / Decrement มัดการอ่าน บวก และเขียนกลับเป็นปฏิบัติการอะตอมเดียว เพราะไร้ซิงโครไนซ์การเพิ่มพร้อมกันจากสองเธรดอาจเสียหนึ่งการเพิ่ม ตระกูล InterlockedExchange / InterlockedCompareExchange ใช้ระหว่างเธรดในโพรเซสต่างได้เมื่อตัวแปรอยู่ในหน่วยความจำร่วม และฟังก์ชัน Interlocked ส่วนใหญ่ให้กำแพงหน่วยความจำเต็ม มีตัวแปร Acquire / Release สำหรับเลือกความหมายลำดับ  2 3 4

  5. Microsoft Learn, Using Condition Variables ตัวอย่างที่ทำแล้วของคิวผู้ผลิต/ผู้บริโภคที่ทำให้เกิดบนบัฟเฟอร์วงมีขอบที่ CRITICAL_SECTION ปกป้อง โครงสร้างที่ InitializeConditionVariable สร้างตัวแปรเงื่อนไข ผู้บริโภครอด้วย SleepConditionVariableCS ผู้ผลิตปลุกด้วย WakeConditionVariable และตัวแปรเงื่อนไขรองรับจาก Windows Vista เป็นต้นไป  2 3

  6. Microsoft Learn, TerminateThread function ว่า TerminateThread จบเธรดเป้าหมายโดยไม่ให้รันโค้ดโหมดผู้ใช้ คริติคอลเซกชันของเป้าหมายไม่ถูกปล่อยถ้าถืออยู่ ล็อกฮีปไม่ถูกปล่อยถ้าเธรดกำลังจัดสรรหน่วยความจำจากฮีป สถานะของ kernel32 หรือสถานะโกลบอลของ DLL อาจเสียหาย และเป็น «ฟังก์ชันอันตรายที่ควรใช้เฉพาะกรณีสุดขั้วที่สุด» ไม่ควรเรียกจนกว่าคุณจะรู้และควบคุมทุกเส้นทางโค้ดที่เธรดเป้าหมายอาจกำลังรัน  2

  7. Microsoft Learn, Warning C6258 ว่าคำเตือนวิเคราะห์โค้ด C6258 จับการใช้ TerminateThread ว่า TerminateThread ทำความสะอาดเธรดอย่างถูกไม่ได้ และขั้นตอนการจบที่ถูกแสดงเป็นการสร้างเหตุการณ์ด้วย CreateEvent ให้แต่ละเธรดเฝ้าสถานะเหตุการณ์ด้วย WaitForSingleObject และให้เธรดจบการทำงานเองเมื่อเหตุการณ์เป็นสัญญาณ  2 3

  8. Microsoft Learn, CreateThreadpoolWork function ว่าการสร้างอ็อบเจกต์งานด้วย CreateThreadpoolWork และให้เธรดเวิร์กเกอร์ของพูลรันคอลแบ็กทุกครั้งที่เรียก SubmitThreadpoolWork ว่ากำหนดสภาพแวดล้อมการรันผ่านสภาพแวดล้อมคอลแบ็ก (TP_CALLBACK_ENVIRON) ได้ และว่ามีตั้งแต่ Windows Vista  2

  9. Microsoft Learn, Thread Pools ว่าพูลเธรดเหมาะกับแอปที่รันงานอะซิงโครนัสสั้นจำนวนมาก หรือที่สร้างเธรดอายุสั้นบ่อย ส่วนประกอบของ API พูลเธรดใหม่ที่ออกแบบใหม่ใน Vista แนวปฏิบัติที่ดีที่สุดคืออย่าจบเธรดพูลด้วย TerminateThread หรือเรียก ExitThread จากในคอลแบ็ก ทำความสะอาดสถานะที่สร้างในคอลแบ็กก่อนกลับ และรักษาแฮนเดิลรอให้มีชีวิตจนกว่าพูลใช้เสร็จ  2

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

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version ตารางความสอดคล้องไลบรารีมาตรฐาน C ที่แสดงเธรด C11 (threads.h) รองรับจาก Visual Studio 2022 17.8 stdatomic.h ถือว่าทดลอง (หลังตัวเลือก /experimental:c11atomics) และการรองรับคอมไพเลอร์ C11 / C17 ต้อง Visual Studio 2019 16.8 หรือใหม่กว่าพร้อม Windows SDK ที่เข้ากัน  2

  12. Microsoft Learn, Interlocked Variable Access ว่าการอ่านหรือเขียนธรรมดาของตัวแปร 32 บิตที่จัดแนวถูกต้องเป็นอะตอม แต่การซิงโครไนซ์ (ลำดับ) ของการเข้าถึงไม่รับประกัน การอ่านหรือเขียนธรรมดาของตัวแปร 64 บิตเป็นอะตอมบน Windows 64 บิตแต่ไม่รับประกันบน Windows 32 บิต และตัวแปรขนาดอื่นไม่รับประกันเป็นอะตอมบนแพลตฟอร์มใด  2

  13. Microsoft Learn, _beginthread, _beginthreadex ว่าทำไม _beginthreadex ปลอดภัยกว่า _beginthread: เธรดที่สร้างด้วย _beginthread อาจทิ้งแฮนเดิลที่คืนให้ใช้ไม่ได้ (หรือชี้ไปเธรดอื่น) ถ้าจบเร็ว แฮนเดิลจาก _beginthreadex ผู้เรียกต้องปิดด้วย CloseHandle และความถูกต้องรับประกัน _beginthreadex ให้ส่งแฮนเดิลให้ API ซิงโครไนซ์ได้ ฟังก์ชันเธรดคืนรหัสออกเธรดตามอนุสัญญาเรียก __stdcall และต้องลิงก์ CRT แบบมัลติเธรด 

บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง

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

ใน C++ มัลติเธรดคือโลกที่ data race คือพฤติกรรมไม่กำหนด บทความนี้ไล่กับดักของดีสตรักเตอร์ std::thread การออกแบบการหยุดด้วย jthread และ st...

แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ Java — แบบแผนยุคเธรดเสมือน

ใน Java แนวปฏิบัติที่ลงตัวของมัลติเธรดคือไม่สร้างเธรดเอง แต่พึ่ง ExecutorService กับเธรดเสมือน บทความนี้เรียบเรียงหลักปฏิบัติ: เลือกระหว่...

Win32 Thread Pool API — คอนเคอร์เรนซีโดยไม่สร้างเธรดเอง ด้วย CreateThreadpoolWork

โค้ดเนทีฟของคุณกระจายการเรียก CreateThread ไปทั่วหรือไม่ บทความนี้อธิบาย Win32 thread pool API ที่ออกแบบใหม่ใน Vista — ออบเจ็กต์สี่ชนิด w...

แอปที่พังเมื่อรีซูมจากสลีป — อีเวนต์พลังงานของ Windows ทำงานอย่างไร และสร้างแอปธุรกิจที่รอดได้อย่างไร

เปิดแล็ปท็อปแล้วการเชื่อมต่อของแอปธุรกิจตาย — สาเหตุคือการออกแบบที่ไม่เคยคิดถึงสลีป บทความนี้ครอบคลุมลำดับการแจ้ง WM_POWERBROADCAST พฤติก...

DllMain และ Loader Lock — เหตุผลจริงที่ถูกบอกให้ "อย่าทำอะไรในการเริ่มต้น DLL"

ทำไมคุณต้องไม่เรียก LoadLibrary หรือซิงโครไนซ์กับเธรดอื่นจาก DllMain บทความนี้อธิบายจากแหล่งปฐมภูมิว่า loader lock ทำให้การแจ้ง DLL ทุกคร...

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

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

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

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

ควรใช้ CreateThread หรือ _beginthreadex?
ใช้ _beginthreadex สำหรับทุกเธรดที่เรียกฟังก์ชันในไลบรารีรันไทม์ C (CRT) _beginthreadex เริ่มต้นข้อมูลภายในต่อเธรดที่ CRT ต้องการก่อนเริ่มเธรด เอกสารทางการระบุชัดว่าถ้าเธรดที่สร้างด้วย CreateThread เรียกฟังก์ชัน CRT CRT อาจจบโพรเซสเมื่อหน่วยความจำต่ำ ในทางปฏิบัติเธรดในแอป C เกือบเสมอเรียกฟังก์ชัน CRT ที่ไหนสักแห่ง (printf, malloc, strtok เป็นต้น) จึงไม่เสียหายที่จะจำกฎเป็น «เสมอ _beginthreadex» หลีกเลี่ยง _beginthread (ไม่มี ex) ด้วย — มีกับดักที่แฮนเดิลที่คืนอาจใช้ไม่ได้ถ้าเธรดที่สร้างจบเร็ว จึงเลือก _beginthreadex ที่แฮนเดิลส่งให้ API ซิงโครไนซ์ได้
ห้ามหยุดเธรดด้วย TerminateThread หรือ?
ไม่ ไม่ควร TerminateThread ลบเธรดเป้าหมายโดยไม่ให้รันโค้ดโหมดผู้ใช้เลย ดังนั้นถ้าเธรดนั้นถือคริติคอลเซกชันก็ไม่ถูกปล่อยเลย ถ้ากำลังจัดสรรหน่วยความจำจากฮีปล็อกฮีปยังถูกถือ และถ้ากำลังจัดการสถานะโกลบอลของ DLL สถานะนั้นเสียหาย เอกสารทางการระบุชัดว่าเป็น «ฟังก์ชันอันตรายที่ควรใช้เฉพาะกรณีสุดขั้วที่สุด» และการวิเคราะห์โค้ดก็ติดเป็นคำเตือน C6258 วิธีหยุดที่ถูกต้องคือการปิดแบบร่วมมือ: สร้างเหตุการณ์หยุด ให้แต่ละเธรดเฝ้าด้วย WaitForSingleObject / WaitForMultipleObjects และให้แต่ละเธรดทำความสะอาดเองแล้วจบเอง
ผมใช้ Mutex สำหรับการกีดกันภายในโพรเซส มีอะไรผิด?
มันทำงาน แต่คุณจ่ายประสิทธิภาพมาก Mutex ของ Win32 เป็นอ็อบเจกต์เคอร์เนลเสมอ ดังนั้นทุกการได้มาและการปล่อยกระตุ้นการเปลี่ยนเข้าโหมดเคอร์เนล สำหรับการกีดกันในโพรเซสเดียว ล็อก SRW หรือ CRITICAL_SECTION — ซึ่งอยู่โหมดผู้ใช้และตกลงรอเคอร์เนลเฉพาะเมื่อแย่งกัน — เร็วกว่ามาก และเอกสารทางการเรียกการใช้ Mutex สำหรับซิงโครไนซ์ในโพรเซสอย่างชัดว่าเป็น «ความผิดพลาดทั่วไป» Mutex คุ้มเมื่อต้องการกีดกันข้ามโพรเซสเป็นอ็อบเจกต์มีชื่อ หรือเมื่ออยากรอพร้อมอ็อบเจกต์เคอร์เนลอื่นด้วย WaitForMultipleObjects
ใช้ threads.h และ stdatomic.h ของ C11 บน Windows ได้ไหม?
ใน MSVC เธรด C11 (threads.h) รองรับตั้งแต่ Visual Studio 2022 17.8 (ต้อง /std:c11 และ Windows SDK ที่เข้ากัน) ส่วน stdatomic.h ยังถือว่าทดลองและต้องการตัวเลือก /experimental:c11atomics (ตามตารางความสอดคล้องทางการ ณ สิงหาคม 2026) เป็นทางเลือกที่ใช้ได้ถ้าพกพาได้คือลำดับแรก แต่สำหรับโค้ดเบสเฉพาะ Windows การเขียนตาม Win32 API (_beginthreadex, ล็อก SRW, ตัวแปรเงื่อนไข, ฟังก์ชัน Interlocked) คือทางเลือกที่เป็นจริงเมื่อดูประวัติและปริมาณข้อมูลที่มี
การใส่ volatile ทำให้แฟล็กที่ใช้ร่วมปลอดภัยหรือไม่?
ไม่ volatile ของ C แค่กดการเพิ่มประสิทธิภาพคอมไพเลอร์อย่างการแคชค่าในเรจิสเตอร์ — ไม่รับประกันทั้งความเป็นอะตอมของปฏิบัติการและลำดับหน่วยความจำข้ามโปรเซสเซอร์ การอ่านหรือเขียนธรรมดาของตัวแปร 32 บิตที่จัดแนวถูกต้องเป็นอะตอมในตัวบน Windows แต่ «อ่าน บวก แล้วเขียนกลับ» แยกเป็นขั้น และไม่มีรับประกันลำดับเทียบกับปฏิบัติการหน่วยความจำรอบข้าง ใช้ตระกูล Interlocked เพื่ออัปเดตตัวนับหรือแฟล็กที่ใช้ร่วม ฟังก์ชัน Interlocked ส่วนใหญ่พกกำแพงหน่วยความจำเต็ม จึงได้การรับประกันลำดับพร้อมกัน เมื่อต้องปกป้องหลายตัวแปรด้วยกัน ใช้ล็อก SRW หรือ CRITICAL_SECTION

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

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

Go Komura

ผู้แทนของ KomuraSoft LLC

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

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

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