เมื่อใดควรใช้คำสำคัญระเหยใน C #


299

ทุกคนสามารถให้คำอธิบายที่ดีเกี่ยวกับคำสำคัญในภาษา C # ได้หรือไม่? ปัญหาอะไรที่แก้ได้และมันไม่ได้? ในกรณีใดบ้างที่ฉันจะต้องใช้การล็อก?


6
เหตุใดคุณต้องการบันทึกการใช้งานการล็อก การล็อกแบบไม่มีการควบคุมเพิ่ม nanoseconds สองสามรายการในโปรแกรมของคุณ คุณไม่สามารถจ่ายเงินสักสองสามเสี้ยววินาทีได้จริงหรือ
Eric Lippert

คำตอบ:


273

ฉันไม่คิดว่าจะมีคนตอบคำถามที่ดีไปกว่าEric Lippert (เน้นในต้นฉบับ):

ใน C # "volatile" หมายถึงไม่เพียง แต่ "ตรวจสอบให้แน่ใจว่าคอมไพเลอร์และ jitter ไม่ได้ทำการจัดเรียงรหัสใหม่หรือลงทะเบียนการเพิ่มประสิทธิภาพการแคชกับตัวแปรนี้" นอกจากนี้ยังหมายถึง "บอกโปรเซสเซอร์ให้ทำสิ่งที่จำเป็นต้องทำเพื่อให้แน่ใจว่าฉันกำลังอ่านค่าล่าสุดแม้ว่าจะหมายถึงการหยุดโปรเซสเซอร์อื่นและทำให้หน่วยความจำหลักตรงกันกับแคช"

ที่จริงแล้วบิตสุดท้ายคือเรื่องโกหก ความหมายที่แท้จริงของการอ่านและเขียนระเหยง่ายมีความซับซ้อนมากกว่าที่ฉันเขียนไว้ที่นี่ ในความเป็นจริงพวกเขาไม่ได้รับประกันว่าโปรเซสเซอร์ทุกตัวจะหยุดสิ่งที่ทำอยู่และอัพเดตแคชไปยัง / จากหน่วยความจำหลัก แต่พวกเขาให้การค้ำประกันอ่อนแอเกี่ยวกับวิธีการเข้าถึงหน่วยความจำก่อนและหลังการอ่านและการเขียนอาจจะสังเกตเห็นที่จะสั่งซื้อด้วยความเคารพซึ่งกันและกัน การดำเนินการบางอย่างเช่นการสร้างเธรดใหม่การเข้าล็อคหรือการใช้หนึ่งในตระกูลของวิธีการแบบอินเตอร์ล็อกที่มีการรับรองที่ดีกว่าเกี่ยวกับการสังเกตการสั่งซื้อ หากคุณต้องการรายละเอียดเพิ่มเติมอ่านหัวข้อ 3.10 และ 10.5.3 ของข้อกำหนด C # 4.0

ตรงไปตรงมาผมกีดกันคุณจากที่เคยทำสนามระเหย เขตข้อมูลที่ระเหยได้เป็นสัญญาณว่าคุณกำลังทำสิ่งที่บ้าคลั่งอย่างจริงจัง: คุณกำลังพยายามอ่านและเขียนค่าเดียวกันในสองเธรดที่แตกต่างกันโดยไม่ต้องใส่กุญแจ ล็อครับประกันว่าหน่วยความจำที่อ่านหรือแก้ไขภายในล็อคถูกตรวจสอบว่าสอดคล้องกันล็อครับประกันว่ามีเพียงเธรดเดียวเท่านั้นที่เข้าถึงหน่วยความจำที่กำหนดในแต่ละครั้งและต่อ ๆ ไป จำนวนสถานการณ์ที่ล็อคช้าเกินไปมีขนาดเล็กมากและความน่าจะเป็นที่คุณจะได้รหัสผิดเพราะคุณไม่เข้าใจรูปแบบหน่วยความจำที่แน่นอนมีขนาดใหญ่มาก ฉันไม่ได้พยายามเขียนรหัส low-lock ใด ๆ ยกเว้นการใช้งานอินเตอร์ล็อคที่น่าสนใจที่สุด ฉันปล่อยให้การใช้งานของ "ระเหย" เพื่อผู้เชี่ยวชาญจริง

