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

· · Windows, มัลติเธรด, C++, การพัฒนา Windows, Win32 API, การปรับปรุงประสิทธิภาพ

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 ที่รันตลอดอายุโปรเซส — งานที่ต้องการ “บุคลิก” บนเธรดถูกถือบนเธรดเฉพาะ เธรดเวิร์กเกอร์เป็นทรัพยากรที่ใช้ร่วมกัน มันถูกยืม

การเลือกระหว่างเธรดเฉพาะกับพูลตรวจก่อนว่าต้องการบุคลิกของเธรด เช่น ลำดับความสำคัญหรือ STA หรือไม่ และว่ารันนานหรือไม่ เฉพาะงานอายุสั้น ปริมาณสูง หรือแบบรอที่ไม่เข้าทั้งคู่จึงไป thread poolใช่ไม่ใช่ไม่ต้องการบุคลิก เช่น priority หรือ STA?เก็บไว้บนเธรดเฉพาะรันนานหรือไม่?ใส่บน thread poolงานอายุสั้น การรอ ตัวตั้งเวลา ความเสร็จ I/O

ภาพ 1: งานเดียวที่อนุญาตให้ใส่บนพูลคืองานที่ “ไม่ต้องการบุคลิกและจบสั้น” นอกนั้นอยู่บนเธรดเฉพาะตามเดิม

ยังมีประวัติชิ้นหนึ่งที่ต้องตรึงไว้ thread-pool API มีสองรุ่น API เก่าที่สืบต่อมาตั้งแต่ Windows 2000 (QueueUserWorkItem, RegisterWaitForSingleObject เป็นต้น) และAPI ใหม่ที่ออกแบบใหม่ทั้งชุดใน Vista (ตระกูล CreateThreadpoolWork) API ใหม่รวมชนิดของเธรดเวิร์กเกอร์ มีเธรดถาวรเฉพาะ หลายพูลในโปรเซสเดียว cleanup group และอื่น ๆ และเอกสารทางการระบุตรง ๆ ว่า “เรียบง่ายกว่า น่าเชื่อถือกว่า มีประสิทธิภาพดีกว่า และยืดหยุ่นกว่า”1 API เก่ายังมีข้อจำกัดเชิงโครงสร้าง เช่น “คุณยกเลิกงานเมื่อเข้าคิวแล้วไม่ได้”2 ตั้งแต่นี้ไป บทความนี้พูดถึงเฉพาะ API ใหม่

ความสอดคล้องระหว่าง thread-pool API เก่ากับ API ใหม่QueueUserWorkItem ของ API เก่าแมปกับออบเจ็กต์ work ของ API ใหม่ timer queue แมปกับ timer การรอที่ลงทะเบียนแมปกับ wait และ BindIoCompletionCallback แมปกับ ioQueueUserWorkItemworkTimer queuestimerRegistered waitswaitBindIoCompletionCallbackio

ภาพ 2: เป้าหมายการย้ายจาก API เก่าเป็นหนึ่งต่อหนึ่ง การสำรวจโค้ดที่มีอยู่เริ่มจากความสอดคล้องนี้ได้

3. ออบเจ็กต์สี่ชนิด — work, timer, wait และ io

ศูนย์กลางของ API ใหม่คือออบเจ็กต์สี่ชนิดที่เงื่อนไขการยิงคอลแบ็กต่างกัน3

ออบเจ็กต์ ฟังก์ชันสร้าง เมื่อคอลแบ็กยิง
work CreateThreadpoolWork เมื่อถูกส่งด้วย SubmitThreadpoolWork
timer CreateThreadpoolTimer เมื่อเวลาหรือคาบที่ระบุมาถึง
wait CreateThreadpoolWait เมื่อออบเจ็กต์เคอร์เนลกลายเป็นสัญญาณ
io CreateThreadpoolIo เมื่อ I/O แบบอะซิงโครนัสบนแฮนเดิลที่เชื่อมเสร็จ
ออบเจ็กต์สี่ชนิดของ thread pool และกลไกคอลแบ็กwork ยิงเมื่อส่งชัดเจน timer ยิงตามเวลา wait ยิงเมื่อออบเจ็กต์เคอร์เนลมีสัญญาณ และ io ยิงเมื่อ I/O แบบอะซิงโครนัสเสร็จ ทั้งสี่รันเป็นคอลแบ็กบนกลุ่มเธรดเวิร์กเกอร์เดียวกันออบเจ็กต์ใด?work(เมื่อส่ง)timer, wait หรือ io?timer(เวลา / คาบ)wait หรือ io?wait(เมื่อมีสัญญาณ)io(ความเสร็จ I/O)เวิร์กเกอร์รันคอลแบ็ก

