(Post 04/12/2007) Trước đó chúng ta đã thảo luận về Event Wait Handles – một cơ chế báo hiệu đơn giản trong đó một luồng sẽ chặn cho đến khi nó nhận được thông báo từ một luồng khác.

  • 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ộ
  • Phần 16: Đồng bộ hóa không chặn

Lớp Monitor cung cấp một cấu trúc báo hiệu mạnh mẽ hơn, thông qua hai phương thức tĩnh – Wait và Pulse. Nguyên tắc là bạn tự viết logic báo hiệu bằng cách sử dụng cờ và trường tùy chỉnh (kết hợp với câu lệnh khóa), sau đó giới thiệu các lệnh Wait và Pulse để giảm thiểu việc quay CPU. Ưu điểm của cách tiếp cận cấp thấp này là chỉ với Wait, Pulse và câu lệnh khóa, bạn có thể đạt được chức năng của AutoResetEvent, ManualResetEvent và Semaphore, cũng như các phương thức tĩnh WaitAll và WaitAny của WaitHandle. Hơn nữa, Wait and Pulse có thể đáp ứng được trong các tình huống mà tất cả các Wait Handles đều bị thử thách một cách phức tạp.

Một vấn đề với Wait and Pulse là tài liệu nghèo nàn của họ – đặc biệt là về lý do tồn tại của họ. Và để làm cho vấn đề tồi tệ hơn, các phương pháp Wait and Pulse có một ác cảm đặc biệt đối với những kẻ ăn vạ: nếu bạn kêu gọi chúng mà không có sự hiểu biết đầy đủ, chúng sẽ biết – và sẽ thích thú tìm kiếm bạn và hành hạ bạn! May mắn thay, có một mô hình đơn giản mà người ta có thể làm theo cung cấp giải pháp an toàn cho mọi trường hợp.

Chờ và xác định xung

Mục đích của Wait and Pulse là cung cấp một cơ chế báo hiệu đơn giản: Chờ các khối cho đến khi nó nhận được thông báo từ một luồng khác; Pulse cung cấp thông báo đó.

Chờ phải thực hiện trước khi Xung để tín hiệu hoạt động. Nếu Pulse thực hiện trước, xung của nó sẽ bị mất và người phục vụ trễ phải đợi một xung mới hoặc bị chặn mãi mãi. Điều này khác với hành vi của AutoResetEvent, trong đó phương thức Set của nó có hiệu ứng “chốt” và do đó sẽ có hiệu quả nếu được gọi trước WaitOne.

Người ta phải chỉ định một đối tượng đồng bộ hóa khi gọi Wait hoặc Pulse. Nếu hai luồng sử dụng cùng một đối tượng, thì chúng có thể báo hiệu cho nhau. Đối tượng đồng bộ hóa phải được khóa trước khi gọi Wait hoặc Pulse.

Ví dụ, nếu x có khai báo này:

class Test {
// Mọi đối tượng kiểu tham chiếu sẽ hoạt động như một đối tượng đồng bộ hóa đối
tượng x = new object ();
}

thì các mã sau sẽ chặn khi vào Monitor.Wait:

lock (x) Monitor.Wait (x);

Đoạn mã sau (nếu được thực thi sau trên một chuỗi khác) giải phóng chuỗi bị chặn:

lock (x) Monitor.Pulse (x);

Khóa chuyển đổi

Để làm cho điều này hoạt động, Monitor.Wait tạm thời giải phóng hoặc chuyển đổi khóa bên dưới trong khi chờ đợi, để một luồng khác (chẳng hạn như luồng thực hiện Xung) có thể lấy được. Phương thức Wait có thể được coi là mở rộng thành mã giả sau:

Monitor.Exit (x); // Giải phóng khóa
chờ xung trên x
Monitor.Enter (x); // Lấy lại khóa