สำหรับการอ่านเพิ่มเติมดู:


29
ฉันจะลงคะแนนนี้ถ้าฉันสามารถ มีข้อมูลที่น่าสนใจมากมายในนั้น แต่ไม่ตอบคำถามของเขา เขาถามเกี่ยวกับการใช้คำสำคัญที่เกี่ยวข้องกับการล็อค สำหรับค่อนข้างสักครู่ (ก่อน 2.0 RT) จำเป็นต้องใช้คำสำคัญระเหยเพื่อทำให้เธรดสแตติกฟิลด์ปลอดภัยหากอินสแตนซ์ของฟิลด์มีรหัสเริ่มต้นใด ๆ ในตัวสร้าง (ดูคำตอบของ AndrewTek) มีรหัส RT 1.1 จำนวนมากที่ยังคงอยู่ในสภาพแวดล้อมการใช้งานจริงและผู้พัฒนาที่ควรทราบว่าทำไมคำหลักนั้นถึงมีอยู่และปลอดภัยที่จะลบออกหรือไม่
พอลอีสเตอร์

3
@PaulEaster ข้อเท็จจริงที่ว่ามันสามารถนำมาใช้สำหรับ doulbe ตรวจสอบล็อค (มักจะอยู่ในรูปแบบเดี่ยว) ไม่ได้หมายความว่ามันควรจะเป็น การใช้โมเดลหน่วยความจำ. NET อาจเป็นวิธีปฏิบัติที่ไม่ถูกต้องคุณควรใช้รูปแบบ ECMA แทน ตัวอย่างเช่นคุณอาจต้องการพอร์ตเป็นโมโนหนึ่งวันซึ่งอาจมีโมเดลที่แตกต่างกัน ฉันต้องเข้าใจด้วยเช่นกันว่าฮาร์ดแวร์ที่มีส่วนประกอบต่างกันสามารถเปลี่ยนแปลงสิ่งต่างๆ สำหรับข้อมูลเพิ่มเติมโปรดดูที่: stackoverflow.com/a/7230679/67824 สำหรับทางเลือกซิงเกิลตันที่ดีกว่า (สำหรับ. NET ทุกเวอร์ชัน) ดู: csharpindepth.com/articles/general/singleton.aspx
Ohad Schneider

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

3
สิ่งนี้หมายความว่าการล็อกและตัวแปรที่เปลี่ยนแปลงได้นั้นไม่เกิดร่วมกันในกรณีต่อไปนี้: ถ้าฉันใช้การล็อกรอบ ๆ ตัวแปรบางตัวไม่จำเป็นต้องประกาศตัวแปรนั้นว่าเป็นสารระเหยอีกต่อไป?
giorgim

4
@Giorgi ใช่ - อุปสรรคหน่วยความจำรับประกันโดยvolatileจะมีคุณธรรมล็อค
Ohad Schneider

54

หากคุณต้องการได้รับเทคนิคเพิ่มเติมเล็กน้อยเกี่ยวกับสิ่งที่คำสำคัญระเหยพิจารณาโปรแกรมต่อไปนี้ (ฉันใช้ DevStudio 2005):