ภาพ 3: เงื่อนไขการยิงต่างกัน แต่ทั้งสี่รวมในกลไกที่ “เวิร์กเกอร์บนพูลเดียวกันรันคอลแบ็ก”

การรวมนี้คือจุดแข็งเชิงปฏิบัติ แทนที่จะเขียนการประมวลผลตามคาบ การตอบอีเวนต์ และการประมวลผลความเสร็จของ I/O แต่ละอย่างบนเธรดเฉพาะ คุณจัดแนวได้บนสไตล์คอลแบ็กเดียว ตัวตั้งเวลารวมเข้าคิวตัวตั้งเวลาเดียวของทั้งพูล และการรอถูกรวมเข้าสู่เธรดรอจำนวนน้อย — เธรดที่ “หลับอย่างเดียว” หายไปจากโปรเซส1

การแทนที่เธรดที่รออย่างเดียวด้วย wait objectเธรดที่รออย่างเดียวซึ่งเคยหลับหนึ่งเส้นต่ออีเวนต์กลายเป็น wait object และถูกรวมเข้าสู่เธรดรอของพูล ดังนั้นคอลแบ็กรันเฉพาะเมื่อมีสัญญาณ5 เธรดรอเฉพาะที่หลับแยกกันกิน 5 สแตกและ 5 เธรด5 wait objectรวมเข้าสู่เธรดรอของพูลคอลแบ็กรันเฉพาะเมื่อมีสัญญาณ

ภาพ 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 ยังพยายามยกเลิกการส่งที่ยังไม่เริ่ม

วงจรชีวิตของออบเจ็กต์ workสร้างด้วย CreateThreadpoolWork ส่งด้วย SubmitThreadpoolWork แล้วคอลแบ็กรันขนานกัน ตอนปิดระบบ หยุดการส่งใหม่ก่อน รอทุกคอลแบ็กเสร็จด้วย WaitForThreadpoolWorkCallbacks แล้วปิดด้วย CloseThreadpoolWorkสร้างด้วย CreateThreadpoolWorkส่งด้วย SubmitThreadpoolWork (ทำซ้ำได้)คอลแบ็กรันขนานกันหยุดการส่งใหม่รอความเสร็จด้วย WaitForThreadpoolWorkCallbacksปิดด้วย CloseThreadpoolWork

ภาพ 5: ลำดับการปิดระบบคือ “หยุดการส่ง → รอความเสร็จ → ปิด” ข้ามข้อใดข้อหนึ่งแล้วคุณได้ use-after-free หรือการแข่ง

ตามค่าเริ่มต้น คอลแบ็กรันบนพูลเริ่มต้นของโปรเซส สำหรับหลายงานนั่นพอ บทถัดไปมีไว้เมื่อคุณอยากแยกพูล

5. พูลกำหนดเองและ cleanup group

แยกพูล คุณสร้างพูลอิสระด้วย CreateThreadpool และตั้งขอบบนและขอบล่างของจำนวนเธรดด้วย SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum8 การใช้ทั่วไปคือการแยก เพื่อไม่ให้ “งานแบบแบตช์ที่อาจช้า” กินเวิร์กเกอร์ของ “งานที่ต้องตอบทันที” คุณแยกพูลและให้งบเธรดของตนเองแก่แต่ละพูล

ผูกด้วยสภาพแวดล้อมคอลแบ็ก พูลที่งานรันอยู่ถูกระบุด้วยการเริ่มต้น TP_CALLBACK_ENVIRON (สภาพแวดล้อมคอลแบ็ก) ชี้ไปที่พูลด้วย SetThreadpoolCallbackPool และส่งสิ่งนั้นเป็นอาร์กิวเมนต์ที่สามของ CreateThreadpoolWork เป็นต้น9

พับด้วย cleanup group ในมอดูลที่สร้างออบเจ็กต์จำนวนมาก การประมวลผลปิดระบบมักกลายเป็นการท่อง “รอทั้งหมด ปิดทั้งหมด” หากคุณสร้างกลุ่มด้วย CreateThreadpoolCleanupGroup และแนบแต่ละออบเจ็กต์ผ่านสภาพแวดล้อมคอลแบ็ก การเรียก CloseThreadpoolCleanupGroupMembers ครั้งเดียวทำการรอความเสร็จและปล่อยของทุกออบเจ็กต์สมาชิกพร้อมกัน34

