व्यावहारिक मल्टीथ्रेडिंग सर्वोत्तम प्रथाएँ: .NET संस्करण — और थ्रेड जोड़ने से पहले क्या तय करें
· Go Komura · Windows, मल्टीथ्रेडिंग, C#, .NET, व्यावसायिक ऐप, बग जाँच, डिज़ाइन
«प्रसंस्करण धीमा था, इसलिए थ्रेड जोड़कर समांतर किया, अब योग कभी-कभी गलत आता है।» «पृष्ठभूमि कार्य जोड़ा, अब महीने में एक बार ऐप जम जाता है।» «डिबगर में पुनरुत्पादित नहीं होता कहते हैं, पर ग्राहक स्थल पर निश्चित होता है।» — मल्टीथ्रेडेड प्रोग्रामिंग का ख़तरा यह है कि लिखते ही सही चलता दिखता है। रेस-कंडीशन बग समय-निर्भर होते हैं: परीक्षण से निकल जाते हैं और केवल उत्पादन में चेहरा दिखाते हैं।
साथ ही, मल्टीकोर हार्डवेयर अब सामान्य है, इसलिए व्यावसायिक ऐप में भी मल्टीथ्रेडिंग टाली नहीं जा सकती — «UI जमाए बिना भारी काम चलाएँ», «कई उपकरण या फ़ाइलें समवर्ती संसाधित करें» जैसी आवश्यकताएँ। महत्त्वपूर्ण है और थ्रेड जोड़ने से पहले डिज़ाइन सिद्धांत तय करना। मल्टीथ्रेडिंग बग डिबगिंग से नहीं मिटते; डिज़ाइन से उनके घुसने की जगह ही मिटाते हैं।
यह लेख व्यावहारिक मल्टीथ्रेडिंग श्रृंखला का .NET संस्करण है। Windows पर व्यावसायिक ऐप बनाते हुए जिन्हें मल्टीथ्रेडिंग जोड़नी पड़े, उनके लिए भाषा और OS से निरपेक्ष डिज़ाइन सिद्धांत और C#/.NET के ठोस औज़ार, अगस्त 2026 तक के प्राथमिक स्रोतों पर आधारित। सिद्धांत स्वयं Linux या C++ पर नहीं बदलते। नेटिव कोड लिख रहे हों तो वही सिद्धांत प्रत्येक भाषा के औज़ारों पर मैप करने वाले «C++ संस्करण» और «C संस्करण» देखें, Java लिख रहे हों तो «Java संस्करण»।
1. पहले निष्कर्ष
- पहली सर्वोत्तम प्रथा यह है कि थ्रेड स्वयं न बनाएँ।
new Threadके बजाय Task, थ्रेड पूल,Parallelवर्ग जैसे ऊपरी API पर चलें, और थ्रेड संख्या का प्रबंधन रनटाइम पर छोड़ दें।12 - समांतर करते समय सबसे पहले काटें «साझा परिवर्तनीय स्थिति»। कई थ्रेड एक ही चर में लिखें, वही रेस का स्रोत है; लॉक से सुरक्षित करने से पहले विभाजन, अपरिवर्तनीयता, और सौंपने से साझा करना स्वयं घटाएँ।3
- लॉक अनुशासन रखें। «कौन सा लॉक कौन सा डेटा सुरक्षित करता है» एक-से-एक तय करें, और लॉक ऑब्जेक्ट बाहर से न दिखने वाला समर्पित इंस्टेंस हो।
lock(this)औरlock(typeof(X))वर्जित हैं। .NET 9 से समर्पितSystem.Threading.Lockप्रकार इस्तेमाल करें।4 - थ्रेडों के बीच डेटा सौंपना कतार पर केंद्रित करें।
System.Threading.Channelsया समवर्ती संग्रह पर बना उत्पादक/उपभोक्ता रूप इधर-उधर लॉक बिखेरने से सरल डिज़ाइन देता है, और सीमा भी साफ़ रहती है।56 - रुकने का तरीका सबसे पहले डिज़ाइन करें। रोक के लिए
CancellationTokenसे सहयोगी रद्दीकरण एकमात्र सही उत्तर है;Thread.Abort.NET (Core वंश) पर रनटाइम अपवाद फेंकता है।78 - UI केवल UI थ्रेड का है। WinForms नियंत्रण और WPF तत्व, जिस थ्रेड ने उन्हें बनाया उसके अलावा किसी से न छुएँ। दूसरे थ्रेड से
Control.Invoke/Dispatcherके ज़रिए अनुरोध करें।910 - «समांतर मतलब तेज़» हमेशा सत्य नहीं। प्रति-पुनरावृत्ति काम छोटा हो तो समांतरता का ओवरहेड उलटा धीमा कर सकता है। अपनाने से पहले हमेशा मापें।3
2. मल्टीथ्रेडिंग कठिन क्यों है — रेस कंडीशन और डेडलॉक
उबालें तो मल्टीथ्रेडिंग दो तरह की समस्याएँ लाती है।4
रेस कंडीशन वह बग है जिसमें परिणाम इस पर निर्भर करता है कि कई थ्रेड किसी खास कोड तक किस क्रम में पहुँचते हैं। क्लासिक उदाहरण साझा काउंटर की वृद्धि है: एक पंक्ति 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) है — अर्थात् Task केंद्रित API परिवार। 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 की प्रतीक्षा वाला काम थ्रेड जोड़ने का विषय नहीं है। प्रतीक्षा करते पूरा थ्रेड बाँधना व्यर्थ है; async/await से अतुल्यकालिक I/O प्रतीक्षा के दौरान कोई थ्रेड नहीं खाता। यह भेद — CPU-बाउंड काम समांतर करें, I/O-बाउंड काम अतुल्यकालिक बनाएँ — मल्टीथ्रेड डिज़ाइन के द्वार पर खींची जाने वाली पहली रेखा है।
flowchart TB
accTitle: थ्रेड खड़ा करने से पहले की शाखाएँ
accDescr: «थ्रेड खड़ा करने» से पहले की शाखाएँ। अधिकांश व्यावसायिक प्रसंस्करण ऊपर के तीन निकासों में से किसी में गिरता है, और new Thread तक पहुँचना असाधारण मामला ही है
S["समवर्ती चलाना है काम"] --> Q1{"काम का प्रधान भाग?"}
Q1 -->|"I/O प्रतीक्षा प्रधान<br/>फ़ाइल, नेटवर्क, DB"| ASYNC["async/await अतुल्यकालिक I/O<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 की गारंटी भी उथली है: गुण जिस ऑब्जेक्ट की ओर इशारा करता है उसे नहीं बचाती। थ्रेडों के बीच वास्तव में सुरक्षित साझा करना हो तो ImmutableArray<T> जैसे System.Collections.Immutable का अपरिवर्तनीय संग्रह इस्तेमाल करें, या साझा करते क्षण कॉपी दें और लिखने का मार्ग ही काट दें। तब शर्त यह है कि तत्व प्रकार 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) चैनल चुनना। सीमा लगते डिफ़ॉल्ट व्यवहार «लेखक जगह की प्रतीक्षा करे» है, और वही प्राकृतिक बैकप्रेशर बनता है। उत्पादन खपत से तेज़ हो और असीमित कतार इस्तेमाल करें तो टाइम बम बनता है: चलता रहता है, पर मेमोरी बढ़ती जाती है।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); }
}
}
C# का lock कथन अपवाद फेंके जाने पर भी लॉक विश्वसनीय रूप से छोड़ने की गारंटी देता है। विस्तार लॉक ऑब्जेक्ट के प्रकार पर निर्भर करता है: साधारण ऑब्जेक्ट पर finally में Monitor.Exit का कॉल बनता है, Lock प्रकार पर EnterScope() और उसका निपटान।413 अर्थात् Lock प्रकार का फ़ील्ड Monitor से अलग तंत्र है, और यदि कोड का कोई भाग हाथ से Monitor.Enter(_gate) लिखे तो lock (_gate) के साथ पारस्परिक बहिष्करण नहीं ठहरता। दोनों प्रकारों पर Monitor.Enter / Exit हाथ से लिखना बंद करें और पूरी तरह lock वाक्यविन्यास पर एक करें — यही सुरक्षित है।4
5.2. लॉक पकड़े धीमा या बाहरी काम न करें
लॉक जितनी देर कम पकड़ें उतना अच्छा; पकड़े केवल वही करें जो वह डेटा पढ़ना-लिखना है। लॉक पकड़े I/O करना, या इवेंट या कॉलबैक से बाहरी कोड बुलाना, पकड़ केवल लंबी नहीं करता — वह मार्ग खोलता है जहाँ बुलाया गया कोड दूसरा लॉक लेने की कोशिश कर डेडलॉक करे। लॉक के बाहर तैयार करें, लॉक के भीतर केवल बदलें — यही मूल रूप है।
ध्यान दें कि lock के भीतर await नहीं कर सकते (कंपाइल त्रुटि)। यह प्रतिबंध नहीं, रक्षा है: Monitor में थ्रेड आत्मीयता है — जिस थ्रेड ने लॉक लिया वही छोड़ना चाहिए — जो अतुल्यकालिक कोड से मेल नहीं खाती, जहाँ await के आर-पार निष्पादक थ्रेड बदल सकता है। अतुल्यकालिक कोड में बहिष्करण के लिए प्रारंभिक गिनती 1 वाला SemaphoreSlim इस्तेमाल करें।14
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
एक चर का अणु अपडेट — काउंटर बढ़ाना-घटाना, ध्वज बदलना — lock से तेज़ Interlocked वर्ग (Increment / Add / CompareExchange) है। प्रतिस्पर्धा न हो तो एक 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(); // मेल के बाद स्रोत निपटाएँ (WaitHandle जैसे OS संसाधन छोड़ता है)।
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 या रद्दीकरण-सक्षम API के OperationCanceledException से खत्म होने पर Task «विफल (Faulted)» नहीं «रद्द (Cancelled)» वर्गीकृत होता है (इस उदाहरण की तरह लूप शर्त से सामान्य निकास सफल पूर्णता गिना जाता है)। एक और: StopAsync का catch when फ़िल्टर से केवल अपने टोकन से जन्मा रद्दीकरण पकड़ता है। OperationCanceledException बिना शर्त निगलें तो प्रसंस्करण के भीतर दूसरे टोकन — प्रति-तत्व समय-सीमा आदि — की असली विफलता भी «रुक गया, इसलिए ठीक» दिखेगी। ध्यान दें कि टोकन-मेल से यह पहचान WorkLoop के भीतर लिंक किया टोकन (6.1 का लिंक संयोजन) इस्तेमाल करने पर टूटती है, क्योंकि उड़कर आने वाले अपवाद पर लिंक-पक्ष का टोकन होता है। उस रचना में, WorkLoop के निकास पर ct.ThrowIfCancellationRequested() बुलाकर बाहरी टोकन में «अनुवाद» कर निकलें, या फ़िल्टर when (cts.IsCancellationRequested) तक ढीला कर «रोक माँगे रहते रद्दीकरण सामान्य» मान लें — दोनों में से एक डिज़ाइन के रूप में स्पष्ट चुनें।
flowchart TB
accTitle: सहयोगी रद्दीकरण का रूप
accDescr: सहयोगी रद्दीकरण का रूप। रोकने वाला पक्ष केवल Cancel() बुलाता है; «कब और कैसे खत्म हो» प्रत्येक प्रसंस्करण स्वयं तय करता है। इसलिए अवस्था सुसंगत रखते हुए रुक सकता है
OWNER["रोकने वाला पक्ष"] -->|"Cancel() एक बार बुलाता"| CTS["CancellationTokenSource"]
CTS -->|"Token सौंपता"| W1["वर्कर प्रसंस्करण 1"]
CTS -->|"Token सौंपता"| W2["वर्कर प्रसंस्करण 2"]
CTS -->|"Token सौंपता"| W3["लाइब्रेरी का<br/>रद्दीकरण-सक्षम API"]
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.Kill से रोकें।8
6.3. प्रतीक्षा पोलिंग से नहीं, प्रतीक्षा हैंडल से
«ध्वज उठे तक Sleep(100) लूप में प्रतीक्षा» लिखना CPU और प्रतिक्रिया दोनों बर्बाद करता है। थ्रेडों के बीच संकेत के लिए ManualResetEventSlim और SemaphoreSlim जैसे सिंक्रनाइज़ेशन प्रिमिटिव हैं, जो संकेत मिलने तक थ्रेड को सही सुलाते हैं।13 Windows पर टाइमर परिशुद्धता और इवेंट प्रतीक्षा का चुनाव «Windows पर Sleep(1) के बजाय Event Wait क्यों चुनें» में विस्तार से है।
7. UI थ्रेड की विशेष स्थिति — Windows डेस्कटॉप ऐप का नियम
Windows डेस्कटॉप ऐप पर सामान्य सिद्धांतों के ऊपर एक और मज़बूत बाधा है। नियम: UI केवल उसी थ्रेड से छूआ जा सकता है जिसने उसे बनाया (UI थ्रेड)।
WinForms नियंत्रण थ्रेड-सुरक्षित नहीं; कई थ्रेड से चलाने पर नियंत्रण असंगत अवस्था में धकेल जाता है — रेस, डेडलॉक, फ्रीज़ का कारण। Windows ऐप से माँगता है कि सिस्टम संदेश लेने वाला एक समर्पित थ्रेड हो, और UI का निर्माण तथा संचालन उसी थ्रेड पर केंद्रित हो।9 WPF की संरचना बिल्कुल वही है: UI तत्व केवल UI थ्रेड बदल सकता है।10
दूसरे थ्रेड से UI अद्यतन करना हो तो सीधे न छुएँ — «UI थ्रेड को अनुरोध» में बदलें।
flowchart LR
accTitle: UI अद्यतन को अनुरोध में बदलना
accDescr: UI अद्यतन को «अनुरोध» में बदलें। पृष्ठभूमि थ्रेड का काम संदेश कतार पर काम रखवाने तक है — नियंत्रण हमेशा UI थ्रेड स्वयं छूता है
OS["Windows<br/>माउस, कीबोर्ड, पुनः चित्रण"] --> Q["UI थ्रेड की<br/>संदेश कतार"]
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 UI थ्रेड का SynchronizationContext पकड़ता है और निरंतरता स्वतः UI थ्रेड पर फिर चलाता है, इसलिए हाथ से Invoke लिखने की जगहें बहुत घट जाती हैं। पर यह बिना शर्त गुण नहीं। पृष्ठभूमि कॉलबैक से घुसा कोड, या ConfigureAwait(false) के बाद की निरंतरता, UI थ्रेड पर नहीं लौटती, इसलिए उस मार्ग पर UI छुएँ तो स्पष्ट डिस्पैच अभी भी चाहिए। इस रूप पर एक करें: «भारी काम Task.Run या अतुल्यकालिक I/O को, परिणाम स्क्रीन पर await के बाद की निरंतरता में» — यही आधुनिक Windows ऐप का मूल रूप है। UI थ्रेड और async/await का संबंध «WPF/WinForms async और UI थ्रेड एक पन्ने पर» में एक चित्र में समेटा है।
और जहाँ COM शामिल हो — Office एकीकरण, विरासत घटक आदि — एक और परत जुड़ती है: COM का अपना थ्रेडिंग मॉडल (STA/MTA)। «UI थ्रेड पर COM ऑब्जेक्ट बनाया, दूसरे थ्रेड से बुलाया, जम गया» जैसी दुर्घटनाएँ इसी परत की हैं, और «COM STA/MTA मूल बातें - थ्रेडिंग मॉडल और हैंग से कैसे बचें» में समझाई गई हैं।
8. नेटिव कोड (C++/C) में लिखते समय
अब तक के सिद्धांत — थ्रेड सीधे न बनाएँ, साझा परिवर्तनीय स्थिति घटाएँ, लॉक अनुशासन, रुकने का डिज़ाइन — नेटिव कोड पर ज्यों के त्यों लागू होते हैं। बदलते हैं औज़ार। C++ में RAII के साथ std::jthread / std::mutex / std::atomic; C में Win32 API के _beginthreadex, SRW लॉक, कंडीशन वेरिएबल, और स्टॉप-इवेंट पैटर्न। प्रत्येक, भाषा-विशिष्ट जालों सहित (std::thread का डिस्ट्रक्टर, TerminateThread का ख़तरा, DllMain और लोडर लॉक आदि), इस श्रृंखला के «C++ संस्करण» और «C संस्करण» में है।
9. सत्यापन और डिबग — इस मान्यता पर तैयारी कि पुनरुत्पादित नहीं होगा
मल्टीथ्रेडिंग बग परीक्षण से मिलने की अपेक्षा नहीं कर सकते। सामान्य इकाई परीक्षण उस चलान को सफलता गिनता है जहाँ रेस संयोग से नहीं लगी। तैयारी तीन परतों में सोचें।
पहली रक्षा रेखा अब तक के डिज़ाइन सिद्धांत स्वयं हैं। पाँच साझा परिवर्तनीय स्थितियों वाले ऐप और पचास वाले में संदेह की जगहें दस गुना भिन्न हैं। समीक्षा में तालिका से जाँचें: «कौन सा परिवर्तनीय डेटा साझा है», «प्रत्येक कौन सा लॉक सुरक्षित करता है», «लॉक लेने का क्रम अद्वितीय है», «रोक पथ कहाँ हैं»। वह डिज़ाइन जिसके लिए यह तालिका न लिख सकें अधूरा है, चाहे चल रहा हो।
दूसरा, विसंगतियाँ छिपाने के बजाय अवलोकनीय बनाएँ। Monitor.TryEnter की समय-सीमा से लॉक-प्रतीक्षा विसंगति पकड़कर लॉग करें,4 थ्रेड पूल पर फेंके काम के अनदेखे अपवाद निगलें नहीं — दर्ज करें, और हैंग पर पूर्ण डंप लेने को तैयार रहें ताकि हर थ्रेड का स्टैक देखा जा सके — «कभी-कभी ही होता» बग से लड़ाई इस बात से तय होती है कि जो एक बार हो, उससे कितनी जानकारी निकाल सकें। डंप और लॉग की व्यवस्था «Windows ऐप क्रैश के लिए लॉगिंग और डंप कैप्चर डिज़ाइन» में है।
तीसरा, भार के नीचे हिलाएँ। विकास मशीन पर अशुभ अंतर्ग्रथन लगाना आसान बनाने वाला तनाव-परीक्षण — कोर से अधिक समांतरता से लंबे समय चलाना, प्रसंस्करण क्रम यादृच्छिक करना, कृत्रिम विलंब घुसाना — शिपिंग से पहले रेस निकालने का यथार्थवादी साधन है। डिबगर में गायब बग अक्सर रिलीज़ बिल्ड प्लस भारी भार के नीचे पुनरुत्पादित होता है।
10. सार — और थ्रेड जोड़ने से पहले जाँच सूची
उबालें तो मल्टीथ्रेडेड प्रोग्रामिंग की सर्वोत्तम प्रथा «सिंक्रनाइज़ेशन सही लिखने का कौशल» नहीं, «सिंक्रनाइज़ेशन लिखे बिना चल जाने वाला डिज़ाइन» है। शुरू करने से पहले इन आठ प्रश्नों का उत्तर दे सकें तो लगभग हर बड़ी दुर्घटना रुक सकती है।
- यह काम CPU-बाउंड है या I/O-बाउंड (बाद वाला हो तो उत्तर थ्रेड नहीं, async/await है)?
- क्या
new Threadलिखने वाले हैं (क्या Task,Parallel, या थ्रेड पूल से व्यक्त हो सकता है)? - थ्रेडों के बीच कौन सा परिवर्तनीय डेटा साझा है — गिन सकते हैं?
- वह साझा करना विभाजन, अपरिवर्तनीयता, या कतार से सौंपने से मिटाया जा सकता है?
- बचे प्रत्येक साझा डेटा के लिए ठीक एक संगत लॉक तय है?
- लॉक लेने का क्रम हर थ्रेड पर अद्वितीय है, और लॉक पकड़े बाहरी कॉल तो नहीं?
CancellationTokenहर लंबे ऑपरेशन तक पहुँचा है, और रोक पथ समझा सकते हैं?- UI छूने वाला कोड UI थ्रेड पर केंद्रित है?
मल्टीथ्रेडिंग बग लिखे दिन नहीं दिखते — भूल जाने के बहुत बाद ग्राहक स्थल पर दाँत निकालते हैं। उलटा कहें तो डिज़ाइन चरण पर यह जाँच सूची चला लें तो «कभी-कभी क्रैश», «महीने में एक बार जमना» जैसी सबसे महँगी विफलताएँ कोड लिखने से पहले तोड़ी जा सकती हैं।
संबंधित लेख
- व्यावहारिक मल्टीथ्रेडिंग सर्वोत्तम प्रथाएँ: C++ संस्करण — RAII और jthread से संरचना द्वारा दुर्घटनाएँ मिटाना
- व्यावहारिक मल्टीथ्रेडिंग सर्वोत्तम प्रथाएँ: C संस्करण — Win32 API तरीके से सुरक्षित लिखना
- व्यावहारिक मल्टीथ्रेडिंग सर्वोत्तम प्रथाएँ: Java संस्करण — आभासी थ्रेड युग की परंपराएँ
- C# async/await व्यावहारिक निर्णय तालिका - Task.Run और ConfigureAwait
- WPF/WinForms async और UI थ्रेड एक पन्ने पर
- Windows I/O की गहराई (भाग 3) — I/O Completion Ports (IOCP) और .NET Thread Pool: async/await के नीचे की तह
- COM STA/MTA मूल बातें - थ्रेडिंग मॉडल और हैंग से कैसे बचें
- Windows पर Sleep(1) के बजाय Event Wait क्यों चुनें
- साझा मेमोरी के जाल और व्यावहारिक सर्वोत्तम प्रथाएँ
संबंधित परामर्श क्षेत्र
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इंस्टेंस इस्तेमाल करने पर; C#lockकथन केfinallyमेंMonitor.Exitकी गारंटी पर;Monitor.TryEnterसमय-सीमा से डेडलॉक पता लगाने पर; सरल अवस्था परिवर्तन के लिएInterlockedवर्ग तेज़ होने पर; और स्थैतिक डेटा डिफ़ॉल्ट थ्रेड-सुरक्षित, इंस्टेंस डेटा डिफ़ॉल्ट गैर-थ्रेड-सुरक्षित वाली डिज़ाइन मार्गदर्शन पर। ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 -
Microsoft Learn, System.Threading.Channels library. इस पर कि चैनल उत्पादक/उपभोक्ता मॉडल का FIFO है जो सिंक्रनाइज़ेशन भीतर सँभालता है; CreateBounded से क्षमता-सीमित चैनल बन सकता है; सीमा लगते डिफ़ॉल्ट व्यवहार लेखक की प्रतीक्षा है, DropOldest जैसे अन्य FullMode भी चुन सकते हैं; और लेखन पठन से तेज़ हो तो बैकप्रेशर लगता है। ↩ ↩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 है; .NET Core और .NET 5 से Thread.Abort PlatformNotSupportedException फेंकता है, और .NET 5 से कंपाइल-समय निंदा चेतावनी (SYSLIB0006) भी लगती है; सहयोगी रद्दीकरण का जवाब न देने वाले तृतीय-पक्ष कोड को ज़बरदस्ती खत्म करने के लिए अलग प्रक्रिया में चलाकर Process.Kill से रोकना चाहिए। ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. इस पर कि WinForms नियंत्रण तक पहुँच थ्रेड-सुरक्षित नहीं, कई थ्रेड से संचालन असंगत अवस्था, रेस, डेडलॉक और फ्रीज़ लाता है; हर नियंत्रण उसी थ्रेड पर बनना और छूआ जाना चाहिए, Windows सिस्टम संदेश पहुँचाने के लिए समर्पित UI थ्रेड माँगता है; और दूसरे थ्रेड से Control.Invoke, .NET 9 से Control.InvokeAsync, या BackgroundWorker से सुरक्षित बुलाएँ। ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). इस पर कि WPF में UI परिवर्तन एक थ्रेड तक सीमित हैं, पृष्ठभूमि थ्रेड UI थ्रेड के Dispatcher पर कार्य आइटम पंजीकृत कर अनुरोध करता है; 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# में Monitor सीधे नहीं
lockकथन इस्तेमाल होना चाहिए; ReaderWriterLockSlim लेखन बहिष्कृत करता है और समवर्ती पठन देता है; SemaphoreSlim एक प्रक्रिया के भीतर हल्का सेमाफोर है, जबकि Semaphore नामित है और प्रक्रिया-पार सिंक्रनाइज़ेशन के लिए इस्तेमाल हो सकता है। ↩ ↩2 ↩3 -
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. इस पर कि C#
lockकथन औरLockप्रकार थ्रेड आत्मीयता रखते हैं इसलिएawaitके आर-पार इस्तेमाल नहीं हो सकते (await के पहले-बाद निरंतरता चलाने वाला थ्रेड बदल सकता है); अतुल्यकालिक कोड में बहिष्करण के लिए गिनती 1 वालेSemaphoreSlimकोWaitAsyncऔरfinallyमेंReleaseसे इस्तेमाल करें; और थ्रॉटलिंग के लिए boundedChannelविकल्प है। ↩
संबंधित लेख
निकटवर्ती विषयों में गहराई से जाने के लिए समान टैग वाले नवीनतम लेख।
व्यावहारिक मल्टीथ्रेडिंग सर्वोत्तम प्रथाएँ: C++ संस्करण — RAII और jthread से संरचना द्वारा दुर्घटनाएँ मिटाना
C++ में मल्टीथ्रेडिंग वह संसार है जहाँ डेटा रेस अपरिभाषित व्यवहार बन जाता है। यह लेख std::thread के डिस्ट्रक्टर का जाल, jthread और stop_t...
व्यावहारिक मल्टीथ्रेडिंग सर्वोत्तम अभ्यास: C संस्करण — Win32 API तरीके से सुरक्षित लिखना
Win32 के साथ C में मल्टीथ्रेडिंग का स्थापित तरीका _beginthreadex से थ्रेड बनाना, SRW लॉक और कंडीशन वेरिएबल, Interlocked फ़ंक्शन, और स्टॉप...
C# और PowerShell से WMI/CIM का उपयोग — हार्डवेयर जानकारी, प्रोसेस निगरानी और रिमोट क्वेरी की व्यावहारिक मार्गदर्शिका
PC का सीरियल नंबर लेना, डिस्क खाली स्थान की निगरानी, और प्रोसेस शुरू होने का पता लगाने का आम तरीका WMI/CIM है। यह लेख Get-CimInstance जैस...
स्प्यूरियस वेकअप — Condition Variable "बिना सूचना के" क्यों जागती है और Windows पर सही प्रतीक्षा कैसे करें
कंडीशन वेरिएबल की प्रतीक्षा सूचना आए बिना भी लौट सकती है (स्प्यूरियस वेकअप)। यह लेख Windows इम्प्लीमेंटेशन से समझाता है कि विनिर्देश इसे ...
व्यावहारिक मल्टीथ्रेडिंग बेस्ट प्रैक्टिस: Java संस्करण — वर्चुअल थ्रेड युग की रीतियाँ
Java में मल्टीथ्रेडिंग की स्थापित रीति यह है कि थ्रेड सीधे न बनाएँ, बल्कि ExecutorService और वर्चुअल थ्रेड पर चलें। यह लेख व्यावहारिक सिद...
संबंधित विषय
ये पृष्ठ विषय को सेवाओं और निर्णयों के व्यापक संदर्भ में रखते हैं।
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 की प्रतीक्षा वाला काम थ्रेड जोड़ने का विषय ही नहीं — सही कदम async/await से अतुल्यकालिक I/O है।
- volatile जोड़ने से कुछ थ्रेड-सुरक्षित हो जाता है?
- नहीं। volatile जो गारंटी देता है वह क्रम है — कि उस फ़ील्ड तक पहुँच आसपास की मेमोरी संक्रियाओं के सापेक्ष पुनर्क्रमित न हो (acquire/release अर्थ) — «पढ़ो, गणना करो, वापस लिखो» जैसी संयुक्त संक्रिया की अणुता नहीं। उदाहरण के लिए, कई थ्रेड volatile int काउंटर पर ++ करें तो जोड़ फिर भी खो जाते हैं। काउंटर बढ़ाना-घटाना या तुलना-कर-बदलने के लिए Interlocked वर्ग इस्तेमाल करें, और कई चर एक साथ सुरक्षित करने हों तो lock। volatile लगभग केवल सरल स्थिति-ध्वज जैसी जगह पर विचार योग्य है — जहाँ एक थ्रेड लिखता है और बाकी केवल पढ़ते हैं — और वह ध्वज भी अब CancellationToken से व्यक्त करना मानक है।
- कभी-कभी ही होने वाला बग मल्टीथ्रेडिंग से है या नहीं, कैसे पहचानें?
- तीन संकेत संदेह जगाते हैं: «वही ऑपरेशन कभी पुनरुत्पादित होता है कभी नहीं», «डिबगर लगाने या लॉग जोड़ने पर पुनरुत्पादन रुक जाता है», और «केवल भारी भार या स्टार्टअप के तुरंत बाद होता है»। समय-निर्भर बग की पहचान है कि परिणाम हर चलान पर बदलता है — यही रेस कंडीशन की परिभाषा है। काटने के लिए पहले हर साझा परिवर्तनीय डेटा गिनें, और प्रत्येक के लिए तालिका बनाएँ कि कौन सा लॉक उसे सुरक्षित करता है। एक भी असुरक्षित पहुँच संदिग्ध है। हैंग हो तो हर थ्रेड का स्टैक लें और देखें कि उनकी लॉक प्रतीक्षा चक्र तो नहीं बनाती। Visual Studio डिबगर में रोककर Parallel Stacks देखें, या उत्पादन में डंप पकड़कर विश्लेषण करें।