Thứ Tư, 27 tháng 6, 2012

C# – Cơ bản về Thread (P2)


Foreground và Background Thread

Ứng dụng phân biệt Thread theo hai loại: Foreground thread và Background thread. Các thread ban đầu được tạo ra đều là foreground. Ứng dụng sẽ vẫn tiếp tục chạy nếu như tất cả các foreground thread chưa chạy xong mặc dù bạn đã thực hiện lệnh tắt ứng dụng. Và nếu tất cả các foreground thread hoàn thành, ứng dụng sẽ tắt, đồng thời tất cả background thread cũng bị “khai tử” theo.
Xét về độ ưu tiên, foreground và background không có sự khác biệt nào trừ phi bạn đặt lại giá trị này cho chúng. Để xác định một Thread là foreground hay background, bạn sử dụng thuộc tính IsBackground. Hãy xem một ví dụ đơn giản sau để thấy được sự khác biệt giữa foreground và background thread:
01static void Main(string[] args)
02{
03    Thread t1 = new Thread(() =>
04        {
05            Thread.Sleep(1000);
06            Console.WriteLine("Thread t1 started");
07        });
08    // t1.IsBackground = true;
09    t1.Start();
10    Console.WriteLine("Main thread ending...");
11}
Output:
Main thread ending…
Thread t1 started
Trong thread t1 tôi để Sleep(1000) để phương thức Main() có thời gian kết thúc. Và như bạn thấy kết quả xuất ra, thread t1 vẫn tiếp tục chạy mặc dù Main() đã hoàn thành công việc (thread chính đã kết thúc). Bây giờ bạn uncomment dòng t1.IsBackground = true và chạy lại, kết quả sẽ chỉ xuất ra một dòng sau:
Main thread ending…
Background thread ứng dụng rất nhiều để thực hiện các tác vụ nền trong ứng dụng. Ngoài ra, .Net cung cấp lớp BackgroundWorker sử dụng background thread giúp lập trình thread trở nên dễ dàng và được ứng dụng khá phổ biến trong Windows Form.

Thread Pooling

Thread Pooling là một kĩ thuật cho phép bạn sử dụng các thread hiệu quả hơn bằng cách quản lý và phân phối chúng hợp lý, tận dụng tối đa thời gian nhàn rỗi và tăng hiệu suất của chương trình. Thread pooling là một kĩ thuật được áp dụng phổ biến trong các ứng dụng về I/O bất đồng bộ tập tin và truyền tải dữ liệu trên mạng.
Mỗi chương trình được cung cấp một Thread pool khi khởi tạo, vì thế bạn không cần tạo một thể hiện của thread pool để sử dụng. Một đặc điểm của Thread pool là các thread sẽ được đặt ở chế độ background (Background Thread). Các tác vụ khi được thêm vào Thread pool sẽ được thực thi khi có một thread đang ở trạng thái sẵn sàng. Sau khi kết thúc một tác vụ, thread sẽ chuyển về trạng thái sẵn sàng để chờ một công việc khác.
Bạn có thể tưởng tượng thread pool giống như một hàng đợi hay phòng bán vé với mặc định là 25 người làm việc, khi một người hoàn tất công việc bán vé cho khách thì khách hàng tiếp theo sẽ đến bắt đầu một giao dịch mới.
Để sử dụng thread pool, bạn chỉ sử dụng phương thức tĩnh QueueUserWorkItem() của lớp ThreadPool. Phương thức này nhận tham số là một phương thức callback hoặc delegate, có thể dùng overload thứ hai để truyền thêm tham số cho phương thức cần thực thi. Sau khi được truyền vào thread pool, tác vụ đó được đặt vào hàng đợi và sẵn sàng thực thi bất cứ lúc nào có thread ở trạng thái sẵn sàng.
Sau đây là một ví dụ đơn giản về sử dụng Thread Pool:
01class ThreadPooling
02{
03 
04    static void Main()
05    {
06        ThreadPool.QueueUserWorkItem(ThreadProc);
07        ThreadPool.QueueUserWorkItem(ThreadProc, 123);
08    }
09 
10    static void ThreadProc(object data)
11    {
12        for (int i = 0; i < 10; i++)
13        {
14            Console.WriteLine("Thread callback: " + data);
15            Thread.Sleep(500);
16        }
17    }
18}
Để cho thấy các 2 tác vụ thêm vào ThreadPool chạy đồng thời, ta làm chậm tốc tộ thực thi bằng cách dùng Sleep().
Bạn có thể thay đổi số thread lớn nhất mà thread pool tạo ra bằng cách sử dụng phương thức ThreadPool.SetMaxThreads(). Trong mỗi phiên bản .Net giá trị mặc định này không giống nhau, ví dụ phiên bản .Net 2.0 thì giá trị này là 25, trong .Net 3.5 là 250. Bạn có thể kiểm tra điều này bằng cách sử dụng phương thức ThreadPool.GetMaxThreads().