การผูกการตั้งค่าผ่านสภาพแวดล้อมคอลแบ็กสภาพแวดล้อมคอลแบ็กชี้ไปที่พูลกำหนดเองและ cleanup group work และ timer ที่สร้างด้วยสภาพแวดล้อมนั้นรันบนพูลนั้น และการทำงานเป็นกลุ่มบน cleanup group รวมการรอความเสร็จและการปล่อยสภาพแวดล้อมคอลแบ็ก (TP_CALLBACK_ENVIRON)พูลกำหนดเอง (ควบคุมจำนวนเธรด)Cleanup groupส่งตอนสร้าง work / timer / wait / ioรอความเสร็จและปล่อยในครั้งเดียว

ภาพ 6: สภาพแวดล้อมคอลแบ็กคือกลไกที่ฉีด “รันบนพูลใด และใครทำความสะอาด” ตอนสร้างออบเจ็กต์

6. กับดัก — วินัยในคอลแบ็ก

บั๊กของ thread pool เกือบทั้งหมดมาจาก “ทำตามใจบนเธรดที่ยืมมา”

การบล็อกนาน พูลปรับจำนวนเธรดโดยสมมติว่าคอลแบ็กคืนเร็ว การทำงานนานหรือรอนานที่ค่าเริ่มต้นทำให้การรันคอลแบ็กอื่นล่าช้า คอลแบ็กที่อาจรันนานควรประกาศ “อันนี้จะรันนาน” ด้วย CallbackMayRunLong (พูลถือเป็นคำใบ้ให้เพิ่มเธรด) หรือส่งไปเธรดเฉพาะตั้งแต่แรก โปรดทราบว่า CallbackMayRunLong คืน FALSE เมื่อเตรียมเวิร์กเกอร์สำหรับคอลแบ็กอื่นไม่ได้ หากคุณบล็อกต่อโดยไม่ตรวจค่าที่คืนมา คุณยังอุดพูล ดังนั้นเมื่อเป็น FALSE ให้ไปทางที่ไม่บล็อก — แยกงาน ส่งไปเธรดเฉพาะ เป็นต้น5

การรอความเสร็จแบบซิงโครนัสบนพูลเดียวกัน รูปแบบที่ในคอลแบ็ก A คุณรอด้วย WaitForThreadpoolWorkCallbacks หรือคล้ายกันเพื่อความเสร็จของงาน B ที่ส่งเข้าพูลเดียวกัน จะกลายเป็นเดดล็อกแบบพูลอดอาหาร ในวินาทีที่ทุกเวิร์กเกอร์ “รอเวิร์กเกอร์อื่น” เขียนการพึ่งพาระหว่างงานใหม่ไม่เป็นการรอ แต่เป็นการต่อเนื่องที่ “ส่งงานถัดไปจากคอลแบ็กความเสร็จของ B”

โครงสร้างของเดดล็อกแบบพูลอดอาหารหากทุกเธรดเวิร์กเกอร์รอความเสร็จแบบซิงโครนัสของงานอื่นที่ส่งเข้าพูลเดียวกัน จะไม่มีเวิร์กเกอร์ว่างเหลือให้รันงานนั้น และทุกคนรอตลอดไปเวิร์กเกอร์ 1: รอให้งาน X เสร็จงาน X และ Y รอรันเวิร์กเกอร์ 2: รอให้งาน Y เสร็จไม่มีเวิร์กเกอร์ว่างเหลือให้รันทุกคนรอตลอดไป (เดดล็อกอดอาหาร)

ภาพ 7: หากคุณรอเวิร์กเกอร์แบบซิงโครนัสจากในเวิร์กเกอร์ จะไม่มีใครเหลือให้รันงานที่กำลังรอ

การทำให้สถานะของเธรดสกปรก เธรดเวิร์กเกอร์ถูกใช้ซ้ำสำหรับคอลแบ็กถัดไป การเปลี่ยนลำดับความสำคัญของเธรด สถานะการเริ่มต้น COM ค่าที่ทิ้งใน TLS ล็อกที่ลืมออก — สิ่งเหล่านี้กลายเป็นการปนเปื้อนของคอลแบ็กถัดไป (ที่ไม่เกี่ยวข้อง) “ฟังก์ชันที่คุณโยนเข้าพูลต้องไม่พึ่งพาบุคลิกของเธรด” เป็นคำเตือนทางการมาตั้งแต่ยุค API เก่า6 มีกลไกเฉพาะสำหรับการทำความสะอาด เช่น LeaveCriticalSectionWhenCallbackReturns สามารถขอให้พูล “ปล่อยล็อกนี้เมื่อคอลแบ็กนี้คืน”3

