Thứ Hai, ngày 04 tháng 1 năm 2010

// // 1 comment

Giới thiệu về Thread - C#

1 thread là 1 chuỗi liên tiếp những sự thực thi trong chương trình. Trong 1 chương trình C# , chương trình khởi chạy bắt đầu bằng phương thức main() và tiếp tục cho đến khi kết thúc hàm main().

Cấu trúc này rất hay cho những chương trình có 1 chuỗi xác định những nhiệm vụ liên tiếp. Nhưng thường thì 1 chương trình cần làm nhiều hơn 1 công việc vào cùng một lúc. Ví dụ trong Internet Explorer khi ta đang tải 1 trang web thì ta nhấn nút back hay 1 link nào đó , để làm việc này Internet Explorer sẽ phải làm ít nhất là 3 việc cùng 1 lúc :
- Tải xuống các thành phần nội dung của trang web cùng với các tập tin đi kèm .
- Trình bày nội dung trang web
- Xử lí các thao tác người dùng

Để đơn giản vấn đề ta giả sử Internet Explorer chỉ làm 2 công việc :
- Trình bày nội dung trang web
- Xem người dùng có nhập gì không

Để thực hành việc này ta sẽ viết 1 phương thức dùng để tải xuống nội dung và thể hiện trang web. Giả sử rằng việc trình bày trang web mất nhiều thời gian ( do phải thi hành các đoạn javascript hay các hiệu ứng nào đó .. ). Vì thế sau một khoảng thời gian ngắn khoảng 1/12 giây, phương thức sẽ kiểm tra xem người dùng có nhập gì không. Nếu có thì nó sẽ được xử lí, nếu không thì việc trình bày trang sẽ được tiếp tục. và sau 1/12 giây việc kiểm tra sẽ được lặp lại.

Các ứng dụng với đa luồng

Trong ví dụ trên minh hoạ tình huống 1 ứng dụng cần làm nhiều hơn 1 công việc. Vì vậy giải pháp rõ ràng là cho ứng dụng thực thi nhiều luồng. Như đã nói trên, 1 luồng đại diện cho 1 chuỗi liên tiếp các lệnh mà máy tính thực thi. Do đó không có lí do nào 1 ứng dụng lại chỉ có 1 luồng. Thực vậy ta có thể có nhiều luồng nếu ta muốn. Tất cả điều cần là mỗi lần ta tạo 1 luồng thực thi mới, ta chỉ định 1 phương thức mà thực thi nên bắt đầu với. Luồng đầu tiên trong ứng dụng luôn được thực thi trong main() vì đây là phương thức mà .NET runtime luôn lấy để bắt đầu. Các luồng sau sẽ được bắt đầu bên trong ứng dụng.

Công việc này làm như thế nào ?

- Một bộ xử lí chỉ có thể làm một việc vào một lúc, nếu có một hệ thống đa xử lí , theo lí thuyết có thể có nhiều hơn một lệnh được thi hành đồng bộ, mỗi lệnh trên một bộ xử lí, tuy nhiên ta chỉ làm việc trên một bộ xử lí do đó các công việc không thể xảy ra cùng lúc. Thực sự thì hệ điều hành window làm điều này bằng một thủ tục gọi là pre emptive multitasking

- Thủ tục này nghĩa là window lấy 1 luồng vào trong vài tiến trình và cho phép luồng đó chạy 1 khoảng thời gian ngắn gọi là time slice. khi thời gian này kế thúc. Window lấy quyền điều khiển lại và lấy 1 luồng khác và lại cấp 1 khoảng thời gian time slice . Vì khoảng thời gian này quá ngắn nên ta có cảm tưởng như mọi thứ đều xảy ra cùng lúc.

