Trong bài học này chúng ta sẽ học cách vận dụng kỹ thuật lập trình bất đồng bộ (asynchronous programming) trong lập trình socket giúp tăng hiệu suất của server và tăng khả năng đáp ứng (responsiveness) của client.
Trong bài học trước chúng ta đã xem xét kỹ thuật lập trình đa luồng. Kỹ thuật này giúp chúng ta xây dựng chương trình server hiệu quả hơn và có thể phục vụ cùng lúc nhiều client. Sử dụng kỹ thuật này cũng giúp xây dựng client phản ứng tốt hơn với người dùng. Kỹ thuật đa luồng giúp client không bị treo khi thực hiện các tác vụ kéo dài.
Ngoài kỹ thuật đa luồng, trong lập trình socket cũng rất thường xuyên sử dụng kỹ thuật bất đồng bộ với cùng một mục tiêu.
Ví dụ về lập trình bất đồng bộ
Hãy cùng thực hiện một ví dụ nhỏ để hiểu cách lập trình bất đồng bộ sử dụng lớp Task.
Tạo một ứng dụng console và viết code cho file Program.cs như sau:
namespace ConsoleApp1; internal class Program { static void Main(string[] args) { Console.WriteLine("Hello, World!"); Task.Factory.StartNew(() => Log("This information need to be logged")); Console.WriteLine("Press enter to exit"); Console.ReadLine(); } static void Log(string message) { Thread.Sleep(5000); Console.WriteLine(message); } }
Đoạn code đơn giản trên bao gồm phương thức entry point Main và phương thức Log.
Phương thức Log() khi được gọi sẽ dừng luồng thực thi trong 5000ms rồi mới in ra thông điệp.
Trong phương thức Main, phương thức Log được gọi theo cách đặc biệt:
Task.Factory.StartNew(() => Log("This information need to be logged"));
Đây là cách thức cơ bản để tạo lời gọi phương thức bất đồng bộ với lớp Task.
Sau đó, Main sẽ in ra màn hình dòng thông báo “Press enter to exit” và dừng màn hình console chờ bấm phím enter.
Nếu chạy chương trình bạn sẽ thấy kết quả như sau:
Nếu để ý kỹ kết quả in ra màn hình bạn có thể thấy một điểm bất thường. Vốn dĩ, phương thức Task.Factory.StartNew được gọi trước. Theo tư duy thông thường, bạn có thể nghĩ rằng, phương thức Log sẽ dừng màn hình 5s, sau đó in ra thông điệp “This information need to be logged”, cuối cùng mới in ra thông báo “Press enter to exit”.
Tuy nhiên, trên màn hình Console thực tế lại in dòng thông báo “Press enter to exit” trước, qua 5 giây mới in thông điệp log. Trong khoảng thời gian 5 giấy đó, nếu bạn bấm Enter, chương trình sẽ thoát mà không cần chờ in ra thông điệp log.
Điều này có nghĩa là chương trình đã không hoạt động theo cách thức thông thường. Luồng thực thi không chờ phương thức hoàn thành mà ngay lập tức thực hiện các phương thức kế tiếp nó trong danh sách lệnh.
Mô hình lập trình ứng dụng như vậy có tên gọi là lập trình bất đồng bộ. Đây là một mô hình lập trình được sử dụng rất phổ biến trong phát triển ứng dụng mạng, cả cho server và client.
Khái niệm bất đồng bộ (Asynchronous Programming)
Mô hình đồng bộ (synchronous)
Trong mô hình lập trình quen thuộc của chúng ta, các công việc được thực hiện theo trật tự thời gian. Công việc sắp xếp trước thực hiện xong mới đến lượt công việc tiếp theo.
Giả sử, có ba công việc được sắp xếp theo trình tự là T1, T2, T3; 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.
Mô hình lập trình theo đó các công việc bắt đầu kết thúc theo đúng trình tự thời gian như trên được gọi là mô hình lập trình đồng bộ. Mô hình này đơn giản và phù hợp với cách suy nghĩ bình thường.
Trong mô hình đa luồng ở bài trước, mỗi luồng thực chất cũng là một chuỗi lệnh đồng bộ.
Mô hình bất đồng bộ (asynchronous)
Giả sử (vẫn 3 nhiệm vụ T1, T2, T3 như trên) bây giờ chúng ta không chờ T1 kết thúc mà bắt đầu luôn T2, ngay sau khi bắt đầu T2 chúng ta bắt đầu luôn T3.
Trong mô hình này, nhiệm vụ sau không phải chờ nhiệm vụ trước kết thúc nữa. Tổng thời gian thực hiện của ba nhiệm vụ không phải là t1 + t2 + t3 nữa mà chỉ nhỉnh hơn thời gian thực hiện nhiệm vụ dài nhất. Rõ ràng mô hình này có lợi thế hơn về thời gian thực hiện.
Mô hình trong đó các nhiệm vụ không phải tuân thủ theo trình tự thời gian như trên được gọi là mô hình bất đồng bộ (asynchronous). Như vậy mô hình bất đồng bộ cũng cho phép thực hiện song song nhiều nhiệm vụ cùng lúc.
Trong lập trình socket, mô hình bất đồng bộ được sử dụng rất phổ biến ở cả client và server. Đối với server, mô hình này cho phép xử lý đồng thời nhiều client và phát huy tốt khả năng xử lý song song của server. Đối với client, nó giúp chương trình không bị treo giao diện khi thực hiện các nhiệm vụ kéo dài.
Bất đồng bộ và đa luồng
Bất đồng bộ và đa luồng là hai khái niệm và mô hình khác nhau, mặc dù cùng hướng tới những mục tiêu tương tự. Mô hình bất đồng bộ có thể thực hiện trên một luồng hoặc trên nhiều luồng khác nhau, tùy từng nền tảng cụ thể.
Để dễ hình dung, hãy xem ví dụ sau.
Giả sử trong một nhà bếp có một người đầu bếp (rất chăm chỉ nhưng cũng rất máy móc :)). Bình thường, tại mỗi thời điểm anh ta chỉ nấu đúng một món. Nấu xong món này anh ta mới chuyển sang nấu món thứ hai. Đây là mô hình quen thuộc nhất của chúng ta: đồng bộ và đơn luồng.
Giả sử nhà hàng muốn tăng hiệu suất phục vụ khác, giờ có hai phương án: hoặc thuê thêm đầu bếp, hoặc bắt anh đầu bếp kia phải tối ưu hóa hoạt động của mình.
Phương án tăng thêm số đầu bếp giống như bổ sung thêm luồng phụ, tức là mô hình đa luồng. Phương án này có vấn đề. Khi có quá nhiều đầu bếp mà số dụng cụ nấu ăn không đổi sẽ dẫn đến tranh nhau dùng (cạnh tranh về tài nguyên). Thuê thêm đầu bếp cũng tốn kém cho nhà hàng.
Giờ yêu cầu anh đầu bếp như sau. Giả sử bắt đầu xào rau, vì rau cần một lúc mới chín, trong thời gian đó anh ta có thể chuyển qua xào thịt. Trong lúc xào thịt sẽ liên tục kiểm tra xem rau chín chưa. Nếu chính thì múc ra đĩa rồi quay lại xào thịt tiếp. Nếu trong lúc xào thịt mà có yêu cầu món mới thì lại tiếp tục bố trí thời gian chuyển qua lại giữa các món như vậy. Phương án này tương tự như lối suy nghĩ theo mô hình bất đồng bộ.
Hỗ trợ lập trình bất đồng bộ trong .NET
Mô hình bất đồng bộ có nhiều ưu điểm và được xây dựng trên hầu hết các nền tảng phát triển ứng dụng. Trên .NET framework có ba mô hình lập trình lập trình bất đồng bộ: APM, EAP, và TAP.
Mô hình APM (Asynchronous Programming Model) ra đời từ đầu cùng với những phiên bản .NET đầu tiên. Mô hình này sử dụng các cặp phương thức bất đồng bộ được xây dựng riêng, bao gồm một phương thức bắt đầu bằng Begin và một phương thức bắt đầu bằng End.
Mô hình EAP (Event-based Asynchronous Pattern) hoạt động dựa trên mô hình sự kiện. Mô hình EAP sử dụng các sự kiện (events) để thông báo cho người dùng khi một phương thức bất đồng bộ hoàn thành hoặc có lỗi xảy ra.
Mô hình TAP (Task-based Asynchronous Programming) xuất hiện sau cùng và thay đổi hoàn toàn cách lập trình bất đồng bộ. TAP được xây dựng trên bộ thư viện TPL (Task Parallel Library, dùng cho xử lý song song). TAP sử dụng hai từ khóa async và await để đánh dấu các phương thức bất đồng bộ và đợi kết quả của chúng. TAP là mô hình phổ biến nhất trong .NET hiện nay.
Mô hình APM (Asynchronous Programming Model)
Để nắm được kỹ thuật cơ bản của APM áp dụng trong lập trình socket, chúng ta cùng thực hiện lại bài tập lập trình socket Tcp cơ bản nhưng áp dụng kỹ thuật lập trình bất đồng bộ APM trên server.
using System; using System.Net; using System.Net.Sockets; using System.Text; namespace Client { internal class Program { private static void Main(string[] args) { Console.Title = "Tcp Client"; Console.Write("Server IP address: "); var serverIpStr = Console.ReadLine(); var serverIp = IPAddress.Parse(serverIpStr); var serverPort = 1308; var serverEndpoint = new IPEndPoint(serverIp, serverPort); var size = 1024; while (true) { Console.Write("# Text >>> "); var text = Console.ReadLine(); var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); socket.Connect(serverEndpoint); var sendBuffer = Encoding.ASCII.GetBytes(text); socket.Send(sendBuffer); socket.Shutdown(SocketShutdown.Send); var receiveBuffer = new byte[size]; var length = socket.Receive(receiveBuffer); var result = Encoding.ASCII.GetString(receiveBuffer, 0, length); socket.Close(); Console.WriteLine($">>> {result}"); } } } }
using System; using System.Net; using System.Net.Sockets; using System.Text; namespace Server { internal class Program { private static void Main(string[] args) { Console.Title = "Tcp Server"; var listener = new TcpListener(IPAddress.Any, 1308); listener.Start(); listener.BeginAcceptSocket(AcceptCallback, listener); // dừng màn hình Console.ReadLine(); } private static readonly int _size = 1024; private static readonly byte[] _buffer = new byte[_size]; private static void AcceptCallback(IAsyncResult ar) { var listener = ar.AsyncState as TcpListener; listener.BeginAcceptSocket(AcceptCallback, listener); var socket = listener.EndAcceptSocket(ar); socket.BeginReceive(_buffer, 0, _size, SocketFlags.None, ReceiveCallback, socket); } private static void ReceiveCallback(IAsyncResult ar) { var socket = ar.AsyncState as Socket; int count = socket.EndReceive(ar); var request = Encoding.ASCII.GetString(_buffer, 0, count); Console.WriteLine($"Received: {request}"); var response = request.ToUpper(); var buffer = Encoding.ASCII.GetBytes(response); socket.BeginSend(buffer, 0, buffer.Length, SocketFlags.None, SendCallback, socket); } private static void SendCallback(IAsyncResult ar) { var socket = ar.AsyncState as Socket; int count = socket.EndSend(ar); Console.WriteLine($"{count} bytes have been sent to client"); } } }
using System; using System.Net; using System.Net.Sockets; using System.Text; namespace Server { internal class Program { private static readonly TcpListener _listener = new TcpListener(IPAddress.Any, 1308); private static Socket _socket; private static void Main(string[] args) { Console.Title = "Tcp Server"; _listener.Start(); _listener.BeginAcceptSocket(AcceptCallback, null); // dừng màn hình Console.ReadLine(); } private static readonly int _size = 1024; private static readonly byte[] _buffer = new byte[_size]; private static void AcceptCallback(IAsyncResult ar) { _listener.BeginAcceptSocket(AcceptCallback, _listener); _socket = _listener.EndAcceptSocket(ar); _socket.BeginReceive(_buffer, 0, _size, SocketFlags.None, ReceiveCallback, null); } private static void ReceiveCallback(IAsyncResult ar) { int count = _socket.EndReceive(ar); var request = Encoding.ASCII.GetString(_buffer, 0, count); Console.WriteLine($"Received: {request}"); var response = request.ToUpper(); var buffer = Encoding.ASCII.GetBytes(response); _socket.BeginSend(buffer, 0, buffer.Length, SocketFlags.None, SendCallback, null); } private static void SendCallback(IAsyncResult ar) { int count = _socket.EndSend(ar); Console.WriteLine($"{count} bytes have been sent to client"); } } }
using System; using System.Net; using System.Net.Sockets; using System.Text; namespace Server { internal class Program { private static readonly TcpListener _listener = new TcpListener(IPAddress.Any, 1308); private static Socket _socket; private static void Main(string[] args) { Console.Title = "Tcp Server"; _listener.Start(); var ar = _listener.BeginAcceptSocket(null, null); AcceptCallback(ar); // dừng màn hình Console.ReadLine(); } private static readonly int _size = 1024; private static readonly byte[] _buffer = new byte[_size]; private static void AcceptCallback(IAsyncResult ar) { _listener.BeginAcceptSocket(AcceptCallback, _listener); _socket = _listener.EndAcceptSocket(ar); var iar = _socket.BeginReceive(_buffer, 0, _size, SocketFlags.None, null, null); ReceiveCallback(iar); } private static void ReceiveCallback(IAsyncResult ar) { int count = _socket.EndReceive(ar); var request = Encoding.ASCII.GetString(_buffer, 0, count); Console.WriteLine($"Received: {request}"); var response = request.ToUpper(); var buffer = Encoding.ASCII.GetBytes(response); var iar = _socket.BeginSend(buffer, 0, buffer.Length, SocketFlags.None, null, null); SendCallback(iar); } private static void SendCallback(IAsyncResult ar) { int count = _socket.EndSend(ar); Console.WriteLine($"{count} bytes have been sent to client"); } } }
Qua ví dụ trên chúng ta có thể để ý có một số phương thức của lớp Socket (và cả lớp TcpClient, TcpListener) được tổ chức thành cặp: BeginAcceptSocket/EndAcceptSocket, BeginReceive/EndReceive, BeginSend/EndSend. Đây là các cặp phương thức bất đồng bộ tương ứng của các phương thức (đồng bộ) AcceptSocket, Receive, Send mà chúng ta đã biết.
Các cặp phương thức bắt đầu bằng Begin/End như vậy là một đặc điểm nhận dạng của mô hình APM.
Nguyên lý chung của mô hình này nằm ở chỗ:
- Phương thức Begin sẽ bắt đầu quá trình bất đồng bộ;
- Khi quá trình bất đồng bộ kết thúc sẽ tự động gọi một phương thức được gọi chung là callback;
- Trong phương thức callback sẽ gọi tới phương thức End để lấy kết quả từ quá trình bất đồng bộ;
- Do quá trình gọi lẫn nhau của các phương thức như vậy, vòng lặp không thể sử dụng mà phải dùng đến đệ quy để tạo vòng lặp.
Kỹ thuật lập trình socket bất đồng bộ với mô hình EAP
Mô hình EAP (Event-based Asynchronous Pattern) là một mô hình lập trình bất đồng bộ khác trong .NET Framework. Mô hình này cho phép các lớp có tính năng bất đồng bộ tuân thủ một chuẩn thiết kế nhất quán và dễ sử dụng. Mô hình EAP sử dụng các sự kiện (events) để thông báo cho người dùng khi một phương thức bất đồng bộ hoàn thành hoặc có lỗi xảy ra. Mô hình EAP cũng cung cấp các cơ chế để hủy bỏ hoặc xử lý ngoại lệ của các phương thức bất đồng bộ.
Một số ưu điểm của mô hình EAP là:
- Giúp tăng hiệu suất và khả năng đáp ứng của ứng dụng khi thực hiện các tác vụ kéo dài.
- Giúp ẩn đi các vấn đề phức tạp liên quan đến thiết kế đa luồng.
- Giúp thống nhất cách triển khai và sử dụng các tính năng bất đồng bộ trong .NET Framework.
Một số ví dụ về các lớp sử dụng mô hình EAP trong .NET Framework là:
- System.ComponentModel.BackgroundWorker: Lớp này giúp thực hiện các tác vụ nặng trên một luồng nền và thông báo tiến trình và kết quả cho luồng giao diện người dùng. Lớp này đã được xem xét trong bài học về lập trình đa luồng.
- System.Net.WebClient: Lớp này giúp thực hiện các yêu cầu HTTP bất đồng bộ và trả về kết quả hoặc lỗi qua các sự kiện. Lớp này sẽ được xem xét trong bài học về giao thức HTTP.
Mặc dù EAP được xem là một kỹ thuật cũ, trong lập trình socket vẫn có thể tiếp tục khai thác kỹ thuật này thông qua lớp BackgroundWorker. Một điểm đặc biệt là, lớp BackgroundWorker đã được xem xét ở nội dung về lập trình đa luồng. Thực tế là, lớp BackgroundWorker thực hiện xử lý bất đồng bộ bằng cách khởi tạo một luồng mới và thực thi code trên luồng này. Vì vậy, BackgroundWorker đồng thời thực hiện xử lý đa luồng và bất đồng bộ.
Chúng ta cùng xem lại code sử dụng lớp BackgroundWorker đã viết ở bài học về lập trình đa luồng:
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; }
Trong mô hình EAP sử dụng bởi BackgroundWorker, chúng ta cần:
- Chỉ định các phương thức sẽ được thực thi bất đồng bộ bằng cách gắn chúng với sự kiện DoWork;
- Chỉ định các phương thức sẽ được gọi khi quá trình bất đồng bộ kết thúc bằng cách gắn chúng với sự kiện RunWorkerCompleted.
- Kích hoạt quá trình bất đồng bộ với phương thức RunWorkerAsync.
Kỹ thuật lập trình socket bất đồng bộ với mô hình TAP
TAP là mô hình lập trình bất đồng bộ phổ biến nhất hiện nay và là mô hình được khuyến khích sử dụng. Hầu hết các thư viện mới, nếu hỗ trợ bất đồng bộ, đều sử dụng mô hình TAP.
Thực hành
Để hiểu mô hình này, chúng ta hãy cùng thực hiện một bài thực hành nhỏ.
Bước 1. Tạo một solution với hai dự án: dự án dạng ConsoleApp đặt tên là Server; dự án dạng Windows Forms App đặt tên là ClientGui. Thiết lập để debug đồng thời cả dự án.
Bước 2. Viết code cho Server như sau:
using System.Net; using System.Net.Sockets; namespace Server; internal class Program { static void Main(string[] args) { Console.WriteLine("-- Server --"); Start(); Console.WriteLine("Server started."); Thread.Sleep(-1); } static async Task Start() { TcpListener listener = new(IPAddress.Any, 1308); Console.WriteLine("Server starting ..."); listener.Start(10); while (true) { TcpClient client = await listener.AcceptTcpClientAsync(); var ns = client.GetStream(); var sr = new StreamReader(ns); var sw = new StreamWriter(ns) { AutoFlush = true }; var request = await sr.ReadLineAsync(); Console.WriteLine($"{request}"); Thread.Sleep(5000); var response = $"[{DateTime.Now}] Thank you!"; await sw.WriteLineAsync(response); ns.Close(); } } }
Bước 3. Thiết kế giao diện cho Client như hình sau:
Thiết kế giao diện đơn giản này bao gồm một TextBox, một Button và một ListBox. Tất cả đều để tên mặc định (textBox1, button1, listBox1).
Bước 4. Mở file code của Form1 và viết code như sau:
using System.Net.Sockets; using System.Net; namespace ClientGui { public partial class Form1 : Form { public Form1() { InitializeComponent(); } private async void button1_Click(object sender, EventArgs e) { TcpClient client = new(); await client.ConnectAsync(IPAddress.Loopback, 1308); var ns = client.GetStream(); var sr = new StreamReader(ns); var sw = new StreamWriter(ns) { AutoFlush = true }; await sw.WriteLineAsync(textBox1.Text); var response = await sr.ReadLineAsync(); listBox1.Items.Add(response); ns.Close(); } } }
Lưu ý, phương thức button1_Click là phương thức xử lý sự kiện bấm nút của button1. Phương thức này sẽ được tự động sinh ra khi click đúp vào nút button1 trên giao diện thiết kế.
Bước 5. Dịch và chạy chương trình. Thiết lập để debug đồng thời cả hai dự án. Chạy chương trình (F5 hoặc Ctrl+F5) sẽ thu được kết quả chạy như sau:
Khi chạy chương trình chúng ta có thể để ý một số vấn đề sau:
- Khi gõ một chuỗi ký tự vào textBox1 và ấn button1, chuỗi ký tự này sẽ được chuyển sang server bằng dịch vụ TCP.
- Khi server nhận được truy vấn sẽ không trả lời ngay lập tức mà dừng lại 5 giây.
- Trong khoản thời gian chờ đợi này, giao diện client không bị treo. Chúng ta vẫn có thể tương tác với giao diện bình thường. Thậm chí chúng ta còn có thể gửi các truy vấn khác.
- Code client không cần thực hiện lời gọi Invoke để cập nhật điều khiển listBox1 với giá trị phản hồi mới.
Đặc điểm 3 và 4 là hai điểm rất quan trọng của mô hình bất đồng bộ TAP.
Mô hình bất đồng bộ TAP
Mô hình bất đồng bộ TAP dễ nhận biết bởi cặp từ khóa async / await.
Một phương thức có thể được gọi theo kiểu bất đồng bộ nếu nó có kiểu trả về là Task hoặc Task<T>, đồng thời sử dụng từ khóa await trước lời gọi phương thức đó.
await client.ConnectAsync(IPAddress.Loopback, 1308);
await sw.WriteLineAsync(textBox1.Text);
var response = await sr.ReadLineAsync();
Kiểu trả về của các phương thức bất đồng bộ có sự tương đồng với phương thức đồng bộ tương ứng. Ví dụ, nếu kiểu trả về đồng bộ là void thì kiểu trả về bất đồng bộ là Task; nếu kiểu trả về đồng bộ là string thì kiểu trả về bất đồng bộ tương ứng là Task<string>.
Lớp Socket và các lớp hỗ trợ của nó (TcpClient, TcpListener) đều xây dựng sẵn các phương thức bất đồng bộ TAP. Các phương thức này đều có hậu tố Async để phân biệt với các phương thức đồng bộ tương ứng. Khi gọi một phương thức theo kiểu bất đồng bộ với từ khóa await, phương thức chứa lời gọi này phải có từ khóa async để báo cho trình biên dịch biết rằng cần xử lý phương thức này theo kiểu bất đồng bộ.
static async Task Start() {
TcpListener listener = new(IPAddress.Any, 1308);
Console.WriteLine("Server starting ...");
listener.Start(10);
while (true) {
TcpClient client = await listener.AcceptTcpClientAsync();
…
}
}
Thứ tự lời gọi bất đồng bộ hoàn toàn tương tự với lời gọi đồng bộ. Do đó có thể dễ dàng chuyển đổi từ mô hình đồng bộ sang mô hình bất đồng TAP với những thay đổi nhỏ trong code. Về logic và cách tư duy nó gần giống như mô hình đồng bộ thông thường.
Như vậy có thể thấy lập trình với mô hình bất đồng bộ TAP đơn giản hơn rất nhiều so với mô hình APM và EAP. Tuy nhiên, TAP có sự khác biệt lớn với hai mô hình còn lại.
Khi một phương thức được gọi theo kiểu bất đồng bộ với từ khóa await, phương thức đó sẽ được thực thi thường bởi một luồng mới lấy từ threadpool. Do vậy, trong quá trình thực thi phương thức, luồng gọi không bị khóa. Sau khi phương thức bất đồng bộ hoàn thành, kết quả được tự động trả về luồng chính và phương thức tiếp theo sẽ được thực thi.
Như vậy, lối tư duy ở đây gần như là giống ở mô hình đồng bộ. Sự khác biệt chỉ là luồng gọi sẽ không bị khóa. Điều này thể hiện rất rõ trong phương thức xử lý sự kiện của button1 trong ví dụ trên. Khi client chờ phản hồi (do server luôn trả lời muộn sau 5 giây), luồng chính không bị khóa. Chúng ta có thể tiếp tục gửi truy vấn mới trong thời gian đó, hoặc có thể tương tác với giao diện đồ họa bình thường. Còn trong mô hình đồng bộ, trong 5 giây chờ đợi đó, giao diện đồ họa sẽ bị treo, và chúng ta sẽ không thể tương tác được với giao diện. Giờ hãy để ý thêm một vấn đề nữa:
static void Main(string[] args) {
Console.WriteLine("-- Server --");
StartAsync();
Console.WriteLine("Server started.");
Thread.Sleep(-1);
}
static async Task StartAsync() { … }
Hãy để ý rằng trong phương thức Main có lời gọi phương thức StartAsync nhưng không dùng từ khóa await. Chúng ta có thể để ý thấy rằng, ngay sau lời gọi StartAsync, phương thức WriteLine được gọi và in ra màn hình dòng “Server started”. Và nếu chúng ta bỏ đi dòng lệnh Thread.Sleep(-1) thì chương trình sẽ đóng lại ngay lập tức. Điều này rất giống cách biểu hiện của mô hình APM chúng ta đã xem xét.
Như vậy, ưu điểm lớn của mô hình TAP là cho phép chúng ta thực hiện lời gọi phương thức bất đồng bộ nhưng theo tư duy của lập trình đồng bộ (với từ khóa await), đồng thời không khóa luồng gọi. Từ đây có thể dễ dàng chuyển đổi code đồng bộ sang code bất đồng bộ.
Một ưu điểm nữa của mô hình TAP là kết quả luôn được trả về luồng gọi. Vì vậy, đối với ứng dụng với giao diện đồ họa, chúng ta không cần gọi phương thức Invoke để cập nhật giá trị của điều khiển.
Chúng ta cũng hoàn toàn có thể sử dụng mô hình TAP để thu được kết quả như mô hình APM bằng cách bỏ đi từ khóa await trước lời gọi bất đồng bộ. Khi đó luồng gọi sẽ không chờ kết quả thực hiện phương thức bất đồng bộ nữa mà tiếp tục thực thi hết những phương thức còn lại sau lời gọi bất đồng bộ.
Một ưu điểm lớn nữa của mô hình TAP là chúng ta có thể gọi bất kỳ phương thức nào theo kiểu bất đồng bộ. Chúng ta đã gặp trường hợp này trong ví dụ đầu bài học:
Task.Factory.StartNew(() => Log("This information need to be logged"));
Trong ví dụ này, Log là một phương thức đồng bộ thông thường. Chúng ta có thể gọi Log theo kiểu bất bất đồng bộ qua phương thức Task.Factory.StartNew. Từ .NET Framework 4.5 đưa thêm vào phương thức Task.Run để đơn giản hóa hơn nữa việc gọi phương thức theo kiểu bất đồng bộ. Mô hình APM và EAP thực tế cũng cho phép gọi phương thức theo kiểu bất đồng bộ nhưng phức tạp hơn rất nhiều. Đồng thời, tính năng này lại không thể áp dụng trên .NET mới. Vì vậy chúng ta chỉ có thể sử dụng được các phương thức đã xây dựng sẵn của APM và EAP. Điều này khiến hai mô hình này hạn chế hơn nhiều và không được khuyến khích sử dụng trên .NET mới.
Một số vấn đề cần lưu ý
Mô hình lập trình bất đồng bộ có nhiều ưu điểm so với mô hình đồng bộ. Tuy nhiên, không phải lúc nào cũng nên áp dụng mô hình bất đồng bộ cho mọi loại ứng dụng và cho mọi loại thao tác của chương trình. Nếu áp dụng không phù hợp có thể gây khó khăn cho chương trình và làm giảm hiệu suất. Sau đây chúng ta sẽ xem xét một số lưu ý cần cân nhắc khi áp dụng mô hình bất đồng bộ.
Những tình huống KHÔNG nên áp dụng bất đồng bộ
Khi ứng dụng khai thác một cơ sở dữ liệu không sử dụng vùng kết nối, bạn không nên áp dụng mô hình bất đồng bộ. Vùng kết nối (connection pool) là kỹ thuật cho phép tạo và duy trì một tập các kết nối dùng chung nhằm tăng hiệu suất cho các ứng dụng bằng cách sử dụng lại các kết nối khi có yêu cầu thay vì việc tạo kết nối mới. Khi sử dụng vùng kết nối, các kết nối được tạo ra trước đó sẽ được tái sử dụng khi có yêu cầu, thay vì việc tạo kết nối mới. Trong trường hợp máy chủ cơ sở dữ liệu không sử dụng dụng tính năng vùng kết nối, lời gọi bất đồng bộ tới cơ sở dữ liệu sẽ không có lợi ích gì, nhưng lại làm phức tạp code. Thực tế trong trường hợp này, dù là lời gọi đồng bộ hay bất đồng bộ đều như nhau. Vì vậy, hãy sử dụng lời gọi đồng bộ cho đơn giản.
Đối với các phương thức đơn giản và hoạt động trong thời gian ngắn, bạn không nên áp dụng kỹ thuật bất đồng bộ. Chúng ta cần xem xét thời gian thực hiện của mã lệnh ở mô hình đồng bộ. Nếu thời gian thực thi ngắn thì nên tiếp tục sử dụng mã đồng bộ. Bởi vì việc chuyển ứng dụng sang dạng bất đồng bộ mà hiệu suất tăng không đáng kể thì lợi ích thu được không xứng đáng với công sức bỏ ra. Ngoài ra, kỹ thuật bất đồng bộ cũng khiến code trở nên khó đọc, khó bảo trì.
Đối với các ứng dụng có nhiều tài nguyên được chia sẻ, bạn không nên áp dụng kỹ thuật bất đồng bộ. Tài nguyên được chia sẻ trong một ứng dụng thường là các biến toàn cục hoặc tệp hệ thống. Khi ứng dụng khai thác rất nhiều tài nguyên chia sẻ, bạn nên sử dụng mô hình đồng bộ. Trong trường hợp này, sử dụng kỹ thuật bất đồng bộ hoặc đa luồng thậm chí có thể làm giảm hiệu suất của chương trình.
Những tình huống NÊN áp dụng bất đồng bộ
Khi ghi nhật ký hoặc theo dõi hoạt động, bạn nên áp dụng lời gọi bất đồng bộ. Ghi nhật ký (Logging) và theo dõi hoạt động (Auditing) là những thao tác giúp ghi lại quá trình hoạt động của ứng dụng, như những sự kiện phát sinh, sự biến đổi của dữ liệu, người chịu trách nhiệm, thời gian xảy ra, v.v.. Các thao tác này không thuộc về tính năng nghiệp vụ nhưng lại rất quan trọng với quá trình vận hành của phần mềm. Nhật ký và theo dõi thường được ghi vào file hoặc vào cơ sở dữ liệu khi có bất kỳ sự thay đổi nào về dữ liệu hoặc sự kiện diễn ra. Các thao tác này thường mất thời gian. Nếu sử dụng lời gọi đồng bộ sẽ ảnh hưởng rất lớn để máy chủ hoặc trải nghiệm của người dùng. Khi sử dụng lời gọi bất đồng bộ, thao tác ghi vào file hoặc cơ sở dữ liệu sẽ không ảnh hưởng đến luồng thực thi bình thường.
Nếu bạn cần gọi tới các dịch vụ bên ngoài, hãy sử dụng lời gọi bất đồng bộ. Dịch vụ ngoài có thể là các dịch vụ do Web API cung cấp hoặc một hệ quản trị cơ sở dữ liệu. Khi gọi dịch vụ ngoài, điều khiển sẽ rời khỏi ứng dụng và đi đến hệ thống để thực hiện lời gọi dịch vụ mạng. Ứng dụng sẽ chuyển sang trạng thái bị khóa cho đến khi nhận được phản hồi từ hệ thống. Trong suốt thời gian chờ đợi kết quả thực hiện lời gọi dịch vụ ngoài, ứng dụng sẽ bị “treo” tạm thời. Điều này có nghĩa là giao diện sẽ không được làm mới, các thao tác khác sẽ không được thực hiện. Điều này sẽ làm giảm hiệu suất và khả năng đáp ứng của chương trình, cũng như ảnh hưởng tới trải nghiệm của người dùng. Vì vậy, hãy cố gắng gọi dịch vụ theo kiểu bất đồng bộ.
Nếu bạn muốn tạo ra tính đáp ứng cao của giao diện và tăng cường trải nghiệm của người dùng, hãy sử dụng mô hình bất đồng bộ. Như đã biết, giao diện ứng dụng được “vẽ” ra bởi luồng thực thi chính. Nếu thực hiện các thao tác mất nhiều thời gian như gọi tới dịch vụ ngoài hoặc gọi tới dịch vụ hệ thống, luồng thực thi chính sẽ bị khóa và giao diện bị treo. Để cải thiện trải nghiệm người dùng, hãy sử dụng mô hình bất đồng bộ trên các ứng dụng có giao diện đồ họa.
Kết luận
Trong bài học này chúng ta đã cùng xem xét khái niệm bất đồng bộ và một kỹ thuật lập trình bất đồng bộ trong .NET.
Bất đồng bộ cùng với đa luồng là hai kỹ thuật sử dụng phổ biến với cả client và server giúp ứng dụng mạng hoạt động hiệu quả hơn.