Do đó, Chờ có thể chặn hai lần: một lần khi chờ xung và một lần nữa khi lấy lại khóa độc quyền. Điều này cũng có nghĩa là bản thân Pulse không hoàn toàn bỏ chặn người phục vụ: chỉ khi chuỗi xung thoát khỏi câu lệnh khóa của nó thì người phục vụ mới có thể thực sự tiếp tục.

Chuyển đổi khóa của Wait có hiệu lực bất kể mức độ lồng khóa. Nếu Wait được gọi bên trong hai câu lệnh khóa lồng nhau:

lock (x)
lock (x)
Monitor.Wait (x);

sau đó, Chờ một cách hợp lý sẽ mở rộng thành phần sau:

Monitor.Exit (x); Monitor.Exit (x); // Thoát 2 lần để nhả khóa
chờ xung trên x
Monitor.Enter (x); Monitor.Enter (x); // Khôi phục cấp độ lồng trước đó

Phù hợp với ngữ nghĩa khóa thông thường, chỉ lệnh gọi đầu tiên tới Monitor.Enter mới tạo cơ hội chặn.

Tại sao khóa?

Tại sao Wait and Pulse được thiết kế để chúng chỉ hoạt động trong một ổ khóa? Lý do chính là Wait có thể được gọi có điều kiện – mà không ảnh hưởng đến an toàn luồng. Để lấy một ví dụ đơn giản, giả sử chúng ta chỉ muốn Wait nếu một trường boolean được gọi là sẵn có là false. Đoạn mã sau an toàn theo chuỗi:

lock (x) {
if (! sẵn có) Monitor.Wait (x);
có sẵn = sai;
}

Một số luồng có thể chạy điều này đồng thời và không luồng nào có thể lấn át luồng khác giữa việc kiểm tra trường khả dụng và gọi Monitor.Wait. Hai tuyên bố có hiệu quả nguyên tử. Một trình thông báo tương ứng sẽ an toàn theo chuỗi tương tự:

lock (x)
if (! available) {
available = true;
Monitor.Pulse (x);
}

Chỉ định thời gian chờ

Thời gian chờ có thể được chỉ định khi gọi Wait, tính bằng mili giây hoặc dưới dạng TimeSpan. Chờ sau đó trả về false nếu nó bị loại bỏ vì hết thời gian. Thời gian chờ chỉ áp dụng cho giai đoạn “chờ đợi” (chờ xung): quá trình Chờ đã hết thời gian sau đó sẽ vẫn chặn để lấy lại khóa, bất kể mất bao lâu. Đây là một ví dụ:

lock (x) {
if (! Monitor.Wait (x, TimeSpan.FromSeconds (10)))
Console.WriteLine (“Không thể đợi!”);
Console.WriteLine (“Nhưng này, tôi vẫn có khóa trên x!”);
}

Cơ sở lý luận cho hành vi này là trong một ứng dụng Wait / Pulse được thiết kế tốt, đối tượng mà người ta gọi Wait and Pulse chỉ bị khóa trong một thời gian ngắn. Vì vậy, việc mua lại khóa phải là một hoạt động gần như tức thì.

Nhịp đập và ghi nhận

Một tính năng quan trọng của Monitor.Pulse là nó thực thi không đồng bộ, có nghĩa là bản thân nó không chặn hoặc tạm dừng theo bất kỳ cách nào. Nếu một luồng khác đang đợi đối tượng xung, nó sẽ được thông báo, nếu không thì xung không có tác dụng và sẽ bị bỏ qua một cách im lặng.

Xung cung cấp giao tiếp một chiều: một luồng xung báo hiệu một luồng đang chờ. Không có cơ chế xác nhận nội tại: Xung không trả về giá trị cho biết xung của nó đã được nhận hay chưa. Hơn nữa, khi một máy thông báo xung và nhả khóa của nó, không có gì đảm bảo rằng một người phục vụ đủ điều kiện sẽ bắt đầu cuộc sống ngay lập tức. Có thể có một độ trễ tùy ý, theo quyết định của bộ lập lịch luồng – trong thời gian đó, không luồng nào bị khóa. Điều này gây khó khăn để biết khi nào người phục vụ đã thực sự tiếp tục, trừ khi người phục vụ xác nhận cụ thể, chẳng hạn như thông qua cờ tùy chỉnh.