- Tương tự như khi window của bạn khởi động nhiều hơn 1 tiến trình (chương trình ứng dụng) thì thủ tục này vẫn được áp dụng vì có thể có nhiều tiến trình khác đang chạy trên hệ thống mỗi tiến trình cần các time slice cho mỗi luồng của nó. Vì vậy khi ta có nhiều cửa sổ trên màn hình mỗi cái đại diện cho một tiến trình khác nhau, ta vẫn có thể nhấn vào bất kì của sổ nào và nó đáp ứng ngay lập tức. Thực sự việc đáp ứng này không phải ngay lập tức - nó xảy ra vào sau khoảng thời gian time slice của luồng đương thời.

Thao tác luồng

.Net framework cung cấp 1 thư viện riêng cho việc sử dụng và thao tác luồng nằm trong namespace System.Threading.

Khởi tạo 1 luồng

Giả sử rằng ta đang viết 1 trình biên tập hình ảnh đồ hoạ, và người dùng yêu cầu thay đổi độ sâu của màu trong ảnh. Khởi tạo 1 đối tượng luồng theo cú pháp sau :


      // entryPoint được khai báo trước là 1 delegate kiểu ThreadStart
      Thread depthChangeThread = new Thread(entryPoint);


Đoạn mã trên biểu diễn 1 hàm dựng của Thread 1 thông số chỉ định điểm nhập của 1 luồng. - đó là phương thức nơi luồng bắt đầu thi hành.trong tình huống này ta dùng thông số là delegate, 1 delegate đã được định nghĩa trong System.Threading gọi là ThreadStart :


        public delegate void ThreadStart();


Thông số ta truyền cho hàm dựng phải là 1 delegate kiểu void.

Ta bắt đầu luồng bằng cách gọi phương thức Thread.Start(), giả sử rằng ta có phương thức kiểu void  ChangeColorDepth() :


    void ChangeColorDepth()
    {
        // xử lí để thay đổi màu
    }

Như vậy ta sẽ có một luồng mới xử lí thay đổi màu như đoạn mã sau :


        ThreadStart entryPoint = new ThreadStart(ChangeColorDepth);
        Thread depthChangeThread = new Thread(entryPoint);
        depthChangeThread.Name = "Depth Change Thread";
        depthChangeThread.Start();


Sau điểm này, ta đang có hai luồng và cả hai sẽ chạy đồng bộ.

Trong mã này ta đặt tên cho luồng bằng cách dùng thuộc tính Thread.Name. Không cần thiết làm điều này nhưng nó có thể hữu ích trong một số trường hợp :

Lưu ý rằng bởi vì điểm đột nhập luồng ( trong ví dụ này là ChangeColorDepth() ) không thể lấy bất kì thông số nào. Ta sẽ phải tìm 1 cách nào đó để truyền tham số cho phương thức nếu cần. Cũng vậy phương thức không thể trả về bất cứ thứ gì .

Mỗi lần ta bắt đầu 1 luồng khác, ta cũng có thể tạm hoãn, hồi phục hay chấm dứt. Tạm hoãn nghĩa là cho luồng đó ngủ ( sleep) - nghĩa là không chạy trong 1 khoảng thời gian. Sau đó nó thể đưọc phục hồi, nghĩa là trả nó về thời diểm mà nó bị tạm hoãn lại. Nếu luồng bị chấm dứt window sẽ huỷ tất cả dữ liệu mà liên hệ đến luồng đó, để luồng không thể được bắt đầu lại.

Một số thao tác luồng :


        // Tạm hoãn
        depthChangeThread.Suspend();
        // Khôi phục
        depthChangeThread.Resume();
        // Chấm dứt (hủy bỏ)
        depthChangeThread.Abort();
        // Tạm dừng và chờ đợi cho đến khi luồng kết thúc
        depthChangeThread.Join();



Phương thức Suspend() có thể không làm cho luồng bị định chỉ tức thời mà có thể là sau 1 vài lệnh, điều này là để luồng được đình chỉ an toàn. đối với phương thức Abort() nó kết thúc luồng bằng cách ném ra ngoại lệ ThreadAbortException. Sau khi ThreadAbortException được ném ra chương trình chấm dứt hoàn hoàn sau thời điểm đó nếu không được xử lý bằng khối lệnh try-catch-finally.

