แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ .NET — สิ่งที่ต้องตัดสินก่อนเพิ่มเธรด

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

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

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

บทความนี้เป็น ฉบับ .NET ของซีรีส์มัลติเธรดเชิงปฏิบัติ มุ่งนักพัฒนาที่เขียนแอปธุรกิจบน Windows แล้วพบว่าต้องมัลติเธรด โดยเรียบเรียงหลักการออกแบบที่ใช้ได้ไม่ว่าภาษาหรือ OS พร้อมเครื่องมือรูปธรรมใน C#/.NET จากแหล่งปฐมภูมิ ณ สิงหาคม 2026 หลักการเองไม่เปลี่ยนทั้งบน Linux และใน C++ หากเขียนเนทีฟ ดูบทความคู่ที่แมปหลักเดียวกันลงเครื่องมือของแต่ละภาษา — «ฉบับ C++» และ «ฉบับ C» — หากเขียน Java ดู «ฉบับ Java»

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

  • แนวปฏิบัติข้อแรกคืออย่าสร้างเธรดเอง ใช้ API ระดับสูงอย่าง Task พูลเธรด และคลาส Parallel แทน new Thread แล้วมอบการจัดการจำนวนเธรดให้รันไทม์12
  • สิ่งที่ควรตัดก่อนเมื่อขนานคือ «สถานะที่เปลี่ยนแปลงร่วมกัน» จุดที่หลายเธรดเขียนตัวแปรเดียวกันคือแหล่งกำเนิดของการแข่ง ก่อนจะกางล็อกไปปกป้อง ให้ลดการแชร์เองด้วยการแยกข้อมูล ความไม่เปลี่ยนแปลง และการส่งต่อ3
  • ให้ล็อกมีวินัย ตัดสินแบบหนึ่งต่อหนึ่งว่า «ข้อมูลใดถูกล็อกใดปกป้อง» และให้อ็อบเจกต์ล็อกเป็นอินสแตนซ์เฉพาะที่มองไม่เห็นจากภายนอก lock(this) และ lock(typeof(X)) ห้าม จาก .NET 9 เป็นต้นไป ใช้ชนิดเฉพาะ System.Threading.Lock4
  • ส่งข้อมูลระหว่างเธรดให้ผ่านคิว โครงผู้ผลิต/ผู้บริโภคบน System.Threading.Channels หรือคอลเล็กชันคอนเคอร์เรนต์ออกแบบง่ายกว่าการโปรยล็อกไปทั่ว และเส้นขอบก็ชัด56
  • ออกแบบวิธีหยุดก่อนอย่างอื่น การยกเลิกแบบร่วมมือด้วย CancellationToken คือคำตอบที่ถูกเพียงอย่างเดียวในการหยุด ส่วน Thread.Abort บน .NET (สาย Core) โยนข้อยกเว้นตอนรัน78
  • UI เป็นของเธรด UI โดยเฉพาะ ทั้งคอนโทรล WinForms และอิลิเมนต์ WPF ห้ามแตะจากเธรดอื่นนอกจากเธรดที่สร้างมัน จากเธรดอื่น ให้ขอผ่าน Control.Invoke / Dispatcher910
  • «ขนานแล้วเร็ว» ไม่เป็นจริงเสมอ ลูปที่งานต่อรอบเล็ก อาจช้าลงเพราะค่าโสหุ้ยของการขนาน วัดก่อนแล้วค่อยรับไปใช้3

2. ทำไมมัลติเธรดจึงยาก — เงื่อนไขแข่งขันและเดดล็อก

ย่อแล้ว ปัญหาที่มัลติเธรดนำเข้ามีสองชนิด4

เงื่อนไขแข่งขัน (race condition) คือบั๊กที่ผลเปลี่ยนตามลำดับที่หลายเธรดถึงชิ้นโค้ดหนึ่ง ตัวอย่างคลาสสิกคือการเพิ่มตัวนับร่วม: บรรทัดเดียว count++ จริง ๆ แตกเป็นสามขั้น — «อ่าน → บวก → เขียนกลับ» หากสองเธรดรันสามขั้นนี้พร้อมกัน การบวกของฝ่ายหนึ่งถูกการเขียนกลับของอีกฝ่ายทับแล้วหายไป ผลเปลี่ยนทุกรอบรัน และผลที่ได้นั้นคาดเดาไม่ได้4

เงื่อนไขแข่งขันบนตัวนับร่วมเงื่อนไขแข่งขันคลาสสิกที่การบวกบนตัวนับร่วมหายไป หากเธรดอื่นแทรกกลางสามขั้นของ 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 — แค่นั้นพอให้ทั้งคู่หยุดตลอดกาล4

การรอเป็นวงของเดดล็อกการรอเป็นวงของเดดล็อก ทันทีที่ลูกศรการรอเป็นวง ทุกเธรดในวงนั้นหยุดตลอดกาลรอการปล่อยกล็อก 2รอการปล่อยกล็อก 1เธรด Aถือกล็อก 1เธรด Bถือกล็อก 2

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

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

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

3. หลักที่ 1: อย่าสร้างเธรดเอง

3.1. ขี่ Task และพูลเธรด

การสร้างเธรดตรงด้วย new Thread(...) ใน .NET วันนี้เป็นทางเลือกสุดท้ายที่ยกเว้น ตั้งแต่ .NET Framework 4 เครื่องมือที่แนะนำสำหรับโค้ดมัลติเธรดและขนานคือ TPL (Task Parallel Library) คือตระกูล API ที่ศูนย์กลางคือ Task TPL ปรับระดับความเป็นขนานแบบพลวัตให้เข้ากับโปรเซสเซอร์ที่มี และรับงานระดับต่ำทั้งหมด — แยกงาน จัดตารางลงพูลเธรด รองรับการยกเลิก จัดการสถานะ — แทนคุณ1

พูลเธรดเป็นโครงสร้างพื้นฐานที่ .NET เองใช้กว้างขวาง — รัน Task ความเสร็จของ I/O อะซิงโครนัส คอลแบ็กตัวตั้งเวลา และอื่น ๆ — และตราบที่คุณโยนงานสั้น ๆ นักพัฒนาไม่ต้องจัดการวงจรชีวิตของเธรดเอง2

// รันงานหนักที่กิน CPU ในพื้นหลัง
var result = await Task.Run(() => HeavyCalculation(input));

// รันหลายงานอิสระพร้อมกันแล้วรอทั้งหมด (เมื่อจำนวนน้อย)
// ※ รูปนี้สมมติว่า ProcessAsync เป็นเมธอดอะซิงโครนัสที่ I/O เป็นหลัก
//   WhenAll แค่ «รอ Task ที่รันอยู่แล้ว» ดังนั้นถ้าอยากขนานการคำนวณ CPU
//   ให้ห่อแต่ละงานด้วย Task.Run(() => Calc(x)) เพื่อขึ้นพูลเธรด
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// ถ้าจำนวนมาก ให้จำกัดจำนวนที่รันพร้อมกันแล้วปล่อยไหล
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// จุดสำคัญมีสองข้อ: ต่อโทเคนของผู้เรียกเข้า ParallelOptions
// (ลืมข้อนี้แล้ว ct ในตัวงานเป็น None เสมอ) และส่ง ct นั้นเข้าตัวงานด้วย (อย่าทิ้ง)

มีข้อควรระวังหนึ่งข้อ Task.WhenAll(items.Select(...)) เริ่มประมวลผลทุกสมาชิกพร้อมกัน ณ จุดที่ถูกนับ งานจำนวนหยิบมือถึงสองสามสิบชิ้นที่กำหนดไว้ไม่เป็นไร แต่ใช้กับคอลเล็กชันใหญ่จะหมดซ็อกเก็ต การเชื่อมต่อ DB และหน่วยความจำในคราวเดียว งานที่จำนวนอ่านไม่ได้ ให้จำกัดจำนวนที่รันพร้อมกันอย่าง Parallel.ForEachAsync ด้านบน หรือควบคุมอัตราไหลด้วยช่องทาง bounded ที่จะกล่าวต่อไป