การแข่งกับการยกเลิกโหลด DLL หาก DLL ที่มีโค้ดถูกยกเลิกโหลดขณะที่คอลแบ็กรัน คุณจะได้ access violation รูปแบบพื้นฐานคือรอความเสร็จอย่างละเอียดในฟังก์ชันปิดระบบของ DLL FreeLibraryWhenCallbackReturns มีไว้สำหรับสถานการณ์ “คอลแบ็กนี้เป็นงานสุดท้าย และเมื่อเสร็จอยากให้ DLL ถูกปล่อยรวมตัวเอง” อย่างไรก็ตาม API นี้เพียง “ปล่อยการอ้างอิงหนึ่งเมื่อคอลแบ็กที่กำลังรันคืน” ไม่ได้ป้องกันการยกเลิกโหลดก่อนที่คอลแบ็กจะเริ่ม คุณใช้เป็นคู่: ถือการอ้างอิงมอดูลของตนเองด้วย GetModuleHandleEx ก่อนส่ง และให้คอลแบ็กปล่อยการอ้างอิงนั้นด้วย API นี้3 และคุณห้ามรอความเสร็จนี้ใน DllMain — ตามที่ระบุใน “DllMain และ Loader Lock” การรอเธรดอื่นใน DllMain คือรูปแบบเดดล็อก

การเตรียมรับการแข่งระหว่างการยกเลิกโหลด DLL กับคอลแบ็กการยกเลิกโหลด DLL ขณะที่คอลแบ็กรันกลายเป็น access violation ดังนั้นรูปแบบพื้นฐานคือรอความเสร็จในฟังก์ชันปิดระบบที่ชัดเจนแล้วปิด เมื่อคอลแบ็กสุดท้ายเองปล่อย DLL ให้ใช้ FreeLibraryWhenCallbackReturnsยกเลิกโหลดระหว่างคอลแบ็กAccess violationรอ แล้วปิดยกเลิกโหลดอย่างปลอดภัยในฟังก์ชันปิดระบบFreeLibraryWhenCallbackReturnsคอลแบ็กสุดท้ายปล่อย DLLไม่ใช่ใน DllMain

ภาพ 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
การตัดสินว่าจะเขียนด้วยเครื่องมือชั้นใดหาก async หรือ thread ของ C++ มาตรฐานพอ ให้ใช้สิ่งนั้น ใช้ Win32 thread pool โดยตรงเมื่อต้องการการรวมตัวตั้งเวลา การรอ และความเสร็จของ I/O การแยกพูลหรือควบคุมจำนวนเธรด หรือไม่อยากมีเธรดของตนเองใน DLL หรือคอมโพเนนต์ COMใช่ไม่การรวม timer, wait และ ioการแยกพูล / ควบคุมจำนวนเลี่ยงเธรดของตนเองใน DLLเครื่องมือ C++ มาตรฐานพอหรือไม่?std::async / std::threadคุณต้องการอะไร?Win32 thread pool

ภาพ 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 เมื่อคุณเลิกจากแนวคิด “สร้างเธรด” ไปสู่แนวคิด “โยนคอลแบ็ก” แล้ว คอนเคอร์เรนซีในโค้ดเนทีฟจะเขียนได้ชัดเจนกว่ามาก

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

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