#include <iostream>
void main()
{
  int j = 0;
  for (int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  std::cout << j;
}

การใช้การตั้งค่าคอมไพเลอร์มาตรฐานที่ดีที่สุด (ปล่อย) คอมไพเลอร์สร้างแอสเซมเบลอร์ต่อไปนี้ (IA32):

void main()
{
00401000  push        ecx  
  int j = 0;
00401001  xor         ecx,ecx 
  for (int i = 0 ; i < 100 ; ++i)
00401003  xor         eax,eax 
00401005  mov         edx,1 
0040100A  lea         ebx,[ebx] 
  {
    j += i;
00401010  add         ecx,eax 
00401012  add         eax,edx 
00401014  cmp         eax,64h 
00401017  jl          main+10h (401010h) 
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
00401019  mov         dword ptr [esp],0 
00401020  mov         eax,dword ptr [esp] 
00401023  cmp         eax,64h 
00401026  jge         main+3Eh (40103Eh) 
00401028  jmp         main+30h (401030h) 
0040102A  lea         ebx,[ebx] 
  {
    j += i;
00401030  add         ecx,dword ptr [esp] 
00401033  add         dword ptr [esp],edx 
00401036  mov         eax,dword ptr [esp] 
00401039  cmp         eax,64h 
0040103C  jl          main+30h (401030h) 
  }
  std::cout << j;
0040103E  push        ecx  
0040103F  mov         ecx,dword ptr [__imp_std::cout (40203Ch)] 
00401045  call        dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (402038h)] 
}
0040104B  xor         eax,eax 
0040104D  pop         ecx  
0040104E  ret              

เมื่อดูที่เอาต์พุตคอมไพเลอร์ได้ตัดสินใจใช้การลงทะเบียน ecx เพื่อเก็บค่าของตัวแปร j สำหรับการวนซ้ำแบบไม่ลบเลือน (ตอนแรก) คอมไพเลอร์ได้กำหนด i ให้กับ eax register ค่อนข้างตรงไปตรงมา มีบิตที่น่าสนใจอยู่สองสามอย่าง - คำสั่ง lea ebx, [ebx] เป็นคำสั่งมัลติไบต์ที่มีประสิทธิภาพเพื่อให้วงกระโดดไปที่แอดเดรสหน่วยความจำที่อยู่ในแนว 16 ไบต์ อีกข้อหนึ่งคือการใช้ edx เพื่อเพิ่มตัวนับลูปแทนการใช้คำสั่ง inc eax คำสั่งเพิ่ม reg, reg มีเวลาแฝงที่ต่ำกว่าใน IA32 คอร์บางตัวเมื่อเทียบกับคำสั่ง inc reg แต่ไม่เคยมีเวลาแฝงที่สูงกว่า

ทีนี้สำหรับลูปที่มีตัวนับลูประเหย ตัวนับถูกเก็บไว้ที่ [esp] และคำสำคัญระเหยบอกคอมไพเลอร์ค่าควรอ่านจาก / เขียนไปยังหน่วยความจำและไม่เคยกำหนดให้ลงทะเบียน คอมไพเลอร์ยังไปไกลถึงการไม่ทำโหลด / เพิ่ม / จัดเก็บเป็นสามขั้นตอนที่แตกต่างกัน (โหลด eax, inc eax, บันทึก eax) เมื่ออัปเดตค่าตัวนับแทนหน่วยความจำจะถูกปรับเปลี่ยนโดยตรงในคำสั่งเดียว เร็ก) วิธีการสร้างโค้ดทำให้มั่นใจได้ว่าค่าของตัวนับลูปนั้นทันสมัยอยู่เสมอในบริบทของซีพียูแกนเดียว ไม่มีการดำเนินการกับข้อมูลที่อาจส่งผลให้เกิดความเสียหายหรือการสูญเสียข้อมูล (ดังนั้นไม่ได้ใช้โหลด / inc / ร้านค้าเนื่องจากค่าสามารถเปลี่ยนแปลงได้ในระหว่างการ inc จึงจะหายไปในร้าน) เนื่องจากอินเตอร์รัปต์สามารถให้บริการได้เมื่อคำสั่งปัจจุบันเสร็จสิ้นแล้วเท่านั้น

เมื่อคุณแนะนำซีพียูตัวที่สองให้กับระบบคำสำคัญที่ไม่ลบเลือนจะไม่ป้องกันข้อมูลที่ถูกอัพเดตโดย CPU ตัวอื่นในเวลาเดียวกัน ในตัวอย่างข้างต้นคุณจะต้องใช้ข้อมูลที่ไม่ได้จัดแนวเพื่อรับความเสียหายที่อาจเกิดขึ้น คำสำคัญระเหยจะไม่ป้องกันความเสียหายที่อาจเกิดขึ้นหากข้อมูลไม่สามารถจัดการแบบอะตอมตัวอย่างเช่นถ้าตัวนับลูปเป็นประเภทยาวยาว (64 บิต) ดังนั้นมันจะต้องใช้สอง 32 บิตในการอัปเดตค่ากลาง ซึ่งการขัดจังหวะสามารถเกิดขึ้นและเปลี่ยนแปลงข้อมูลได้