Đồng bộ hóa và locking

Vấn đề bảo toàn dữ liệu khi dùng thread là rất quan trọng vì có thể gây ra những sai sót khi nhiều thread cùng thay đổi cùng dữ liệu tại một thời điểm. Vì thế .Net cung cấp một số kĩ thuật để đồng bộ việc truy xuất dữ liệu. Một khi được sử dụng, dữ liệu sẽ bị khóa lại và các thread khác muốn sử dụng phải chờ cho đến khi dữ liệu hay tài nguyên được giải phóng.
.Net cung cấp một số giải pháp cho vấn đề này như Monitor, SpinLock, Mutex, WaitHandle,… Trong khuôn khổ bài viết tôi chỉ giới thiệu phương pháp hay được sử dụng và đơn giản nhất là từ khóa lock:
lock (syncObj)
{
// …
}
Tham số sử dụng cho từ khóa lock phải là một đối tượng có kiểu tham chiếu. Bất kì thread nào sử dụng đối tượng syncObj trên để đồng bộ hóa thông qua lock đều phải chờ cho đến khi đối tượng này được giải phóng. Nếu có nhiều thread cùng chờ, chúng sẽ được đặt trong một danh sách kiểu queue (FIFO – First In First Out) để được xử lý theo thứ tự.
Ta có ví dụ sau:
01class ThreadLocking
02{
03    static int amount = 0;
04 
05    static void Main()
06    {
07        Thread t1 = new Thread(IncreaseAmount);
08        Thread t2 = new Thread(DecreaseAmount);
09 
10        t1.Start();
11        t2.Start();
12    }
13 
14    static void IncreaseAmount()
15    {
16        for (int i = 0; i < 100; i++)
17        {
18            amount++;
19 
20            if (amount > 0)
21            {
22                Thread.Sleep(1);
23                Console.Write(amount + "\t");
24            }
25        }
26    }
27    static void DecreaseAmount()
28    {
29        for (int i = 0; i < 100; i++)
30        {
31            amount--;
32        }
33    }
34}
Output:
1          -98
Phương thức IncreaseAmount() sẽ in ra màn hình giá trị của biến amount chỉ khi biến này có giá trị lớn hơn 0. Tuy nhiên khi chạy ví dụ trên bạn có thể nhận được một kết quả sai, tức là một số âm sẽ được in ra màn hình.
Để khắc phục tình trạng này ta tạo thêm một đối tượng để làm “chìa khóa” và sử dụng lock như sau:
01class ThreadLocking
02{
03    static int amount = 0;
04    static object syncObj = new object();
05 
06    static void Main()
07    {
08        Thread t1 = new Thread(IncreaseAmount);
09        Thread t2 = new Thread(DecreaseAmount);
10 
11        t1.Start();
12        t2.Start();
13    }
14 
15    static void IncreaseAmount()
16    {
17        for (int i = 0; i < 100; i++)
18        {
19            lock (syncObj)
20            {
21                amount++;
22                if (amount > 0)
23                {
24                    Thread.Sleep(1);
25                    Console.Write(amount + "\t");
26                }
27            }
28        }
29    }
30    static void DecreaseAmount()
31    {
32        for (int i = 0; i < 100; i++)
33        {
34            lock (syncObj)
35            {
36                amount--;
37            }
38        }
39    }
40}

