“CreateThread หนึ่งเส้นต่อไคลเอนต์” “หนึ่งเส้นสำหรับตัวตั้งเวลา” “หนึ่งเส้นสำหรับรออีเวนต์” — ในโค้ด Windows เนทีฟ เธรดมีแนวโน้มจะเพิ่มจำนวน แบบนี้ แต่ละเธรดกินสแตกและออบเจ็กต์เคอร์เนล และการสร้างทำลายก็มีต้นทุนด้วย งานละเอียด แต่เธรดหนัก — thread pool คือสิ่งที่ OS จัดให้เพื่อดูดซับความไม่ลงรอยนั้น
ความสะดวกของ ThreadPool และ Task.Run ใน .NET เป็นที่รู้จักดี แต่จริง ๆ แล้ว Win32 เนทีฟก็มี thread-pool API มาตรฐานของ OS ที่ออกแบบมาอย่างดี ถูกออกแบบใหม่ทั้งชุดใน Windows Vista API นี้คือรากฐานของคอนเคอร์เรนซีเนทีฟ: จัดการงาน ตัวตั้งเวลา การรอ และ I/O แบบอะซิงโครนัสผ่านกลไกคอลแบ็กเดียวกัน มุ่งไปที่นักพัฒนาที่เขียนแอป บริการ และ DLL ของ Windows ใน C/C++ บทความนี้อธิบายโครงสร้างและการใช้งานของ API นี้ รวมถึงกับดักที่ตกได้ง่าย จากแหล่งปฐมภูมิ
1. สรุปก่อนเลย
- สำหรับการออกงานอายุสั้นจำนวนมาก และการแทนที่เธรดที่รออย่างเดียว thread pool ชนะ
CreateThreadของตนเอง คุณปล่อยการจัดการเธรดให้ OS และลดจำนวนเธรดกับการสลับบริบทได้1 - สิ่งที่ควรใช้คือ API ใหม่ (ตระกูล
CreateThreadpoolWork) การออกแบบใหม่ใน Vista ทำให้เรียบง่าย น่าเชื่อถือ และมีประสิทธิภาพสูงกว่า API เก่า (ตระกูลQueueUserWorkItem) และคุณยังสร้างหลายพูลอิสระในโปรเซสเดียวได้12 - มีออบเจ็กต์สี่ชนิด work ที่คุณส่งงานเข้า timer ที่ยิงตามเวลาหรือคาบ wait ที่ยิงเมื่อออบเจ็กต์เคอร์เนลถูกสัญญาณ และ io ที่ยิงเมื่อ I/O แบบอะซิงโครนัสเสร็จ ทั้งสี่ขี่กลไกคอลแบ็กเดียวกัน3
- การปิดระบบคือ “รอ แล้วปิด” วินัยที่ไม่ทิ้งคอลแบ็กที่กำลังรัน — รอความเสร็จด้วยตระกูล
WaitForThreadpoolWorkCallbacksหรือจัดการเป็นกลุ่มผ่าน cleanup group — เป็นสิ่งที่ต้องมี4 - ในคอลแบ็ก: อย่าบล็อกนาน (หากจะทำ ให้
CallbackMayRunLong); อย่ารอความเสร็จแบบซิงโครนัสบนพูลเดียวกัน; อย่าทำให้สถานะของเธรดสกปรก สามข้อนี้คือกฎเหล็ก56 - การใช้จาก DLL: ระวังการแข่งกับการยกเลิกโหลด รอความเสร็จในฟังก์ชันปิดระบบที่ชัดเจน และรู้จัก API เฉพาะเช่น
FreeLibraryWhenCallbackReturns3
2. ทำไมต้องพูล และเมื่อใดจึงใช้พูล
แนวคิดของ thread pool เรียบง่าย แทนที่จะสร้างเธรดต่องาน คุณโยนงาน (คอลแบ็ก) ไปที่กลุ่มเธรดเวิร์กเกอร์ที่ OS จัดการ เวิร์กเกอร์ทำงานทีละชิ้น และ OS ปรับจำนวนตามโหลด
เอกสารทางการระบุชนิดแอปที่เป็นรูปธรรมที่พูลคุ้มค่า1
- แอปที่ออกงานเล็กจำนวนมากขนานกัน (การค้นหา I/O เครือข่าย เป็นต้น)
- แอปที่สร้างและรื้อเธรดอายุสั้นบ่อย
- แอปที่ประมวลผลงานอิสระขนานกันในพื้นหลัง
- แอปที่ถือเธรดอุทิศเพื่อรอออบเจ็กต์เคอร์เนลหรืออีเวนต์
ข้อสุดท้ายพลาดง่าย หากคุณมีห้าเธรดที่มีอยู่เพียงเพื่อหลับเพื่อ “รันเมื่ออีเวนต์ถูกสัญญาณ” สิ่งเหล่านั้นแทนที่ได้ด้วยห้า wait object บนพูล และการรอถูกรวมเข้าสู่เธรดรอของพูล
ในทางกลับกัน ยังมีงานที่ไม่เหมาะกับพูล งานที่ต้องเปลี่ยนลำดับความสำคัญของเธรด ที่ต้องการ COM STA ที่รันตลอดอายุโปรเซส — งานที่ต้องการ “บุคลิก” บนเธรดถูกถือบนเธรดเฉพาะ เธรดเวิร์กเกอร์เป็นทรัพยากรที่ใช้ร่วมกัน มันถูกยืม
flowchart TB
accTitle: การเลือกระหว่างเธรดเฉพาะกับพูล
accDescr: ตรวจก่อนว่าต้องการบุคลิกของเธรด เช่น ลำดับความสำคัญหรือ STA หรือไม่ และว่ารันนานหรือไม่ เฉพาะงานอายุสั้น ปริมาณสูง หรือแบบรอที่ไม่เข้าทั้งคู่จึงไป thread pool
q1{"ต้องการบุคลิก เช่น priority หรือ STA?"} -->|"ใช่"| ded["เก็บไว้บนเธรดเฉพาะ"]
q1 -->|"ไม่"| q2{"รันนานหรือไม่?"}
q2 -->|"ใช่"| ded
q2 -->|"ไม่"| pool["ใส่บน thread pool"]
pool -.-> ex["งานอายุสั้น การรอ ตัวตั้งเวลา ความเสร็จ I/O"]
ภาพ 1: งานเดียวที่อนุญาตให้ใส่บนพูลคืองานที่ “ไม่ต้องการบุคลิกและจบสั้น” นอกนั้นอยู่บนเธรดเฉพาะตามเดิม
ยังมีประวัติชิ้นหนึ่งที่ต้องตรึงไว้ thread-pool API มีสองรุ่น API เก่าที่สืบต่อมาตั้งแต่ Windows 2000 (QueueUserWorkItem, RegisterWaitForSingleObject เป็นต้น) และAPI ใหม่ที่ออกแบบใหม่ทั้งชุดใน Vista (ตระกูล CreateThreadpoolWork) API ใหม่รวมชนิดของเธรดเวิร์กเกอร์ มีเธรดถาวรเฉพาะ หลายพูลในโปรเซสเดียว cleanup group และอื่น ๆ และเอกสารทางการระบุตรง ๆ ว่า “เรียบง่ายกว่า น่าเชื่อถือกว่า มีประสิทธิภาพดีกว่า และยืดหยุ่นกว่า”1 API เก่ายังมีข้อจำกัดเชิงโครงสร้าง เช่น “คุณยกเลิกงานเมื่อเข้าคิวแล้วไม่ได้”2 ตั้งแต่นี้ไป บทความนี้พูดถึงเฉพาะ API ใหม่
flowchart TB
accTitle: ความสอดคล้องระหว่าง thread-pool API เก่ากับ API ใหม่
accDescr: QueueUserWorkItem ของ API เก่าแมปกับออบเจ็กต์ work ของ API ใหม่ timer queue แมปกับ timer การรอที่ลงทะเบียนแมปกับ wait และ BindIoCompletionCallback แมปกับ io
o1["QueueUserWorkItem"] --> n1["work"]
o2["Timer queues"] --> n2["timer"]
o5["Registered waits"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
ภาพ 2: เป้าหมายการย้ายจาก API เก่าเป็นหนึ่งต่อหนึ่ง การสำรวจโค้ดที่มีอยู่เริ่มจากความสอดคล้องนี้ได้
3. ออบเจ็กต์สี่ชนิด — work, timer, wait และ io
ศูนย์กลางของ API ใหม่คือออบเจ็กต์สี่ชนิดที่เงื่อนไขการยิงคอลแบ็กต่างกัน3
| ออบเจ็กต์ | ฟังก์ชันสร้าง | เมื่อคอลแบ็กยิง |
|---|---|---|
| work | CreateThreadpoolWork | เมื่อถูกส่งด้วย SubmitThreadpoolWork |
| timer | CreateThreadpoolTimer | เมื่อเวลาหรือคาบที่ระบุมาถึง |
| wait | CreateThreadpoolWait | เมื่อออบเจ็กต์เคอร์เนลกลายเป็นสัญญาณ |
| io | CreateThreadpoolIo | เมื่อ I/O แบบอะซิงโครนัสบนแฮนเดิลที่เชื่อมเสร็จ |
flowchart TB
accTitle: ออบเจ็กต์สี่ชนิดของ thread pool และกลไกคอลแบ็ก
accDescr: work ยิงเมื่อส่งชัดเจน timer ยิงตามเวลา wait ยิงเมื่อออบเจ็กต์เคอร์เนลมีสัญญาณ และ io ยิงเมื่อ I/O แบบอะซิงโครนัสเสร็จ ทั้งสี่รันเป็นคอลแบ็กบนกลุ่มเธรดเวิร์กเกอร์เดียวกัน
kind{"ออบเจ็กต์ใด?"}
kind --> w["work(เมื่อส่ง)"]
kind --> more{"timer, wait หรือ io?"}
more --> t["timer(เวลา / คาบ)"]
more --> rest{"wait หรือ io?"}
rest --> wt["wait(เมื่อมีสัญญาณ)"]
rest --> io["io(ความเสร็จ I/O)"]
w --> pool["เวิร์กเกอร์รันคอลแบ็ก"]
t --> pool
wt --> pool
io --> pool
ภาพ 3: เงื่อนไขการยิงต่างกัน แต่ทั้งสี่รวมในกลไกที่ “เวิร์กเกอร์บนพูลเดียวกันรันคอลแบ็ก”
การรวมนี้คือจุดแข็งเชิงปฏิบัติ แทนที่จะเขียนการประมวลผลตามคาบ การตอบอีเวนต์ และการประมวลผลความเสร็จของ I/O แต่ละอย่างบนเธรดเฉพาะ คุณจัดแนวได้บนสไตล์คอลแบ็กเดียว ตัวตั้งเวลารวมเข้าคิวตัวตั้งเวลาเดียวของทั้งพูล และการรอถูกรวมเข้าสู่เธรดรอจำนวนน้อย — เธรดที่ “หลับอย่างเดียว” หายไปจากโปรเซส1
flowchart TB
accTitle: การแทนที่เธรดที่รออย่างเดียวด้วย wait object
accDescr: เธรดที่รออย่างเดียวซึ่งเคยหลับหนึ่งเส้นต่ออีเวนต์กลายเป็น wait object และถูกรวมเข้าสู่เธรดรอของพูล ดังนั้นคอลแบ็กรันเฉพาะเมื่อมีสัญญาณ
old2["5 เธรดรอเฉพาะที่หลับแยกกัน"] -.-> waste["กิน 5 สแตกและ 5 เธรด"]
new2["5 wait object"] --> agg["รวมเข้าสู่เธรดรอของพูล"]
agg --> cb2["คอลแบ็กรันเฉพาะเมื่อมีสัญญาณ"]
ภาพ 4: เธรดที่ “หลับและรออย่างเดียว” เอาออกได้ด้วยการเปลี่ยนเป็น wait object นี่คือก้าวแรกที่ชัดเจนของการย้ายไปพูล
4. รูปแบบพื้นฐาน — ไปกลับหนึ่งรอบด้วยออบเจ็กต์ work
เราเดินมารยาทหนึ่งรอบด้วยออบเจ็กต์ work ซึ่งใช้บ่อยที่สุด4
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// The context is fixed at creation time. Per-item data is passed through a synchronised queue
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // Take one item under exclusive control
ProcessItem(item);
}
// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }
// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) Close
CloseThreadpoolWork(work);
มีสองจุดที่ต้องถือ ประการแรก คุณ SubmitThreadpoolWork ออบเจ็กต์ work เดียวกันได้มากกว่าหนึ่งครั้ง แต่ละครั้งที่ส่งจะรันคอลแบ็ก (ขนานกัน)7 อย่างไรก็ตาม บริบทที่ส่งให้คอลแบ็กถูกตรึงตอนสร้าง ดังนั้นเมื่อคุณเขียน “N รายการของงานชนิดเดียวกัน” ด้วยออบเจ็กต์ work เดียว คุณใส่คิวที่ซิงโครไนซ์ในบริบทอย่างในโค้ดด้านบนแล้วหยิบหนึ่งรายการต่อการส่ง (การออกแบบที่สร้างออบเจ็กต์ work ต่อรายการก็ใช้ได้) ประการที่สอง รอความเสร็จเสมอก่อนปิด การปิดออบเจ็กต์ขณะที่คอลแบ็กที่กำลังรันหรืออยู่ในคิวยังเหลืออยู่ หรือการปล่อยหน่วยความจำที่คอลแบ็กอ้างถึง คือ use-after-free ตามนั้น เพื่อให้การรอนี้เป็นการปิดที่ปลอดภัย การหยุดผู้ส่งก่อน เป็นเงื่อนไขเบื้องต้น — ในโครงสร้างที่เธรดอื่นยัง Submit ขนานกับการรอ การส่งหลังการรอแข่งกับ Close การส่ง TRUE เป็นอาร์กิวเมนต์ที่สองของ WaitForThreadpoolWorkCallbacks ยังพยายามยกเลิกการส่งที่ยังไม่เริ่ม
flowchart TB
accTitle: วงจรชีวิตของออบเจ็กต์ work
accDescr: สร้างด้วย CreateThreadpoolWork ส่งด้วย SubmitThreadpoolWork แล้วคอลแบ็กรันขนานกัน ตอนปิดระบบ หยุดการส่งใหม่ก่อน รอทุกคอลแบ็กเสร็จด้วย WaitForThreadpoolWorkCallbacks แล้วปิดด้วย CloseThreadpoolWork
c["สร้างด้วย CreateThreadpoolWork"] --> s["ส่งด้วย SubmitThreadpoolWork (ทำซ้ำได้)"]
s --> run["คอลแบ็กรันขนานกัน"]
run --> stop3["หยุดการส่งใหม่"]
stop3 --> w["รอความเสร็จด้วย WaitForThreadpoolWorkCallbacks"]
w --> cl["ปิดด้วย CloseThreadpoolWork"]
ภาพ 5: ลำดับการปิดระบบคือ “หยุดการส่ง → รอความเสร็จ → ปิด” ข้ามข้อใดข้อหนึ่งแล้วคุณได้ use-after-free หรือการแข่ง
ตามค่าเริ่มต้น คอลแบ็กรันบนพูลเริ่มต้นของโปรเซส สำหรับหลายงานนั่นพอ บทถัดไปมีไว้เมื่อคุณอยากแยกพูล
5. พูลกำหนดเองและ cleanup group
แยกพูล คุณสร้างพูลอิสระด้วย CreateThreadpool และตั้งขอบบนและขอบล่างของจำนวนเธรดด้วย SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum8 การใช้ทั่วไปคือการแยก เพื่อไม่ให้ “งานแบบแบตช์ที่อาจช้า” กินเวิร์กเกอร์ของ “งานที่ต้องตอบทันที” คุณแยกพูลและให้งบเธรดของตนเองแก่แต่ละพูล
ผูกด้วยสภาพแวดล้อมคอลแบ็ก พูลที่งานรันอยู่ถูกระบุด้วยการเริ่มต้น TP_CALLBACK_ENVIRON (สภาพแวดล้อมคอลแบ็ก) ชี้ไปที่พูลด้วย SetThreadpoolCallbackPool และส่งสิ่งนั้นเป็นอาร์กิวเมนต์ที่สามของ CreateThreadpoolWork เป็นต้น9
พับด้วย cleanup group ในมอดูลที่สร้างออบเจ็กต์จำนวนมาก การประมวลผลปิดระบบมักกลายเป็นการท่อง “รอทั้งหมด ปิดทั้งหมด” หากคุณสร้างกลุ่มด้วย CreateThreadpoolCleanupGroup และแนบแต่ละออบเจ็กต์ผ่านสภาพแวดล้อมคอลแบ็ก การเรียก CloseThreadpoolCleanupGroupMembers ครั้งเดียวทำการรอความเสร็จและปล่อยของทุกออบเจ็กต์สมาชิกพร้อมกัน34
flowchart TB
accTitle: การผูกการตั้งค่าผ่านสภาพแวดล้อมคอลแบ็ก
accDescr: สภาพแวดล้อมคอลแบ็กชี้ไปที่พูลกำหนดเองและ cleanup group work และ timer ที่สร้างด้วยสภาพแวดล้อมนั้นรันบนพูลนั้น และการทำงานเป็นกลุ่มบน cleanup group รวมการรอความเสร็จและการปล่อย
env["สภาพแวดล้อมคอลแบ็ก (TP_CALLBACK_ENVIRON)"] --> cp["พูลกำหนดเอง (ควบคุมจำนวนเธรด)"]
env --> cg["Cleanup group"]
env --> obj["ส่งตอนสร้าง work / timer / wait / io"]
cg -.-> close["รอความเสร็จและปล่อยในครั้งเดียว"]
ภาพ 6: สภาพแวดล้อมคอลแบ็กคือกลไกที่ฉีด “รันบนพูลใด และใครทำความสะอาด” ตอนสร้างออบเจ็กต์
6. กับดัก — วินัยในคอลแบ็ก
บั๊กของ thread pool เกือบทั้งหมดมาจาก “ทำตามใจบนเธรดที่ยืมมา”
การบล็อกนาน พูลปรับจำนวนเธรดโดยสมมติว่าคอลแบ็กคืนเร็ว การทำงานนานหรือรอนานที่ค่าเริ่มต้นทำให้การรันคอลแบ็กอื่นล่าช้า คอลแบ็กที่อาจรันนานควรประกาศ “อันนี้จะรันนาน” ด้วย CallbackMayRunLong (พูลถือเป็นคำใบ้ให้เพิ่มเธรด) หรือส่งไปเธรดเฉพาะตั้งแต่แรก โปรดทราบว่า CallbackMayRunLong คืน FALSE เมื่อเตรียมเวิร์กเกอร์สำหรับคอลแบ็กอื่นไม่ได้ หากคุณบล็อกต่อโดยไม่ตรวจค่าที่คืนมา คุณยังอุดพูล ดังนั้นเมื่อเป็น FALSE ให้ไปทางที่ไม่บล็อก — แยกงาน ส่งไปเธรดเฉพาะ เป็นต้น5
การรอความเสร็จแบบซิงโครนัสบนพูลเดียวกัน รูปแบบที่ในคอลแบ็ก A คุณรอด้วย WaitForThreadpoolWorkCallbacks หรือคล้ายกันเพื่อความเสร็จของงาน B ที่ส่งเข้าพูลเดียวกัน จะกลายเป็นเดดล็อกแบบพูลอดอาหาร ในวินาทีที่ทุกเวิร์กเกอร์ “รอเวิร์กเกอร์อื่น” เขียนการพึ่งพาระหว่างงานใหม่ไม่เป็นการรอ แต่เป็นการต่อเนื่องที่ “ส่งงานถัดไปจากคอลแบ็กความเสร็จของ B”
flowchart TB
accTitle: โครงสร้างของเดดล็อกแบบพูลอดอาหาร
accDescr: หากทุกเธรดเวิร์กเกอร์รอความเสร็จแบบซิงโครนัสของงานอื่นที่ส่งเข้าพูลเดียวกัน จะไม่มีเวิร์กเกอร์ว่างเหลือให้รันงานนั้น และทุกคนรอตลอดไป
w1["เวิร์กเกอร์ 1: รอให้งาน X เสร็จ"] --> q["งาน X และ Y รอรัน"]
w2["เวิร์กเกอร์ 2: รอให้งาน Y เสร็จ"] --> q
q -.-> none["ไม่มีเวิร์กเกอร์ว่างเหลือให้รัน"]
none -.-> dead["ทุกคนรอตลอดไป (เดดล็อกอดอาหาร)"]
ภาพ 7: หากคุณรอเวิร์กเกอร์แบบซิงโครนัสจากในเวิร์กเกอร์ จะไม่มีใครเหลือให้รันงานที่กำลังรอ
การทำให้สถานะของเธรดสกปรก เธรดเวิร์กเกอร์ถูกใช้ซ้ำสำหรับคอลแบ็กถัดไป การเปลี่ยนลำดับความสำคัญของเธรด สถานะการเริ่มต้น COM ค่าที่ทิ้งใน TLS ล็อกที่ลืมออก — สิ่งเหล่านี้กลายเป็นการปนเปื้อนของคอลแบ็กถัดไป (ที่ไม่เกี่ยวข้อง) “ฟังก์ชันที่คุณโยนเข้าพูลต้องไม่พึ่งพาบุคลิกของเธรด” เป็นคำเตือนทางการมาตั้งแต่ยุค API เก่า6 มีกลไกเฉพาะสำหรับการทำความสะอาด เช่น LeaveCriticalSectionWhenCallbackReturns สามารถขอให้พูล “ปล่อยล็อกนี้เมื่อคอลแบ็กนี้คืน”3
การแข่งกับการยกเลิกโหลด DLL หาก DLL ที่มีโค้ดถูกยกเลิกโหลดขณะที่คอลแบ็กรัน คุณจะได้ access violation รูปแบบพื้นฐานคือรอความเสร็จอย่างละเอียดในฟังก์ชันปิดระบบของ DLL FreeLibraryWhenCallbackReturns มีไว้สำหรับสถานการณ์ “คอลแบ็กนี้เป็นงานสุดท้าย และเมื่อเสร็จอยากให้ DLL ถูกปล่อยรวมตัวเอง” อย่างไรก็ตาม API นี้เพียง “ปล่อยการอ้างอิงหนึ่งเมื่อคอลแบ็กที่กำลังรันคืน” ไม่ได้ป้องกันการยกเลิกโหลดก่อนที่คอลแบ็กจะเริ่ม คุณใช้เป็นคู่: ถือการอ้างอิงมอดูลของตนเองด้วย GetModuleHandleEx ก่อนส่ง และให้คอลแบ็กปล่อยการอ้างอิงนั้นด้วย API นี้3 และคุณห้ามรอความเสร็จนี้ใน DllMain — ตามที่ระบุใน “DllMain และ Loader Lock” การรอเธรดอื่นใน DllMain คือรูปแบบเดดล็อก
flowchart TB
accTitle: การเตรียมรับการแข่งระหว่างการยกเลิกโหลด DLL กับคอลแบ็ก
accDescr: การยกเลิกโหลด DLL ขณะที่คอลแบ็กรันกลายเป็น access violation ดังนั้นรูปแบบพื้นฐานคือรอความเสร็จในฟังก์ชันปิดระบบที่ชัดเจนแล้วปิด เมื่อคอลแบ็กสุดท้ายเองปล่อย DLL ให้ใช้ FreeLibraryWhenCallbackReturns
risk["ยกเลิกโหลดระหว่างคอลแบ็ก"] -.-> av["Access violation"]
g1["รอ แล้วปิด"] --> safe["ยกเลิกโหลดอย่างปลอดภัย"]
g1 -.-> g1N["ในฟังก์ชันปิดระบบ"]
g2["FreeLibraryWhenCallbackReturns"] --> safe
g2 -.-> g2n["คอลแบ็กสุดท้ายปล่อย DLL"]
g1 -.-> ng["ไม่ใช่ใน DllMain"]
av ~~~ g1
ภาพ 8: รูปแบบพื้นฐานคือทำ “รอ แล้วปิด” ในฟังก์ชันปิดระบบที่ชัดเจน การรอใน DllMain ชวนเดดล็อกอีกแบบ
ข้อยกเว้นและการล่มในงานที่ส่ง ข้อยกเว้นที่ไม่ถูกจับบนเธรดเวิร์กเกอร์พาโปรเซสไปด้วย ใช้แนวนโยบายจับข้อยกเว้นอย่างครอบคลุมที่ทางเข้าคอลแบ็กแล้วบันทึก แบบเดียวกับที่คุณทำกับฟังก์ชันเธรดของเธรดเฉพาะ
7. ความสัมพันธ์กับไลบรารีมาตรฐานและ .NET — คุณเขียนที่ชั้นใด
สุดท้าย เราจัดระเบียบว่าสิ่งนี้อยู่กับเครื่องมืออื่นอย่างไร
- หาก C++
std::async/std::threadพอ พวกนั้นคือตัวเลือกแรก พกพาได้ โค้ดสั้น และแม้ความหมายของfutureก็ถูกมาตรฐานกำหนดแล้ว10 - เหตุผลที่จะใช้ Win32 thread pool โดยตรง คือเมื่อคุณ (1) อยากได้กลไกคอลแบ็กรวมที่รวม timer, wait และ io (2) อยากแยกพูลหรือควบคุมจำนวนเธรด หรือ (3) ไม่อยากถือเธรดของตนเองใน DLL หรือคอมโพเนนต์ COM
- ฝั่ง .NET
ThreadPoolและTaskเล่นบทบาทเดียวกัน และความเสร็จของ I/O ผูกกับ IOCP โครงสร้างชั้นใต้ถูกอธิบายใน “IOCP และ .NET Thread Pool”
flowchart TB
accTitle: การตัดสินว่าจะเขียนด้วยเครื่องมือชั้นใด
accDescr: หาก async หรือ thread ของ C++ มาตรฐานพอ ให้ใช้สิ่งนั้น ใช้ Win32 thread pool โดยตรงเมื่อต้องการการรวมตัวตั้งเวลา การรอ และความเสร็จของ I/O การแยกพูลหรือควบคุมจำนวนเธรด หรือไม่อยากมีเธรดของตนเองใน DLL หรือคอมโพเนนต์ COM
q1{"เครื่องมือ C++ มาตรฐานพอหรือไม่?"} -->|"ใช่"| std["std::async / std::thread"]
q1 -->|"ไม่"| q2{"คุณต้องการอะไร?"}
q2 -->|"การรวม timer, wait และ io"| tp["Win32 thread pool"]
q2 -->|"การแยกพูล / ควบคุมจำนวน"| tp
q2 -->|"เลี่ยงเธรดของตนเองใน DLL"| tp
ภาพ 9: เมื่อไม่แน่ใจ เริ่มด้วยไลบรารีมาตรฐาน ตาของ API นี้มาเมื่อข้อกำหนดที่มันแสดงไม่ได้ปรากฏ
กล่าวคือ API นี้คือรากฐานของคอนเคอร์เรนซี ณ จุดที่คุณตัดสินใจแล้วว่าจะ “เขียนเนทีฟ” การทำความสะอาดที่เป็นจริงคือแบบเป็นขั้น: ในฐานะเป้าหมายการย้ายจากการเพิ่มจำนวน CreateThread ของตนเอง เริ่มด้วยการแนะนำออบเจ็กต์ work แล้วแทนที่เธรดที่รออย่างเดียวด้วย wait และเธรดตัวตั้งเวลาด้วย timer
8. สรุป
- สำหรับการออกงานอายุสั้นจำนวนมาก และการทำความสะอาดเธรดที่รออย่างเดียวกับเธรดตัวตั้งเวลาอย่างเดียว ให้ใช้ thread pool มาตรฐานของ OS แทนเธรดของตนเอง สิ่งที่ใช้คือ API ใหม่ตั้งแต่ Vista เป็นต้นไป
- ศูนย์กลางคือออบเจ็กต์สี่ชนิด work, timer, wait และ io เงื่อนไขการยิงต่างกัน พวกมันรวมบนกลุ่มเวิร์กเกอร์เดียวกันและสไตล์คอลแบ็กเดียวกัน
- มารยาทคือ “สร้าง → ส่ง → รอความเสร็จ → ปิด” การส่งหลายครั้งรันขนานกัน cleanup group สามารถรวมการประมวลผลปิดระบบได้
- สามกฎเหล็กของคอลแบ็ก: อย่าบล็อกนาน (หากจะทำ ให้
CallbackMayRunLong); อย่ารอแบบซิงโครนัสบนพูลเดียวกัน; อย่าทำให้สถานะของเธรดสกปรก - การใช้จาก DLL: ระวังการแข่งกับการยกเลิกโหลด รอความเสร็จในฟังก์ชันปิดระบบที่ชัดเจน อย่าทำใน
DllMain - ที่ซึ่ง C++ มาตรฐานหรือ .NET พอ ให้ใช้สิ่งนั้น ตาของ API นี้คือเมื่อคุณต้องการการรวม timer/wait/io หรือการควบคุมพูล
thread-pool API อยู่ในฝั่งที่ใหม่กว่าและออกแบบดีกว่าในบรรดา Win32 API เมื่อคุณเลิกจากแนวคิด “สร้างเธรด” ไปสู่แนวคิด “โยนคอลแบ็ก” แล้ว คอนเคอร์เรนซีในโค้ดเนทีฟจะเขียนได้ชัดเจนกว่ามาก
บทความที่เกี่ยวข้อง
- เชิงลึกของ Windows I/O (ตอนที่ 3) — I/O Completion Ports (IOCP) และ .NET Thread Pool: ชั้นใต้ async/await
- แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C++ — ตัดอุบัติเหตุด้วยโครงสร้างด้วย RAII และ jthread
- แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C — เขียนอย่างปลอดภัยแบบ Win32 API
- Spurious Wakeup — ทำไม condition variable จึงตื่น “โดยไม่ถูกแจ้ง” และวิธีรออย่างถูกต้องบน Windows
- DllMain และ Loader Lock — เหตุผลจริงที่ถูกบอกให้ “อย่าทำอะไรในการเริ่มต้น DLL”
- ทำไมบน Windows จึงควรใช้การรออีเวนต์มากกว่า Sleep(1)
ด้านให้คำปรึกษาที่เกี่ยวข้อง
KomuraSoft LLC รับออกแบบการย้ายจากโค้ดเนทีฟที่เธรดเพิ่มจำนวนไปสู่ thread pool การตรวจทานการออกแบบการประมวลผลพร้อมกันในแอป C++ และ DLL และการสอบสวนหาสาเหตุของแฮงและการล่มที่เกิดจากพูลอดอาหารหรือคอลแบ็ก ยินดีให้คำปรึกษาตั้งแต่ขั้นสำรวจโค้ดที่มีอยู่
- การพัฒนาแอปพลิเคชัน Windows
- ที่ปรึกษาด้านเทคนิคและการตรวจทานการออกแบบ
- การตรวจสอบบั๊กและวิเคราะห์หาสาเหตุที่แท้จริง
- ติดต่อเรา
ลิงก์อ้างอิง
-
Microsoft Learn, Thread Pools. ว่าด้วย thread pool เป็นกลุ่มเธรดเวิร์กเกอร์ที่รันคอลแบ็กแบบอะซิงโครนัสอย่างมีประสิทธิภาพแทนแอป ว่าด้วยชนิดแอปที่เหมาะ (ออกงานเล็กจำนวนมากขนานกัน สร้างและรื้อเธรดอายุสั้นบ่อย ประมวลผลงานอิสระขนานกัน การรอเฉพาะบนออบเจ็กต์เคอร์เนล เป็นต้น) และว่าด้วยการออกแบบใหม่ทั้งชุดใน Vista (การรวมชนิดเธรดเวิร์กเกอร์ คิวตัวตั้งเวลาเดียว เธรดถาวรเฉพาะ cleanup group หลายพูลในโปรเซสเดียว และ API ใหม่) ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Thread Pooling. ว่าด้วยโครงสร้างของ thread-pool API รุ่นเก่า (QueueUserWorkItem, timer queues, registered waits, BindIoCompletionCallback) ว่าไม่มีทางยกเลิกงานเมื่อเข้าคิวแล้ว และว่า thread-pool API ใหม่ที่แนะนำใน Vista ถูกระบุว่าเรียบง่ายกว่าและเหนือกว่าด้านความน่าเชื่อถือ ประสิทธิภาพ และความยืดหยุ่น ↩ ↩2
-
Microsoft Learn, threadpoolapiset.h header. ว่าด้วยรายการฟังก์ชันที่รวมฟังก์ชันสร้างออบเจ็กต์สี่ชนิด CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait และ CreateThreadpoolIo cleanup group (CreateThreadpoolCleanupGroup) และการทำความสะอาดที่ผูกกับความเสร็จของคอลแบ็ก (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns เป็นต้น) ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Using the Thread Pool Functions. ว่าด้วยขั้นตอนพื้นฐานของการสร้างด้วย CreateThreadpoolWork การส่งด้วย SubmitThreadpoolWork การรอความเสร็จด้วย WaitForThreadpoolWorkCallbacks และการปิดด้วย CloseThreadpoolWork และตัวอย่างการตั้งค่าที่รวมพูลกำหนดเองกับสภาพแวดล้อมคอลแบ็กและ cleanup group ↩ ↩2 ↩3
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). ว่าด้วยการแจ้งพูลว่าคอลแบ็กปัจจุบันอาจรันนาน เพื่อให้พูลใช้เป็นข้อมูลตัดสินว่าจะสำรองเธรดสำหรับคอลแบ็กอื่นหรือไม่ และว่าให้พิจารณาเธรดเฉพาะสำหรับคอลแบ็กที่รันนานเมื่อทำได้ ↩ ↩2
-
Microsoft Learn, Thread Pooling. ว่าด้วยรายการงานที่ส่งเข้า thread pool และฟังก์ชันที่พวกมันเรียกต้องปลอดภัยต่อ thread pool ว่าอย่าสมมติว่าเธรดที่รันเป็นเธรดเฉพาะถาวร และว่าให้เลี่ยงการใช้ TLS และการเรียกอะซิงโครนัสที่ต้องการเธรดถาวร ↩ ↩2
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). ว่าด้วยการส่งออบเจ็กต์ work เดียวกันได้มากกว่าหนึ่งครั้งโดยไม่รอให้คอลแบ็กก่อนหน้าเสร็จ จึงให้คอลแบ็กรันขนานกัน และว่าพูลสามารถปรับ (throttle) จำนวนเธรดเพื่อประสิทธิภาพ ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). ว่าด้วยการตั้งขอบบนของจำนวนเธรดเวิร์กเกอร์สำหรับพูลที่สร้างด้วย CreateThreadpool ได้ (ขอบล่างคือ SetThreadpoolThreadMinimum) ↩
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). ว่าด้วยการสร้างออบเจ็กต์ work จากฟังก์ชันคอลแบ็กและพอยน์เตอร์บริบท และว่าอาร์กิวเมนต์ที่สาม TP_CALLBACK_ENVIRON สามารถระบุสภาพแวดล้อมการรันของคอลแบ็ก (พูลที่สังกัด เป็นต้น) โดย NULL หมายถึงรันในสภาพแวดล้อมเริ่มต้น ↩
-
Microsoft Learn, <future>. ว่าด้วยการรันอะซิงโครนัสรายงานผ่าน std::async และ future ถูกจัดให้เป็นไลบรารีมาตรฐาน จึงเขียนคอนเคอร์เรนซีโดยไม่จัดการเธรดโดยตรงได้ ↩
บทความที่เกี่ยวข้อง
บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง
Named Pipe ในทางปฏิบัติ — IPC มาตรฐานของ Windows ตั้งแต่การออกแบบถึงความปลอดภัย
คู่มือเชิงปฏิบัติเรื่อง named pipe ซึ่งเป็นการสื่อสารระหว่างโปรเซสมาตรฐานของ Windows บทความนี้จัดระเบียบจากแหล่งปฐมภูมิ ทั้งการเลือกระหว่...
เชิงลึกของการจำลองเสมือนบน Windows (ตอนที่ 3) — เครื่องเสมือนที่บูตในไม่กี่วินาที: ทำไม WSL2, Windows Sandbox และคอนเทนเนอร์จึงเบา
ทำไม WSL2 และ Windows Sandbox จึงเริ่มในไม่กี่วินาทีและรู้สึกเบา? บทความนี้อธิบายกลไก ตั้งแต่ dynamic base image และ direct map ไปจนถึงกา...
เชิงลึกของการจำลองเสมือนบน Windows (ตอนที่ 2) — หน่วยความจำที่แม้เคอร์เนลก็มองไม่เห็น: กลไกของ VBS, HVCI และ Credential Guard
เมื่อติดตั้งใหม่บนฮาร์ดแวร์ที่รองรับ VBS จะเปิดตามค่าเริ่มต้น และใช้ hypervisor กับ SLAT สร้างการแยกที่แข็งกว่าเคอร์เนล บทความนี้อธิบายโค...
เชิงลึกของการจำลองเสมือนบน Windows (ตอนที่ 1) — Windows ของคุณรันอยู่ที่ไหนจริง ๆ? Hypervisor และพาร์ติชัน
เมื่อเปิด Hyper-V แล้ว Windows ฝั่งโฮสต์เองก็รันบน hypervisor ในฐานะ root partition บทความนี้อธิบายรากฐานของการจำลองเสมือนผ่านบทบาทของ VT...
ทำให้เว็บไซต์ของคุณปรากฏในการค้นหาด้วยชื่อพื้นที่ — คู่มือ Local SEO ภาคปฏิบัติสำหรับ SME (หน้าเพจพื้นที่และ Google Business Profile)
สำหรับ SME ที่เว็บไซต์ไม่ปรากฏเมื่อมีคนค้นหา "ชื่อพื้นที่ + ประเภทธุรกิจ" บทความนี้เรียบเรียงลำดับขั้นตอนการปรับปรุง Local SEO ได้แก่ การ...
หัวข้อที่เกี่ยวข้อง
หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ
หัวข้อเทคนิคของ Windows
ประตูสู่การพัฒนา Windows การตรวจสอบบั๊ก และการใช้ประโยชน์จากสินทรัพย์เดิม
บริการที่เกี่ยวข้องกับหัวข้อนี้
บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้
พัฒนาแอป Windows
แอปธุรกิจ การเชื่อมต่ออุปกรณ์ และเครื่องมือสื่อสาร ตั้งแต่ความต้องการจนถึงการพัฒนา
คำถามที่พบบ่อย
คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้
- thread pool ดีกว่าการสร้างเธรดเองด้วย CreateThread อย่างไร
- ประสิทธิภาพเมื่อมีงานอายุสั้นจำนวนมากที่ต้องทำ และการลดโค้ดจัดการเธรด การสร้างและทำลายเธรดมีต้นทุนที่มองข้ามไม่ได้ ดังนั้นแอปที่วน "CreateThread ต่องานแล้วทำลายเมื่อเสร็จ" หรือที่ถือเธรดจำนวนมากซึ่งมีอยู่เพียงเพื่อหลับรออีเวนต์ สามารถลดจำนวนเธรดและการสลับบริบทได้ด้วยการย้ายไปพูล เอกสารทางการยังระบุว่าแอปที่เป็นผู้สมัครของพูลคือแอปที่ออกงานเล็กจำนวนมากขนานกัน แอปที่สร้างเธรดอายุสั้นจำนวนมาก และแอปที่มีเธรดอุทิศเพียงเพื่อรอออบเจ็กต์เคอร์เนล ในทางกลับกัน งานที่ "ต้องการบุคลิกของตนเองบนเธรด" — การเปลี่ยนลำดับความสำคัญ COM STA การประมวลผลอุทิศที่รันนาน — ยังควรถือบนเธรดเฉพาะตามเดิม
- ต่างจากฟังก์ชัน thread pool รุ่นเก่าอย่าง QueueUserWorkItem อย่างไร
- thread pool ถูกออกแบบใหม่ทั้งชุดใน Windows Vista API ตระกูล threadpoolapiset ปัจจุบัน (CreateThreadpoolWork เป็นต้น) คือ API ใหม่ ส่วน QueueUserWorkItem, RegisterWaitForSingleObject เป็นต้น คือ API เก่า (legacy) API ใหม่รวมชนิดของเธรดเวิร์กเกอร์ให้คุณสร้างหลายพูลอิสระในโปรเซสเดียว และมีกลไกเช่นการปล่อยเป็นกลุ่มผ่าน cleanup group และการปล่อยล็อกหรือยกเลิกโหลด DLL ที่ผูกกับความเสร็จของคอลแบ็ก เอกสารทางการยังระบุว่า API ใหม่เรียบง่ายกว่าและเหนือกว่าด้านความน่าเชื่อถือ ประสิทธิภาพ และความยืดหยุ่น API เก่ายังมีข้อจำกัดเชิงโครงสร้าง เช่น "ไม่มีทางยกเลิกงานเมื่อเข้าคิวแล้ว" จึงใช้ API ใหม่ในโค้ดใหม่
- มีสิ่งที่ห้ามทำในคอลแบ็กหรือไม่
- มีสามข้อใหญ่ ประการแรก การบล็อกนานหรือทำงานนานที่ค่าเริ่มต้น พูลปรับจำนวนเธรดโดยสมมติว่าคอลแบ็กจบเร็ว ดังนั้นสำหรับงานที่จะใช้นาน คุณประกาศด้วย CallbackMayRunLong หรือใช้เธรดเฉพาะ ประการที่สอง การรอความเสร็จแบบซิงโครนัสของงานอื่นที่คุณส่งเข้าพูลเดียวกัน หากทุกเวิร์กเกอร์ลงเอยที่ "รอเวิร์กเกอร์อื่น" คุณจะได้เดดล็อกแบบพูลอดอาหาร ประการที่สาม การพึ่งพาบุคลิกของเธรด เธรดเวิร์กเกอร์ถูกใช้ร่วมกันข้ามคอลแบ็ก ดังนั้นการคืนโดยเปลี่ยนลำดับความสำคัญของเธรดหรือสถานะการเริ่มต้น COM หรือทิ้งสถานะใน TLS จะปนเปื้อนคอลแบ็กถัดไป สำหรับการทำความสะอาดตอนท้าย (ปล่อยล็อกหรือยกเลิกโหลด DLL) มีกลไกเฉพาะเช่น LeaveCriticalSectionWhenCallbackReturns และ FreeLibraryWhenCallbackReturns
- มีจุดที่ต้องระวังเมื่อใช้ thread pool จาก DLL หรือไม่
- อันตรายที่สุดคือ "DLL ถูกยกเลิกโหลดขณะที่คอลแบ็กยังรันอยู่" หากคอลแบ็กรันหลังยกเลิกโหลด คุณจะได้ access violation ฝั่ง DLL ต้องในการประมวลผลปิดระบบ รอความเสร็จของคอลแบ็กที่ตนออกอย่างแน่นอน — ด้วยฟังก์ชันรอเช่น WaitForThreadpoolWorkCallbacks หรือ CloseThreadpoolCleanupGroupMembers บน cleanup group — แล้วจึงปิดออบเจ็กต์ การรอสิ่งนี้ใน DllMain อย่างไรก็ตาม อาจเดดล็อกผ่านปฏิสัมพันธ์กับ loader lock ดังนั้นกฎคือทำในฟังก์ชันปิดระบบที่ชัดเจน ไม่ใช่ใน DllMain สำหรับสถานการณ์ที่คอลแบ็กเองอยากปล่อย DLL เพราะ "งานนี้เป็นงานสุดท้าย" มี API เฉพาะคือ FreeLibraryWhenCallbackReturns
- เมื่อมี C++ std::async และ ThreadPool ของ .NET แล้วยังมีโอกาสใช้ API นี้โดยตรงอยู่หรือไม่
- มี เกณฑ์คือ "เครื่องมือที่ชั้นนั้นพอหรือไม่" หากความละเอียดของคอนเคอร์เรนซีที่คุณต้องการใน C++ ถูกครอบด้วย std::async หรือ std::thread ไลบรารีมาตรฐานคือตัวเลือกแรกจากมุมมองความพกพาด้วย ในทางกลับกัน การอยากรวมตัวตั้งเวลา การรอออบเจ็กต์เคอร์เนล และความเสร็จของ I/O แบบอะซิงโครนัสในกลไกคอลแบ็กเดียว การอยากแยกพูลและควบคุมจำนวนเธรดตามชนิดงาน การไม่อยากถือเธรดของตนเองใน DLL หรือคอมโพเนนต์ COM — ข้อกำหนดเหล่านั้นคือสิ่งที่ Win32 thread pool ครอบคลุม ความสัมพันธ์กับ ThreadPool ของ .NET และ IOCP อยู่ในบทความที่เกี่ยวข้อง และตราบที่คุณเขียนเนทีฟ การรู้จักกลไกที่นั่งอยู่ชั้นล่างไม่เสียเปล่า