ดังนั้นคำสำคัญระเหยง่ายสำหรับข้อมูลที่สอดคล้องซึ่งน้อยกว่าหรือเท่ากับขนาดของการลงทะเบียนพื้นเมืองเช่นการดำเนินการที่เป็นอะตอมเสมอ

คำสำคัญระเหยถูกคิดว่าจะใช้กับการดำเนินงาน IO ที่ IO จะเปลี่ยนแปลงตลอดเวลา แต่มีที่อยู่คงที่เช่นหน่วยความจำที่แมปอุปกรณ์ UART และคอมไพเลอร์ไม่ควรนำค่าแรกอ่านจากที่อยู่

หากคุณจัดการกับข้อมูลขนาดใหญ่หรือมี CPU หลายตัวคุณจะต้องใช้ระบบล็อคระดับสูงกว่า (OS) เพื่อจัดการการเข้าถึงข้อมูลอย่างถูกต้อง


นี่คือ C ++ แต่หลักการใช้กับ C #
Skizz

6
Eric Lippert เขียนว่าสารระเหยใน C ++ จะป้องกันไม่ให้คอมไพเลอร์ทำการเพิ่มประสิทธิภาพบางอย่างในขณะที่ใน C # volatile จะทำการสื่อสารเพิ่มเติมระหว่างคอร์ / โปรเซสเซอร์อื่นเพื่อให้แน่ใจว่าค่าล่าสุดจะได้รับการอ่าน
Peter Huber

44

หากคุณใช้. NET 1.1 จำเป็นต้องใช้คีย์เวิร์ดแบบระเหยเมื่อทำการล็อคที่ทำเครื่องหมายไว้สองครั้ง ทำไม? เนื่องจากก่อนหน้า. NET 2.0 สถานการณ์ต่อไปนี้อาจทำให้เกิดเธรดที่สองเพื่อเข้าถึงวัตถุที่ไม่เป็นโมฆะ

  1. เธรด 1 ถามว่าตัวแปรนั้นเป็นโมฆะหรือไม่ //if(this.foo == null)
  2. เธรด 1 กำหนดตัวแปรเป็นโมฆะดังนั้นจึงเข้าสู่การล็อก //lock(this.bar)
  3. เธรด 1 ถามอีกครั้งว่าตัวแปรเป็นโมฆะหรือไม่ //if(this.foo == null)
  4. เธรด 1 ยังคงกำหนดตัวแปรเป็นโมฆะดังนั้นจึงเรียกใช้ตัวสร้างและกำหนดค่าให้กับตัวแปร //this.foo = new Foo ();

ก่อนหน้า. NET 2.0, this.foo สามารถกำหนดอินสแตนซ์ใหม่ของ Foo ได้ก่อนที่ Constructor จะทำงานเสร็จ ในกรณีนี้อาจมีเธรดที่สองเข้ามา (ในระหว่างการเรียกเธรด 1 ของตัวสร้าง Foo) และประสบการณ์ต่อไปนี้:

  1. เธรด 2 ถามว่าตัวแปรนั้นเป็นโมฆะหรือไม่ //if(this.foo == null)
  2. เธรด 2 กำหนดว่าตัวแปรนั้นไม่ใช่โมฆะดังนั้นพยายามใช้มัน //this.foo.MakeFoo ()

ก่อนที่จะ. NET 2.0 คุณสามารถประกาศสิ่งนี้ได้เช่นเดียวกับความผันผวนในการแก้ไขปัญหานี้ ตั้งแต่. NET 2.0 คุณไม่จำเป็นต้องใช้คำสำคัญระเหยเพื่อทำการล็อคการตรวจสอบสองครั้ง