เธรดที่สร้างเองชอบธรรมได้เกือบเฉพาะเมื่อบุคลิกของเธรดเองคือข้อกำหนด — เช่น «ต้องมีลูปข้อความเฉพาะ» «ต้องระบุอพาร์ตเมนต์ของเธรด (STA)» หรือ «ต้องรันตลอดอายุของแอป»

3.2. ความขนานของข้อมูลใช้ Parallel.For / ForEach

สำหรับความขนานของข้อมูล — «ใช้การประมวลผลเดียวกันกับทุกสมาชิกของคอลเล็กชัน เพื่อให้งานทั้งก้อนเร็วขึ้น» — ใช้ Parallel.For / Parallel.ForEach แทนการผ่าลูปลงเธรดเอง TPL จัดการการแยกแหล่งข้อมูล (พาร์ทิชัน) และการจัดสมดุลภาระใหม่ และลูปพื้นฐานไม่ต้องมีล็อก11

อย่างไรก็ตาม เอกสารทางการระบุกับดักสองข้อชัดเจน3

  • อย่าคิดว่าขนานเร็วเสมอ ลูปที่รอบน้อย หรือที่งานต่อรอบเบา อาจช้าลงเพราะค่าโสหุ้ยของการขนานแซงตัวงาน ประสิทธิภาพมีปัจจัยมาก จึงต้องวัดแล้วค่อยตัดสิน
  • อย่าให้นัดพบระหว่างรอบ ไม่มีหลักประกันว่ารอบของ Parallel.For จะรันขนานจริง โค้ดที่รอบหนึ่งรออีเวนต์ที่อีกรอบตั้ง เดดล็อกได้ตามการจัดตาราง

3.3. งานที่ «รอ» ส่งไป I/O อะซิงโครนัส ไม่ใช่เธรด

งานที่รอ I/O เป็นหลัก — ไฟล์ เครือข่าย ฐานข้อมูล — ไม่ใช่เป้าหมายของการเพิ่มเธรด การยึดเธรดทั้งเส้นไว้ระหว่างรอเป็นความสูญเปล่า I/O อะซิงโครนัสด้วย async/await ไม่กินเธรดขณะรอ การแบ่งนี้ (CPU-bound ให้ขนาน I/O-bound ให้อะซิงโครนัส) คือเส้นแรกที่ควรลากที่ปากทางของการออกแบบมัลติเธรด

กิ่งก่อนตั้งเธรดกิ่งที่ต้องเดินก่อน «ตั้งเธรด» งานธุรกิจส่วนใหญ่ตกที่ทางออกสามอันบน และการถึง new Thread เป็นกรณีพิเศษเท่านั้นรอ I/O เป็นหลักไฟล์ เครือข่าย DBการคำนวณที่กิน CPUใช้การประมวลผลเดียวกันกับทุกสมาชิกของคอลเล็กชันก้อนอิสระของงานพื้นหลังลูปข้อความ ข้อกำหนด STA ฯลฯบุคลิกของเธรดเองคือข้อกำหนดมีงานที่อยากรันขนานแกนของงานคืออะไร?I/O อะซิงโครนัสด้วย async/awaitอย่าเพิ่มเธรดรูปร่างของงานคืออะไร?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(ทางสุดท้ายที่ยกเว้น)

ภาพ 3: กิ่งที่ต้องเดินก่อน «ตั้งเธรด» งานธุรกิจส่วนใหญ่ตกที่ทางออกสามอันบน และการถึง new Thread เป็นกรณีพิเศษเท่านั้น

การตัดสินเชิงปฏิบัติของ async/await อยู่ใน «ตารางตัดสินเชิงปฏิบัติสำหรับ C# async/await — Task.Run และ ConfigureAwait» และว่าพูลเธรดกับ I/O อะซิงโครนัสต่อกันอย่างไรที่ชั้นล่าง อธิบายละเอียดใน «เชิงลึกของ Windows I/O (ตอนที่ 3) — I/O Completion Ports (IOCP) และ .NET Thread Pool»

4. หลักที่ 2: ลดสถานะที่เปลี่ยนแปลงร่วมกันให้เหลือน้อย

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

4.1. แยก — ให้แต่ละเธรดแตะเฉพาะข้อมูลของตน

วิธีที่เรียบง่ายและทรงพลังที่สุดคือผ่าข้อมูลต่อเธรด สำหรับการรวมในลูปขนาน แทนที่จะเขียนเข้าตัวแปรผลรวมร่วมทุกรอบ ให้ใช้โอเวอร์โหลดของ Parallel.For ที่รับสถานะเฉพาะเธรด ให้แต่ละเธรดสร้างผลรวมย่อยในมือ แล้วรวมครั้งเดียวตอนท้าย การเขียนเข้าค่าที่แชร์ลดจาก «ทุกรอบ» เหลือ «ครั้งเดียวต่อเธรด» ทั้งต้นทุนซิงโครไนซ์และหน้าต่างการแข่งหดลงเป็นเท่าตัว3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                  // ค่าเริ่มต้นเฉพาะเธรด
    (i, state, local) => local + Weigh(items[i]), // แต่ละรอบบวกเข้า local ของตนเท่านั้น
    local => Interlocked.Add(ref total, local));  // รวมครั้งเดียวต่อเธรด
การรวมเฉพาะเธรดการรวมเฉพาะเธรด ระหว่างประมวลผลแต่ละเธรดแตะเฉพาะข้อมูลของตน จึงไม่มีช่องให้แข่ง และการเขียนเข้าค่าที่แชร์เกิดครั้งเดียวต่อเธรดตอนรวมอาร์เรย์ข้อมูล(เป้าหมายการประมวลผล)เธรด 1ประมวลผลส่วนที่รับผิดชอบแล้วบวกเข้าผลรวมย่อยของตนเท่านั้นเธรด 2ประมวลผลส่วนที่รับผิดชอบแล้วบวกเข้าผลรวมย่อยของตนเท่านั้นเธรด 3ประมวลผลส่วนที่รับผิดชอบแล้วบวกเข้าผลรวมย่อยของตนเท่านั้นรวม: Interlocked.Add สะท้อนเข้าผลรวมครั้งเดียวต่อเธรด

ภาพ 4: การรวมเฉพาะเธรด ระหว่างประมวลผลแต่ละเธรดแตะเฉพาะข้อมูลของตน จึงไม่มีช่องให้แข่ง และการเขียนเข้าค่าที่แชร์เกิดครั้งเดียวต่อเธรดตอนรวม

4.2. ทำให้ไม่เปลี่ยนแปลง — สิ่งที่ไม่เขียนใหม่ แชร์ได้อย่างอิสระ

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

แต่ «ดูเหมือนอ่านอย่างเดียว» กับ «ไม่เปลี่ยนแปลง» เป็นคนละเรื่อง อินเทอร์เฟซอ่านอย่างเดียวอย่าง IReadOnlyList<T> หมายแค่ «เขียนผ่านอินเทอร์เฟซนั้นไม่ได้» — มันกันไม่ให้ List<T> ด้านหลังถูกเขียนผ่านเรเฟอเรนซ์อื่น การรับประกันของ record / init ก็ตื้น: มันไม่ปกป้องอ็อบเจกต์ที่พร็อพเพอร์ตี้ชี้ไป ข้อมูลที่อยากแชร์ระหว่างเธรดอย่างปลอดภัยจริง ๆ ให้ใช้คอลเล็กชันไม่เปลี่ยนแปลงจาก System.Collections.Immutable อย่าง ImmutableArray<T> หรือส่งสำเนา ณ จุดที่แชร์ เพื่อตัดเส้นทางเขียนทั้งเส้น ตอนนั้น ชนิดสมาชิก T เองต้องไม่เปลี่ยนแปลงด้วย คือเงื่อนไข คอลเล็กชันไม่เปลี่ยนแปลงปกป้องแค่ «ลำดับ» เรเฟอเรนซ์ไปยังอ็อบเจกต์สมาชิกที่เปลี่ยนแปลงได้ยังถูกแชร์ตามเดิม ดังนั้นถ้าเนื้อในสมาชิกถูกเขียนผ่านเส้นทางอื่น การแข่งยังอยู่ ทำให้กราฟอ็อบเจกต์ไม่เปลี่ยนแปลงจนถึงใบ หรือส่งสำเนาลึก

