Khi nào nên sử dụng từ khóa dễ bay hơi trong C #?


299

Bất cứ ai cũng có thể cung cấp một lời giải thích tốt về từ khóa dễ bay hơi trong C #? Những vấn đề nào nó giải quyết và nó không? Trong trường hợp nào nó sẽ tiết kiệm cho tôi việc sử dụng khóa?


6
Tại sao bạn muốn tiết kiệm việc sử dụng khóa? Khóa không được kiểm soát thêm một vài nano giây vào chương trình của bạn. Bạn có thể thực sự không đủ khả năng một vài nano giây?
Eric Lippert

Câu trả lời:


273

Tôi không nghĩ có người nào trả lời tốt hơn Eric Lippert (nhấn mạnh vào bản gốc):

Trong C #, "volility" có nghĩa là không chỉ "đảm bảo rằng trình biên dịch và jitter không thực hiện bất kỳ mã nào sắp xếp lại hoặc đăng ký tối ưu hóa bộ đệm ẩn trên biến này". Nó cũng có nghĩa là "bảo các bộ xử lý làm bất cứ điều gì họ cần làm để đảm bảo rằng tôi đang đọc giá trị mới nhất, ngay cả khi điều đó có nghĩa là tạm dừng các bộ xử lý khác và khiến chúng đồng bộ hóa bộ nhớ chính với bộ nhớ cache của chúng".

Thật ra, chút cuối cùng đó là một lời nói dối. Các ngữ nghĩa thực sự của việc đọc và viết dễ bay hơi phức tạp hơn đáng kể so với những gì tôi đã nêu ở đây; trong thực tế, họ không thực sự đảm bảo rằng mọi bộ xử lý đều dừng những gì nó đang làm và cập nhật bộ nhớ cache vào / từ bộ nhớ chính. Thay vào đó, họ cung cấp các đảm bảo yếu hơn về cách truy cập bộ nhớ trước và sau khi đọc và ghi có thể được quan sát để được sắp xếp theo thứ tự đối với nhau . Một số hoạt động nhất định như tạo một luồng mới, nhập khóa hoặc sử dụng một trong các phương thức lồng vào nhau giới thiệu các đảm bảo mạnh mẽ hơn về quan sát đặt hàng. Nếu bạn muốn biết thêm chi tiết, hãy đọc các phần 3.10 và 10.5.3 của thông số kỹ thuật C # 4.0.

Thành thật mà nói, tôi không khuyến khích bạn làm một lĩnh vực đầy biến động . Các trường dễ bay hơi là một dấu hiệu cho thấy bạn đang làm điều gì đó hết sức điên rồ: bạn đang cố đọc và viết cùng một giá trị trên hai luồng khác nhau mà không đặt khóa. Khóa đảm bảo rằng bộ nhớ đọc hoặc sửa đổi bên trong khóa được quan sát là nhất quán, khóa đảm bảo rằng chỉ có một luồng truy cập vào một đoạn bộ nhớ nhất định tại một thời điểm, v.v. Số lượng tình huống khóa quá chậm là rất nhỏ và xác suất bạn sẽ nhận được mã sai vì bạn không hiểu mô hình bộ nhớ chính xác là rất lớn. Tôi không cố gắng viết bất kỳ mã khóa thấp nào ngoại trừ các cách sử dụng tầm thường nhất của các hoạt động lồng vào nhau. Tôi để việc sử dụng "dễ bay hơi" cho các chuyên gia thực sự.

Để đọc thêm xem:


29
Tôi sẽ bỏ phiếu này nếu tôi có thể. Có rất nhiều thông tin thú vị trong đó, nhưng nó không thực sự trả lời câu hỏi của anh ấy. Ông đang hỏi về việc sử dụng từ khóa dễ bay hơi vì nó liên quan đến khóa. Trong một lúc khá lâu (trước 2.0 RT), từ khóa dễ bay hơi là cần thiết để sử dụng đúng cách để tạo một luồng trường tĩnh an toàn nếu trường hợp trường có bất kỳ mã khởi tạo nào trong hàm tạo (xem câu trả lời của AndrewTek). Có rất nhiều mã 1.1 RT vẫn còn trong môi trường sản xuất và các nhà phát triển duy trì nó nên biết tại sao từ khóa đó lại ở đó và nếu nó an toàn để loại bỏ.
Paul Phục sinh

3
@PaulEaster thực tế là nó có thể được sử dụng cho khóa được kiểm tra doulbe (thường là trong mẫu đơn) không có nghĩa là nó nên . Dựa vào mô hình bộ nhớ .NET có lẽ là một thực tiễn tồi - thay vào đó bạn nên dựa vào mô hình ECMA. Ví dụ, bạn có thể muốn chuyển sang đơn âm một ngày, có thể có một mô hình khác. Tôi cũng hiểu rằng các kiến ​​trúc phần cứng khác nhau có thể thay đổi mọi thứ. Để biết thêm thông tin, xem: stackoverflow.com/a/7230679/67824 . Để biết các lựa chọn thay thế đơn lẻ tốt hơn (cho tất cả các phiên bản .NET), hãy xem: csharpindepth.com/articles/general/singleton.aspx
Ohad Schneider

6
Nói cách khác, câu trả lời chính xác cho câu hỏi là: Nếu mã của bạn đang chạy trong thời gian chạy 2.0 trở lên, từ khóa dễ bay hơi hầu như không bao giờ cần thiết và gây hại nhiều hơn là tốt nếu sử dụng không cần thiết. Nhưng trong các phiên bản trước của thời gian chạy, nó cần thiết cho khóa kiểm tra kép thích hợp trên các trường tĩnh.
Paul Phục sinh

3
điều này có nghĩa là khóa và biến dễ bay hơi loại trừ lẫn nhau theo nghĩa sau: nếu tôi đã sử dụng khóa xung quanh một số biến thì không cần phải khai báo biến đó là biến động nữa?
giorgim

4
@Giorgi có - các rào cản bộ nhớ được đảm bảo volatilesẽ có ở đó nhờ vào khóa
Ohad Schneider

54

Nếu bạn muốn có thêm một chút kỹ thuật về những gì từ khóa dễ bay hơi, hãy xem xét chương trình sau (Tôi đang sử dụng 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;
}

Sử dụng các cài đặt trình biên dịch (phát hành) được tối ưu hóa tiêu chuẩn, trình biên dịch tạo trình biên dịch chương trình (IA32) sau:

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              

Nhìn vào đầu ra, trình biên dịch đã quyết định sử dụng thanh ghi ecx để lưu trữ giá trị của biến j. Đối với vòng lặp không bay hơi (lần đầu tiên) trình biên dịch đã gán i cho thanh ghi eax. Khá đơn giản. Mặc dù vậy, có một số bit thú vị - lệnh eb ebx, [ebx] thực sự là một lệnh nop đa bào để vòng lặp nhảy đến địa chỉ bộ nhớ được căn chỉnh 16 byte. Khác là sử dụng edx để tăng bộ đếm vòng lặp thay vì sử dụng lệnh inc eax. Lệnh add reg, reg có độ trễ thấp hơn trên một vài lõi IA32 so với lệnh inc reg, nhưng không bao giờ có độ trễ cao hơn.

Bây giờ cho vòng lặp với bộ đếm vòng lặp dễ bay hơi. Bộ đếm được lưu trữ tại [đặc biệt] và từ khóa dễ bay hơi cho trình biên dịch biết giá trị phải luôn được đọc từ / ghi vào bộ nhớ và không bao giờ được gán cho một thanh ghi. Trình biên dịch thậm chí còn không thực hiện tải / tăng / lưu trữ theo ba bước riêng biệt (tải eax, inc eax, save eax) khi cập nhật giá trị bộ đếm, thay vào đó bộ nhớ được sửa đổi trực tiếp trong một lệnh (thêm mem , reg). Cách mã được tạo ra đảm bảo giá trị của bộ đếm vòng lặp luôn được cập nhật trong bối cảnh của một lõi CPU. Không có thao tác nào trên dữ liệu có thể dẫn đến tham nhũng hoặc mất dữ liệu (do đó không sử dụng tải / inc / store vì giá trị có thể thay đổi trong quá trình inc do đó bị mất trên cửa hàng). Vì các ngắt chỉ có thể được phục vụ khi hướng dẫn hiện tại đã hoàn thành,

Khi bạn giới thiệu CPU thứ hai cho hệ thống, từ khóa dễ bay hơi sẽ không bảo vệ dữ liệu được cập nhật bởi CPU khác cùng lúc. Trong ví dụ trên, bạn sẽ cần dữ liệu không được sắp xếp để có được tham nhũng tiềm năng. Từ khóa dễ bay hơi sẽ không ngăn ngừa tham nhũng tiềm ẩn nếu dữ liệu không thể được xử lý nguyên tử, ví dụ, nếu bộ đếm vòng lặp có loại dài (64 bit) thì sẽ cần hai thao tác 32 bit để cập nhật giá trị, ở giữa mà một ngắt có thể xảy ra và thay đổi dữ liệu.

