«ประมวลผลช้า จึงตั้งเธรดขนาน แล้วผลรวมเพี้ยนเป็นครั้งคราว» «เพิ่มงานพื้นหลัง แล้วแอปค้างเดือนละครั้ง» «บอกว่าดีบักแล้วไม่ทำซ้ำได้ แต่ที่ไซต์ลูกค้าเกิดจริง» — สิ่งที่น่ากลัวของมัลติเธรดคือมันดูถูกต้องทันทีที่เขียนเสร็จ บั๊กเงื่อนไขแข่งขันพึ่งจังหวะ มันลอดการทดสอบแล้วโผล่เฉพาะในงานจริง
ในทางกลับกัน เมื่อมัลติคอร์เป็นเรื่องปกติแล้ว แม้ในแอปธุรกิจก็มีข้อกำหนดที่เลี่ยงมัลติเธรดไม่ได้แน่ ๆ เช่น «รันงานหนักโดยไม่ให้ 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
sequenceDiagram
accTitle: เงื่อนไขแข่งขันบนตัวนับร่วม
accDescr: เงื่อนไขแข่งขันคลาสสิกที่การบวกบนตัวนับร่วมหายไป หากเธรดอื่นแทรกกลางสามขั้นของ count++ ฝ่ายที่เขียนกลับทีหลังทับอีกฝ่าย
participant A as เธรด A
participant M as ตัวแปรร่วม count
participant B as เธรด B
Note over M: count = 10
A->>M: อ่าน(10)
B->>M: อ่าน(10)
A->>A: บวกในท้องถิ่น(11)
B->>B: บวกในท้องถิ่น(11)
A->>M: เขียนกลับ(11)
B->>M: เขียนกลับ(11)
Note over M: บวกไปสองครั้งแต่ count = 11<br/>การบวกของเธรด A หายไป
ภาพ 1: เงื่อนไขแข่งขันคลาสสิกที่การบวกบนตัวนับร่วมหายไป หากเธรดอื่นแทรกกลางสามขั้นของ count++ ฝ่ายที่เขียนกลับทีหลังทับอีกฝ่าย
เดดล็อก คือสถานะที่สองเธรดแต่ละฝ่ายรอกล็อกที่อีกฝ่ายถืออยู่ จึงไม่มีใครเดินต่อได้ เธรด A ถือกล็อก 1 แล้วรอกล็อก 2 เธรด B ถือกล็อก 2 แล้วรอกล็อก 1 — แค่นั้นพอให้ทั้งคู่หยุดตลอดกาล4
flowchart LR
accTitle: การรอเป็นวงของเดดล็อก
accDescr: การรอเป็นวงของเดดล็อก ทันทีที่ลูกศรการรอเป็นวง ทุกเธรดในวงนั้นหยุดตลอดกาล
A["เธรด A<br/>ถือกล็อก 1"] -->|"รอการปล่อยกล็อก 2"| B["เธรด B<br/>ถือกล็อก 2"]
B -->|"รอการปล่อยกล็อก 1"| A
ภาพ 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 ให้อะซิงโครนัส) คือเส้นแรกที่ควรลากที่ปากทางของการออกแบบมัลติเธรด
flowchart TB
accTitle: กิ่งก่อนตั้งเธรด
accDescr: กิ่งที่ต้องเดินก่อน «ตั้งเธรด» งานธุรกิจส่วนใหญ่ตกที่ทางออกสามอันบน และการถึง new Thread เป็นกรณีพิเศษเท่านั้น
S["มีงานที่อยากรันขนาน"] --> Q1{"แกนของงานคืออะไร?"}
Q1 -->|"รอ I/O เป็นหลัก<br/>ไฟล์ เครือข่าย DB"| ASYNC["I/O อะซิงโครนัสด้วย async/await<br/>อย่าเพิ่มเธรด"]
Q1 -->|"การคำนวณที่กิน CPU"| Q2{"รูปร่างของงานคืออะไร?"}
Q2 -->|"ใช้การประมวลผลเดียวกัน<br/>กับทุกสมาชิกของคอลเล็กชัน"| PAR["Parallel.For / ForEach"]
Q2 -->|"ก้อนอิสระของ<br/>งานพื้นหลัง"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"ลูปข้อความ ข้อกำหนด STA ฯลฯ<br/>บุคลิกของเธรดเองคือข้อกำหนด"| TH["new Thread<br/>(ทางสุดท้ายที่ยกเว้น)"]
ภาพ 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)); // รวมครั้งเดียวต่อเธรด
flowchart TB
accTitle: การรวมเฉพาะเธรด
accDescr: การรวมเฉพาะเธรด ระหว่างประมวลผลแต่ละเธรดแตะเฉพาะข้อมูลของตน จึงไม่มีช่องให้แข่ง และการเขียนเข้าค่าที่แชร์เกิดครั้งเดียวต่อเธรดตอนรวม
SRC["อาร์เรย์ข้อมูล(เป้าหมายการประมวลผล)"] --> T1["เธรด 1<br/>ประมวลผลส่วนที่รับผิดชอบแล้ว<br/>บวกเข้าผลรวมย่อยของตนเท่านั้น"]
SRC --> T2["เธรด 2<br/>ประมวลผลส่วนที่รับผิดชอบแล้ว<br/>บวกเข้าผลรวมย่อยของตนเท่านั้น"]
SRC --> T3["เธรด 3<br/>ประมวลผลส่วนที่รับผิดชอบแล้ว<br/>บวกเข้าผลรวมย่อยของตนเท่านั้น"]
T1 --> M["รวม: Interlocked.Add สะท้อน<br/>เข้าผลรวมครั้งเดียวต่อเธรด"]
T2 --> M
T3 --> M
ภาพ 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);
}
flowchart LR
accTitle: โครงผู้ผลิต/ผู้บริโภคคั่นด้วยช่องทาง
accDescr: โครงผู้ผลิต/ผู้บริโภคที่คั่นด้วยช่องทาง ทั้งสองฝ่ายไม่แตะตัวแปรร่วมโดยตรง ทั้งการนัดพบและการควบคุมความจุมอบให้ช่องทาง
P1["ผู้ผลิต 1<br/>WriteAsync"] --> CH["ช่องทาง bounded (ความจุ 100)<br/>คิว FIFO<br/>การซิงโครไนซ์จัดการโดยช่องทาง"]
P2["ผู้ผลิต 2<br/>WriteAsync"] --> CH
CH --> C1["ผู้บริโภค 1<br/>ReadAllAsync"]
CH --> C2["ผู้บริโภค 2<br/>ReadAllAsync"]
CH -.->|"ถ้าเต็ม ให้การเขียนรอ<br/>(แรงดันย้อนกลับ)"| P1
CH -.->|"ถ้าว่าง ให้การอ่านรอ"| C1
ภาพ 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) แล้วยอมรับว่า «การยกเลิกขณะมีคำขอหยุดนับเป็นปกติ»
flowchart TB
accTitle: โครงการยกเลิกแบบร่วมมือ
accDescr: โครงของการยกเลิกแบบร่วมมือ ฝั่งที่หยุดเรียก Cancel() อย่างเดียว «เมื่อใดและจบอย่างไร» แต่งานแต่ละชิ้นตัดสินเอง นั่นคือเหตุที่หยุดได้โดยคงสถานะให้สอดคล้อง
OWNER["ฝั่งที่หยุด"] -->|"เรียก Cancel() ครั้งเดียว"| CTS["CancellationTokenSource"]
CTS -->|"ส่ง Token"| W1["งานเวิร์กเกอร์ 1"]
CTS -->|"ส่ง Token"| W2["งานเวิร์กเกอร์ 2"]
CTS -->|"ส่ง Token"| W3["API ของไลบรารี<br/>ที่รองรับการยกเลิก"]
W1 -->|"ตรวจ IsCancellationRequested<br/>ทำความสะอาดแล้วจบเอง"| E1["จบตามปกติ"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= ถือว่าการยกเลิกเสร็จ"]
W3 -->|"ขัดจังหวะทันทีแม้ระหว่างรอ"| E3["การยกเลิกเสร็จ"]
ภาพ 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»
flowchart LR
accTitle: แปลงการอัปเดต UI เป็นคำขอ
accDescr: แปลงการอัปเดต UI เป็น «คำขอ» งานของเธรดพื้นหลังจบที่ให้ข้อความถูกวางลงคิวข้อความ — ผู้ที่แตะคอนโทรลเสมอคือเธรด UI เอง
OS["Windows<br/>เมาส์ คีย์บอร์ด การวาดใหม่"] --> Q["คิวข้อความของ<br/>เธรด UI"]
BG["เธรดพื้นหลัง<br/>(งานหนัก การสื่อสาร)"] -->|"ขอผ่าน Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["เธรด UI<br/>เธรดเดียวที่แตะคอนโทรลได้"]
BG -.->|"แตะคอนโทรลโดยตรง"| NG["ห้าม<br/>ต้นเหตุของแข่ง เดดล็อก ค้าง"]
ภาพ 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. สรุป — รายการตรวจก่อนเพิ่มเธรด
ย่อแล้ว แนวปฏิบัติของมัลติเธรดไม่ใช่ «ทักษะการเขียนซิงโครไนซ์ให้ถูก» แต่เป็น «ดีไซน์ที่ทำให้ไม่ต้องเขียนซิงโครไนซ์» หากตอบแปดคำถามต่อไปนี้ได้ก่อนลงมือ คุณกันอุบัติเหตุใหญ่ได้เกือบทั้งหมด
- งานนี้ CPU-bound หรือ I/O-bound (อย่างหลังคำตอบคือ async/await ไม่ใช่เธรด)
- กำลังจะเขียน
new Threadอยู่หรือไม่ (แสดงด้วย TaskParallelหรือพูลเธรดได้ไหม) - ข้อมูลที่เปลี่ยนแปลงใดถูกแชร์ระหว่างเธรด — ไล่ได้ไหม
- การแชร์นั้นลบได้ด้วยการแยก ความไม่เปลี่ยนแปลง หรือการส่งผ่านคิวไหม
- ข้อมูลร่วมที่เหลือแต่ละชิ้นมีล็อกคู่กันหนึ่งตัวที่ตัดสินแล้วหรือไม่
- ลำดับการได้ล็อกไม่ซ้ำกันทุกเธรด และไม่มีการเรียกภายนอกขณะถือล็อกหรือไม่
CancellationTokenถูกส่งเข้างานยาวทุกชิ้น และอธิบายเส้นทางหยุดได้หรือไม่- โค้ดที่แตะ UI กระจุกบนเธรด UI หรือไม่
บั๊กมัลติเธรดไม่โผล่ในวันที่เขียน — มันเผยเขี้ยวที่ไซต์ลูกค้าเมื่อคุณลืมไปแล้ว พลิกแล้ว หากเดินรายการนี้ตอนออกแบบ คุณเด็ดชนิดความล้มเหลวที่แพงที่สุด — «แครชเป็นครั้งคราว» «ค้างเดือนละครั้ง» — ออกได้ก่อนเขียนแม้แต่บรรทัดเดียว
บทความที่เกี่ยวข้อง
- แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C++ — ตัดอุบัติเหตุด้วยโครงสร้างด้วย RAII และ jthread
- แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C — เขียนอย่างปลอดภัยแบบ Win32 API
- แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ Java — แบบแผนยุคเธรดเสมือน
- ตารางตัดสินเชิงปฏิบัติสำหรับ C# async/await — Task.Run และ ConfigureAwait
- async ของ WPF/WinForms และเธรด UI ในแผ่นเดียว
- เชิงลึกของ Windows I/O (ตอนที่ 3) — I/O Completion Ports (IOCP) และ .NET Thread Pool: ชั้นใต้ async/await
- พื้นฐาน COM STA/MTA - โมเดลเธรดและวิธีหลีกเลี่ยงแฮง
- ทำไมบน Windows จึงควรใช้การรออีเวนต์มากกว่า Sleep(1)
- กับดักของ Shared Memory และแนวปฏิบัติจริง
ด้านให้คำปรึกษาที่เกี่ยวข้อง
KomuraSoft LLC รับรีวิวการออกแบบแอปธุรกิจที่เกี่ยวข้องกับมัลติเธรด การสอบสวนสาเหตุรากของข้อบกพร่องที่ทำซ้ำยากอย่าง «แครช/ค้างเป็นครั้งคราว» — วิเคราะห์ดัมป์และระบุจุดแข่ง — และการให้คำปรึกษาเทคนิคเรื่องการขนานหรือทำให้อะซิงโครนัสแอปที่มีอยู่ ยินดีให้เข้ามาตั้งแต่ขั้น «ช่วยดูหน่อยว่าดีไซน์นี้แข่งได้หรือไม่»
ลิงก์อ้างอิง
-
Microsoft Learn, Task Parallel Library (TPL). ว่าด้วย TPL ที่เป็นเครื่องมือแนะนำสำหรับโค้ดมัลติเธรดและขนานตั้งแต่ .NET Framework 4 ว่าด้วยการปรับระดับความเป็นขนานแบบพลวัตให้เข้ากับโปรเซสเซอร์ที่มี ว่าด้วยการรับการแยกงาน การจัดตารางลงพูลเธรด การรองรับการยกเลิก และการจัดการสถานะ ว่าด้วยลูปที่งานต่อรอบเล็กอาจช้าลงจากค่าโสหุ้ยของการขนาน และว่าด้วยความเข้าใจพื้นฐานเรื่องล็อก เดดล็อก และเงื่อนไขแข่งขันที่ยังถูกแนะนำแม้ใช้ TPL ↩ ↩2
-
Microsoft Learn, The managed thread pool. ว่าด้วยคลาส ThreadPool ที่ให้พูลเธรดเวิร์กเกอร์ซึ่งระบบจัดการ ให้นักพัฒนาโฟกัสที่งานของแอปแทนการจัดการเธรด และว่าด้วย .NET ที่ใช้พูลเธรดกว้างขวางสำหรับปฏิบัติการ TPL ความเสร็จของ I/O อะซิงโครนัส คอลแบ็กตัวตั้งเวลา การรอที่ลงทะเบียน การเชื่อมต่อซ็อกเก็ต และอื่น ๆ ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. ว่าด้วยลูปขนานที่บางครั้งช้ากว่าแบบอนุกรม จึงต้องวัดเสมอ ว่าด้วยการเลี่ยงการเขียนหน่วยความจำร่วมในลูปขนาน โดยแนะนำโอเวอร์โหลดที่ใช้สถานะเฉพาะเธรด และว่าด้วยการที่ไม่มีหลักประกันว่ารอบของ For/ForEach จะรันขนานจริง ดังนั้นโค้ดที่นัดพบระหว่างรอบอาจเดดล็อก ↩ ↩2 ↩3 ↩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 -
Microsoft Learn, System.Threading.Channels library. ว่าด้วยช่องทางที่เป็น FIFO ของโมเดลผู้ผลิต/ผู้บริโภคที่จัดการซิงโครไนซ์ภายใน ว่าด้วยการสร้างช่องทางที่มีเพดานความจุด้วย CreateBounded ว่าด้วยพฤติกรรมปริยายเมื่อถึงเพดานที่ฝั่งเขียนรอ โดยเลือก FullMode อื่นอย่าง DropOldest ได้ และว่าด้วยแรงดันย้อนกลับที่เกิดเมื่อการเขียนเร็วกว่าการอ่าน ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. ว่าด้วยคอลเล็กชันภายใต้ System.Collections.Concurrent ที่ทำให้ปลอดภัยต่อเธรดด้วยล็อกละเอียดหรือกลไกไร้ล็อก และว่าด้วย ConcurrentQueue กับ ConcurrentStack ที่อิมพลีเมนต์โดยไม่ใช้ล็อก อาศัยปฏิบัติการ Interlocked จึงทนการเพิ่มและลบถี่จากหลายเธรด ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. ว่าด้วยขั้นตอนโมเดลการยกเลิกแบบร่วมมือด้วย CancellationTokenSource และ CancellationToken ว่าด้วยการยกเลิกที่เป็นการร่วมมือไม่ใช่การบังคับ โดยฝั่งผู้ฟังตัดสินวิธีหยุด ว่าด้วยสามวิธีเฝ้าคือการสำรวจ การลงทะเบียนคอลแบ็ก และแฮนเดิลรอ ว่าด้วย ThrowIfCancellationRequested ที่โยน OperationCanceledException ซึ่ง Task ถือเป็นการยกเลิกเสร็จ ว่าด้วยการประกอบหลายโทเคนด้วยโทเคนที่ลิงก์ และว่าด้วยไลบรารีที่ควรมีเมธอดสาธารณะที่รับ CancellationToken ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. ว่าด้วย CancellationToken ที่เป็นวิธีถูกต้องในการหยุดเธรด ว่าด้วย Thread.Abort ที่โยน PlatformNotSupportedException บน .NET Core และ .NET 5 เป็นต้นไป พร้อมคำเตือนเลิกใช้ตอนคอมไพล์ (SYSLIB0006) จาก .NET 5 เป็นต้นไปเช่นกัน และว่าด้วยการบังคับจบโค้ดบุคคลที่สามที่ไม่ตอบการยกเลิกแบบร่วมมือ ที่ต้องรันในโพรเซสแยกแล้วหยุดด้วย Process.Kill ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. ว่าด้วยการเข้าถึงคอนโทรล WinForms ที่ไม่ปลอดภัยต่อเธรด โดยการจัดการจากหลายเธรดนำไปสู่สถานะไม่สอดคล้อง การแข่ง เดดล็อก และค้าง ว่าด้วยคอนโทรลทุกตัวที่ต้องถูกสร้างและเข้าถึงบนเธรดเดียวกัน โดย Windows ต้องการเธรด UI เฉพาะเพื่อส่งข้อความระบบ และว่าด้วยการเรียกอย่างปลอดภัยจากเธรดอื่นด้วย Control.Invoke, Control.InvokeAsync จาก .NET 9 เป็นต้นไป หรือ BackgroundWorker ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). ว่าด้วยการเปลี่ยน UI ใน WPF ที่จำกัดอยู่ที่เธรดเดียว โดยเธรดพื้นหลังลงทะเบียนรายการงานกับ Dispatcher ของเธรด UI เพื่อขอ ว่าด้วย Dispatcher.Invoke ที่เป็นซิงโครนัส ขณะที่ InvokeAsync และ BeginInvoke เป็นอะซิงโครนัส และว่าด้วย Dispatcher ที่ประมวลผลงานเป็นคิวตามลำดับความสำคัญ ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). ว่าด้วย
Parallel.For/Parallel.ForEachที่ให้ความขนานของข้อมูลด้วยรสชาติใกล้ลูป for ว่าด้วยการที่ไม่ต้องสร้างเธรดหรือเข้าคิวงาน และลูปพื้นฐานไม่ต้องมีล็อก และว่าด้วย TPL ที่แยกแหล่งข้อมูลไปหลายเธรดแล้วจัดสมดุลภาระใหม่หากไม่สม่ำเสมอ ↩ -
Microsoft Learn, BlockingCollection<T> Class. ว่าด้วย BlockingCollection ที่เป็นการอิมพลีเมนต์ผู้ผลิต/ผู้บริโภคพร้อมการบล็อกและเพดานความจุ ว่าด้วยเพดานความจุที่กันฝั่งผลิตไม่ให้แซงฝั่งบริโภคเกินไป และว่าด้วยการที่มันไม่ได้ออกแบบมาเพื่อการเข้าถึงอะซิงโครนัส โดยแนะนำ Channel<T> สำหรับผู้ผลิต/ผู้บริโภคอะซิงโครนัส ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. ว่าด้วย Monitor ที่ให้การกีดกันผ่านอ็อบเจกต์ล็อกและมีความใกล้ชิดกับเธรด ว่าด้วยโค้ด C# ที่คาดให้ใช้คำสั่ง
lockแทน Monitor โดยตรง ว่าด้วย ReaderWriterLockSlim ที่กีดกันการเขียนแต่อนุญาตให้อ่านพร้อมกัน และว่าด้วย SemaphoreSlim ที่เป็นเซมาฟอร์เบาสำหรับใช้ในโพรเซสเดียว ขณะที่ Semaphore มีชื่อและใช้ซิงโครไนซ์ข้ามโพรเซสได้ ↩ ↩2 ↩3 -
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 ว่าทำไมข้อกำห...
หัวข้อที่เกี่ยวข้อง
หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ
หัวข้อเทคนิคของ Windows
ประตูสู่การพัฒนา Windows การตรวจสอบบั๊ก และการใช้ประโยชน์จากสินทรัพย์เดิม
บริการที่เกี่ยวข้องกับหัวข้อนี้
บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้
พัฒนาแอป 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 หรือในงานจริง จับดัมป์แล้ววิเคราะห์