วิกิพีเดียมีบทความที่ดีเกี่ยวกับการล็อคสองครั้งที่ผ่านมาและสัมผัสสั้น ๆ ในหัวข้อนี้: http://en.wikipedia.org/wiki/Double-checked_locking


2
นี่คือสิ่งที่ฉันเห็นในรหัสดั้งเดิมและสงสัยเกี่ยวกับมัน นั่นคือเหตุผลที่ฉันเริ่มการวิจัยที่ลึกซึ้งยิ่งขึ้น ขอบคุณ!
Peter Porfy

1
ฉันไม่เข้าใจว่าเธรด 2 จะกำหนดค่าfooอย่างไร ไม่ล็อคเธรด 1 this.barและดังนั้นเฉพาะเธรด 1 เท่านั้นจึงจะสามารถเริ่มต้น foo ที่จุด givne ในเวลาหรือไม่ ฉันหมายความว่าคุณทำการตรวจสอบค่าหลังจากการปลดล็อคอีกครั้งเมื่อมันควรจะมีค่าใหม่จากเธรด 1
gilmishal

24

บางครั้งคอมไพเลอร์จะปรับฟิลด์ให้เหมาะสมและใช้รีจิสเตอร์เพื่อเก็บมัน หากเธรด 1 ทำการเขียนไปยังฟิลด์และเธรดอื่นเข้าถึงได้เนื่องจากการอัปเดตถูกเก็บไว้ในรีจิสเตอร์ (และไม่ใช่หน่วยความจำ) เธรดที่ 2 จะได้รับข้อมูลเก่า

คุณสามารถคิดถึงคำสำคัญที่ลบเลือนว่าเป็นการพูดกับคอมไพเลอร์ "ฉันต้องการให้คุณเก็บค่านี้ไว้ในหน่วยความจำ" สิ่งนี้รับประกันว่าเธรดที่ 2 ดึงค่าล่าสุด


21

จากMSDN : ตัวดัดแปลงแบบระเหยมักใช้สำหรับฟิลด์ที่เข้าถึงโดยเธรดจำนวนมากโดยไม่ต้องใช้คำสั่งล็อกเพื่อเข้าถึงแบบอนุกรม การใช้ตัวแก้ไขแบบระเหยทำให้แน่ใจได้ว่าเธรดหนึ่งดึงค่าล่าสุดที่เขียนโดยเธรดอื่น


13

CLR ชอบที่จะปรับคำแนะนำให้เหมาะสมดังนั้นเมื่อคุณเข้าถึงฟิลด์ในโค้ดอาจไม่สามารถเข้าถึงค่าปัจจุบันของฟิลด์ได้ตลอดเวลา (อาจมาจากสแต็ก ฯลฯ ) การทำเครื่องหมายฟิลด์volatileเพื่อให้แน่ใจว่าค่าปัจจุบันของฟิลด์นั้นเข้าถึงได้โดยคำสั่ง สิ่งนี้มีประโยชน์เมื่อค่าสามารถแก้ไขได้ (ในสถานการณ์ที่ไม่ล็อค) โดยเธรดที่เกิดขึ้นพร้อมกันในโปรแกรมของคุณหรือโค้ดอื่น ๆ ที่รันในระบบปฏิบัติการ

คุณสูญเสียการเพิ่มประสิทธิภาพบางอย่างชัดเจน แต่จะทำให้รหัสง่ายขึ้น


3

ฉันพบบทความนี้โดยJoydip Kanjilalมีประโยชน์มาก!

When you mark an object or a variable as volatile, it becomes a candidate for volatile reads and writes. It should be noted that in C# all memory writes are volatile irrespective of whether you are writing data to a volatile or a non-volatile object. However, the ambiguity happens when you are reading data. When you are reading data that is non-volatile, the executing thread may or may not always get the latest value. If the object is volatile, the thread always gets the most up-to-date value

ฉันจะทิ้งไว้ที่นี่เพื่ออ้างอิง


0