Nếu yêu cầu xác nhận đáng tin cậy, nó phải được mã hóa rõ ràng, thường thông qua một cờ kết hợp với một cờ khác, tương hỗ, Xung và Chờ.

Dựa vào hành động kịp thời từ người phục vụ mà không có cơ chế xác nhận tùy chỉnh được coi là “gây rối” với Pulse and Wait. Bạn sẽ thua!

Hàng đợi và PulseAll

Nhiều hơn một luồng có thể đồng thời Chờ trên cùng một đối tượng – trong trường hợp đó, một “hàng đợi” hình thành phía sau đối tượng đồng bộ hóa (điều này khác với “hàng đợi sẵn sàng” được sử dụng để cấp quyền truy cập vào một khóa). Sau đó, mỗi Pulse sẽ giải phóng một luồng duy nhất ở đầu hàng đợi, vì vậy nó có thể vào hàng đợi sẵn sàng và lấy lại khóa. Hãy nghĩ về nó giống như một bãi đậu xe tự động: bạn xếp hàng đầu tiên tại trạm trả tiền để xác nhận vé của mình (hàng đợi); bạn lại xếp hàng ở cổng rào để được ra ngoài (hàng đợi sẵn sàng).

Tuy nhiên, thứ tự vốn có trong cấu trúc hàng đợi thường không quan trọng trong các ứng dụng Wait / Pulse, và trong những trường hợp này, có thể dễ dàng hình dung ra một “nhóm” các luồng đang chờ. Sau đó, mỗi xung giải phóng một chuỗi chờ từ nhóm.

Monitor cũng cung cấp một phương thức PulseAll để giải phóng toàn bộ hàng đợi, hoặc nhóm các luồng đang chờ trong một lần xử lý. Tuy nhiên, tất cả các luồng xung sẽ không bắt đầu thực thi chính xác cùng một lúc, mà là theo một trình tự có thứ tự, vì mỗi câu lệnh Wait của chúng cố gắng lấy lại cùng một khóa. Trên thực tế, PulseAll di chuyển các luồng từ hàng đợi sang hàng đợi sẵn sàng, để chúng có thể tiếp tục một cách có trật tự.

Cách sử dụng Pulse and Wait

Đây là cách chúng ta bắt đầu. Hãy tưởng tượng có hai quy tắc:

  • cấu trúc đồng bộ hóa duy nhất có sẵn là câu lệnh khóa, còn gọi là Monitor.Enter và Monitor.Exit
  • không có giới hạn nào về việc quay CPU!

Với những quy tắc đó, hãy lấy một ví dụ đơn giản: một chuỗi công nhân tạm dừng cho đến khi nó nhận được thông báo từ chuỗi chính:

class SimpleWaitPulse {
bool go;
object locker = new object ();

void Work () {
Console.Write (“Đang chờ …”);
lock (khóa) {// Hãy quay!
while (! go) {
// Giải phóng khóa để các luồng khác có thể thay đổi cờ đi
Monitor.Exit (locker);
// Lấy lại khóa để chúng ta có thể kiểm tra lại trong vòng lặp while
Monitor.Enter (locker);
}
}
Console.WriteLine (“Đã thông báo!”);
}

void Notify () // được gọi từ một luồng khác
{
lock (locker) {
Console.Write (“Thông báo …”);
go = true;
}
}
}

Đây là một phương pháp chính để thiết lập mọi thứ trong chuyển động:

static void Main () {
SimpleWaitPulse test = new SimpleWaitPulse ();

// Chạy phương thức Work trên luồng của chính nó
new Thread (test.Work) .Start (); // “Đang chờ …”

// Tạm dừng một giây, sau đó thông báo cho worker thông qua luồng chính của chúng ta:
Thread.Sleep (1000);
test.Notify (); // “Thông báo … Đã thông báo!”
}