Join() cũng có 1 số overload khác chỉ định thời gian đợi, nếu hết thời gian này việc thi hành sẽ được tiếp tục.

Nếu một luồng chính muốn thi hành 1 vài hành động trên nó, nó cần 1 tham chiếu đến đối tượng luồng mà đại diện cho luồng riêng. Nó có thể lấy 1 tham chiếu sử dụng thuộc tính static, CurrentThread ,của lớp Thread :


Thread myOwnThread = Thread.CurrentThread;


Có hai cách khác nhau mà ta có thể thao tác lớp Thread:

- Ta có thể khởi tạo 1 đối tượng luồng, mà sẽ đại diện cho luồng đang chạy và các thành viên thể hiện của nó áp dụng đến luồng đang chạy.

- Ta có thể gọi 1 số phương thức static. Những phương thức này sẽ áp dụng đến luồng mà ta thực sự đang gọi phương thức từ nó.

Xem ví dụ đại diện sau:


    static void DisplayNumbers()

    {
        Thread thisThread = Thread.CurrentThread;
        thisThread.Name = "Main Thread";
        string name = thisThread.Name;
        Console.WriteLine("Starting thread: ", name);
        Console.WriteLine("Current Culture: ", thisThread.CurrentCulture);

        ThreadStart workerStart = new ThreadStart(StartMethod);

        Thread workerThread = new Thread(workerStart);
        workerThread.Name = "Worker";
        workerThread.Start();

        Console.WriteLine("Main Thread Finished");
        Console.ReadLine();
    }


Trong phương thức main() ta thực hiện lấy thông tin luồng hiện tài và đặt tên cho nó.

Kế tiếp đó ta tạo ra luồng công việc mới, đặt tên, và bắt đầu nó, truyền cho nó 1 delegate mà chỉ định phương thức mà nó phải bắt đầu trong đó,  phương thức workerStart. cuối cùng , ta gọi phương thức DisplayNumber() để bắt đầu đếm. Delegate ham chiếu của luồng này là :


        static void StartMethod()
        {
            DisplayNumbers();
            Thread.Sleep(1000);

            Console.WriteLine("Worker Thread Finished");
        }


Vấn đề ở đây là việc bắt đầu 1 luồng là 1 tiến trình lớn .sau khi khởi tạo 1 luồng mới ,luồng chính gặp dòng mã :

            workerThread.Start();

Phương thức này thông báo cho window rằng luồng mới được bắt đầu, sau đó trả về ngay lập tức.

Ưu tiên luồng

Ta có thể đăng kí các độ ưu tiên khác nhau cho các luồng khác nhau trong 1 tiến trình.Nói chung, 1 luồng không đưọc cấp phát 1 time slice nào nếu có 1 luồng có độ ưu tiên cao hơn đang làm việc. Lợi điểm của điều này là ta có thể thiết lập độ ưu tiên cao hơn cho luồng xử lí việc nhập của người dùng.

Các luồng có độ ưu tiên cao có thể cản trở các luồng có độ ưu tiên thấp cho đó ta cần thận trọng khi cấp quyền ưu tiên . độ ưu tiên của luồng được định nghĩa là các giá trị trong bản liệt kê ThreadPriority. Các giá trị : Highest, AboveNormal, Normal, BelowNormal, Lowest

Lưu ý rằng mỗi luồng có 1 độ ưu tiên cơ sở. Và những giá trị này liên quan đến độ ưu tiên trong tiến trình. cho 1 luồng có độ ưu tiên cao hơn đảm bảo nó sẽ chiếm quyền ưu tiên so với các luồng khác trong tiến trình. nhưng cũng có 1 số luồng khác của hệ thống đang chạy có quyền ưu tiên còn cao hơn . Windows có khuynh hướng đặt độ ưu tiên cao cho các luồng hệ điều hành của riêng nó.