4.3. ส่งต่อ — ส่งผ่านคิวแทนการแชร์

ถึงอย่างนั้นก็ยังต้องขยับข้อมูลระหว่างเธรด เมื่อนั้น แทนที่จะ «ให้ทั้งสองฝ่ายแตะตัวแปรร่วม» ให้ใช้โครงผู้ผลิต/ผู้บริโภคที่ ฝ่ายหนึ่งเขียน อีกฝ่ายอ่าน โดยมีคิวคั่นกลาง

ตัวเลือกแรกบน .NET คือ System.Threading.Channels เป็น FIFO ที่ผู้ผลิตเขียนข้อมูลแบบอะซิงโครนัส และผู้บริโภคอ่านออกแบบอะซิงโครนัส โดยช่องทางจัดการงานซิงโครไนซ์ทั้งหมด5

var channel = Channel.CreateBounded<WorkItem>(100); // ความจุ 100 ให้แรงดันย้อนกลับ

// ฝั่งผู้ผลิต
await channel.Writer.WriteAsync(item, ct); // ถ้าเต็ม รอจนมีที่ว่าง
// …เมื่อผู้ผลิตทุกคนเขียนจบ:
channel.Writer.Complete();   // ประกาศ «ไม่มีอีกแล้ว» ไม่มีอันนี้ลูปฝั่งอ่านจบไม่ได้

// ฝั่งผู้บริโภค
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
โครงผู้ผลิต/ผู้บริโภคคั่นด้วยช่องทางโครงผู้ผลิต/ผู้บริโภคที่คั่นด้วยช่องทาง ทั้งสองฝ่ายไม่แตะตัวแปรร่วมโดยตรง ทั้งการนัดพบและการควบคุมความจุมอบให้ช่องทางถ้าเต็ม ให้การเขียนรอ(แรงดันย้อนกลับ)ถ้าว่าง ให้การอ่านรอผู้ผลิต 1WriteAsyncช่องทาง bounded (ความจุ 100)คิว FIFOการซิงโครไนซ์จัดการโดยช่องทางผู้ผลิต 2WriteAsyncผู้บริโภค 1ReadAllAsyncผู้บริโภค 2ReadAllAsync

ภาพ 5: โครงผู้ผลิต/ผู้บริโภคที่คั่นด้วยช่องทาง ทั้งสองฝ่ายไม่แตะตัวแปรร่วมโดยตรง ทั้งการนัดพบและการควบคุมความจุมอบให้ช่องทาง

สิ่งสำคัญในงานจริงคือ เลือกช่องทางที่มีเพดานความจุ (bounded) พฤติกรรมปริยายเมื่อถึงเพดานคือ «ฝั่งเขียนรอที่ว่าง» ซึ่งกลายเป็นแรงดันย้อนกลับ (backpressure) ตามธรรมชาติ ใช้คิวไร้เพดานในชุดที่การผลิตแซงการบริโภค แล้วคุณได้ระเบิดเวลาที่ «ยังรันอยู่» แต่หน่วยความจำโตเรื่อย ๆ5

ในโลกซิงโครนัส บทบาทเดียวกับช่องทาง bounded คือ BlockingCollection<T> ที่ระบุความจุ มันมีทั้งการบล็อกและการควบคุมความจุ: เพดานความจุกันฝั่งผลิตไม่ให้แซงฝั่งบริโภคเกินไป และเมื่อว่างจะบล็อกให้ฝั่งบริโภครอ12 ในทางกลับกัน ConcurrentQueue<T> / ConcurrentStack<T> เป็นคอลเล็กชันเร็วที่ทำให้ปลอดภัยต่อเธรดด้วยปฏิบัติการ Interlocked โดยไม่ใช้ล็อก6 แต่เป็นคิวปลอดภัยต่อเธรดเปล่า ที่ไม่มีทั้งเพดานความจุและกลไก «ถ้าว่างก็รอ» ให้คิดว่าเป็นชิ้นส่วน ไม่ใช่พระเอกของการส่งงาน และ BlockingCollection<T> ไม่ได้ออกแบบมาเพื่อการเข้าถึงอะซิงโครนัส ดังนั้นถ้าจะคู่กับ async/await ให้เลือก Channel<T>12

อีกข้อ: จงระวังความเข้าใจผิดว่า «เปลี่ยนดิกชันนารีเป็น ConcurrentDictionary แล้วปลอดภัยต่อเธรด» แม้ปฏิบัติการเดี่ยวจะปลอดภัยต่อเธรด ปฏิบัติการประกอบ อย่าง «ตรวจว่ามีแล้วค่อยเพิ่ม» ยังแข่งอยู่ (ใช้เมธอดสำหรับปฏิบัติการประกอบอย่าง GetOrAdd) และ GetOrAdd เองก็มีข้อควรระวังของมัน: ค่าที่ถูกเก็บลงไปรับประกันว่ามีหนึ่งค่า แต่ ฟังก์ชันโรงงานที่สร้างค่านั้นอาจถูกเรียกหลายครั้งเมื่อมีการแย่ง ใส่ผลข้างเคียงในโรงงาน — เปิดการเชื่อมต่อ สร้างไฟล์ ฯลฯ — การรันซ้ำจะรั่ว จึงทำให้โรงงานปราศจากผลข้างเคียง หรือถ้าต้องการเริ่มต้นที่เกิดพอดีครั้งเดียว ให้เก็บ Lazy<T> เป็นค่าแทน การเปลี่ยนชนิดคอลเล็กชันทดแทนการลดสถานะที่เปลี่ยนแปลงร่วมกันไม่ได้

5. หลักที่ 3: ให้ล็อกมีวินัย

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

5.1. ตัดสิน «ปกป้องอะไร» แล้วล็อกด้วยอ็อบเจกต์เฉพาะ

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

ให้อ็อบเจกต์ล็อกเป็นอินสแตนซ์เฉพาะที่ไม่เปิดออกภายนอก lock(this) แชร์ล็อกกับโค้ดภายนอกที่อ้างอินสแตนซ์ของคุณได้ และ lock(typeof(X)) แชร์กับทั้งแอปพลิเคชันโดเมน — ทั้งคู่เป็นแหล่งเพาะเดดล็อก จาก .NET 9 / C# 13 เป็นต้นไป แนะนำให้ใช้อินสแตนซ์ของชนิดเฉพาะ System.Threading.Lock เป็นอ็อบเจกต์ล็อก4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+ (ก่อนหน้านั้นเป็น readonly object)
    private readonly List<Order> _orders = [];    // ข้อมูลที่ _gate ปกป้อง

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

คำสั่ง lock ของ C# รับประกันว่าล็อกถูกปล่อยแม้มีข้อยกเว้น วิธีที่มันถูกขยายขึ้นกับชนิดของอ็อบเจกต์ล็อก: อ็อบเจกต์ธรรมดาเป็นรูปที่เรียก Monitor.Exit ใน finally ส่วนชนิด Lock เป็นรูปที่เรียก EnterScope() แล้วทำลายสโคปนั้น413 นั่นคือ ฟิลด์ชนิด Lock เป็นกลไกคนละชุดกับ Monitor หากโค้ดบางส่วนเขียน Monitor.Enter(_gate) ด้วยมือ การกีดกันร่วมกับ lock (_gate) จะไม่ตั้ง ไม่ว่าชนิดใด การเลิกเขียน Monitor.Enter / Exit ด้วยมือแล้วรวมที่รูป lock เสมอปลอดภัยกว่า4