Phương thức Work là nơi chúng ta xoay vòng – ngốn thời gian của CPU bằng cách lặp liên tục cho đến khi cờ đi là đúng! Trong vòng lặp này, chúng ta phải tiếp tục bật khóa – giải phóng và mua lại nó thông qua các phương thức Thoát và Nhập của Màn hình – để một luồng khác chạy phương thức Thông báo có thể tự nhận khóa và sửa đổi cờ đi. Trường chia sẻ phải luôn được truy cập từ bên trong một khóa để tránh các vấn đề về biến động (hãy nhớ rằng tất cả các cấu trúc đồng bộ hóa khác, chẳng hạn như từ khóa biến động, nằm ngoài giới hạn trong giai đoạn thiết kế này!)

Bước tiếp theo là chạy nó và kiểm tra xem nó có thực sự hoạt động hay không. Đây là kết quả từ phương pháp Chính kiểm tra:

 

Đang chờ … (tạm dừng) Thông báo … Đã thông báo!

Bây giờ chúng ta có thể giới thiệu Wait and Pulse. Chúng tôi làm điều này bằng cách:

  • thay thế khóa chuyển đổi (Monitor.Exit theo sau là Monitor.Enter) bằng Monitor.Wait
  • chèn lệnh gọi đến Monitor.Pulse khi điều kiện chặn bị thay đổi (nghĩa là cờ đi được sửa đổi).

Đây là lớp được cập nhật, với các câu lệnh Console được bỏ qua cho ngắn gọn:

class SimpleWaitPulse {
bool go;
object locker = new object ();

void Work () {
lock (khóa)
while (! go) Monitor.Wait (khóa);
}

void Notify () {
lock (locker) {
go = true;
Monitor.Pulse (tủ khóa);
}
}
}

Lớp học vẫn hoạt động như trước đây, nhưng với sự quay vòng bị loại bỏ. Lệnh Wait ngầm thực hiện mã mà chúng ta đã loại bỏ – Monitor.Exit, theo sau là Monitor.Exit, nhưng có thêm một bước ở giữa: trong khi khóa được giải phóng, nó sẽ đợi một luồng khác gọi Pulse. Phương thức Notifier thực hiện điều này, sau khi đặt cờ đi đúng. Công việc đã xong.

Xung và Chờ Tổng quát

Bây giờ hãy mở rộng mô hình. Trong ví dụ trước, điều kiện chặn của chúng tôi chỉ liên quan đến một trường boolean – cờ đi. Trong một trường hợp khác, chúng tôi có thể yêu cầu một cờ bổ sung do chuỗi chờ đặt để báo hiệu rằng nó đã sẵn sàng hoặc đã hoàn tất. Nếu chúng ta ngoại suy bằng cách giả sử có thể có bất kỳ số lượng trường nào liên quan đến bất kỳ số điều kiện chặn nào, thì chương trình có thể được tổng quát hóa thành mã giả sau (ở dạng xoay vòng của nó):

class X {
Blocking Fields: một hoặc nhiều đối tượng liên quan đến (các) điều kiện chặn, ví dụ:
bool go; bool đã sẵn sàng; int semaphoreCount; Xếp hàngConsumerQ …

object locker = new object (); // bảo vệ tất cả các trường trên!

… SomeMethod {
… bất cứ khi nào tôi muốn CHẶN dựa trên các trường chặn:
lock (locker)
while (! Trường chặn theo ý thích của tôi) {
// Cho các chuỗi khác cơ hội thay đổi trường chặn!
Monitor.Exit (tủ khóa);
Monitor.Enter (tủ khóa);
}

… bất cứ khi nào tôi muốn thay đổi một hoặc nhiều trường chặn:
lock (locker) {thay đổi trường chặn (s)}
}
}