Vì vậy, từ khóa dễ bay hơi chỉ tốt cho dữ liệu được căn chỉnh nhỏ hơn hoặc bằng kích thước của các thanh ghi riêng sao cho các hoạt động luôn luôn là nguyên tử.

Từ khóa dễ bay hơi được hình thành để sử dụng cho các hoạt động IO trong đó IO sẽ liên tục thay đổi nhưng có địa chỉ không đổi, chẳng hạn như thiết bị UART được ánh xạ bộ nhớ và trình biên dịch không nên tiếp tục sử dụng lại giá trị đầu tiên được đọc từ địa chỉ.

Nếu bạn đang xử lý dữ liệu lớn hoặc có nhiều CPU thì bạn sẽ cần một hệ thống khóa (HĐH) cấp cao hơn để xử lý việc truy cập dữ liệu đúng cách.


Đây là C ++ nhưng nguyên tắc áp dụng cho C #.
Skizz

6
Eric Lippert viết rằng tính không ổn định trong C ++ chỉ ngăn trình biên dịch thực hiện một số tối ưu hóa, trong khi trong C # dễ bay hơi, một số giao tiếp giữa các lõi / bộ xử lý khác để đảm bảo rằng giá trị mới nhất được đọc.
Peter Huber

44

Nếu bạn đang sử dụng .NET 1.1, từ khóa dễ bay hơi là cần thiết khi thực hiện khóa kiểm tra kép. Tại sao? Bởi vì trước .NET 2.0, kịch bản sau đây có thể khiến luồng thứ hai truy cập vào một đối tượng không null, nhưng chưa được xây dựng đầy đủ:

  1. Chủ đề 1 hỏi nếu một biến là null. //if(this.foo == null)
  2. Chủ đề 1 xác định biến là null, vì vậy hãy nhập khóa. //lock(this.bar)
  3. Chủ đề 1 hỏi LẠI nếu biến là null. //if(this.foo == null)
  4. Chủ đề 1 vẫn xác định biến là null, vì vậy nó gọi hàm tạo và gán giá trị cho biến. //this.foo = new Foo ();

Trước .NET 2.0, this.foo có thể được chỉ định phiên bản mới của Foo, trước khi hàm tạo hoàn tất chạy. Trong trường hợp này, một luồng thứ hai có thể đi vào (trong cuộc gọi của luồng 1 đến hàm tạo của Foo) và trải nghiệm như sau:

  1. Chủ đề 2 hỏi nếu biến là null. //if(this.foo == null)
  2. Chủ đề 2 xác định biến là KHÔNG null, vì vậy hãy thử sử dụng nó. //this.foo.MakeFoo ()

Trước .NET 2.0, bạn có thể khai báo this.foo là không ổn định để khắc phục vấn đề này. Kể từ .NET 2.0, bạn không còn cần phải sử dụng từ khóa dễ bay hơi để thực hiện khóa kiểm tra kép.

Wikipedia thực sự có một bài viết hay về Khóa kiểm tra hai lần và chạm nhanh vào chủ đề này: http://en.wikipedia.org/wiki/Double-checked_locking


2
đây chính xác là những gì tôi thấy trong một mã kế thừa và đã tự hỏi về nó. đó là lý do tại sao tôi bắt đầu một nghiên cứu sâu hơn. Cảm ơn!
Peter Porfy

1
Tôi không hiểu làm thế nào Thread 2 sẽ gán giá trị cho foo? Không phải là khóa 1 this.barvà do đó chỉ có luồng 1 mới có thể khởi tạo foo tại một thời điểm givne? Ý tôi là, bạn hãy kiểm tra giá trị sau khi khóa được phát hành lại, khi nào thì nó cũng phải có giá trị mới từ luồng 1.
gilmishal

24

Đôi khi, trình biên dịch sẽ tối ưu hóa một trường và sử dụng một thanh ghi để lưu trữ nó. Nếu luồng 1 thực hiện ghi vào trường và một luồng khác truy cập vào nó, vì bản cập nhật được lưu trong một thanh ghi (và không phải bộ nhớ), luồng thứ 2 sẽ nhận được dữ liệu cũ.

