11 Chủ đề trong C # - Phần 7: Xử lý Chờ

(Đăng ngày 23/10/2007) Về mặt hiệu suất, chi phí chung với tất cả các Xử lý Chờ thường chạy trong vùng vài micro giây. Hiếm khi điều này là hậu quả trong bối cảnh mà chúng được sử dụng.

  • 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

Câu lệnh khóa (hay còn gọi là Monitor.Enter / Monitor.Exit) là một ví dụ về cấu trúc đồng bộ hóa luồng. Mặc dù khóa phù hợp để thực thi quyền truy cập độc quyền vào một tài nguyên hoặc phần mã cụ thể, nhưng có một số tác vụ đồng bộ hóa mà nó vụng về hoặc không đầy đủ, chẳng hạn như báo hiệu một chuỗi nhân viên đang chờ bắt đầu tác vụ.

API Win32 có tập hợp các cấu trúc đồng bộ hóa phong phú hơn và những cấu trúc này được hiển thị trong khuôn khổ .NET thông qua các lớp EventWaitHandle, Mutex và Semaphore. Một số hữu ích hơn các lớp khác: chẳng hạn, lớp Mutex chủ yếu tăng gấp đôi những gì được cung cấp bởi khóa, trong khi EventWaitHandle cung cấp chức năng báo hiệu độc đáo.

Cả ba lớp đều dựa trên lớp WaitHandle trừu tượng, mặc dù về mặt hành vi, chúng khá khác nhau. Một trong những điểm chung của chúng là chúng có thể, tùy chọn, được “đặt tên”, cho phép chúng hoạt động trên tất cả các quy trình của hệ điều hành, thay vì chỉ trên các luồng trong quy trình hiện tại.

EventWaitHandle có hai lớp con: AutoResetEvent và ManualResetEvent (không liên quan đến sự kiện hoặc đại biểu C #). Cả hai lớp đều lấy tất cả các chức năng của chúng từ lớp cơ sở của chúng: điểm khác biệt duy nhất của chúng là chúng gọi hàm tạo của lớp cơ sở với một đối số khác.

Về mặt hiệu suất, chi phí chung với tất cả các Xử lý Chờ thường chạy trong vùng vài micro giây. Hiếm khi điều này là hậu quả trong bối cảnh mà chúng được sử dụng.

AutoResetEvent là hữu ích nhất trong các lớp WaitHandle, và là một cấu trúc đồng bộ hóa chủ yếu, cùng với câu lệnh khóa.

AutoResetEvent

AutoResetEvent giống như một cửa quay vé: việc chèn một vé cho phép chính xác một người đi qua. “Tự động” trong tên của lớp đề cập đến thực tế là một cửa quay mở tự động đóng lại hoặc “đặt lại” sau khi ai đó được cho qua. Một luồng chờ hoặc chặn tại cửa quay bằng cách gọi WaitOne (đợi tại cửa quay “một” này cho đến khi nó mở ra) và một vé được chèn bằng cách gọi phương thức Đặt. Nếu một số chủ đề gọi WaitOne, một hàng đợi sẽ hình thành phía sau cửa quay. Một vé có thể đến từ bất kỳ luồng nào – nói cách khác, bất kỳ luồng nào (không bị chặn) có quyền truy cập vào đối tượng AutoResetEvent đều có thể gọi Set trên đó để giải phóng một luồng bị chặn.

Nếu Set được gọi khi không có luồng nào đang đợi, thì xử lý vẫn mở trong khoảng thời gian cho đến khi một số luồng gọi WaitOne. Hành vi này giúp tránh một cuộc chạy đua giữa một chủ đề cho cửa quay và một chủ đề chèn một vé (“rất tiếc, đã chèn vé quá sớm, thật xui xẻo, bây giờ bạn sẽ phải chờ vô thời hạn!”) Tuy nhiên, gọi Set liên tục trên cửa quay mà tại đó không có ai đang đợi sẽ không cho phép cả nhóm đi qua khi họ đến: chỉ người tiếp theo được cho qua và những chiếc vé phụ bị “lãng phí”.

WaitOne chấp nhận một tham số thời gian chờ tùy chọn – phương thức sau đó trả về false nếu quá trình chờ kết thúc vì hết thời gian chờ thay vì lấy tín hiệu. WaitOne cũng có thể được hướng dẫn để thoát khỏi bối cảnh đồng bộ hóa hiện tại trong thời gian chờ (nếu đang sử dụng chế độ khóa tự động) để ngăn chặn quá mức.

Một phương pháp Đặt lại cũng được cung cấp để đóng cửa quay – nếu nó được mở, mà không cần chờ đợi hoặc chặn.

Một AutoResetEvent có thể được tạo theo một trong hai cách. Đầu tiên là thông qua hàm tạo của nó:

EventWaitHandle wh = new AutoResetEvent (false);

Nếu đối số boolean là true, phương thức Set của xử lý được gọi tự động, ngay sau khi xây dựng. Một phương pháp khác của tính năng tức thời là thông qua lớp cơ sở của nó, EventWaitHandle:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto);