คอมไพเลอร์บางครั้งเปลี่ยนลำดับของคำสั่งในรหัสเพื่อปรับให้เหมาะสม โดยปกตินี่ไม่ใช่ปัญหาในสภาพแวดล้อมแบบเธรดเดียว แต่อาจเป็นปัญหาในสภาพแวดล้อมแบบมัลติเธรด ดูตัวอย่างต่อไปนี้:

 private static int _flag = 0;
 private static int _value = 0;

 var t1 = Task.Run(() =>
 {
     _value = 10; /* compiler could switch these lines */
     _flag = 5;
 });

 var t2 = Task.Run(() =>
 {
     if (_flag == 5)
     {
         Console.WriteLine("Value: {0}", _value);
     }
 });

หากคุณรัน t1 และ t2 คุณคาดว่าจะไม่มีผลลัพธ์หรือ "Value: 10" เป็นผลลัพธ์ อาจเป็นได้ว่าคอมไพเลอร์สลับสายภายในฟังก์ชั่น t1 หาก t2 ดำเนินการอาจเป็นไปได้ว่า _flag มีค่าเท่ากับ 5 แต่ _value มีค่า 0 ดังนั้นตรรกะที่คาดไว้อาจใช้งานไม่ได้

ในการแก้ไขปัญหานี้คุณสามารถใช้คำหลักระเหยง่ายที่คุณสามารถนำไปใช้กับฟิลด์ คำสั่งนี้ปิดใช้งานการเพิ่มประสิทธิภาพของคอมไพเลอร์เพื่อให้คุณสามารถบังคับลำดับที่ถูกต้องในรหัสของคุณ

private static volatile int _flag = 0;

คุณควรใช้สารระเหยถ้าคุณต้องการจริงๆเพราะมันปิดการใช้งานการเพิ่มประสิทธิภาพคอมไพเลอร์บางอย่างก็จะส่งผลกระทบต่อประสิทธิภาพการทำงาน มันไม่รองรับกับทุกภาษา. NET (Visual Basic ไม่รองรับ) ดังนั้นจึงเป็นอุปสรรคต่อการทำงานร่วมกันของภาษา


2
ตัวอย่างของคุณไม่ดีจริงๆ โปรแกรมเมอร์ไม่ควรมีความคาดหวังใด ๆ เกี่ยวกับค่าของ _flag ในงาน t2 ตามความจริงที่ว่าโค้ดของ t1 ถูกเขียนขึ้นก่อน เขียนก่อน! = ดำเนินการก่อน ไม่สำคัญว่าคอมไพเลอร์จะสลับสองบรรทัดใน t1 หรือไม่ แม้ว่าคอมไพเลอร์ไม่ได้สลับคำสั่งเหล่านั้น แต่ Console.WriteLne ของคุณในสาขาอื่นอาจยังทำงานได้แม้จะมีคำหลักระเหยใน _flag
Jakotheshadows

@ jakotheshadows คุณพูดถูกฉันได้แก้ไขคำตอบแล้ว แนวคิดหลักของฉันคือการแสดงให้เห็นว่าตรรกะที่คาดหวังอาจจะแตกเมื่อเราเรียกใช้ t1 และ t2 พร้อมกัน
Aliaksei Maniuk

0

ดังนั้นเพื่อสรุปทั้งหมดคำตอบที่ถูกต้องสำหรับคำถามคือ: หากรหัสของคุณกำลังทำงานในรันไทม์ 2.0 หรือใหม่กว่าคำสำคัญที่ระเหยง่ายนั้นแทบจะไม่จำเป็นเลยและจะเป็นอันตรายมากกว่าดีถ้าใช้โดยไม่จำเป็น IE ไม่เคยใช้มัน แต่ในรุ่นก่อนหน้าของรันไทม์มันเป็นสิ่งจำเป็นสำหรับการตรวจสอบล็อคที่เหมาะสมในฟิลด์คงที่ ฟิลด์สแตติกเฉพาะที่คลาสมีรหัสการกำหนดค่าเริ่มต้นคลาสสแตติก


-4

หลายกระทู้สามารถเข้าถึงตัวแปร การปรับปรุงล่าสุดจะอยู่ในตัวแปร

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.