Bạn có thể nghĩ về từ khóa dễ bay hơi khi nói với trình biên dịch "Tôi muốn bạn lưu trữ giá trị này trong bộ nhớ". Điều này đảm bảo rằng luồng thứ 2 lấy giá trị mới nhất.


21

Từ MSDN : Công cụ sửa đổi dễ bay hơi thường được sử dụng cho một trường được truy cập bởi nhiều luồng mà không sử dụng câu lệnh khóa để tuần tự hóa truy cập. Sử dụng công cụ sửa đổi dễ bay hơi đảm bảo rằng một luồng truy xuất giá trị cập nhật nhất được ghi bởi một luồng khác.


13

CLR thích tối ưu hóa các hướng dẫn, vì vậy khi bạn truy cập vào một trường trong mã, nó có thể không phải lúc nào cũng truy cập vào giá trị hiện tại của trường (có thể là từ ngăn xếp, v.v.). Đánh dấu một lĩnh vực làvolatile đảm bảo rằng giá trị hiện tại của trường được truy cập bởi lệnh. Điều này hữu ích khi giá trị có thể được sửa đổi (trong kịch bản không khóa) bởi một luồng đồng thời trong chương trình của bạn hoặc một số mã khác đang chạy trong hệ điều hành.

Bạn rõ ràng mất một số tối ưu hóa, nhưng nó giữ cho mã đơn giản hơn.


3

Tôi thấy bài viết này của Joydip Kanjilal rất hữu ích!

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

Tôi sẽ chỉ để nó ở đây để tham khảo


0

Trình biên dịch đôi khi thay đổi thứ tự của các câu lệnh trong mã để tối ưu hóa nó. Thông thường đây không phải là vấn đề trong môi trường đơn luồng, nhưng nó có thể là một vấn đề trong môi trường đa luồng. Xem ví dụ sau:

 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);
     }
 });

Nếu bạn chạy t1 và t2, bạn sẽ không có kết quả đầu ra hoặc "Giá trị: 10". Nó có thể là trình biên dịch chuyển dòng bên trong chức năng t1. Nếu t2 sau đó thực thi, có thể là _flag có giá trị là 5, nhưng _value có 0. Vì vậy logic dự kiến ​​có thể bị phá vỡ.

Để khắc phục điều này, bạn có thể sử dụng từ khóa dễ bay hơi mà bạn có thể áp dụng cho trường. Câu lệnh này vô hiệu hóa tối ưu hóa trình biên dịch để bạn có thể buộc thứ tự đúng trong mã của bạn.

private static volatile int _flag = 0;

Bạn chỉ nên sử dụng biến động nếu bạn thực sự cần nó, bởi vì nó vô hiệu hóa tối ưu hóa trình biên dịch nhất định, nó sẽ làm giảm hiệu suất. Nó cũng không được hỗ trợ bởi tất cả các ngôn ngữ .NET (Visual Basic không hỗ trợ nó), vì vậy nó cản trở khả năng tương tác ngôn ngữ.


2
Ví dụ của bạn là thực sự xấu. Lập trình viên không bao giờ có bất kỳ kỳ vọng nào về giá trị của _flag trong tác vụ t2 dựa trên thực tế là mã của t1 được viết trước. Viết trước! = Thực hiện trước. Sẽ không có vấn đề gì nếu trình biên dịch DOES chuyển hai dòng đó trong t1. Ngay cả khi trình biên dịch không chuyển đổi các câu lệnh đó, Console.WriteLne của bạn trong nhánh khác vẫn có thể thực thi, ngay cả với từ khóa dễ bay hơi trên _flag.
Jakotheshadows

@jakotheshadows, bạn nói đúng, tôi đã chỉnh sửa câu trả lời của mình. Ý tưởng chính của tôi là chỉ ra rằng logic dự kiến ​​có thể bị phá vỡ khi chúng tôi chạy đồng thời t1 và t2
Aliaksei Maniuk

0

Vì vậy, để tổng hợp tất cả những điều này, câu trả lời chính xác cho câu hỏi là: Nếu mã của bạn đang chạy trong thời gian chạy 2.0 trở lên, từ khóa dễ bay hơi gần như không bao giờ cần thiết và gây hại nhiều hơn là tốt nếu sử dụng không cần thiết. IE Đừng bao giờ sử dụng nó. NHƯNG trong các phiên bản trước của thời gian chạy, nó cần thiết cho khóa kiểm tra kép thích hợp trên các trường tĩnh. Các trường tĩnh cụ thể có lớp có mã khởi tạo lớp tĩnh.


Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.