5.2. ขณะถือล็อก อย่าทำ «สิ่งที่ใช้เวลา» หรือ «สิ่งภายนอก»

ยิ่งถือล็อกสั้นยิ่งดี สิ่งที่ทำได้ขณะถือคืออ่านและเขียนข้อมูลที่มันปกป้องเท่านั้น การเขียนที่ทำ I/O ขณะถือล็อก หรือที่เรียกโค้ดภายนอกผ่านอีเวนต์หรือคอลแบ็ก ไม่ได้แค่ยืดเวลาที่ถือ — มันเปิดเส้นที่ผู้ถูกเรียกพยายามหยิบล็อกอื่นแล้วเดดล็อก เตรียมนอกกล็อก และในกล็อกทำแค่สลับเข้า คือรูปพื้นฐาน

และ await ใน lock ไม่ได้ (เป็นข้อผิดพลาดตอนคอมไพล์) นี่เป็นการป้องกัน ไม่ใช่แค่ข้อจำกัด: Monitor มีความใกล้ชิดกับเธรด — เธรดที่ได้ล็อกต้องเป็นฝ่ายปล่อย — ซึ่งเข้ากันไม่ได้กับโค้ดอะซิงโครนัสที่เธรดอาจเปลี่ยนก่อนและหลัง await สำหรับการกีดกันในโค้ดอะซิงโครนัส ใช้ SemaphoreSlim ที่นับเริ่มต้นเป็น 114

private readonly SemaphoreSlim _asyncGate = new(1, 1);

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. ล็อกหลายตัวให้หยิบในลำดับเดียวกันเสมอ

เมื่อมีล็อกสองตัวขึ้นไป ลำดับการได้ที่สลับกันตามเธรดคือแบบเดดล็อกคลาสสิก ทางแก้เรียบง่าย: ตั้งกฎว่า ทุกเธรดหยิบล็อกในลำดับเดียวกัน จุดที่รับประกันลำดับไม่ได้ ให้ใช้โอเวอร์โหลดที่มีหมดเวลาของ Monitor.TryEnter ถ้าไม่ได้ ให้ปล่อยแล้วลองใหม่ (หรือบันทึกความผิดปกติ) — นั่นเปลี่ยนแฮงตลอดกาลเป็นความล้มเหลวที่ตรวจพบได้4

5.4. อัปเดตง่ายใช้ Interlocked อ่านบ่อยใช้ ReaderWriterLockSlim

การอัปเดตอะตอมของตัวแปรเดียวอย่างการเพิ่ม/ลดตัวนับหรือสลับแฟล็ก คลาส Interlocked (Increment / Add / CompareExchange) เร็วกว่า lock หากไม่มีการแย่ง ต้นทุนอาจเหลือแค่คำนำหน้าคำสั่ง CPU เดียว4 ในทางกลับกัน Interlocked ทำได้แค่นั้น มันรักษาความคงที่ของหลายตัวแปรพร้อมกันไม่ได้ โครงสร้างไร้ล็อกที่เขียนเองคู่กับ volatile เป็นเครื่องมือของผู้เชี่ยวชาญที่ต้องการความเข้าใจลึกเรื่องโมเดลหน่วยความจำ และไม่ใช่สิ่งที่ควรเขียนในแอปธุรกิจ

สำหรับข้อมูลร่วมที่ «อ่านบ่อย แต่เขียนน้อย» ยังมีทางเลือก ReaderWriterLockSlim ซึ่งกีดกันเฉพาะการเขียน ให้อ่านผ่านพร้อมกันได้13

6. หลักที่ 4: ออกแบบวิธีหยุดก่อนอย่างอื่น

คำถามแรกที่ควรถามเมื่อรีวิวดีไซน์มัลติเธรดคือ «นี่หยุดอย่างไร» โค้ดที่เริ่มรันเขียนได้โดยไม่คิด แต่โค้ดที่หยุดอย่างปลอดภัยไม่เกิดถ้าไม่ได้ออกแบบ

6.1. การยกเลิกแบบร่วมมือ (CancellationToken) คือคำตอบที่ถูกเพียงอย่างเดียว