Ta có thay đổi độ ưu tiên của luồng bằng cách thay đổi giá trị Priority.


        ThreadStart workerStart = new ThreadStart(StartMethod);
        Thread workerThread = new Thread(workerStart);
        workerThread.Name = "Worker";
        workerThread.Priority = ThreadPriority.AboveNormal;
        workerThread.Start();


Đồng bộ hóa

1 khía cạnh chủ yếu khác của luồng là sự đồng bộ hay là việc truy nhập 1 biến bởi nhiều luồng vào cùng thời điểm. Nếu ta không đảm bảo được sự đồng bộ thì sẽ gây ra các lỗi khó kiểm soát.

Đồng bộ là gì ?

Hãy nhìn câu lệnh sau :

message = “ there”; // message là 1 chuỗi chứa chữ “hello”

Trông có vẻ như là 1 lệnh nhưng thực sự thì lệnh phải thi hành nhiều thao tác khi thực thi câu lệnh này. bộ nhớ sẽ cần được cấp phát để lưu trữ 1 chuỗi dài hơn, biến message sẽ cần được tham chiếu đến vùng nhớ mới, chuỗi thực sự cần được sao chép ..

Trong tính huống 1 câu lệnh đơn có thể được phiên dịch thành nhiều lệnh của mã máy, có thể xảy ra trường hợp time slice của luồng đang xử lí các lệnh trên kết thúc. Nếu điều này xảy ra , 1 luồng khác trong cùng tiến trình có thể nhận time slice và nếu việc truy nhập vào biến có liên quan đến câu lệnh trên ( ví dụ như biến message ở trên ) không được đồng bộ, thì luồng khác có thể đọc và viết vào cùng 1 biến , với ví dụ trên liệu luồng khác đó sẽ thấy giá trị mới hay cũ của biến message ?

Thật may mắn là C# cung cấp 1 cách thức dễ dàng để giải quyết việc đồng bộ trong việc truy nhập biến bằng từ khóa lock :


        object x = new object();
        lock (x)
        {
            DoSomething();
        }

Câu lệnh lock sẽ bao 1 đối tượng gọi là mutual exclusion lock hay mutex. Trong khi mutex bao 1 biến,thì không 1 luồng nào được quyền truy nhập vào biến đó. Trong đoạn mã trên khi câu lệnh hợp thực thi và nếu time slice của luồng này kết thúc và luồng kế tiếp thử truy xuất vào biến x, việc truy xuất đến biến sẽ bị từ chối. Thay vào đó window đơn giản đặt luồng đó ngủ cho đến khi mutex được giải phóng.

Mutex là 1 cơ chế đơn giản được dùng để điều khiển việc truy nhập vào các biến. Tất cả việc điều khiển này nằm trong lớp System.Threading.Monitor. Câu lệnh lock là một cách viết tắt cho 1 số phương thức gọi đến lớp này .

Các vấn đề đồng bộ

Việc đồng bộ các luồng là quan trọng trong các ứng dụng đa luồng. Tuy nhiên có 1 số lỗi khó kiểm soát và khó thăm dò có thể xuất hiện cụ thể là deadlock và race condition.

Deadlock

Deadlock là 1 lỗi mà có thể xuất hiện khi hai luồng cần truy nhập vào các tài nguyên mà bị khoá lẫn nhau. giả sử 1 luồng đang chạy theo đoạn mã sau , trong đó a, b là 2 đối tượng tham chiếu mà cả hai luồng cần truy nhập :


        lock (a)
        {
            // do something
            lock (b)
            {
                // do something
            }
        }



Vào cùng lúc đó 1 luồng khác đang chạy :


        lock (b)
        {
            // do something
            lock (a)
            {
                // do something
            }
        }