Phương thức khởi tạo của EventWaitHandle cũng cho phép tạo một ManualResetEvent (bằng cách chỉ định EventResetMode.Manual).

Người ta nên gọi Close on a Wait Handle để giải phóng tài nguyên hệ điều hành khi nó không còn cần thiết. Tuy nhiên, nếu Xử lý Chờ sẽ được sử dụng cho vòng đời của một ứng dụng (như trong hầu hết các ví dụ trong phần này), người ta có thể lười biếng và bỏ qua bước này vì nó sẽ được tự động xử lý trong quá trình xé bỏ miền ứng dụng- xuống.

Trong ví dụ sau, một luồng được bắt đầu mà công việc của nó chỉ đơn giản là đợi cho đến khi được báo hiệu bởi một luồng khác.

class BasicWaitHandle {
static EventWaitHandle wh = new AutoResetEvent (false);

static void Main () {
new Thread (Waiter) .Start ();
Thread.Sleep (1000); // Chờ một lúc …
wh.Set (); // OK – đánh thức nó
}
static void Waiter () {
Console.WriteLine (“Đang chờ …”);
wh.WaitOne (); // Chờ thông báo
Console.WriteLine (“Notified”);
}
}

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

Tạo sự kiện xuyên quy trình

Phương thức khởi tạo của EventWaitHandle cũng cho phép tạo một EventWaitHandle “có tên” – có khả năng hoạt động trên nhiều tiến trình. Tên chỉ đơn giản là một chuỗi – và có thể là bất kỳ giá trị nào không vô tình xung đột với của người khác! Nếu tên đã được sử dụng trên máy tính, một tên sẽ nhận được tham chiếu đến EventWaitHandle cơ bản tương tự, nếu không hệ điều hành sẽ tạo một tên mới. Đây là một ví dụ:

EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto,
“MyCompany.MyApp.SomeName”);

Nếu hai ứng dụng từng chạy mã này, chúng sẽ có thể báo hiệu cho nhau: xử lý chờ sẽ hoạt động trên tất cả các luồng trong cả hai quy trình.

Nhìn nhận

Giả sử chúng ta muốn thực hiện các tác vụ ở chế độ nền mà không phải tạo một luồng mới mỗi khi chúng ta nhận được một nhiệm vụ. Chúng ta có thể đạt được điều này với một luồng worker liên tục lặp lại – đợi một tác vụ, thực thi nó và sau đó chờ tác vụ tiếp theo. Đây là một kịch bản đa luồng phổ biến. Cũng như cắt giảm chi phí trong việc tạo luồng, việc thực thi tác vụ được tuần tự hóa, loại bỏ khả năng tương tác không mong muốn giữa nhiều nhân viên và tiêu thụ tài nguyên quá mức.

Tuy nhiên, chúng ta phải quyết định xem phải làm gì nếu nhân viên đó đã bận rộn với nhiệm vụ trước đó khi một nhiệm vụ mới xuất hiện. Giả sử trong tình huống này chúng ta chọn chặn người gọi cho đến khi tác vụ trước đó hoàn thành. Một hệ thống như vậy có thể được triển khai bằng hai đối tượng AutoResetEvent: một AutoResetEvent “sẵn sàng” được đặt bởi công nhân khi nó sẵn sàng và một AutoResetEvent “đi” được đặt bởi chuỗi gọi khi có một nhiệm vụ mới. Trong ví dụ dưới đây, một trường chuỗi đơn giản được sử dụng để mô tả tác vụ (được khai báo bằng từ khóa dễ bay hơi để đảm bảo cả hai luồng luôn thấy cùng một phiên bản):

