Trong bài học này chúng ta sẽ làm quen với khái niệm luồng (thread) và các kỹ thuật lập trình đa luồng (multithreading programming) của .NET framework thường được sử dụng để phát triển các ứng dụng mạng.
Thread
Khái niệm thread
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ự (lời gọi) 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à scheduler (bộ lập lịch). Chuỗi lệnh mà scheduler 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).
Thread và Process
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) và ít nhất là một luồng thực thi cho các lệnh. “Không gian” dành cho chương trình như vậy được gọi là tiến trình (process).
Chúng ta phân biệt process và thread ở những điểm sau:
Thread tồn tại như một bộ phận của process. Trong một process phải có ít nhất một thread. Khi tạo ra một process thì cũng đồng thời tạo ra thread chính của process. Giao diện của một ứng dụng luôn do thread chính tạo ra.
Các process tồn tại biệt lập. Việc trao đổi thông tin giữa các process 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 thread tồn tại trong cùng một process cùng chia sẻ tài nguyên chung và các trạng thái của process (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 process giống như các chương trình ứng dụng khác nhau.
Mô hình đơn luồng (Single threading )
Mặc định trong mỗi process 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 process đượ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 socket đế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.
Các ứng dụng này có chung một đặc điểm: tại mỗi thời điểm, server chỉ có thể phục vụ duy nhất một client. Nếu số lượng client nhỏ và thời gian thực hiện công việc không dài, đây có thể không phải là một nhược điểm lớn. Tuy nhiên, nếu số lượng client tăng lên, hoặc khối lượng tính toán để phục vụ cho mỗi client tăng lên (ví dụ, khi truyền tải một file lớn), lập trình đơn luồng cho server là không phù hợp.
Đối với client, 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 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 đa luồng (Multithreading)
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 thread trong khuôn khổ một process. Các thread chia sẻ tài nguyên của process 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. Khả năng có lợi ích lớn khi phát triển chương trình client.
- Server có khả năng phục vụ đồng thời nhiều client.
- 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.
- Giảm mức tiêu thụ tài nguyên khi phục vụ đồng thời nhiều client.
- 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 thread 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 thread có thể phá hỏng cả process.
Thực hành: Xây dựng ứng dụng upload file
Ở phần này, chúng ta sẽ xây dựng một ứng dụng client/server đơn giản cho phép client upload file lên server. Ứng dụng này được thực hiện bằng kỹ thuật lập trình đơn luồng cơ bản đã học. Đây là ứng dụng cơ sở để phần sau chúng ta nâng cấp thành phần server bằng các kỹ thuật lập trình đa luồng.
Trong ứng dụng này chúng ta tạo ra một giao thức đơn giản, trong đó:
Để upload một file, client gửi cho server gói tin có định dạng như sau:
Server sau khi nhận hết dữ liệu sẽ gửi ngược lại client dòng ký tự “200 OK, thank you”.
Ứng dụng này sử dụng các kỹ thuật lập trình với luồng mạng và luồng file.
Chuẩn bị solution
- Tạo một solution trống với tên gọi TcpFileUploader.
- Trong solution tạo hai project (Console App) Client và Server.
- Thiết lập để debug đồng thời cả client và server.
Viết code cho Client và Server
using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; namespace Client { internal class Program { private static void Main(string[] args) { Console.Title = "File uploader client"; Console.Write("Server ip: "); var ip = IPAddress.Parse(Console.ReadLine()); var ep = new IPEndPoint(ip, 1308); while (true) { Console.Write("file > "); var path = Console.ReadLine(); if (File.Exists(path)) { var fileName = Path.GetFileName(path); var fileNameBytes = Encoding.UTF8.GetBytes(fileName); var fStream = File.OpenRead(path); var client = new TcpClient(); client.Connect(ep); var nsStream = client.GetStream(); var writer = new BinaryWriter(nsStream); writer.Write(fileNameBytes.Length); writer.Write(fStream.Length); writer.Write(fileNameBytes); var size = 512; var buffer = new byte[size]; var count = 0; while (true) { count = fStream.Read(buffer, 0, size); if (count == 0) { fStream.Close(); break; } nsStream.Write(buffer, 0, count); } nsStream.Flush(); var reader = new StreamReader(nsStream); var response = reader.ReadLine(); Console.WriteLine(response); client.Close(); } } } } }
using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; namespace Server { class Program { static readonly string _home = @"E:\TEMP"; static void Serve(TcpClient client) { var nsStream = client.GetStream(); var reader = new BinaryReader(nsStream); var fileNameLength = reader.ReadInt32(); var fileDataLength = reader.ReadInt64(); var fileNameBytes = reader.ReadBytes(fileNameLength); var fileName = Encoding.UTF8.GetString(fileNameBytes); Console.WriteLine($"File to receive: {fileName}"); Console.WriteLine($"Bytes to receive: {fileDataLength}"); var path = Path.Combine(_home, fileName); var fStream = File.OpenWrite(path); var length = 0L; var size = 512; var buffer = new byte[size]; while (length < fileDataLength) { var count = nsStream.Read(buffer, 0, size); fStream.Write(buffer, 0, count); length += count; } Console.WriteLine($"File saved as: {path}"); Console.WriteLine($"Bytes received: {fStream.Length}"); Console.WriteLine("-----------"); fStream.Close(); var writer = new StreamWriter(nsStream) { AutoFlush = true }; writer.WriteLine("200 OK, Thank you!"); client.Close(); } static void Main(string[] args) { Console.Title = "File uploader server"; var listener = new TcpListener(IPAddress.Any, 1308); listener.Start(10); while (true) { var client = listener.AcceptTcpClient(); Serve(client); } } } }
Các kỹ thuật lập trình đa luồng trong .NET
Trong khi áp dụng các kỹ thuật lập trình đa luồng dưới đây, chúng ta thực hiện thống nhất một logic. Ngay sau khi server tiếp nhận một liên kết tcp, liên kết này sẽ được chuyển sang một luồng riêng rẽ để thực hiện tải dữ liệu về server. Liên kết sẽ đóng lại sau khi hoàn thành nhiệm vụ tải file. Luồng chính của server chỉ làm nhiệm vụ tiếp nhận kết nối tcp từ client.
Như vậy, cứ mỗi client kết nối tới sẽ tạo ra một luồng riêng để phục vụ client này. Server ngay sau khi tạo luồng mới sẽ quay trở lại chờ tiếp nhận yêu cầu kết nối từ client khác. Bằng cách này, server có khả năng đồng thời phục vụ nhiều client.
.NET framework hỗ trợ ba phương pháp khác nhau cho lập trình đa luồng: sử dụng lớp ThreadPool, lớp BackgroundWorker, 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. Chúng ta sẽ điều chỉnh code của phương thức Main của Server để sử dụng ThreadPool như sau:
using System.Threading; ... static void Main(string[] args) { Console.Title = "File uploader server"; var listener = new TcpListener(IPAddress.Any, 1308); listener.Start(10); while (true) { var client = listener.AcceptTcpClient(); //Serve(client); ThreadPool.QueueUserWorkItem(Callback, client); } } // Xây dựng thêm phương thức Callback private static void Callback(object state) { var client = state as TcpClient; Console.WriteLine($"Starting a new thread: {Thread.CurrentThread.ManagedThreadId}"); Serve(client); }
Ở trên chúng ta bổ sung không gian tên using System.Threading;
, xây dựng phương thức mới private static void Callback(object state
), và sử dụng phương thức QueueUserWorkItem
của lớp ThreadPool
.
Chạy thử nghiệm server với hai client cùng lúc upload hai file cỡ lớn (1,4G và 1,8G).
Thread pool là một danh sách các luồng được .NET tạo sẵn và quản lý, giúp việc lập trình đa luồng đơn giản hết mức có thể. Do các thread trong thread pool được tạo sẵn, việc sử dụng các thread này rất đơn giản và hiệu quả. Các phiên bản đầu của .NET (32 bit) chỉ hỗ trợ 25 thread. Các phiên bản mới nhất của .NET (64 bit) hỗ trợ tới hàng chục ngàn thread.
- 1023 in Framework 4.0 (32-bit environment)
- 32767 in Framework 4.0 (64-bit environment)
- 250 per core in Framework 3.5
- 25 per core in Framework 2.0
Để chạy phương thức Callback
trong luồng mới, chỉ cần gọi ThreadPool.QueueUserWorkItem(Callback, client)
. Trong đó, client
là tham số cần truyền từ luồng chính sang phương thức ở luồng phụ. Phương thức Callback
phải có một tham số kiểu object
, là nơi chứa biến cần truyền từ luồng chính sang.
Lớp ThreadPool phù hợp nhất với các công việc dạng fire-and-forget, nghĩa là không cần quan tâm đến kết quả trả lại của phương thức.
Sử dụng BackgroundWorker
Chúng ta viết lại code của server để sử dụng lớp BackgroundWorker
using System.ComponentModel; using System.Threading; ... static void Main(string[] args) { Console.Title = "File uploader server"; var listener = new TcpListener(IPAddress.Any, 1308); listener.Start(10); while (true) { var client = listener.AcceptTcpClient(); //Serve(client); //ThreadPool.QueueUserWorkItem(Callback, client); var worker = new BackgroundWorker(); worker.DoWork += Worker_DoWork; worker.RunWorkerCompleted += Worker_RunWorkerCompleted; worker.RunWorkerAsync(client); } } private static void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { Console.WriteLine($"Thread execution completed: {e.Result}"); } private static void Worker_DoWork(object sender, DoWorkEventArgs e) { var client = e.Argument as TcpClient; Console.WriteLine($"Starting a new thread: {Thread.CurrentThread.ManagedThreadId}"); Serve(client); e.Result = Thread.CurrentThread.ManagedThreadId; }
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.
Sự kiện DoWork sẽ được kích hoạt khi chúng ta gọi phương thức RunWorkerAsync. 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ụ. Ngoài ra còn có 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ụ.
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ụ.
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(client)
. 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 TcpClient
.
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
Chúng ta viết lại code của server để sử dụng lớp Thread
using System.Threading; ... static void Main(string[] args) { Console.Title = "File uploader server"; var listener = new TcpListener(IPAddress.Any, 1308); listener.Start(10); while (true) { var client = listener.AcceptTcpClient(); //Serve(client); //ThreadPool.QueueUserWorkItem(Callback, client); //var worker = new BackgroundWorker(); //worker.DoWork += Worker_DoWork; //worker.RunWorkerCompleted += Worker_RunWorkerCompleted; //worker.RunWorkerAsync(client); var thread = new Thread(Start); thread.Start(client); } } private static void Start(object state) { var client = state as TcpClient; Console.WriteLine($"Starting a new thread: {Thread.CurrentThread.ManagedThreadId}"); Serve(client); }
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 nhưng cũng phức tạp nhất khi sử dụng. Chỉ nên sử dụng lớp Thread nếu có những yêu cầu đặc biệt về xử lý luồng.
Đối với những yêu cầu đơn giản (như bài toán truyền file của chúng ta), việc sử dụng Thread, BackgroundWorker hay ThreadPool không có sự khác biệt lớn.
Kết luận
Trong bài học này chúng ta đã cùng xem xét khái niệm luồng thực thi và một số kỹ thuật lập trình đa luồng trong .NET framework.
Các kỹ thuật này rất quan trọng khi xây dựng thành phần server. Chúng giúp server đồng thời xử lý nhiều connection, phục vụ cùng lúc nhiều client. Qua đó tăng hiệu suất của server.
Các kỹ thuật này cũng đóng vai trò quan trọng khi xây dựng client với giao diện đồ họa. Chúng giúp chương trình thân thiện với người dùng do không treo ứng dụng khi thực hiện các tác vụ kéo dài.
Dạ chị Mai Chi, chị giải đáp giúp em với ạ. Ý của mỗi dấu chấm trên mình sẽ làm như nào ạ