Lập trình đa luồng trong C#

    1

    Trong bài học này chúng ta sẽ cùng tìm hiểu chi tiết về mô hình lập trình đa luồng và các kỹ thuật lập trình đa luồng trong C#.

    Lập trình đa luồng là một kỹ thuật cho phép chạy nhiều công việc đồng thời trong một ứng dụng. Trong lĩnh vực phát triển phần mềm, lập trình đa luồng (multithreading) là một kỹ thuật quan trọng để tận dụng tối đa sức mạnh của hệ thống và tăng cường hiệu suất ứng dụng.

    Hãy cùng tìm hiểu chi tiết các vấn đề về lập trình đa luồng trong C#.

    Ví dụ

    Trước khi làm quen với các khái niệm, chúng ta sẽ cùng thực hiện một ví dụ nhỏ để hiểu được vì sao lại cần kỹ thuật lập trình đa luồng.

    Bước 1. Tạo một project kiểu Console App đặt tên là SingleThread trong solution đặt tên là Multithreading.

    Bước 2. Viết code cho Program.cs như sau:

    namespace SingleThread {
        internal class Program {
            static void Main(string[] args) {
                Task1();
                Task2();
                Task3();
                Console.ReadKey();
            }
            static void Task1() {
                for (int i = 0; i < 5; i++) {
                    Console.Write("Task1: {0}  ", i);
                    Thread.Sleep(500);
                }
                Console.WriteLine();
            }
            static void Task2() {
                for (int i = 0; i < 7; i++) {
                    Console.Write("Task2: {0}  ", i);
                    Thread.Sleep(500);
                }
                Console.WriteLine();
            }
            static void Task3() {
                for (int i = 0; i < 9; i++) {
                    Console.Write("Task3: {0}  ", i);
                    Thread.Sleep(500);
                }
                Console.WriteLine();
            }
        }
    }

    Chạy thử chương trình sẽ thu được kết quả như sau:

    Trong ví dụ nhỏ trên chúng ta xây dựng 3 phương thức Task1, Task2, Task3. Trong mỗi phương thức chúng ta giả lập một công việc xử lý mất nhiều thời gian bằng cách dừng thực thi trong 500ms rồi mới in giá trị ra màn hình. Trong phương thức Main chúng ta lần lượt gọi 3 phương thức trên theo thứ tự Task1 => Task2 => Task3.

    Qua ví dụ trên chúng ta có nhận xét như sau:

    • Các phương thức được thực hiện theo đúng thứ tự chỉ định trong code. Mặc dù nghe rất bình thường nhưng đây là một điểm rất quan trọng. Thứ tự này giúp chúng ta tự mình định hình cách thực thi của chương trình.
    • Phương thức thứ trước phải hoàn thành mới bắt đầu thực hiện phương thức tiếp theo. Đây là một đặc thù của mô hình lập trình có tên gọi là “mô hình đồng bộ”.

    Giả sử thời gian thực hiện T1 là t1 giây, với T2 là t2 giây, T3 là t3 giây. Khi nhiệm vụ T1 đã được bắt đầu thực hiện thì phải chờ T1 kết thúc, T2 mới được bắt đầu (sau t1 s). Một khi T2 đã bắt đầu thì phải chờ T2 kết thúc, T3 mới được bắt đầu (sau t1 + t2 s). Tổng thời gian thực hiện của cả ba nhiệm vụ là t1 + t2 + t3 s (xem hình dưới đây).

    Đây là mô hình (và tư duy) lập trình rất quen thuộc đối với chúng ta. Mô hình này được gọi là mô hình đồng bộ và đơn luồng.

    Khi nhìn lại code chúng ta thấy một vấn đề khác: Các phương thức Task1, Task2, Task3 hoàn toàn độc lập nhau. Giữa chúng không có mối quan hệ gì về dữ liệu, tức là không có dữ liệu nào chia sẻ, cũng không chờ đợi kết quả của nhau. Nếu để cho chúng phải chạy theo đúng mô hình đơn luồng đồng bộ thì lãng phí thời gian và khả năng của CPU. Nếu ứng dụng trên được tạo ra với giao diện đồ họa, trong suốt thời gian chạy, giao diện sẽ bị treo.

    Vì vậy, người ta hướng tới việc xây dựng chương trình theo một mô hình khác. Tuy nhiên, trước khi nói rõ về mô hình này, chúng ta phải làm quen với một số khái niệm.

    Luồng thực thi và tiến trình

    Khái niệm tiến trình

    Khi một chương trình được tải vào bộ nhớ và thực thi, hệ điều hành bố trí cho chương trình một “không gian” độc lập (và biệt lập). “Không gian” dành cho chương trình như vậy được gọi là tiến trình (process).

    Như vậy, có thể hình dung, từ khía cạnh lập trình, tiến trình chính là một chương trình đang thực thi.

    Từ khía cạnh hệ điều hành, một tiến trình có thể xem là một không gian địa chỉ riêng trong bộ nhớ. Không gian này giúp bảo mật cho các chương trình để chúng không thể tùy ý truy cập dữ liệu của nhau. Qua đó giúp các chương trình có thể bắt đầu và dừng độc lập với nhau, và độc lập với hoạt động của hệ điều hành.

    Khái niệm luồng thực thi

    Khi lập trình theo mô hình imperative hoặc hướng đối tượng, chúng ta xây dựng các phương thức và chỉ định trình tự (theo thời gian) thực hiện các phương thức đó.

    Trình tự thực hiện các phương thức khi lập trình như vậy được gọi là dòng điều khiển (flow of control) hoặc dòng thực thi (flow of execution). Dòng điều khiển cho phép chúng ta hình dung ra thứ tự thực hiện (và tự thực hiện trong đầu) các phương thức khi chương trình chạy.

    Khi chương trình thực sự chạy, hệ điều hành bố trí cho CPU lần lượt thực hiện các lệnh theo trật tự đã được chỉ định trong code. Việc bố trí này được thực hiện bởi một chương trình đặc biệt của hệ điều hành gọi là bộ lập lịch (scheduler). Chuỗi lệnh mà bộ lập lịch có thể quản lý độc lập được gọi là một luồng thực thi (thread of execution).

    Có thể hình dung luồng thực thi như là một người công nhân chuyên thực hiện các nhiệm vụ mà chương trình giao. Nếu có một công nhân thì tại mỗi thời điểm chỉ có thể thực hiện được một nhiệm vụ (đơn luồng). Nếu muốn thực hiện nhiều công việc cùng lúc thì phải thuê thêm công nhân (đa luồng).

    Tiến trình và luồng thực thi

    Chúng ta phân biệt tiến trình và luồng thực thi ở một số điểm.

    Luồng thực thi tồn tại như một bộ phận của tiến trình. Trong một tiến trình phải có ít nhất một luồng thực thi. Khi tạo ra một tiến trình thì cũng đồng thời tạo ra luồng thực thi chính của tiến trình. Giao diện của một ứng dụng luôn do luồng thực thi chính tạo ra.

    Các tiến trình tồn tại biệt lập. Việc trao đổi thông tin giữa các tiến trình khá khó khăn, ví dụ, phải thông qua dịch vụ mạng hoặc cơ chế truyền thông liên tiến trình của hệ điều hành. Trong khi đó, các luồng thực thi tồn tại trong cùng một tiến trình cùng chia sẻ tài nguyên chung và các trạng thái của tiến trình (như không gian bộ nhớ).

    Từ góc nhìn lập trình, các luồng có thể xem như là code của cùng một chương trình và có thể sử dụng chung biến với nhau, còn các tiến trình giống như các chương trình ứng dụng khác nhau.

    Mô hình đơn luồng và đa luồng

    Mô hình ứng dụng đơn luồng

    Mặc định trong mỗi tiến trình thường chỉ tạo ra một luồng thực thi. Luồng thực thi tạo ra đầu tiên cùng với tiến trình được gọi là luồng chính (main thread). Chỉ có một luồng thực thi đồng nghĩa với việc tại mỗi thời điểm chỉ có duy nhất một lệnh được thực hiện. Phương thức tiếp theo chỉ bắt đầu thực hiện khi phương thức trước đó kết thúc.

    Một ứng dụng chỉ sử dụng một luồng duy nhất được gọi là ứng dụng đơn luồng (single threading). Trong suốt quá trình học lập trình đến giờ (kể cả trong học lập trình căn bản) chúng ta đều chỉ xây dựng các ứng dụng theo mô hình đơn luồng.

    Giao diện của ứng dụng luôn do luồng chính “vẽ” ra. Khi phát ra các lệnh thực hiện trong thời gian dài (ví dụ, khi upload/download file lớn), giao diện có thể hoàn toàn bị treo. Điều này ảnh hưởng đến trải nghiệm của người dùng.

    Các CPU hiện đại đều có nhiều nhân. Các hệ điều hành hiện đại đều hỗ trợ đa nhiệm rất tốt. Điều này có nghĩa là về mặt phân cứng và hệ thống đều cho phép thực hiện song song nhiều công việc một lúc để tăng cường hiệu quả.

    Các lý do trên cùng với một vài vấn đề khác đưa tới yêu cầu phải xây dựng các chương trình với nhiều luồng thực thi để đảm bảo cùng thực hiện nhiều công việc đồng thời. Các ứng dụng có khả năng thực hiện cùng lúc nhiều công việc như vậy được gọi là ứng dụng đa luồng (multithreading).

    Mô hình ứng dụng đa luồng

    Mô hình ứng dụng đa luồng gặp chủ yếu trong các hệ điều hành đa nhiệm. Đây là mô hình lập trình và thực thi được sử dụng rất phổ biến. Mô hình này cho phép tạo ra và sử dụng nhiều luồng thực thi trong khuôn khổ một tiến trình. Các luồng thực thi chia sẻ tài nguyên của tiến trình nhưng lại có thể thực thi độc lập.

    So sánh với mô hình đơn luồng, mô hình này có nhiều ưu điểm:

    • Khả năng tương tác tốt hơn với người dùng do ứng dụng không bị “treo” khi thực hiện các nhiệm vụ kéo dài.
    • Thực thi nhanh hơn trên các hệ thống nhiều CPU hoặc nhiều lõi, vốn sử dụng phổ biến hiện nay.
    • Tận dụng tốt hơn khả năng của hệ thống.

    Ở chiều ngược lại, mô hình đa luồng cũng có những nhược điểm:

    • Vấn đề đồng bộ hóa khi nhiều luồng thực thi cùng chia sẻ không gian địa chỉ. Việc này có thể dẫn đến cạnh tranh tài nguyên, tình trạng deadlock và livelock (các luồng khóa lẫn nhau).
    • Lỗi từ một luồng thực thi có thể phá hỏng cả tiến trình.

    Các kỹ thuật lập trình đa luồng trong .NET

    .NET hỗ trợ ba kỹ thuật khác nhau cho lập trình đa luồng: sử dụng lớp ThreadPool, lớp BackgroundWorker, và lớp Thread.

    Sử dụng ThreadPool

    Sử dụng lớp ThreadPool là phương pháp đơn giản nhất để tạo luồng thực thi mới trong .NET.

    ThreadPool là một lớp có sẵn trong .NET, cung cấp một pool (bể) các luồng sẵn sàng để thực thi các công việc. Thay vì tạo ra và quản lý các luồng mới mỗi khi cần, lớp ThreadPool sử dụng lại các luồng có sẵn trong pool, giúp giảm bớt chi phí tạo và quản lý luồng.

    Lớp ThreadPool cung cấp một cơ chế tự động phân phối công việc (work items) cho các luồng có sẵn trong pool. Khi một công việc được gửi đến ThreadPool, nó sẽ tự động được giao cho một trong các luồng có sẵn để thực thi. Khi công việc hoàn thành, luồng sẽ trở lại pool và sẵn sàng nhận công việc mới.

    Hãy cùng thực hiện một ví dụ. Tạo một project mới thuộc loại Console App và viết code cho Program.cs như sau:

    using System;
    using System.Text;
    using System.Threading;
    class Program {
        static CountdownEvent countdownEvent = new CountdownEvent(10);
        static void Main() {
            Console.OutputEncoding = Encoding.Unicode;
            // Gửi các công việc tới ThreadPool
            for (int i = 0; i < 10; i++) {
                ThreadPool.QueueUserWorkItem(ProcessTask, i);
            }
            // Đợi cho tất cả các công việc hoàn thành
            countdownEvent.Wait();
            Console.WriteLine("Tất cả các công việc đã hoàn thành.");
            Console.ReadKey();
        }
        static void ProcessTask(object state) {
            int taskId = (int)state;
            Console.WriteLine("Công việc {0} đang được thực thi bởi luồng {1}.", taskId, Thread.CurrentThread.ManagedThreadId);
            // Giả lập thời gian xử lý công việc
            Thread.Sleep(1000);
            Console.WriteLine("Công việc {0} đã hoàn thành.", taskId);
            countdownEvent.Signal();
        }
    }
    

    Chạy chương trình chúng ta thu được kết quả như sau:

    Trong ví dụ trên, chúng ta sử dụng vòng lặp để gửi 10 công việc tới ThreadPool bằng cách sử dụng phương thức QueueUserWorkItem. Mỗi công việc được thực thi trong phương thức ProcessTask, được truyền vào một tham số chứa thông tin về công việc.

    Trong ví dụ này, chúng ta cũng sử dụng CountdownEvent để đếm số công việc cần chờ hoàn thành (trong trường hợp này là 10). Mỗi khi một công việc hoàn thành, chúng ta gọi phương thức Signal() của countdownEvent để giảm đếm. Khi tất cả các công việc đã hoàn thành và đếm đạt đến 0, chương trình sẽ tiếp tục thực hiện các dòng lệnh tiếp theo (in ra thông báo “Tất cả các công việc đã hoàn thành”).

    Kỹ thuật sử dụng lớp ThreadPool khá đơn giản:

    Bước 1. Tạo phương thức cần thực thi trong luồng phụ. Trong ví dụ trên, phương thức đó là:

    static void ProcessTask(object state) { ... }

    Phương thức này bắt buộc phải có tham số đầu vào thuộc kiểu object và kiểu trả về là void. Tham số kiểu object được sử dụng để trao đổi dữ liệu giữa lời gọi phương thức trên luồng chính với phương thức thực thi trên luồng phụ. Trong ví dụ trên, chúng ta truyền một số thứ tự sang phương thức chạy trên luồng phụ.

    Bước 2. Gọi phương thức ThreadPool.QueueUserWorkItem. Khi gọi phương thức này, chúng ta sẽ truyền tên phương thức cần chạy trên luồng phụ, đồng thời cung cấp đối tượng đầu vào mà phương thức này cần dùng.

    ThreadPool.QueueUserWorkItem(ProcessTask, i);

    Sau lời gọi này, .NET sẽ bố trí một luồng mới để thực thi phương thức.

    Lưu ý, ở vị trí của phương thức ProcessTask chúng ta có thể sử dụng cả hàm lambda và hàm cục bộ.

    Sử dụng BackgroundWorker

    Lớp BackgroundWorker được xây dựng theo mô hình hướng sự kiện (event-driven). Theo đó, các phương thức thực thi trong luồng mới được viết theo kiểu phương thức xử lý sự kiện.

    Hãy cùng thực hiện một ví dụ. Tạo một project mới thuộc loại Console App và viết code cho Program.cs như sau:

    using System;
    using System.ComponentModel;
    using System.Text;
    using System.Threading;
    class Program {
        static BackgroundWorker backgroundWorker;
        static void Main() {
            Console.OutputEncoding = Encoding.Unicode;
            // Khởi tạo và cấu hình lớp BackgroundWorker
            backgroundWorker = new BackgroundWorker();
            backgroundWorker.WorkerReportsProgress = true;
            backgroundWorker.DoWork += BackgroundWorker_DoWork;
            backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
            backgroundWorker.RunWorkerCompleted += BackgroundWorker_RunWorkerCompleted;
            // Bắt đầu thực thi công việc trong background
            backgroundWorker.RunWorkerAsync("Dữ liệu đầu vào");
            // Tiếp tục thực hiện các tác vụ khác trong luồng chính
            for (int i = 0; i < 5; i++) {
                Console.WriteLine("Thực hiện tác vụ trong luồng chính.");
                Thread.Sleep(1000);
            }
            Console.ReadKey();
        }
        static void BackgroundWorker_DoWork(object sender, DoWorkEventArgs e) {
            // Lấy dữ liệu công việc từ tham số
            string jobData = e.Argument as string;
            Console.WriteLine(jobData);
            // Thực hiện công việc dài hạn trong background
            for (int i = 0; i < 10; i++) {
                // Thông báo tiến trình
                backgroundWorker.ReportProgress(i * 10);
                // Giả lập thời gian xử lý công việc
                Thread.Sleep(1000);
            }
            // Kết quả của công việc được trả về
            e.Result = "OK";
            // Có thể sử dụng e.Cancel để kiểm soát việc hủy công việc
        }
        static void BackgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e) {
            // Xử lý thông báo tiến trình
            Console.WriteLine("Tiến trình trong luồng phụ: {0}%", e.ProgressPercentage);
        }
        static void BackgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
            // Xử lý khi công việc đã hoàn thành
            if (e.Error != null) {
                // Xử lý khi có lỗi xảy ra trong quá trình thực hiện công việc
            } else if (e.Cancelled) {
                // Xử lý khi công việc bị hủy
            } else {
                // Xử lý kết quả công việc
                string result = e.Result as string;
                Console.WriteLine("Hoàn thành với kết quả: " + result);
            }
        }
    }
    

    Kết quả chạy chương trình như sau:

    Lưu ý rằng lớp BackgroundWorker thuộc namespace System.ComponentModel, do đó cần phải thêm lệnh using tương ứng ở đầu file code để sử dụng lớp này.

    Trong ví dụ này, chúng ta sử dụng lớp BackgroundWorker để thực hiện một công việc dài hạn trong background và thông báo tiến trình của công việc cho luồng chính.

    • Sự kiện gắn với DoWork sẽ được kích hoạt khi chúng ta gọi phương thức RunWorkerAsync. Tất cả các phương thức gán cho sự kiện DoWork sẽ được thực thi ở trong luồng phụ.
    • Sự kiện RunWorkerCompleted được kích hoạt một khi hoàn thành thực thi toàn bộ code trên luồng phụ.
    • Sự kiện ProgressChanged được kích hoạt mỗi khi có sự thay đổi giá trị của một biến nào đó trên luồng phụ.

    BackgroundWorker cho phép truyền tham số cho phương thức thực thi ở luồng khác khi gọi worker.RunWorkerAsync(“Dữ liệu đầu vào”). Giá trị này được lấy ra trong thân phương thức (thực thi ở luồng phụ) bằng lệnh var client = e.Argument as string.

    Nên sử dụng BackgroundWorker nếu chúng ta muốn dễ dàng lấy kết quả thực hiện phương thức ở luồng phụ, hoặc muốn theo dõi sự biến đổi của biến trong luồng phụ. BackgroundWorker cũng rất phù hợp khi xây dựng client với công nghệ windows forms.

    Sử dụng lớp Thread

    Thread là lớp cơ bản nhất dùng để lập trình đa luồng trong .NET. Đây là class linh hoạt nhất vả cung cấp tất cả những gì cần thiết đối với xử lý luồng. Hãy cùng thực hiện ví dụ sau:

    using System;
    using System.Threading;
    class Program {
        static void Main() {
            // Khởi tạo và bắt đầu một luồng mới
            Thread thread = new Thread(WorkerMethod);
            thread.Start();
            // Tiếp tục thực hiện các tác vụ khác trong luồng chính
            for (int i = 0; i < 5; i++) {
                Console.WriteLine("Thực hiện tác vụ trong luồng chính.");
                Thread.Sleep(1000);
            }
            Console.ReadKey();
        }
        static void WorkerMethod() {
            // Mã xử lý được thực thi trong luồng mới
            for (int i = 0; i < 10; i++) {
                Console.WriteLine("Thực hiện tác vụ trong luồng con.");
                Thread.Sleep(1000);
            }
        }
    }
    

    Kết quả thực hiện chương trình như sau:

    Để tạo một luồng trong C#, chúng ta có thể sử dụng hàm tạo của lớp Thread và truyền vào một phương thức cần được thực thi trong luồng đó. Sau đó, gọi phương thức Start() để bắt đầu thực thi luồng. Trong ví dụ trên, phương thức cần thực thi trong luồng mới là WorkerMethod.

    Phương thức Start có thể nhận một đối tượng thuộc kiểu object làm tham số. Đối tượng này về sau có thể được sử dụng bởi phương thức trong luồng mới.

    Ngoài ra, lớp Thread cũng cung cấp các phương thức để quản lý các thuộc tính và trạng thái của luồng, bao gồm:

    • Join(): Chờ đợi cho luồng kết thúc thực thi trước khi tiếp tục thực hiện các tác vụ khác trong ứng dụng.
    • Sleep(): Ngừng thực thi của luồng trong một khoảng thời gian nhất định.
    • Abort(): Dừng thực thi của luồng.
    • IsAlive: Kiểm tra xem luồng đang hoạt động hay không.
    • Priority: Thiết lập hoặc truy xuất độ ưu tiên của luồng.

    Việc sử dụng lớp Thread trực tiếp có thể dẫn đến các vấn đề về đồng bộ hóa và hiệu suất trong ứng dụng. Luồng tạo ra bởi Thread có thời gian khởi tạo lâu hơn so với ThreadPool. Vì vậy, nếu không có những yêu cầu đặc biệt về xử lý luồng, chúng ta nên sử dụng ThreadPool. Nếu muốn áp dụng mô hình hướng sự kiện, hãy sử dụng BackgroundWorker.

    Một số lưu ý với lập trình đa luồng trong C#

    Thứ tự thực hiện phương thức

    Quay trở lại ví dụ Sample01 ban đầu và điều chỉnh code của Program.cs như sau:

    using System.Diagnostics;
    namespace Sample01 {
        internal class Program {
            static void Main(string[] args) {
                Stopwatch sw = new();
                sw.Start();
                //Task1();
                //Task2();
                //Task3();
                ThreadPool.QueueUserWorkItem(o => Task1());
                ThreadPool.QueueUserWorkItem(o => Task2());
                ThreadPool.QueueUserWorkItem(o => Task3());
                sw.Stop();
                Console.WriteLine("Total execution time: {0} ticks, {1} ms", sw.ElapsedTicks, sw.ElapsedMilliseconds);
                //ThreadPool.QueueUserWorkItem(o => Task1());
                //ThreadPool.QueueUserWorkItem(o => Task2());
                //ThreadPool.QueueUserWorkItem(o => Task3());            
                Console.ReadKey();
            }
            static void Task1() {
                Stopwatch sw = new();
                sw.Start();
                for (int i = 0; i < 7; i++) {                
                    Console.Write("Task1: {0}  ", i);                
                    Thread.Sleep(500);
                }
                sw.Stop();
                Console.WriteLine("Task 1 execution time: {0}", sw.ElapsedMilliseconds);
            }
            static void Task2() {
                Stopwatch sw = new();
                sw.Start();
                for (int i = 0; i < 5; i++) {                
                    Console.Write("Task2: {0}  ", i);                
                    Thread.Sleep(500);
                }
                sw.Stop();
                Console.WriteLine("Task 2 execution time: {0}", sw.ElapsedMilliseconds);
            }
            static void Task3() {
                Stopwatch sw = new();
                sw.Start();
                for (int i = 0; i < 9; i++) {                
                    Console.Write("Task3: {0}  ", i);                
                    Thread.Sleep(500);
                }
                sw.Stop();
                Console.WriteLine("Task 3 execution time: {0}", sw.ElapsedMilliseconds);
            }
        }
    }

    Trong mỗi phương thức chúng ta đặt thêm một đồng hồ tính giờ Stopwatch để đo thời gian thực hiện của mỗi phương thức. Trong phương thức Main, chúng ta cũng đặt một đồng hồ đếm giờ trước khi chạy các phương thức và đếm thời gian thực hiện tổng.

    Kết quả chạy chương trình như sau:

    Ở đây chúng ta thấy kết quả có chút khác thường:

    Thứ nhất, sau khi kích hoạt cả 3 phương thức ở 3 luồng phụ, đồng hồ đếm giờ báo tổng thời gian thực hiện có 0 ms. Chúng ta phải đếm số tick (1 tick = 1/2500000 giây) ra kết quả 2129 tick, rất nhỏ so với 1ms. Điều này có nghĩa là .NET gần như ngay lập tức kích hoạt cả 3 phương thức mà không mất thời gian.

    Thứ hai, chúng ta thấy các phương thức bắt đầu thực thi không giống như thứ tự kích hoạt, mà là Task1, Task3, Task2. Nếu chạy chương trình nhiều lần, chúng ta thậm chí thứ tự này còn biến đổi. Điều này không có nghĩa là các phương thức này được kích hoạt sai thứ tự, mà các phương thức bắt đầu thực thi với mốc thời gian khác nhau.

    Có thể giải thích điều này như sau: Khi .NET gặp yêu cầu chạy phương thức trên một luồng mới, nó sẽ kích hoạt luồng. Việc kích hoạt luồng bắt đầu ngay cùng với lời gọi ThreadPool.QueueUserWorkItem. Thứ tự kích hoạt luồng thì đúng theo trình tự chỉ trong code.

    Tuy nhiên, khi kích hoạt mỗi luồng nó cũng mất thời gian, tùy thuộc vào nhiều yếu tố. Khi một luồng được kích hoạt đầy đủ thì code mới bắt đầu chạy. Nhưng code chạy trên luồng đó lại có thể bắt đầu khác nhau, tùy tình trạng của luồng.

    Luồng chính và luồng phụ

    Khi mỗi tiến trình được tạo ra, hệ điều hành cũng đồng thời cung cấp cho nó một luồng thực thi. Luồng thực thi được tạo ra đầu tiên cùng với tiến trình như vậy được gọi là luồng thực thi chính.

    Giao diện của mỗi chương trình ứng dụng được “vẽ” bởi luồng thực thi chính này.

    Như vậy, nếu chương trình thực hiện một công việc nặng kéo dài, luồng thực thi chính sẽ không thể vẽ lại giao diện, dẫn đến tình trạng giao diện bị treo.

    Luồng thực thi chính quyết định vòng đời của ứng dụng. Khi luồng thực thi chính bị hủy bỏ cũng có nghĩa là tiến trình bị hủy bỏ.

    Ngoài luồng thực thi chính, chương trình cũng có thể yêu cầu tạo thêm các luồng thực thi phụ. Khi này chúng ta có môt ứng dụng đa luồng. Luồng thực thi phụ phụ thuộc vào luồng thực thi chính. Nếu hủy bỏ luồng chính thì các luồng phụ bị hủy bỏ theo.

    Khi sử dụng kỹ thuật lập trình đa luồng, các luồng phụ sinh ra phụ thuộc vào luồng chính. Khi luồng chính bị hủy thì các luồng phụ cũng bị hủy bỏ theo. Cũng vì vậy chúng ta không cần tự mình hủy bỏ các luồng thực thi phụ khi kết thúc chương trình.

    Sử dụng đa luồng với giao diện đồ họa

    Khi sử dụng kỹ thuật lập trình đa luồng với ứng dụng có giao diện đồ họa, .NET không cho phép code thực thi trên luồng phụ cập nhật điều khiển (được tạo ra và duy trì bởi luồng chính). Khi cố tình cập nhật điều khiển từ luồng phụ, chương trình sẽ bị lỗi khi thực thi, mặc dù không bị báo lỗi khi biên dịch. Trong trường hợp này, tùy vào công nghệ đang sử dụng sẽ có giải pháp khác nhau.

    Đối với ứng dụng Windows Forms, chúng ta cần sử dụng phương thức Invoke của Form để cập nhật dữ liệu. Cách sử dụng phương thức Invoke như sau:

    Invoke(()=> { //lệnh cập nhật giá trị điều khiển });

    Phương thức Invoke nhập tham số là một phương thức chứa lệnh cập nhật giá trị điều khiển. Thường chúng ta tạo ra một phương thức Lambda thuộc kiểu Action.

    Nếu sử dụng Windows Presentation Foundation (WPF), chúng ta có thể thu được hiệu quả tương tự với lớp Dispatcher như sau

    Dispatcher.Invoke(()=> { //lệnh cập nhật giá trị điều khiển });

    Phương thức Invoke của Dispatcher hoạt đồng tự tự như Invoke của Form.

    + Nếu bạn thấy site hữu ích, trước khi rời đi hãy giúp đỡ site bằng một hành động nhỏ để site có thể phát triển và phục vụ bạn tốt hơn.
    + Nếu bạn thấy bài viết hữu ích, hãy giúp chia sẻ tới mọi người.
    + Nếu có thắc mắc hoặc cần trao đổi thêm, mời bạn viết trong phần thảo luận cuối trang.
    Cảm ơn bạn!

    Kết luận

    Trong bài học này chúng ta đã làm quen với mô hình ứng dụng đa luồng và kỹ thuật lập trình trong C# .NET. Mô hình đa luồng phù hợp với những nhiệm vụ độc lập kéo dài để tăng hiệu suất của ứng dụng. Trong các kỹ thuật lập trình đa luồng được C# .NET hỗ trợ, ThreadPool và BackgroundWorker là hai kỹ thuật đơn giản và khuyến khích sử dụng.

    Theo dõi
    Thông báo của
    guest

    1 Thảo luận
    Cũ nhất
    Mới nhất
    Phản hồi nội tuyến
    Xem tất cả bình luận
    hiếu

    Mong các anh chị Admin ra thêm bài về async/await !!!