Có thể xảy ra biến cố sau: Luồng đầu tiên yêu cầu 1 lock trên a, trong khi vào cùng thời điểm đó luồng thứ hai yêu cầu lock trên b. 1 khoảng thời gian ngắn sau , luồng a gặp câu lệnh lock(b) , và ngay lập tức bước vào trạng thái ngủ, đợi cho lock trên b được giải phóng . và tương tự sau đó , luồng thứ hai gặp câu lệnh lock(a) và cũng rơi vào trạng thái ngủ chờ cho đến khi lock trên a được giải phóng . không may , lock trên a sẽ không bao giờ được giải phóng bởi vì luồng đầu tiên mà đã lock trên a đang ngủ và không thức dậy . cho đến khi lock trên b được giải phóng điều này cũng không thể xảy ra cho đến khi nào luồng thứ hai thức dậy . kết quả là deadlock. cả hai luồng đều không làm gì cả, đợi lẫn nhau để giải phóng lock . loại lỗi này làm toàn ứng dụng bị treo , ta phải dùng Task Manager để hủy nó .

Deadlock có thể được tránh nếu cả hai luồn yêu cầu lock trên đối tượng theo cùng thứ tự . trong ví dụ trên nếu luồng thứ hai yêu cầu lock cùng thứ tự với luồng đầu , a đầu tiên rồi tới b thì những luồng mà lock trên a đầu sẽ hoàn thành nhiệm vụ của nó sau đó các luồng khác sẽ bắt đầu.

Race condition

Race condition là 1 cái gì đó tinh vi hơn deadlock .nó hiếm khi nào dừng việc thực thi của tiến trình , nhưng nó có thể dẫn đến việc dữ liệu bị lỗi .nói chung nó xuất hiện khi vài luồng cố gắng truy nhập vào cùng 1 dữ liệu và không quan tâm đến các luồng khác làm gì để hiểu ta xem ví dụ sau :

Giả sử ta có 1 mảng các đối tượng, mỗi phần tử cần được xử lí bằng 1 cách nào đó, và ta có 1 số luồng giữa chúng làm tiến trình này. Ta có thể có 1 đối tuợng gọi là ArrayController chứa mảng đối tượng và 1 số int chỉ định số phẩn tử được xử lí.

        int GetObject(int index)
        {
            // trả về đối tượng với chỉ mục được cho
        }

        // chỉ định bao nhiêu đối tượng được xử lí
        int ObjectsProcessed
        {
            get;
            set;
        }

Bây giờ mỗi luồng mà dùng để xử lí các đối tượng có thể thi hành đoạn mã sau :

        lock(ArrayController)
        {
            int nextIndex = ArrayController.ObjectsProcessed;
            Console.WriteLine("object to be processed next is ", nextIndex);

            ArrayController.ObjectsProcessed;
            object next = ArrayController.GetObject();
        }
        ProcessObject(next);

Nếu ta muốn tài nguyên không bị giữ quá lâu , ta có thể không giữ lock trên ArrayController trong khi ta đang trình bày thông điệp người dùng. Do đó ta viết lại đoạn mã trên :


        lock(ArrayController)

        {
            int nextIndex = ArrayController.ObjectsProcessed;
        }

        Console.WriteLine("object to be processed next is ", nextIndex);
        lock(ArrayController)

        {
            ArrayController.ObjectsProcessed;
            object next = ArrayController.GetObject();
        }
        ProcessObject(next);


Ta có thể gặp 1 vấn đề . nếu 1 luồng lấy 1 đối tưọng ( đối tượng thứ 11 trong mảng) và đi tới trình bày thông điệp nói về việc xử lí đối tượng này . trong khi đó luồng thứ hai cũng bắt đầu thi hành cũng đoạn mã gọi ObjectProcessed, và quyết định đối tượng xử lí kế tiếp là đối tượng thứ 11, bởi vì luồng đầu tiên vẫn chưa được cập nhật .

ArrayController.ObjectsProcessed trong khi luồng thứ hai đang viết đến màn hình rằng bây giờ nó sẽ xử lí đối tượng thứ 11 , luồng đầu tiên yêu cầu 1 lock khác trên ArrayController và bên trong lock này tăng ObjectsProcessed. không may , nó quá trễ . cả hai luồng đều đang xử lí cùng 1 đối tượng và loại tình huống này ta gọi là Race Condition

1 comments:

Nặc danh nói...

Bài viết rất hay. CÁm ơn bạn