โมเดลการหยุดของ .NET รวมศูนย์ที่ การยกเลิกแบบร่วมมือ ฝั่งที่อยากหยุดสร้าง CancellationTokenSource แล้วส่ง Token ของมันให้งานแต่ละชิ้น เมื่ออยากหยุด ให้เรียก Cancel() ฝั่งงานเฝ้าโทเคน และ ณ จุดที่ตนเองสะดวก ทำความสะอาดแล้วจบ — เพราะเป็นการร่วมมือ ไม่ใช่การบังคับ ฝั่งงานจึงจบได้โดยคงสถานะให้สอดคล้องตลอด7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // ปฏิเสธ Start ซ้ำขณะยังรันอยู่
        throw new InvalidOperationException("เวิร์กเกอร์กำลังรันอยู่แล้ว");
    if (_worker is { IsFaulted: true })       // อย่าสร้างใหม่ทับความล้มเหลวครั้งก่อนที่กลืนไว้
        throw new InvalidOperationException("เวิร์กเกอร์ครั้งก่อนล้มเหลว", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // จับลงโลคัลก่อน เพื่อไม่ให้แข่งกับ Start ใหม่หลังหยุด
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // เฝ้าด้วยการสำรวจ
    {
        ProcessNextItem(ct);              // ส่ง ct เข้าการเรียกที่บล็อกเพื่อขัดจังหวะทันที
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // ตรึงลงโลคัล เพื่อว่าแม้ฟิลด์ถูกสลับ
    var worker = _worker;    // ขณะรอ ก็ไม่หยุดเป้าหมายผิดตัว
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // คอลแบ็กที่ลงทะเบียนบนโทเคนอาจโยนข้อยกเว้น
    catch (Exception ex) { cancelFailure = ex; }   // จับไว้ รายงานหลังรวมแล้ว

    try
    {
        try { await worker; }    // รวมให้สำเร็จไม่ว่า Cancel สำเร็จหรือไม่ และสังเกตความล้มเหลวกลางทาง
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // ถือเป็น «ปกติ» เฉพาะการหยุดที่เราขอเอง
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // อย่าทิ้งความล้มเหลวฝ่ายใด
        }
    }
    finally
    {
        cts.Dispose();           // ทิ้งซอร์สเมื่อรวมแล้ว (ปล่อยทรัพยากร OS เช่น WaitHandle)
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // อย่าให้ StopAsync ถัดไปใช้ซอร์สที่ทิ้งแล้ว
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

พึงทราบว่า Start / StopAsync นี้เป็นโครงขั้นต่ำที่ สมมติว่าถูกเรียกตามลำดับจากเธรดเดียว (เช่น เธรด UI) หากหลายเธรดอาจจัดการวงจรชีวิตพร้อมกัน ให้ทำให้ Start / StopAsync เองเป็นอนุกรมด้วยสิ่งอย่าง SemaphoreSlim — จะเสียของถ้าปฏิบัติการจัดการเวิร์กเกอร์เองแข่ง ก่อนจะได้ปกป้องเวิร์กเกอร์เสียอีก

ตัวอย่างเล็กนี้ยังฝังกลเม็ดที่คุ้มในงานจริง ก่อนอื่น Start ปฏิเสธการเรียกซ้ำขณะรันอยู่ การเขียนทับ _cts และ _worker โดยไม่มีเงื่อนไขจะทำเรเฟอเรนซ์ไปยังเวิร์กเกอร์ก่อนหน้าหาย เหลือ «เธรดหลง» ที่รันคู่กัน — หยุดก็ไม่ได้ รวมก็ไม่ได้ API วงจรชีวิต (Start/Stop) ปกป้อง «มีได้ครั้งละหนึ่ง» ด้วยตนเองเป็นพื้นฐาน นอกนั้นอีกสามข้อ ข้อแรก API หยุดรอความเสร็จ Cancel() แค่ «ขอ» การยกเลิก ณ จุดที่มันคืน เวิร์กเกอร์อาจยังอยู่กลาง ProcessNextItem ทำให้เป็น Stop() ที่ขอแล้วคืนทันที จะสร้างการแข่งใหม่ที่ผู้เรียกเริ่มทำความสะอาดขณะเวิร์กเกอร์ยังรัน ข้อสอง ถือ Task ไว้ อย่าทิ้ง โยนทิ้งด้วย _ = Task.Run(...) แล้วไม่มีใครรู้หากเวิร์กเกอร์ตายด้วยข้อยกเว้น ข้อสาม จับโทเคนลงตัวแปรโลคัลก่อนส่ง อย่าอ้าง _cts.Token ในแลมบ์ดา อ้างในแลมบ์ดาแล้วมันถูกประเมินตอนรัน หากมี Start ใหม่ทันทีหลังหยุด จะเกิดการสลับที่เวิร์กเกอร์เก่าไปคว้าโทเคนใหม่ ส่งโทเคนเดียวกันเป็นอาร์กิวเมนต์ที่สองของ Task.Run ด้วย แล้วเมื่อฝั่งงานจบด้วย ThrowIfCancellationRequested หรือ OperationCanceledException จาก API ที่รองรับการยกเลิก Task จะถูกจัดเป็น «Canceled» ไม่ใช่ «Faulted» (ในตัวอย่างนี้ การออกตามเงื่อนไขลูปตามปกติยังนับว่าเสร็จสำเร็จ) อีกข้อ: catch ใน StopAsync ใช้ตัวกรอง when กุม เฉพาะการยกเลิกที่มาจากโทเคนของตนเอง การกลืน OperationCanceledException โดยไม่มีเงื่อนไขจะทำให้ความล้มเหลวจริงที่โทเคนอื่นภายในงานโยน — เช่น หมดเวลาต่อสมาชิก — ดูเหมือน «หยุดแล้วจึงปกติ» และการระบุด้วยการจับคู่โทเคนนี้พัง หาก WorkLoop ภายในใช้ โทเคนที่ลิงก์ (การประกอบลิงก์ในข้อ 6.1) เพราะข้อยกเว้นที่บินออกมาถือโทเคนฝั่งลิงก์ ในโครงนั้น ให้เลือกอย่างชัดเจน ในฐานะการออกแบบ ว่าจะเรียก ct.ThrowIfCancellationRequested() ที่ทางออกของ WorkLoop เพื่อ «แปล» กลับเป็นโทเคนชั้นนอกก่อนออก หรือจะผ่อนตัวกรองเป็น when (cts.IsCancellationRequested) แล้วยอมรับว่า «การยกเลิกขณะมีคำขอหยุดนับเป็นปกติ»

โครงการยกเลิกแบบร่วมมือโครงของการยกเลิกแบบร่วมมือ ฝั่งที่หยุดเรียก Cancel() อย่างเดียว «เมื่อใดและจบอย่างไร» แต่งานแต่ละชิ้นตัดสินเอง นั่นคือเหตุที่หยุดได้โดยคงสถานะให้สอดคล้องเรียก Cancel() ครั้งเดียวส่ง Tokenส่ง Tokenส่ง Tokenตรวจ IsCancellationRequestedทำความสะอาดแล้วจบเองThrowIfCancellationRequestedขัดจังหวะทันทีแม้ระหว่างรอฝั่งที่หยุดCancellationTokenSourceงานเวิร์กเกอร์ 1งานเวิร์กเกอร์ 2API ของไลบรารีที่รองรับการยกเลิกจบตามปกติOperationCanceledException= ถือว่าการยกเลิกเสร็จการยกเลิกเสร็จ

ภาพ 6: โครงของการยกเลิกแบบร่วมมือ ฝั่งที่หยุดเรียก Cancel() อย่างเดียว «เมื่อใดและจบอย่างไร» แต่งานแต่ละชิ้นตัดสินเอง นั่นคือเหตุที่หยุดได้โดยคงสถานะให้สอดคล้อง

แบบแผนฝั่งไลบรารีก็ตกลงแล้ว ปฏิบัติการที่ยกเลิกได้ควรมีเมธอดสาธารณะที่รับ CancellationToken และในลูปคำนวณ ให้ตรวจ IsCancellationRequested เป็นระยะ หรือเรียก ThrowIfCancellationRequested() อย่างหลังโยน OperationCanceledException ซึ่ง Task ถือเป็น «การยกเลิกเสร็จ» ไม่ใช่ «ความล้มเหลว» เมื่ออยากหยุดทั้งจากโทเคนที่ส่งเข้ามาจากภายนอกและจากเหตุภายใน (เช่น หมดเวลา) ให้ประกอบด้วยโทเคนที่ลิงก์7

6.2. ให้ถือว่า Thread.Abort ไม่มีอยู่

Thread.Abort — «ฆ่าเธรดที่ไม่ยอมฟังจากภายนอก» — บน .NET Core / .NET 5 เป็นต้นไป แค่โยน PlatformNotSupportedException ใช้ไม่ได้อีกแล้ว การโยนข้อยกเว้นเข้าเธรดโดยไม่รู้ว่ามันกำลังรันตรงไหน เสี่ยงขัดจังหวะการปล่อยทรัพยากรและทำลายสถานะ หากต้องบังคับจบโค้ดบุคคลที่สามที่ไม่ตอบการยกเลิกแบบร่วมมือ (หรือเขียนให้ตอบไม่ได้) คำแนะนำทางการคือรันในโพรเซสแยกแล้วหยุดด้วย Process.Kill8

6.3. เมื่อรอ ให้ใช้แฮนเดิลรอ ไม่ใช่การสำรวจ

การเขียน «วน Sleep(100) จนแฟล็กถูกตั้ง» เปลืองทั้ง CPU และการตอบสนอง สำหรับสัญญาณระหว่างเธรด มีปริมิทีฟซิงโครไนซ์อย่าง ManualResetEventSlim และ SemaphoreSlim ซึ่งให้เธรดหลับอย่างถูกต้องจนกว่าจะถูกสัญญาณ13 การเลือกระหว่างความละเอียดของตัวตั้งเวลากับการรออีเวนต์บน Windows อธิบายละเอียดใน «ทำไมบน Windows จึงควรใช้การรออีเวนต์มากกว่า Sleep(1)»

7. กรณีพิเศษของเธรด UI — กติกาของแอปเดสก์ท็อป Windows

แอปเดสก์ท็อป Windows มีข้อจำกัดแข็งอีกข้อซ้อนบนหลักทั่วไป กติกาที่ว่า เฉพาะเธรดที่สร้าง UI (เธรด UI) เท่านั้นที่แตะมันได้

คอนโทรล WinForms ไม่ปลอดภัยต่อเธรด การจัดการจากหลายเธรดผลักคอนโทรลเข้าสถานะไม่สอดคล้อง ก่อการแข่ง เดดล็อก และค้าง Windows บังคับให้แอปมีเธรดเฉพาะหนึ่งเส้นที่รับข้อความระบบ และการสร้างกับจัดการ UI ต้องกระจุกบนเธรดนั้น9 WPF มีโครงเดียวกันทุกประการ: เปลี่ยนอิลิเมนต์ UI ได้เฉพาะเธรด UI10

เมื่ออยากอัปเดต UI จากเธรดอื่น อย่าแตะโดยตรง — แปลงเป็นการ «ขอไปยังเธรด UI»

แปลงการอัปเดต UI เป็นคำขอแปลงการอัปเดต UI เป็น «คำขอ» งานของเธรดพื้นหลังจบที่ให้ข้อความถูกวางลงคิวข้อความ — ผู้ที่แตะคอนโทรลเสมอคือเธรด UI เองขอผ่าน Control.Invoke /Dispatcher.InvokeAsyncแตะคอนโทรลโดยตรงWindowsเมาส์ คีย์บอร์ด การวาดใหม่คิวข้อความของเธรด UIเธรดพื้นหลัง(งานหนัก การสื่อสาร)เธรด UIเธรดเดียวที่แตะคอนโทรลได้ห้ามต้นเหตุของแข่ง เดดล็อก ค้าง

ภาพ 7: แปลงการอัปเดต UI เป็น «คำขอ» งานของเธรดพื้นหลังจบที่ให้ข้อความถูกวางลงคิวข้อความ — ผู้ที่แตะคอนโทรลเสมอคือเธรด UI เอง

เฟรมเวิร์ก วิธีขอ
WinForms Control.Invoke (ซิงโครนัส) / Control.BeginInvoke (อะซิงโครนัส) / จาก .NET 9 เป็นต้นไป Control.InvokeAsync9
WPF Dispatcher.Invoke (ซิงโครนัส) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (อะซิงโครนัส)10

ในจำนวนนี้ รูปซิงโครนัส (Control.Invoke / Dispatcher.Invoke) ต้องระวัง หากเธรด UI กำลังรอความเสร็จของเวิร์กเกอร์นั้นแบบซิงโครนัส แล้วเวิร์กเกอร์เรียก Invoke คุณได้ เดดล็อกที่ต่างฝ่ายรอกันและกัน (การรอเป็นวงในหมวด 2 พอดี) ให้รูปอะซิงโครนัส (BeginInvoke / InvokeAsync) เป็นค่าเริ่มต้นสำหรับการแจ้งและความคืบหน้าจากเธรดพื้นหลัง และจำกัดรูปซิงโครนัสไว้ที่สถานการณ์ที่คุณยืนยันได้ว่าเธรด UI ไม่ได้รอคุณ

ในงานจริงยังมีคำตอบที่ดีกว่าอีกขั้น เขียนงานที่ เริ่มบนเธรด UI ด้วย async/await แล้ว await จับ SynchronizationContext ของเธรด UI และกลับมารันความต่อเนื่องบนเธรด UI โดยอัตโนมัติ จุดที่ต้องเขียน Invoke ด้วยมือจึงลดลงมาก อย่างไรก็ตาม นี่ไม่ใช่สมบัติไร้เงื่อนไข โค้ดที่เข้าจากคอลแบ็กพื้นหลัง หรือความต่อเนื่องหลัง ConfigureAwait(false) ไม่กลับสู่เธรด UI ดังนั้นหากแตะ UI ตามเส้นนั้น ยังต้องดิสแพตช์อย่างชัดเจน การจัดรูปให้ «งานหนักไป Task.Run หรือ I/O อะซิงโครนัส การสะท้อนผลบนจออยู่ในความต่อเนื่องหลัง await» คือรูปพื้นฐานของแอป Windows สมัยใหม่ ความสัมพันธ์ระหว่างเธรด UI กับ async/await สรุปเป็นภาพเดียวใน «async ของ WPF/WinForms และเธรด UI ในแผ่นเดียว»

และเมื่อมี COM เกี่ยวข้อง — การรวม Office คอมโพเนนต์เลกาซี ฯลฯ — มีชั้นเพิ่มคือโมเดลเธรดของ COM เอง (STA/MTA) อุบัติเหตุอย่าง «สร้างอ็อบเจกต์ COM บนเธรด UI แต่เรียกจากเธรดอื่นแล้วค้าง» อยู่ชั้นนี้ อธิบายใน «พื้นฐาน COM STA/MTA - โมเดลเธรดและวิธีหลีกเลี่ยงแฮง»

8. เมื่อเขียนเนทีฟ (C++/C)

หลักจนถึงตรงนี้ — อย่าสร้างเธรดตรง ๆ ลดสถานะที่เปลี่ยนแปลงร่วมกัน วินัยล็อก การออกแบบวิธีหยุด — ใช้ได้กับโค้ดเนทีฟโดยไม่เปลี่ยน สิ่งที่เปลี่ยนคือเครื่องมือ ใน C++ คู่เทียบคือ RAII กับ std::jthread / std::mutex / std::atomic ใน C คือ _beginthreadex ของ Win32 API ล็อก SRW ตัวแปรเงื่อนไข และแพทเทิร์นอีเวนต์หยุด แต่ละภาษาครอบคลุมรวมกับดักเฉพาะภาษา (ดีสตรักเตอร์ของ std::thread อันตรายของ TerminateThread DllMain กับ loader lock และอื่น ๆ) ใน «ฉบับ C++» และ «ฉบับ C» ของซีรีส์นี้

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

คุณพึ่งการทดสอบเพื่อหาบั๊กมัลติเธรดไม่ได้ การทดสอบหน่วยธรรมดานับรอบที่ «บังเอิญไม่แข่ง» เป็นผ่าน คิดการเตรียมเป็นสามชั้น

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

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

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

10. สรุป — รายการตรวจก่อนเพิ่มเธรด

ย่อแล้ว แนวปฏิบัติของมัลติเธรดไม่ใช่ «ทักษะการเขียนซิงโครไนซ์ให้ถูก» แต่เป็น «ดีไซน์ที่ทำให้ไม่ต้องเขียนซิงโครไนซ์» หากตอบแปดคำถามต่อไปนี้ได้ก่อนลงมือ คุณกันอุบัติเหตุใหญ่ได้เกือบทั้งหมด

  1. งานนี้ CPU-bound หรือ I/O-bound (อย่างหลังคำตอบคือ async/await ไม่ใช่เธรด)
  2. กำลังจะเขียน new Thread อยู่หรือไม่ (แสดงด้วย Task Parallel หรือพูลเธรดได้ไหม)
  3. ข้อมูลที่เปลี่ยนแปลงใดถูกแชร์ระหว่างเธรด — ไล่ได้ไหม
  4. การแชร์นั้นลบได้ด้วยการแยก ความไม่เปลี่ยนแปลง หรือการส่งผ่านคิวไหม
  5. ข้อมูลร่วมที่เหลือแต่ละชิ้นมีล็อกคู่กันหนึ่งตัวที่ตัดสินแล้วหรือไม่
  6. ลำดับการได้ล็อกไม่ซ้ำกันทุกเธรด และไม่มีการเรียกภายนอกขณะถือล็อกหรือไม่
  7. CancellationToken ถูกส่งเข้างานยาวทุกชิ้น และอธิบายเส้นทางหยุดได้หรือไม่
  8. โค้ดที่แตะ UI กระจุกบนเธรด UI หรือไม่

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

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

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

KomuraSoft LLC รับรีวิวการออกแบบแอปธุรกิจที่เกี่ยวข้องกับมัลติเธรด การสอบสวนสาเหตุรากของข้อบกพร่องที่ทำซ้ำยากอย่าง «แครช/ค้างเป็นครั้งคราว» — วิเคราะห์ดัมป์และระบุจุดแข่ง — และการให้คำปรึกษาเทคนิคเรื่องการขนานหรือทำให้อะซิงโครนัสแอปที่มีอยู่ ยินดีให้เข้ามาตั้งแต่ขั้น «ช่วยดูหน่อยว่าดีไซน์นี้แข่งได้หรือไม่»

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

  1. Microsoft Learn, Task Parallel Library (TPL). ว่าด้วย TPL ที่เป็นเครื่องมือแนะนำสำหรับโค้ดมัลติเธรดและขนานตั้งแต่ .NET Framework 4 ว่าด้วยการปรับระดับความเป็นขนานแบบพลวัตให้เข้ากับโปรเซสเซอร์ที่มี ว่าด้วยการรับการแยกงาน การจัดตารางลงพูลเธรด การรองรับการยกเลิก และการจัดการสถานะ ว่าด้วยลูปที่งานต่อรอบเล็กอาจช้าลงจากค่าโสหุ้ยของการขนาน และว่าด้วยความเข้าใจพื้นฐานเรื่องล็อก เดดล็อก และเงื่อนไขแข่งขันที่ยังถูกแนะนำแม้ใช้ TPL  2

  2. Microsoft Learn, The managed thread pool. ว่าด้วยคลาส ThreadPool ที่ให้พูลเธรดเวิร์กเกอร์ซึ่งระบบจัดการ ให้นักพัฒนาโฟกัสที่งานของแอปแทนการจัดการเธรด และว่าด้วย .NET ที่ใช้พูลเธรดกว้างขวางสำหรับปฏิบัติการ TPL ความเสร็จของ I/O อะซิงโครนัส คอลแบ็กตัวตั้งเวลา การรอที่ลงทะเบียน การเชื่อมต่อซ็อกเก็ต และอื่น ๆ  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. ว่าด้วยลูปขนานที่บางครั้งช้ากว่าแบบอนุกรม จึงต้องวัดเสมอ ว่าด้วยการเลี่ยงการเขียนหน่วยความจำร่วมในลูปขนาน โดยแนะนำโอเวอร์โหลดที่ใช้สถานะเฉพาะเธรด และว่าด้วยการที่ไม่มีหลักประกันว่ารอบของ For/ForEach จะรันขนานจริง ดังนั้นโค้ดที่นัดพบระหว่างรอบอาจเดดล็อก  2 3 4

  4. Microsoft Learn, Managed threading best practices. ว่าด้วยนิยามของเงื่อนไขแข่งขัน (ตัวอย่างที่การเพิ่มตัวนับแตกเป็นอ่าน บวก เขียนกลับ แล้วถูกทับจนหาย) และเดดล็อก ว่าด้วยการใช้การยกเลิกแบบร่วมมือแทน Thread.Abort ว่าด้วยการห้ามใช้ชนิดหรือ this เป็นอ็อบเจกต์ล็อก และว่าจาก .NET 9 / C# 13 เป็นต้นไปควรใช้อินสแตนซ์ System.Threading.Lock เฉพาะ ว่าด้วยคำสั่ง lock ของ C# ที่รับประกัน Monitor.Exit ใน finally ว่าด้วยการตรวจเดดล็อกด้วยหมดเวลาของ Monitor.TryEnter ว่าด้วยคลาส Interlocked ที่เร็วกว่าสำหรับการเปลี่ยนสถานะง่าย และว่าด้วยแนวทางออกแบบที่ข้อมูลสแตติกควรปลอดภัยต่อเธรดโดยปริยาย ส่วนข้อมูลอินสแตนซ์ไม่ควรปลอดภัยต่อเธรดโดยปริยาย  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library. ว่าด้วยช่องทางที่เป็น FIFO ของโมเดลผู้ผลิต/ผู้บริโภคที่จัดการซิงโครไนซ์ภายใน ว่าด้วยการสร้างช่องทางที่มีเพดานความจุด้วย CreateBounded ว่าด้วยพฤติกรรมปริยายเมื่อถึงเพดานที่ฝั่งเขียนรอ โดยเลือก FullMode อื่นอย่าง DropOldest ได้ และว่าด้วยแรงดันย้อนกลับที่เกิดเมื่อการเขียนเร็วกว่าการอ่าน  2 3

  6. Microsoft Learn, Thread-safe collections. ว่าด้วยคอลเล็กชันภายใต้ System.Collections.Concurrent ที่ทำให้ปลอดภัยต่อเธรดด้วยล็อกละเอียดหรือกลไกไร้ล็อก และว่าด้วย ConcurrentQueue กับ ConcurrentStack ที่อิมพลีเมนต์โดยไม่ใช้ล็อก อาศัยปฏิบัติการ Interlocked จึงทนการเพิ่มและลบถี่จากหลายเธรด  2

  7. Microsoft Learn, Cancellation in Managed Threads. ว่าด้วยขั้นตอนโมเดลการยกเลิกแบบร่วมมือด้วย CancellationTokenSource และ CancellationToken ว่าด้วยการยกเลิกที่เป็นการร่วมมือไม่ใช่การบังคับ โดยฝั่งผู้ฟังตัดสินวิธีหยุด ว่าด้วยสามวิธีเฝ้าคือการสำรวจ การลงทะเบียนคอลแบ็ก และแฮนเดิลรอ ว่าด้วย ThrowIfCancellationRequested ที่โยน OperationCanceledException ซึ่ง Task ถือเป็นการยกเลิกเสร็จ ว่าด้วยการประกอบหลายโทเคนด้วยโทเคนที่ลิงก์ และว่าด้วยไลบรารีที่ควรมีเมธอดสาธารณะที่รับ CancellationToken  2 3

  8. Microsoft Learn, Using threads and threading. ว่าด้วย CancellationToken ที่เป็นวิธีถูกต้องในการหยุดเธรด ว่าด้วย Thread.Abort ที่โยน PlatformNotSupportedException บน .NET Core และ .NET 5 เป็นต้นไป พร้อมคำเตือนเลิกใช้ตอนคอมไพล์ (SYSLIB0006) จาก .NET 5 เป็นต้นไปเช่นกัน และว่าด้วยการบังคับจบโค้ดบุคคลที่สามที่ไม่ตอบการยกเลิกแบบร่วมมือ ที่ต้องรันในโพรเซสแยกแล้วหยุดด้วย Process.Kill  2

  9. Microsoft Learn, How to handle cross-thread operations with controls. ว่าด้วยการเข้าถึงคอนโทรล WinForms ที่ไม่ปลอดภัยต่อเธรด โดยการจัดการจากหลายเธรดนำไปสู่สถานะไม่สอดคล้อง การแข่ง เดดล็อก และค้าง ว่าด้วยคอนโทรลทุกตัวที่ต้องถูกสร้างและเข้าถึงบนเธรดเดียวกัน โดย Windows ต้องการเธรด UI เฉพาะเพื่อส่งข้อความระบบ และว่าด้วยการเรียกอย่างปลอดภัยจากเธรดอื่นด้วย Control.Invoke, Control.InvokeAsync จาก .NET 9 เป็นต้นไป หรือ BackgroundWorker  2 3

  10. Microsoft Learn, Threading model (WPF). ว่าด้วยการเปลี่ยน UI ใน WPF ที่จำกัดอยู่ที่เธรดเดียว โดยเธรดพื้นหลังลงทะเบียนรายการงานกับ Dispatcher ของเธรด UI เพื่อขอ ว่าด้วย Dispatcher.Invoke ที่เป็นซิงโครนัส ขณะที่ InvokeAsync และ BeginInvoke เป็นอะซิงโครนัส และว่าด้วย Dispatcher ที่ประมวลผลงานเป็นคิวตามลำดับความสำคัญ  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). ว่าด้วย Parallel.For / Parallel.ForEach ที่ให้ความขนานของข้อมูลด้วยรสชาติใกล้ลูป for ว่าด้วยการที่ไม่ต้องสร้างเธรดหรือเข้าคิวงาน และลูปพื้นฐานไม่ต้องมีล็อก และว่าด้วย TPL ที่แยกแหล่งข้อมูลไปหลายเธรดแล้วจัดสมดุลภาระใหม่หากไม่สม่ำเสมอ 

  12. Microsoft Learn, BlockingCollection<T> Class. ว่าด้วย BlockingCollection ที่เป็นการอิมพลีเมนต์ผู้ผลิต/ผู้บริโภคพร้อมการบล็อกและเพดานความจุ ว่าด้วยเพดานความจุที่กันฝั่งผลิตไม่ให้แซงฝั่งบริโภคเกินไป และว่าด้วยการที่มันไม่ได้ออกแบบมาเพื่อการเข้าถึงอะซิงโครนัส โดยแนะนำ Channel<T> สำหรับผู้ผลิต/ผู้บริโภคอะซิงโครนัส  2

  13. Microsoft Learn, Overview of synchronization primitives. ว่าด้วย Monitor ที่ให้การกีดกันผ่านอ็อบเจกต์ล็อกและมีความใกล้ชิดกับเธรด ว่าด้วยโค้ด C# ที่คาดให้ใช้คำสั่ง lock แทน Monitor โดยตรง ว่าด้วย ReaderWriterLockSlim ที่กีดกันการเขียนแต่อนุญาตให้อ่านพร้อมกัน และว่าด้วย SemaphoreSlim ที่เป็นเซมาฟอร์เบาสำหรับใช้ในโพรเซสเดียว ขณะที่ Semaphore มีชื่อและใช้ซิงโครไนซ์ข้ามโพรเซสได้  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. ว่าด้วยคำสั่ง lock ของ C# และชนิด Lock ที่มีความใกล้ชิดกับเธรด จึงใช้ข้าม await ไม่ได้ (เพราะเธรดที่รันความต่อเนื่องอาจเปลี่ยนก่อนและหลัง await) ว่าด้วยการใช้ SemaphoreSlim นับ 1 ผ่าน WaitAsync และ Release ใน finally สำหรับการกีดกันในโค้ดอะซิงโครนัส และว่าด้วย Channel แบบ bounded ที่เป็นทางเลือกสำหรับงานจำกัดอัตรา 

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

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

แนวทางที่ลงตัวของมัลติเธรดใน C กับ Win32 คือสร้างเธรดด้วย _beginthreadex, ล็อก SRW และตัวแปรเงื่อนไข, ฟังก์ชัน Interlocked และการออกแบบหย...

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

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

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

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

ใช้ WMI/CIM จาก C# และ PowerShell — คู่มือปฏิบัติการดึงข้อมูลฮาร์ดแวร์ เฝ้าโพรเซส และสอบถามระยะไกล

หมายเลขซีเรียลของพีซี การเฝ้าพื้นที่ว่างดิสก์ และการตรวจจับการเริ่มโพรเซส คำตอบมาตรฐานคือ WMI/CIM บทความนี้อธิบายการใช้ cmdlet CIM เช่น G...

Spurious Wakeup — ทำไม condition variable จึงตื่น "โดยไม่ถูกแจ้ง" และวิธีรออย่างถูกต้องบน Windows

การรอของ condition variable สามารถกลับได้แม้ไม่มีการแจ้งมาถึง (spurious wakeup) บทความนี้อธิบายจากการอิมพลีเมนต์ของ Windows ว่าทำไมข้อกำห...

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

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

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

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

ทำไมควรเลี่ยง lock(this) หรือ lock(typeof(MyClass))?
เพราะอ็อบเจกต์ที่เป็นเป้าหมายล็อกมองเห็นได้จากโค้ดนอกของคุณ this คืออินสแตนซ์ของคุณเอง ดังนั้นโค้ดภายนอกที่อ้างอินสแตนซ์นั้นได้ สามารถล็อกอ็อบเจกต์เดียวกัน ก่อการแย่งหรือเดดล็อกที่ไม่ได้ตั้งใจ typeof(MyClass) อันตรายกว่าอีก: อ็อบเจกต์ Type มีเพียงหนึ่งต่อแอปพลิเคชันโดเมน จึงไปแชร์ล็อกกับโค้ดที่ไม่เกี่ยวกันเลย ใช้เป็นเป้าหมายล็อกซึ่งเป็นอ็อบเจกต์เฉพาะที่ไม่เปิดออกภายนอก จาก .NET 9 / C# 13 เป็นต้นไป คำแนะนำคือใช้อินสแตนซ์ของชนิดเฉพาะ System.Threading.Lock เป็นอ็อบเจกต์ล็อก
สร้างเธรดได้กี่เส้น? จำนวนที่เหมาะสมคือเท่าไร?
«อย่าตัดสินจำนวนเธรดเอง» คือคำตอบสมัยนี้ ใช้ Task และคลาส Parallel แล้วพูลเธรดจะปรับระดับความเป็นขนานอัตโนมัติตามจำนวนคอร์ CPU และภาระ ดีไซน์ที่เรียก new Thread ซ้ำด้วยมือ มักเกินหรือขาดบนเครื่องลูกค้าที่จำนวนคอร์ต่าง สิ่งที่ควรใส่ใจไม่ใช่จำนวนเส้น แต่ชนิดของงาน: การคำนวณที่กิน CPU เต็มไม่เร็วขึ้นเมื่อขนานเกินจำนวนคอร์ และงานที่รอ I/O เป็นหลักไม่ควรเพิ่มเธรดเลย — คำตอบที่ถูกคือ I/O อะซิงโครนัสด้วย async/await
ใส่ volatile แล้วจะปลอดภัยต่อเธรดไหม?
ไม่ volatile รับประกันลำดับ — ว่าการเข้าถึงฟิลด์นั้นไม่ถูกจัดเรียงใหม่กับปฏิบัติการหน่วยความจำรอบข้าง (ซีแมนติกส์ acquire/release) — ไม่ใช่ความเป็นอะตอมของการดำเนินการประกอบอย่าง «อ่าน คำนวณ เขียนกลับ» ตัวอย่างเช่น แม้หลายเธรดทำ ++ บนตัวนับ volatile int การบวกก็ยังหาย ใช้คลาส Interlocked สำหรับเพิ่ม/ลดตัวนับหรือเปรียบเทียบแล้วสลับ และใช้ lock เมื่อต้องปกป้องหลายตัวแปรพร้อมกัน volatile พิจารณาได้เกือบเฉพาะกรณีง่ายอย่างแฟล็กหยุด ที่เธรดหนึ่งเขียน ที่เหลือแค่อ่าน — และแฟล็กนั้นตอนนี้มาตรฐานคือแสดงด้วย CancellationToken
แยกได้อย่างไรว่าบั๊กที่เกิดเป็นครั้งคราวมาจากมัลติเธรด?
สามสัญญาณที่ควรสงสัยคือ «การกระทำเดียวกันทำซ้ำได้บ้างไม่ได้บ้าง» «พอติดดีบักเกอร์หรือเพิ่มล็อกแล้วไม่ทำซ้ำ» และ «เกิดเฉพาะตอนภาระสูงหรือทันทีหลังเริ่ม» บั๊กที่พึ่งจังหวะมีลักษณะว่าผลเปลี่ยนทุกรอบรัน — นั่นคือนิยามของเงื่อนไขแข่งขันเอง วิธีแยก: ไล่ข้อมูลที่เปลี่ยนแปลงที่แชร์ทั้งหมด แล้วทำตารางว่าแต่ละชิ้นถูกล็อกใดปกป้อง การเข้าถึงที่ไม่ถูกปกป้องแม้จุดเดียวคือผู้ต้องสงสัย หากแฮง ให้จับสแต็กทุกเธรดแล้วดูว่าพวกมันรอกล็อกของกันและกันเป็นวงหรือไม่ หยุดชั่วคราวในดีบักเกอร์ Visual Studio แล้วดู Parallel Stacks หรือในงานจริง จับดัมป์แล้ววิเคราะห์

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

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

Go Komura

ผู้แทนของ KomuraSoft LLC

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

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

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