class AcknowledgedWaitHandle {
static EventWaitHandle ready = new AutoResetEvent (false);
static EventWaitHandle go = new AutoResetEvent (false);
nhiệm vụ chuỗi biến động tĩnh;

static void Main () {
new Thread (Work) .Start ();

// Báo hiệu cho worker 5 lần
for (int i = 1; i <= 5; i ++) {
ready.WaitOne (); // Đầu tiên hãy đợi cho đến khi worker sẵn sàng
task = “a” .PadRight (i, ‘h’); // Gán nhiệm vụ
go.Set (); // Bảo nhân viên đi!
}

// Yêu cầu công nhân kết thúc bằng null-task
ready.WaitOne (); task = null; go.Set ();
}

static void Work () {
while (true) {
ready.Set (); // Cho biết chúng tôi đã sẵn sàng
go.WaitOne (); // Chờ để được khởi động …
if (task == null) return; // Thoát khỏi
Console.WriteLine (task) một cách duyên dáng ;
}
}
}

 

ah
ahh
ahhh
ahhhh

Chú ý rằng chúng ta gán một nhiệm vụ null để báo hiệu luồng worker thoát ra. Việc gọi Ngắt hoặc Hủy trên luồng của nhân viên trong trường hợp này sẽ hoạt động tốt như nhau – với điều kiện lần đầu tiên chúng tôi gọi là ready.WaitOne. Điều này là do sau khi gọi hàm ready.WaitOne, chúng ta có thể chắc chắn về vị trí của worker – trong hoặc ngay trước lệnh go.WaitOne – và do đó tránh được các biến chứng khi làm gián đoạn mã tùy ý. Gọi Ngắt hoặc Hủy cũng sẽ yêu cầu chúng tôi bắt được ngoại lệ do hậu quả trong worker.

Nhà sản xuất / Người tiêu dùng Hàng đợi

Một kịch bản phân luồng phổ biến khác là có một nhân viên nền xử lý các tác vụ từ một hàng đợi. Đây được gọi là hàng đợi Nhà sản xuất / Người tiêu dùng: nhà sản xuất xếp hàng các nhiệm vụ; người tiêu dùng sắp xếp lại các nhiệm vụ trên một chuỗi công nhân. Nó giống như ví dụ trước, ngoại trừ việc người gọi không bị chặn nếu nhân viên đang bận với một nhiệm vụ.

Hàng đợi của Nhà sản xuất / Người tiêu dùng có thể mở rộng, trong đó nhiều người tiêu dùng có thể được tạo – mỗi người phục vụ cùng một hàng đợi, nhưng trên một chuỗi riêng biệt. Đây là một cách tốt để tận dụng lợi thế của hệ thống đa xử lý trong khi vẫn hạn chế số lượng nhân công để tránh những cạm bẫy của các luồng đồng thời không bị ràng buộc (chuyển đổi ngữ cảnh quá mức và tranh chấp tài nguyên).

Trong ví dụ dưới đây, một AutoResetEvent duy nhất được sử dụng để báo hiệu worker, chỉ chờ khi nó hết nhiệm vụ (khi hàng đợi trống). Một lớp tập hợp chung được sử dụng cho hàng đợi, mà quyền truy cập của nó phải được bảo vệ bằng khóa để đảm bảo an toàn cho luồng. Công nhân được kết thúc bằng cách đặt một nhiệm vụ rỗng:

sử dụng Hệ thống;
sử dụng System.Threading;
sử dụng System.Collections.Generic;

class ProducerConsumerQueue: IDisposable {
EventWaitHandle wh = new AutoResetEvent (false);
Thợ chỉ;
object locker = new object ();
Xếp hàng task = new Queue();

public ProducerConsumerQueue () {
worker = new Thread (Work);
worker.Start ();
}

public void EnqueueTask (string task) {
lock (locker) task.Enqueue (task);
wh.Set ();
}

public void Dispose () {
EnqueueTask (null); // Báo hiệu người tiêu dùng thoát.
worker.Join (); // Chờ luồng của người tiêu dùng kết thúc.
wh.Close (); // Giải phóng mọi tài nguyên hệ điều hành.
}

void Work () {
while (true) {
string task = null;
lock (locker)
if (task.Count> 0) {
task = nhiệm vụ.Dequeue ();
if (task == null) return;
}
if (task! = null) {
Console.WriteLine (“Thực hiện nhiệm vụ:” + task);
Thread.Sleep (1000); // mô phỏng công việc …
}
else
wh.WaitOne (); // Không còn tác vụ – đợi tín hiệu
}
}
}