Deadlock

Đồng bộ hóa khi sử dụng thread là một công việc cần thiết, tuy nhiên nếu không cẩn thận bạn sẽ gặp phải tình trạng chương trình dừng hoạt động vô thời hạn. Tình trạng này được đặt tên là deadlock. Deadlock xảy ra khi có ít nhất hai thread cùng đợi thread kia giải phóng, thật “trùng hợp” là cả hai lại đang giữ “chìa khóa” của nhau.
Để dễ hiểu bạn hãy tưởng tượng có hai người hàng xóm bị nhốt trong hai căn phòng của chính mình và người này lại giữ chìa khóa của người kia. Và người này sẽ không thể đưa chìa khóa cho người kia nếu như không ra khỏi phòng. Rốt cuộc cả hai sẽ bị nhốt trong phòng mãi mãi? (Thật may là trong thực tế thì hai người hàng xóm vẫn còn rất nhiều cách để thoát khỏi tình trạng này). Vâng, rắc rối này sẽ được gọi là deadlock nếu bạn làm việc với thread.
Ví dụ nhỏ này sẽ cho ta thấy một tình trạng deadlock được tạo ra và chương trình sẽ không bao giờ tắt nếu bạn không can thiệp:
01class ThreadDeadlock
02{
03    static object syncObj1 = new object();
04    static object syncObj2 = new object();
05 
06    static void Main()
07    {
08        Thread t1 = new Thread(Foo);
09        Thread t2 = new Thread(Bar);
10 
11        t1.Start();
12        t2.Start();
13    }
14 
15    static void Foo()
16    {
17        Console.WriteLine("Inside Foo method");
18        lock (syncObj1)
19        {
20            Console.WriteLine("Foo: lock(syncObj1)");
21            Thread.Sleep(100);
22            lock (syncObj2)
23            {
24                Console.WriteLine("Foo: lock(syncObj2)");
25            }
26        }
27 
28    }
29    static void Bar()
30    {
31        Console.WriteLine("Inside Bar method");
32        lock (syncObj2)
33        {
34            Console.WriteLine("Bar: lock(syncObj2)");
35            Thread.Sleep(100);
36            lock (syncObj1)
37            {
38                Console.WriteLine("Bar: lock(syncObj1)");
39            }
40        }
41    }
42}
Output:
Inside Foo method
Foo: lock(syncObj1)
Inside Bar method
Bar: lock(syncObj2)
Với mỗi phương thức, tôi sử dụng Thread.Sleep(100) để thread chứa phương thức kia có thời gian thực thi trước khi phương thức này kết thúc. Và như kết quả bạn thấy, câu lệnh lock(syncObj2) của Foo() và lock(syncObj1) của Bar() sẽ không bao giờ được thực hiện vì hai đối tượng syncObj1 và syncObj2 đã bị khóa bởi hai thread khác nhau.
Để khắc phục tình trạng này bạn cần sử dụng lock cho các đối tượng theo thứ tự rõ ràng. Tuy nhiên điều này không thể khắc phục hoàn toàn với những chương trình tương đối phức tạp. Nếu dự án và mã nguồn của bạn không được tổ chức tốt, nguy cơ xảy ra rắc rối này rất cao và khó kiểm soát. Lời khuyên tốt nhất dành cho bạn là tổ chức chương trình rõ ràng, dễ quản lý và sử dụng những kĩ thuật đồng bộ cho phép quy định thời gian. Các kĩ thuật này sẽ được trình bày trong một bài viết khác.

Không có nhận xét nào: