11 Chủ đề trong C # - Phần 4: Khóa và an toàn luồng

(Post 12/10/2007) Khóa thực thi quyền truy cập độc quyền và được sử dụng để đảm bảo chỉ một luồng có thể nhập các phần mã cụ thể tại một thời điểm.

  • 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

Khóa thực thi quyền truy cập độc quyền và được sử dụng để đảm bảo chỉ một luồng có thể nhập các phần mã cụ thể tại một thời điểm. Ví dụ, hãy xem xét lớp sau:

class ThreadUnsafe {
static int val1, val2;

static void Go () {
if (val2! = 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
}

Điều này không an toàn cho luồng: nếu Go được gọi đồng thời bởi hai luồng thì có thể nhận được lỗi chia cho 0 – bởi vì val2 có thể được đặt thành 0 trong một luồng ngay khi luồng kia đang ở giữa việc thực hiện câu lệnh if và Console.WriteLine.

Đây là cách khóa có thể khắc phục sự cố:

class ThreadSafe {
static object locker = new object ();
static int val1, val2;

static void Go () {
lock (locker) {
if (val2! = 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
}
}

Chỉ một luồng có thể khóa đối tượng đồng bộ hóa (trong trường hợp này là khóa) tại một thời điểm và mọi luồng cạnh tranh sẽ bị chặn cho đến khi khóa được giải phóng. Nếu nhiều luồng tranh chấp khóa, chúng sẽ được xếp hàng đợi – trên “hàng đợi sẵn sàng” và được cấp khóa trên cơ sở ai đến trước được phục vụ trước khi nó có sẵn. Các khóa độc quyền đôi khi được cho là thực thi quyền truy cập tuần tự vào bất kỳ thứ gì được bảo vệ bởi khóa, bởi vì quyền truy cập của một luồng không thể trùng lặp với quyền truy cập của luồng khác. Trong trường hợp này, chúng tôi đang bảo vệ logic bên trong phương thức Go, cũng như các trường val1 và val2.

Một chuỗi bị chặn trong khi đang chờ khóa cạnh tranh có Chuỗi trạng thái WaitSleepJoin. Sau đó, chúng ta sẽ thảo luận về cách một luồng bị chặn ở trạng thái này có thể được giải phóng cưỡng bức thông qua một luồng khác gọi phương thức Ngắt hoặc Hủy bỏ của nó. Đây là một kỹ thuật khá nặng thường có thể được sử dụng để kết thúc một chuỗi worker.

Câu lệnh lock của C # thực chất là một phím tắt cú pháp để gọi phương thức Monitor.Enter và Monitor.Exit, trong một khối try-last. Đây là những gì thực sự đang xảy ra trong phương pháp Go của ví dụ trước:

Monitor.Enter (tủ khóa);
thử {
if (val2! = 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
cuối cùng {Monitor.Exit (tủ khóa); }

Gọi Monitor.Exit mà không gọi Monitor.Enter trước trên cùng một đối tượng sẽ ném ra một ngoại lệ.

Monitor cũng cung cấp phương thức TryEnter cho phép xác định thời gian chờ – tính bằng mili giây hoặc là TimeSpan. Sau đó, phương thức trả về true – nếu khóa được lấy – hoặc sai – nếu không có khóa nào do phương thức đã hết thời gian chờ. TryEnter cũng có thể được gọi mà không cần đối số, “kiểm tra” khóa, hết thời gian ngay lập tức nếu khóa không thể lấy được ngay.

Chọn đối tượng đồng bộ hóa

Bất kỳ đối tượng nào có thể nhìn thấy đối với từng luồng dự phần đều có thể được sử dụng như một đối tượng đồng bộ hóa, tuân theo một quy tắc cứng: nó phải là một kiểu tham chiếu. Chúng tôi cũng rất khuyến nghị rằng đối tượng đồng bộ hóa được đặt trong phạm vi riêng tư cho lớp (tức là trường cá thể riêng) để ngăn chặn một tương tác không chủ ý từ mã bên ngoài khóa cùng một đối tượng. Theo các quy tắc này, đối tượng đồng bộ hóa có thể tăng gấp đôi như đối tượng mà nó đang bảo vệ, chẳng hạn như với trường danh sách bên dưới:

lớp ThreadSafe {
Danh sách list = danh sách mới ();

void Test () {
lock (list) {
list.Add (“Mục 1”);

Trường chuyên dụng thường được sử dụng (chẳng hạn như khóa, trong ví dụ trước), vì nó cho phép kiểm soát chính xác phạm vi và mức độ chi tiết của khóa. Sử dụng đối tượng hoặc loại chính nó làm đối tượng đồng bộ hóa, tức là:

khóa (cái này) {…}

hoặc là:

lock (typeof (Widget)) {…} // Để bảo vệ quyền truy cập vào tĩnh

không được khuyến khích vì nó có khả năng cung cấp phạm vi công khai cho đối tượng đồng bộ hóa.

Khóa không hạn chế quyền truy cập vào chính đối tượng đồng bộ hóa theo bất kỳ cách nào. Nói cách khác, x.ToString () sẽ không chặn vì một luồng khác đã gọi lock (x) – cả hai luồng phải gọi lock (x) để việc chặn xảy ra.

Khóa lồng nhau

Một luồng có thể nhiều lần khóa cùng một đối tượng, thông qua nhiều lệnh gọi tới Monitor.Enter hoặc thông qua các câu lệnh khóa lồng nhau. Sau đó, đối tượng được mở khóa khi một số câu lệnh Monitor.Exit tương ứng đã thực thi hoặc câu lệnh khóa ngoài cùng đã thoát. Điều này cho phép ngữ nghĩa tự nhiên nhất khi một phương thức gọi phương thức khác như sau:

static object x = new object ();

static void Main () {
lock (x) {
Console.WriteLine (“Tôi có khóa”);
Tổ ();
Console.WriteLine (“Tôi vẫn có khóa”);
}
Đây là khóa được phát hành.
}

static void Nest () {
lock (x) {

} Đã
phát hành khóa? Không hẳn!
}

Một sợi chỉ có thể chặn trên khóa đầu tiên hoặc khóa ngoài cùng.

Khi nào thì khóa

Theo quy tắc cơ bản, bất kỳ trường nào có thể truy cập vào nhiều luồng phải được đọc và ghi trong một khóa. Ngay cả trong trường hợp đơn giản nhất – một thao tác gán trên một trường – người ta phải xem xét đồng bộ hóa. Trong lớp sau, cả phương thức Tăng và Gán đều không an toàn theo luồng:

class ThreadUnsafe {
static int x;
static void Increment () {x ++; }
static void Assign () {x = 123; }
}

Dưới đây là các phiên bản an toàn theo chuỗi của Tăng và Chỉ định:

class ThreadUnsafe {
static object locker = new object ();
int x tĩnh;

static void Increment () {lock (khóa) x ++; }
static void Assign () {lock (locker) x = 123; }
}

Để thay thế cho khóa, người ta có thể sử dụng cấu trúc đồng bộ hóa không chặn trong những tình huống đơn giản này. Điều này được thảo luận trong Phần 4 (cùng với các lý do mà các tuyên bố đó yêu cầu đồng bộ hóa).

Khóa và tính nguyên tử

Nếu một nhóm biến luôn được đọc và ghi trong cùng một khóa, thì người ta có thể nói rằng các biến được đọc và ghi nguyên tử. Giả sử các trường x và y chỉ được đọc hoặc được gán trong một khóa trên khóa đối tượng:

lock (khóa) {if (x! = 0) y / = x; }

Người ta có thể nói x và y được truy cập nguyên tử, bởi vì khối mã không thể bị phân chia hoặc phủ đầu bởi các hành động của một luồng khác theo cách sẽ thay đổi x hoặc y và làm mất hiệu lực của nó. Bạn sẽ không bao giờ gặp lỗi chia cho không, với điều kiện x và y luôn được truy cập trong cùng một khóa độc quyền này.

Cân nhắc về Hiệu suất

Quá trình khóa của chính nó rất nhanh: một khóa thường có được trong hàng chục nano giây giả sử không có khóa. Nếu quá trình chặn xảy ra, việc chuyển đổi tác vụ do đó sẽ di chuyển chi phí gần hơn đến vùng micro giây, mặc dù nó có thể là mili giây trước khi chuỗi thực sự được lên lịch lại. Đến lượt nó, điều này bị thu hẹp bởi số giờ làm thêm – hoặc làm thêm giờ – có thể là kết quả của việc không khóa khi bạn nên có!

Khóa có thể có tác dụng phụ nếu được sử dụng không đúng cách – đồng thời nghèo nàn, bế tắc và khóa chủng tộc. Đồng thời kém chất lượng xảy ra khi quá nhiều mã được đặt trong câu lệnh khóa, khiến các luồng khác chặn không cần thiết. Bế tắc là khi hai luồng mỗi luồng chờ một ổ khóa do người kia giữ và vì vậy cả hai luồng không thể tiếp tục. Một cuộc đua khóa xảy ra khi một trong hai luồng có thể có được khóa trước, chương trình sẽ phá vỡ nếu luồng “sai” thắng.

Bế tắc thường là hội chứng của quá nhiều đối tượng đồng bộ hóa. Một quy tắc tốt là bắt đầu từ phía có ít đối tượng hơn để khóa, tăng mức độ chi tiết của khóa khi một tình huống hợp lý liên quan đến việc chặn quá nhiều phát sinh.

An toàn chủ đề

Mã an toàn luồng là mã không có tính xác định khi đối mặt với bất kỳ tình huống đa luồng nào. Chủ yếu đạt được sự an toàn của luồng khi khóa và bằng cách giảm khả năng tương tác giữa các luồng.

Một phương pháp an toàn theo chuỗi trong mọi trường hợp được gọi là reentrant. Các loại mục đích chung hiếm khi an toàn toàn bộ theo chuỗi, vì những lý do sau:

  • gánh nặng phát triển về an toàn toàn bộ luồng có thể rất đáng kể, đặc biệt nếu một loại có nhiều trường (mỗi trường là một tiềm năng tương tác trong bối cảnh đa luồng tùy ý)
  • an toàn luồng có thể đòi hỏi chi phí hiệu suất (phải trả một phần, cho dù loại có thực sự được sử dụng bởi nhiều luồng hay không)
  • kiểu an toàn luồng không nhất thiết làm cho chương trình sử dụng nó an toàn theo luồng – và đôi khi công việc liên quan đến kiểu an toàn có thể làm cho kiểu trước đó trở nên dư thừa.

Do đó, an toàn luồng thường được triển khai ở nơi cần thiết, để xử lý một kịch bản đa luồng cụ thể.

Tuy nhiên, có một số cách để “gian lận” và để các lớp lớn và phức tạp chạy an toàn trong môi trường đa luồng. Một là hy sinh tính chi tiết bằng cách gói các phần mã lớn – thậm chí truy cập vào toàn bộ đối tượng – xung quanh một khóa độc quyền – thực thi quyền truy cập tuần tự ở mức cao. Chiến thuật này cũng rất quan trọng trong việc cho phép một đối tượng không an toàn theo luồng được sử dụng trong mã an toàn cho luồng – và hợp lệ khi sử dụng cùng một khóa độc quyền để bảo vệ quyền truy cập vào tất cả các thuộc tính, phương thức và trường trên đối tượng không an toàn của luồng.

Bỏ qua các kiểu nguyên thủy, rất ít kiểu .NET framework khi được khởi tạo là an toàn theo luồng đối với bất kỳ thứ gì hơn là truy cập chỉ đọc đồng thời. Nhà phát triển phải tăng cường an toàn luồng – thường sử dụng các khóa độc quyền.

Một cách khác để gian lận là giảm thiểu tương tác luồng bằng cách giảm thiểu dữ liệu được chia sẻ. Đây là một cách tiếp cận tuyệt vời và được sử dụng ngầm trong các máy chủ trang web và ứng dụng bậc trung “không trạng thái”. Vì nhiều yêu cầu của khách hàng có thể đến đồng thời, mỗi yêu cầu đến trên luồng riêng của nó (nhờ kiến ​​trúc ASP.NET, Dịch vụ Web hoặc Điều khiển từ xa) và điều này có nghĩa là các phương thức chúng gọi phải an toàn theo luồng. Một thiết kế không trạng thái (phổ biến vì lý do khả năng mở rộng) về bản chất hạn chế khả năng tương tác, vì các lớp không thể duy trì dữ liệu giữa mỗi yêu cầu.

Các loại khung an toàn và .NET Framework

Khóa có thể được sử dụng để chuyển đổi mã không an toàn theo luồng thành mã an toàn cho luồng. Một ví dụ điển hình là với .NET framework – gần như tất cả các kiểu non-original của nó đều không an toàn theo luồng khi được khởi tạo, nhưng chúng có thể được sử dụng trong mã đa luồng nếu tất cả quyền truy cập vào bất kỳ đối tượng nhất định nào được bảo vệ thông qua một khóa. Đây là một ví dụ, trong đó hai chuỗi đồng thời thêm các mục vào cùng một bộ sưu tập Danh sách, sau đó liệt kê danh sách:

class ThreadSafe {
static List list = danh sách mới ();

static void Main () {
new Thread (AddItems) .Start ();
luồng mới (AddItems) .Start ();
}

static void AddItems () {
for (int i = 0; i <100; i ++)
lock (list)
list.Add (“Item” + list.Count);

chuỗi [] mục;
lock (list) items = list.ToArray ();
foreach (chuỗi s trong các mục) Console.WriteLine (s);
}
}

Trong trường hợp này, chúng tôi đang khóa chính đối tượng danh sách, điều này tốt trong trường hợp đơn giản này. Tuy nhiên, nếu chúng ta có hai danh sách liên quan với nhau, chúng ta sẽ cần phải khóa trên một đối tượng chung – có lẽ là một trường riêng biệt, nếu cả hai danh sách đều không thể hiện chính nó là ứng cử viên hiển nhiên.
Việc liệt kê các bộ sưu tập .NET cũng không an toàn theo luồng theo nghĩa là một ngoại lệ được ném ra nếu một luồng khác thay đổi danh sách trong quá trình liệt kê. Thay vì khóa trong khoảng thời gian liệt kê, trong ví dụ này, trước tiên, chúng tôi sao chép các mục vào một mảng. Điều này tránh việc giữ khóa quá mức nếu những gì chúng ta đang làm trong quá trình điều tra có thể tốn nhiều thời gian.

Đây là một giả thiết thú vị: hãy tưởng tượng nếu lớp List thực sự là một luồng an toàn. Nó sẽ giải quyết những gì? Tiềm năng, rất ít! Để minh họa, giả sử chúng tôi muốn thêm một mục vào danh sách an toàn chuỗi giả định của chúng tôi, như sau:

if (! myList.Contains (newItem)) myList.Add (newItem);

Cho dù danh sách có an toàn theo chuỗi hay không, thì tuyên bố này chắc chắn là không! Toàn bộ câu lệnh if sẽ phải được bọc trong một chiếc khóa – để ngăn chặn sự ưu tiên giữa việc kiểm tra tàu container và thêm mặt hàng mới. Khóa tương tự này sau đó sẽ cần được sử dụng ở mọi nơi mà chúng tôi đã sửa đổi danh sách đó. Ví dụ, câu lệnh sau cũng sẽ cần được bao bọc – trong một khóa giống hệt nhau:

myList.Clear ();

để đảm bảo nó không vượt trước tuyên bố cũ. Nói cách khác, chúng tôi sẽ phải khóa gần như chính xác như với các lớp thu thập không an toàn luồng của chúng tôi. Do đó, an toàn luồng tích hợp có thể thực sự lãng phí thời gian!

Người ta có thể tranh luận về điểm này khi viết các thành phần tùy chỉnh – tại sao phải xây dựng theo luồng an toàn khi nó có thể dễ dàng bị dư thừa?

Có một đối số phản đối: bao bọc một đối tượng xung quanh một khóa tùy chỉnh chỉ hoạt động nếu tất cả các luồng đồng thời đều biết và sử dụng, khóa – điều này có thể không đúng nếu đối tượng nằm trong phạm vi rộng rãi. Tình huống xấu nhất xảy ra với các thành viên tĩnh trong kiểu công khai. Ví dụ: hãy tưởng tượng thuộc tính tĩnh trên cấu trúc DateTime, DateTime.Now, không an toàn cho chuỗi và rằng hai lệnh gọi đồng thời có thể dẫn đến kết quả đầu ra bị cắt xén hoặc một ngoại lệ. Cách duy nhất để khắc phục điều này với khóa bên ngoài có thể là tự khóa kiểu – khóa (typeof (DateTime)) – xung quanh các cuộc gọi đến DateTime.Now – sẽ chỉ hoạt động nếu tất cả các lập trình viên đồng ý làm điều này. Và điều này khó xảy ra, vì việc khóa một kiểu được nhiều người coi là Điều xấu!

Vì lý do này, các thành viên tĩnh trên cấu trúc DateTime được đảm bảo an toàn cho chuỗi. Đây là một mẫu phổ biến trong suốt khuôn khổ .NET – các thành viên tĩnh là an toàn luồng, trong khi các thành viên thể hiện thì không. Làm theo mẫu này cũng giúp bạn dễ dàng viết các loại tùy chỉnh, để không tạo ra các câu hỏi hóc búa về an toàn luồng không thể!

Khi viết các thành phần cho tiêu dùng công cộng, một chính sách tốt là ít nhất phải lập trình như không loại trừ sự an toàn của luồng. Điều này có nghĩa là phải đặc biệt cẩn thận với các thành viên tĩnh – cho dù được sử dụng trong nội bộ hay công khai.

(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