Đây là một phương pháp chính để kiểm tra hàng đợi:

class Test {
static void Main () {
using (ProducerConsumerQueue q = new ProducerConsumerQueue ()) {
q.EnqueueTask (“Xin chào”);
for (int i = 0; i <10; i ++) q.EnqueueTask (“Nói” + i);
q.EnqueueTask (“Tạm biệt!”);
}
// Thoát khỏi câu lệnh using gọi phương thức Dispose của q, phương thức này
// xếp hàng một nhiệm vụ rỗng và đợi cho đến khi người tiêu dùng kết thúc.
}
}

Thực hiện nhiệm vụ: Xin chào
Thực hiện nhiệm vụ: Nói 1
Thực hiện nhiệm vụ: Nói 2
Thực hiện nhiệm vụ: Nói 3


Thực hiện nhiệm vụ: Nói 9
Tạm biệt!

Lưu ý rằng trong ví dụ này, chúng tôi sẽ đóng Xử lý Chờ một cách rõ ràng khi ProducerConsumerQueue của chúng tôi bị xử lý – vì chúng tôi có thể tạo và hủy nhiều phiên bản của lớp này trong vòng đời của ứng dụng.

ManualResetEvent

ManualResetEvent là một biến thể của AutoResetEvent. Nó khác ở chỗ nó không tự động thiết lập lại sau khi một luồng được cho phép trong cuộc gọi WaitOne, và do đó, hoạt động giống như một cổng: gọi Set sẽ mở cổng, cho phép bất kỳ số luồng nào mà WaitOne ở cổng đi qua; gọi Reset sẽ đóng cổng, có khả năng là một hàng đợi người chờ tích lũy cho đến khi nó mở tiếp.

Người ta có thể mô phỏng chức năng này với trường boolean “gateOpen” (được khai báo với từ khóa dễ bay hơi) kết hợp với “spin-sleep” – liên tục kiểm tra cờ, và sau đó ngủ trong một khoảng thời gian ngắn.

Đôi khi, ManualResetEvents được sử dụng để báo hiệu rằng một hoạt động cụ thể đã hoàn tất hoặc rằng một luồng đã hoàn thành khởi tạo và sẵn sàng thực hiện công việc.

Mutex

Mutex cung cấp chức năng tương tự như câu lệnh khóa của C #, làm cho Mutex hầu như là dư thừa. Một ưu điểm của nó là nó có thể hoạt động trên nhiều quy trình – cung cấp một khóa toàn máy tính hơn là một khóa toàn ứng dụng.

Mặc dù Mutex nhanh hợp lý, nhưng khóa lại nhanh hơn hàng trăm lần. Để có được Mutex mất vài micro giây; để có được một khóa mất hàng chục nano giây (giả sử không có khóa).

Với một lớp Mutex, phương thức WaitOne nhận được khóa độc quyền, chặn nếu nó bị cạnh tranh. Sau đó, khóa độc quyền được phát hành với phương thức ReleaseMutex. Cũng giống như câu lệnh lock của C #, một Mutex chỉ có thể được giải phóng từ cùng một chủ đề lấy được nó.

Cách sử dụng phổ biến cho Mutex xuyên quy trình là đảm bảo rằng chỉ một phiên bản của chương trình có thể chạy tại một thời điểm. Đây là cách nó được thực hiện:

class OneAtATimePlease {
// Sử dụng tên duy nhất cho ứng dụng (ví dụ: bao gồm URL công ty của bạn)
static Mutex mutex = new Mutex (false, “oreilly.com OneAtATimeDemo”);

static void Main () {
// Chờ 5 giây nếu có – trong trường hợp một phiên bản
// khác của chương trình đang trong quá trình tắt.

if (! mutex.WaitOne (TimeSpan.FromSeconds (5), false)) {
Console.WriteLine (” Phiên bản khác của ứng dụng đang chạy. Tạm biệt!”);
trở về;
}
try {
Console.WriteLine (“Đang chạy – nhấn Enter để thoát”);
Console.ReadLine ();
}
cuối cùng {mutex.ReleaseMutex (); }
}
}