KomuraSoft LLC รับออกแบบการย้ายจากโค้ดเนทีฟที่เธรดเพิ่มจำนวนไปสู่ thread pool การตรวจทานการออกแบบการประมวลผลพร้อมกันในแอป C++ และ DLL และการสอบสวนหาสาเหตุของแฮงและการล่มที่เกิดจากพูลอดอาหารหรือคอลแบ็ก ยินดีให้คำปรึกษาตั้งแต่ขั้นสำรวจโค้ดที่มีอยู่

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

  1. Microsoft Learn, Thread Pools. ว่าด้วย thread pool เป็นกลุ่มเธรดเวิร์กเกอร์ที่รันคอลแบ็กแบบอะซิงโครนัสอย่างมีประสิทธิภาพแทนแอป ว่าด้วยชนิดแอปที่เหมาะ (ออกงานเล็กจำนวนมากขนานกัน สร้างและรื้อเธรดอายุสั้นบ่อย ประมวลผลงานอิสระขนานกัน การรอเฉพาะบนออบเจ็กต์เคอร์เนล เป็นต้น) และว่าด้วยการออกแบบใหม่ทั้งชุดใน Vista (การรวมชนิดเธรดเวิร์กเกอร์ คิวตัวตั้งเวลาเดียว เธรดถาวรเฉพาะ cleanup group หลายพูลในโปรเซสเดียว และ API ใหม่)  2 3 4 5

  2. Microsoft Learn, Thread Pooling. ว่าด้วยโครงสร้างของ thread-pool API รุ่นเก่า (QueueUserWorkItem, timer queues, registered waits, BindIoCompletionCallback) ว่าไม่มีทางยกเลิกงานเมื่อเข้าคิวแล้ว และว่า thread-pool API ใหม่ที่แนะนำใน Vista ถูกระบุว่าเรียบง่ายกว่าและเหนือกว่าด้านความน่าเชื่อถือ ประสิทธิภาพ และความยืดหยุ่น  2

  3. Microsoft Learn, threadpoolapiset.h header. ว่าด้วยรายการฟังก์ชันที่รวมฟังก์ชันสร้างออบเจ็กต์สี่ชนิด CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait และ CreateThreadpoolIo cleanup group (CreateThreadpoolCleanupGroup) และการทำความสะอาดที่ผูกกับความเสร็จของคอลแบ็ก (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns เป็นต้น)  2 3 4 5 6

  4. Microsoft Learn, Using the Thread Pool Functions. ว่าด้วยขั้นตอนพื้นฐานของการสร้างด้วย CreateThreadpoolWork การส่งด้วย SubmitThreadpoolWork การรอความเสร็จด้วย WaitForThreadpoolWorkCallbacks และการปิดด้วย CloseThreadpoolWork และตัวอย่างการตั้งค่าที่รวมพูลกำหนดเองกับสภาพแวดล้อมคอลแบ็กและ cleanup group  2 3

  5. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). ว่าด้วยการแจ้งพูลว่าคอลแบ็กปัจจุบันอาจรันนาน เพื่อให้พูลใช้เป็นข้อมูลตัดสินว่าจะสำรองเธรดสำหรับคอลแบ็กอื่นหรือไม่ และว่าให้พิจารณาเธรดเฉพาะสำหรับคอลแบ็กที่รันนานเมื่อทำได้  2

  6. Microsoft Learn, Thread Pooling. ว่าด้วยรายการงานที่ส่งเข้า thread pool และฟังก์ชันที่พวกมันเรียกต้องปลอดภัยต่อ thread pool ว่าอย่าสมมติว่าเธรดที่รันเป็นเธรดเฉพาะถาวร และว่าให้เลี่ยงการใช้ TLS และการเรียกอะซิงโครนัสที่ต้องการเธรดถาวร  2

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). ว่าด้วยการส่งออบเจ็กต์ work เดียวกันได้มากกว่าหนึ่งครั้งโดยไม่รอให้คอลแบ็กก่อนหน้าเสร็จ จึงให้คอลแบ็กรันขนานกัน และว่าพูลสามารถปรับ (throttle) จำนวนเธรดเพื่อประสิทธิภาพ 

  8. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). ว่าด้วยการตั้งขอบบนของจำนวนเธรดเวิร์กเกอร์สำหรับพูลที่สร้างด้วย CreateThreadpool ได้ (ขอบล่างคือ SetThreadpoolThreadMinimum) 

  9. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). ว่าด้วยการสร้างออบเจ็กต์ work จากฟังก์ชันคอลแบ็กและพอยน์เตอร์บริบท และว่าอาร์กิวเมนต์ที่สาม TP_CALLBACK_ENVIRON สามารถระบุสภาพแวดล้อมการรันของคอลแบ็ก (พูลที่สังกัด เป็นต้น) โดย NULL หมายถึงรันในสภาพแวดล้อมเริ่มต้น 

  10. 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 ได้แก่ การ...

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

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

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

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

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 อยู่ในบทความที่เกี่ยวข้อง และตราบที่คุณเขียนเนทีฟ การรู้จักกลไกที่นั่งอยู่ชั้นล่างไม่เสียเปล่า

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

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

Go Komura

ผู้แทนของ KomuraSoft LLC

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

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

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