Sau đó, chúng tôi áp dụng Pulse and Wait như đã làm trước đây:

  • Trong các vòng chờ, chuyển đổi khóa được thay thế bằng Màn hình.
  • Bất cứ khi nào một điều kiện chặn được thay đổi, Pulse được gọi trước khi giải phóng khóa.

Đây là mã giả được cập nhật:

Wait / Pulse Boilerplate # 1: Cơ bản sử dụng Wait / Pulse

class X {
<Blocking Fields …>
object locker = new object ();

… SomeMethod {

… bất cứ khi nào tôi muốn CHẶN dựa trên các trường chặn:
lock (khóa)
while (! Các trường chặn theo ý thích của tôi)
Monitor.Wait (khóa);

… bất cứ khi nào tôi muốn thay đổi một hoặc nhiều trường chặn:
lock (locker) {
thay đổi (các) trường chặn
Monitor.Pulse (locker);
}
}
}

Điều này cung cấp một mô hình mạnh mẽ để sử dụng Wait and Pulse. Dưới đây là các tính năng chính của mẫu này:

  • Các điều kiện chặn được thực hiện bằng cách sử dụng các trường tùy chỉnh (có khả năng hoạt động mà không cần Chờ và Xung, mặc dù có quay)
  • Wait luôn được gọi trong một vòng lặp while để kiểm tra điều kiện chặn của nó (chính nó trong một câu lệnh khóa)
  • Một đối tượng đồng bộ hóa duy nhất (trong ví dụ trên, bộ khóa) được sử dụng cho tất cả các Chờ và Xung, và để bảo vệ quyền truy cập vào tất cả các đối tượng liên quan đến tất cả các điều kiện chặn
  • Khóa chỉ được giữ trong thời gian ngắn

Quan trọng nhất, với mô hình này, xung nhịp không buộc người phục vụ tiếp tục. Thay vào đó, nó thông báo cho người phục vụ rằng có điều gì đó đã thay đổi, khuyên họ nên kiểm tra lại tình trạng chặn của nó. Sau đó, người phục vụ xác định xem nó có nên tiếp tục hay không (thông qua một lần lặp khác của vòng lặp while) – chứ không phải bộ xử lý xung. Lợi ích của cách tiếp cận này là nó cho phép các điều kiện chặn phức tạp mà không cần logic đồng bộ phức tạp.

Một lợi ích khác của mô hình này là khả năng miễn nhiễm với các tác động của một xung bị bỏ lỡ. Một xung bị bỏ lỡ xảy ra khi Pulse được gọi trước Chờ – có lẽ do cuộc chạy đua giữa người thông báo và người phục vụ. Nhưng vì trong mô hình này, một xung có nghĩa là “kiểm tra lại điều kiện chặn của bạn” (chứ không phải “tiếp tục”), một xung sớm có thể được bỏ qua một cách an toàn vì điều kiện chặn luôn được kiểm tra trước khi gọi Wait, nhờ vào câu lệnh while.

Với thiết kế này, người ta có thể xác định nhiều trường chặn và để chúng tham gia vào nhiều điều kiện chặn, nhưng vẫn sử dụng một đối tượng đồng bộ hóa duy nhất trong suốt (trong ví dụ của chúng tôi là tủ khóa). Điều này thường tốt hơn so với việc có các đối tượng đồng bộ hóa riêng biệt để khóa, Xung và Chờ, trong đó một đối tượng tránh khả năng bị bế tắc. Hơn nữa, với một đối tượng khóa duy nhất, tất cả các trường chặn được đọc và ghi thành một đơn vị, tránh các lỗi nguyên tử tinh vi. Tuy nhiên, bạn không nên sử dụng đối tượng đồng bộ hóa cho các mục đích ngoài phạm vi cần thiết (điều này có thể được hỗ trợ bằng cách khai báo riêng đối tượng đồng bộ hóa, cũng như tất cả các trường chặn).

(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