Một tính năng tốt của Mutex là nếu ứng dụng kết thúc mà không có ReleaseMutex được gọi trước, CLR sẽ tự động giải phóng Mutex.

Semaphore

Semaphore giống như một hộp đêm: nó có một sức chứa nhất định, được thực thi bởi một người bảo lãnh. Sau khi đã đầy, không còn ai có thể vào hộp đêm và hàng đợi sẽ được dựng lên bên ngoài. Sau đó, đối với mỗi người rời đi, một người có thể vào từ người đứng đầu hàng đợi. Hàm tạo yêu cầu tối thiểu hai đối số – số lượng địa điểm hiện có trong hộp đêm và tổng sức chứa của hộp đêm.

Semaphore có dung lượng tương tự như Mutex hoặc khóa, ngoại trừ Semaphore không có “chủ sở hữu” – đó là luồng bất khả tri. Bất kỳ luồng nào cũng có thể gọi Release trên Semaphore, trong khi với Mutex và lock, chỉ luồng lấy được tài nguyên mới có thể giải phóng nó.

Trong ví dụ sau, mười luồng thực hiện một vòng lặp với câu lệnh Sleep ở giữa. Semaphore đảm bảo rằng không quá ba luồng có thể thực thi câu lệnh Sleep đó cùng một lúc:

class SemaphoreTest {
static Semaphore s = new Semaphore (3, 3); // Có sẵn = 3; Dung lượng = 3

static void Main () {
for (int i = 0; i <10; i ++) new Thread (Go) .Start ();
}

static void Go () {
while (true) {
s.WaitOne ();
Thread.Sleep (100); // Chỉ có 3 luồng có thể đến đây cùng một lúc
s.Release ();
}
}
}

WaitAny, WaitAll và SignalAndWait

Ngoài các phương thức Set và WaitOne, có các phương thức tĩnh trên lớp WaitHandle để bẻ khóa các loại hạt đồng bộ hóa phức tạp hơn.

Các phương thức WaitAny, WaitAll và SignalAndWait tạo điều kiện thuận lợi cho việc chờ đợi trên nhiều Bộ xử lý Chờ, có khả năng thuộc các loại khác nhau.
SignalAndWait có lẽ là hữu ích nhất: nó gọi WaitOne trên một WaitHandle, trong khi gọi Set trên một WaitHandle khác – trong một hoạt động nguyên tử. Người ta có thể sử dụng phương pháp này trên một cặp EventWaitHandles để thiết lập hai luồng sao cho chúng “gặp nhau” tại cùng một thời điểm, theo kiểu sách giáo khoa. AutoResetEvent hoặc ManualResetEvent sẽ thực hiện thủ thuật. Chủ đề đầu tiên thực hiện như sau:

WaitHandle.SignalAndWait (wh1, wh2);

trong khi luồng thứ hai làm ngược lại:

WaitHandle.SignalAndWait (wh2, wh1);

WaitHandle.WaitAny đợi bất kỳ một trong một dãy các xử lý chờ; WaitHandle.WaitAll sẽ đợi trên tất cả các tay cầm đã cho. Sử dụng phép tương tự cửa quay của vé, các phương pháp này giống như xếp hàng đồng thời ở tất cả các cửa quay – đi qua cửa quay đầu tiên để mở (trong trường hợp WaitAny) hoặc đợi cho đến khi tất cả chúng mở (trong trường hợp WaitAll).

WaitAll thực sự có giá trị đáng ngờ vì có một kết nối kỳ lạ với luồng căn hộ – một phần lùi từ kiến ​​trúc COM cũ. WaitAll yêu cầu người gọi phải ở trong một căn hộ đa luồng – đây là mô hình căn hộ ít phù hợp nhất để có khả năng tương tác – đặc biệt đối với các ứng dụng Windows Forms, cần thực hiện các tác vụ trần tục như tương tác với khay nhớ tạm!

May mắn thay, khung công tác .NET cung cấp một cơ chế báo hiệu nâng cao hơn khi các Wait Handles khó xử lý hoặc không phù hợp – Monitor.Wait và Monitor.Pulse.

(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