11 Chủ đề trong C # - Phần 16: Đồng bộ hóa không chặn

(Post 30/11/2007) Trước đó, chúng tôi đã nói rằng nhu cầu đồng bộ hóa phát sinh ngay cả trường hợp đơn giản là gán hoặc tăng một trường. Mặc dù khóa luôn có thể đáp ứng nhu cầu này, nhưng khóa cạnh tranh có nghĩa là một luồng phải chặn, chịu chi phí và độ trễ tạm thời được lên lịch. Cấu trúc đồng bộ hóa không chặn của khung công tác .NET có thể thực hiện các hoạt động đơn giản mà không bao giờ chặn, tạm dừng hoặc chờ đợi. Những điều này liên quan đến việc sử dụng các hướng dẫn hoàn toàn là nguyên tử và hướng dẫn trình biên dịch sử dụng ngữ nghĩa đọc và ghi “dễ bay hơi”. Đôi khi những cấu trúc này cũng có thể đơn giản hơn để sử dụng so với ổ khóa.

  • Phần 1: Tổng quan và khái niệm
  • Phần 2: Tạo và bắt đầu chủ đề
  • Phần 3: Cơ bản về đồng bộ hóa
  • Phần 4: Khóa và an toàn chỉ
  • Phần 5: Ngắt và hủy bỏ
  • Phần 6: Trạng thái luồng
  • Phần 7: Xử lý Chờ
  • Phần 8: Bối cảnh đồng bộ hóa
  • Phần 9: Căn hộ và Hình thức Windows
  • Phần 10: BackgroundWorker
  • Phần 11: ReaderWriterLock
  • Phần 12: Tổng hợp luồng
  • Phần 13: Đại biểu không đồng bộ
  • Phần 14: Bộ hẹn giờ
  • Phần 15: Lưu trữ cục bộ

Tính nguyên tử và được lồng vào nhau

Một câu lệnh là nguyên tử nếu nó thực thi dưới dạng một lệnh duy nhất không thể phân chia. Tính nguyên tử nghiêm ngặt loại trừ mọi khả năng bị đánh phủ đầu. Trong C #, một phép đọc hoặc gán đơn giản trên một trường 32 bit trở xuống là nguyên tử (giả sử là CPU 32 bit). Các phép toán trên các trường lớn hơn không phải là nguyên tử, cũng như các câu lệnh kết hợp nhiều hơn một thao tác đọc / ghi:

class Atomicity {
static int x, y;
tĩnh dài z;

static void Test () {
long myLocal;
x = 3; // Nguyên tử
z = 3; // Không nguyên tử (z là 64 bit)
myLocal = z; // Không nguyên tử (z là 64 bit)
y + = x; // Không nguyên tử (thao tác đọc AND)
x ++; // Phi nguyên tử (thao tác đọc VÀ ghi)
}
}

Đọc và ghi các trường 64-bit là không nguyên tử trên CPU 32-bit theo nghĩa là hai vị trí bộ nhớ 32-bit riêng biệt có liên quan. Nếu luồng A đọc giá trị 64-bit trong khi luồng B đang cập nhật nó, thì luồng A có thể kết thúc bằng sự kết hợp theo chiều dọc của các giá trị cũ và mới.

Các toán tử đơn nguyên của loại x ++ yêu cầu đầu tiên đọc một biến, sau đó xử lý nó, sau đó ghi lại. Hãy xem xét lớp sau:

class ThreadUnsafe {
static int x = 1000;
static void Go () {for (int i = 0; i <100; i ++) x–; }
}

Bạn có thể mong đợi rằng nếu 10 luồng đồng thời chạy Go, thì x sẽ kết thúc bằng 0. Tuy nhiên, điều này không được đảm bảo, vì có thể một luồng sẽ lấn lướt một luồng khác giữa việc truy xuất giá trị hiện tại của x, giảm nó và ghi lại (dẫn đến một giá trị lỗi thời đang được viết).

Một cách để giải quyết những vấn đề này là kết hợp các hoạt động phi nguyên tử xung quanh một câu lệnh khóa. Trên thực tế, khóa mô phỏng tính nguyên tử. Tuy nhiên, lớp Interlocked cung cấp một giải pháp đơn giản và nhanh hơn cho các hoạt động nguyên tử đơn giản:

class Program {
static long sum;

static void Main () {// sum

// Các phép toán tăng / giảm đơn giản:
Interlocked.Increment (ref sum); // 1
Interlocked.Decrement (ref sum); // 0

// Thêm / trừ một giá trị:
Interlocked.Add (ref sum, 3); // 3

// Đọc trường 64-bit:
Console.WriteLine (Interlocked.Read (ref sum)); // 3

// Ghi trường 64-bit trong khi đọc giá trị trước đó:
// (Trường này in ra “3” trong khi cập nhật tổng thành 10)
Console.WriteLine (Interlocked.Exchange (ref sum, 10)); // 10

// Chỉ cập nhật một trường nếu nó khớp với một giá trị nhất định (10):
Interlocked.CompareExchange (tổng tham chiếu, 123, 10); // 123
}
}

Sử dụng Interlocked thường hiệu quả hơn trong việc lấy khóa, bởi vì nó không bao giờ có thể chặn và chịu trách nhiệm tạm thời lên lịch cho luồng của nó.

Interlocked cũng có hiệu lực trên nhiều quy trình – trái ngược với câu lệnh lock, chỉ có hiệu lực trên các luồng trong quy trình hiện tại. Một ví dụ về nơi điều này có thể hữu ích là đọc và ghi vào bộ nhớ dùng chung.

Rào cản bộ nhớ và sự biến động

Hãy xem xét lớp học này:

class Không an toàn {
static bool endIsNigh, đã hối cải;

static void Main () {
new Thread (Wait) .Start (); // Khởi động trình phục vụ đang quay
Thread.Sleep (1000); // Chờ một chút để làm ấm!
hối cải = true;
endIsNigh = true;
Console.WriteLine (“Đang …”);
}

static void Wait () {
while (! endIsNigh); // Quay cho đến khi endIsNigh
Console.WriteLine (“Gone,” + repented);
}
}

Đây là một câu hỏi: liệu một độ trễ đáng kể có thể tách “Đang …” khỏi “Đi” – nói cách khác, phương thức Chờ có thể tiếp tục quay trong vòng lặp while của nó sau khi cờ endIsNigh đã được đặt thành true không? Hơn nữa, phương thức Wait có thể viết “Gone, false” không?

Về mặt lý thuyết, câu trả lời cho cả hai câu hỏi là có, trên một máy đa xử lý, nếu bộ lập lịch luồng chỉ định hai luồng CPU khác nhau. Các trường đã được đăng ký và endIsNigh có thể được lưu vào bộ nhớ đệm trong thanh ghi CPU để cải thiện hiệu suất, với độ trễ tiềm ẩn trước khi các giá trị cập nhật của chúng được ghi lại vào bộ nhớ. Và khi các thanh ghi CPU được ghi trở lại bộ nhớ, nó không nhất thiết phải theo thứ tự chúng được cập nhật ban đầu.

Bộ nhớ đệm này có thể được phá vỡ bằng cách sử dụng các phương thức tĩnh Thread.VolatileRead và Thread.VolatileWrite để đọc và ghi vào các trường. VolatileRead có nghĩa là “đọc giá trị mới nhất”; VolatileWrite có nghĩa là “ghi ngay lập tức vào bộ nhớ”. Chức năng tương tự có thể đạt được một cách thanh lịch hơn bằng cách khai báo trường với công cụ sửa đổi dễ bay hơi:

class ThreadSafe {
// Luôn sử dụng ngữ nghĩa đọc / ghi
dễ bay hơi : biến tĩnh bool endIsNigh, đã được hối cải;

Nếu từ khóa dễ bay hơi được sử dụng ưu tiên cho các phương pháp VolatileRead và VolatileWrite, người ta có thể nghĩ theo thuật ngữ đơn giản nhất, đó là “không lưu trữ trường này vào bộ nhớ cache!”

Hiệu ứng tương tự có thể đạt được bằng cách gói quyền truy cập vào các câu lệnh đã được đăng ký và endIsNigh trong khóa. Điều này hoạt động bởi vì một tác dụng phụ (dự định) của việc khóa là tạo ra một rào cản bộ nhớ – một đảm bảo rằng sự biến động của các trường được sử dụng trong câu lệnh khóa sẽ không mở rộng ra ngoài phạm vi của câu lệnh khóa. Nói cách khác, các trường sẽ được làm mới khi nhập khóa (đọc dễ bay hơi) và được ghi vào bộ nhớ trước khi thoát khỏi khóa (ghi dễ bay hơi).

Trên thực tế, sử dụng câu lệnh lock sẽ là cần thiết nếu chúng ta cần truy cập vào các trường end và endIsNigh về mặt nguyên tử, chẳng hạn, để chạy một cái gì đó như sau:

lock (khóa) {if (endIsNigh) repented = true; }

Một khóa cũng có thể thích hợp hơn khi một trường được sử dụng nhiều lần trong một vòng lặp (giả sử khóa được giữ trong khoảng thời gian của vòng lặp). Trong khi một thao tác đọc / ghi dễ bay hơi đánh bại một khóa hiệu suất, không chắc rằng một nghìn thao tác đọc / ghi dễ bay hơi sẽ đánh bại một khóa!

Biến động chỉ liên quan đến các loại tích phân nguyên thủy (và con trỏ không an toàn) – các loại khác không được lưu trong bộ nhớ cache trong thanh ghi CPU và không thể được khai báo bằng từ khóa biến động. Ngữ nghĩa đọc và ghi linh hoạt được áp dụng tự động khi các trường được truy cập thông qua lớp Interlocked.

Nếu một người có chính sách luôn truy cập các trường có thể truy cập bởi nhiều luồng trong một câu lệnh khóa, thì các trường có thể thay đổi và được khóa liên tục là không cần thiết.

(Sưu tầm)

FPT Aptech trực thuộc Tổ chức Giáo dục FPT có hơn 25 năm kinh nghiệm đào tạo lập trình viên quốc tế tại Việt Nam, và luôn là sự lựa chọn ưu tiên của các sinh viên và nhà tuyển dụng.
0981578920
icons8-exercise-96