Lập trình bất đồng bộ asynchronous

Từ .NET Framework 4.5 nó thêm vào thư viện có tên Task Parallel Library (TPL) - TPL giúp lập trình chạy song song (đa luồng) dễ dàng hơn. Trong C# đồng thời thêm vào hai từ khóa là asyncawait, đây là hai từ khóa chính để sử dụng trong lập trình bất đồng bộ.

Lập trình bất đồng bộ (asynchronous) là một phương thức mà khi gọi nó chạy ở chế độ nền (liên quan đến một tiến trình, task), trong khi đó tiến trình gọi nó không bị khóa - block. Trong .NET có triển khai một số mô hình lập trình bất đồng bộ như Asynchronous pattern, mẫu bất đồng bộ theo sự kiện và theo tác vụ (TAP - task-based asynchronous pattern)

Phần này sẽ nói về TAP - task-based asynchronous pattern - mộ hình lập trình bất đồng bộ thông dụng trên .NET hiện nay.

Lập trình đồng bộ synchronous

Bình thường, khi lập trình gọi một phương thức nào đó thì phương thức đó chạy và kết thúc thì các dòng code tiếp theo sau lời gọi phương thức đó mới được thực thi, đó là chạy đồng bộ, có nghĩa là thread gọi phương thức bị khóa lại cho đến khi phương thức kết thúc.

Thử xem ví dụ đơn giản sau:

using System;
using System.Net;
using System.Threading;
namespace CS21_ASYNCHRONOUS
{
    class Program
    {
        static string DownloadWebpage(string url, bool showresult) {
            using (var client = new WebClient())
            {
                Console.Write("Starting download ...");
                string content = client.DownloadString(url);
                Thread.Sleep(3000);
                if (showresult)
                    Console.WriteLine(content.Substring(0, 150));

                return content;
            }
        }

        static void Main(string[] args)
        {
            string url = "https://code.visualstudio.com/";
            DownloadWebpage(url, true);
            Console.WriteLine("Do somthing ...");
        }
    }
}

Phương thức DownloadWebpage sử dụng lớp WebClient để tải về một trang web, trả về nội dung trang.

Khi chạy, thì lời gọi DownloadWebpage(url, true);, phương thức này thi hành xong thì dòng code Console.WriteLine("Do somthing ..."); mới được thi hành.

Vấn đề là khi DownloadWebpage(url, true); chạy, nó sẽ khóa thread gọi nó, làm cho các dòng code tiếp theo phải chờ, nếu hàm đó thi hành mất nhiều thời gian - trong khi tài nguyên vẫn đủ để làm các việc khác - thì chương trình vẫn cứ phải chờ phương thức trên kết thúc thì mới thi hành được tác vụ khác - đặt biệt là khi gọi phương thức trong các tiến trình UI, giao diện người dùng không tương tác được.

Để giải quyết vấn đề này, trong khi chờ cho DownloadWebpage(url, true); thi hành xong, chương trình vẫn thi hành được các tác vụ khác thì cần đến kỹ thuật lập trình bất đồng bộ (trước đây gọi là lập trình đa tiến trình, đa luồng)

Lớp Task

Lớp Task nó biểu thị tác vụ bất đồng bộ, nếu tác vụ bất đồng bộ đó thi hành xong có kiểu trả về thì dùng Task<T>, để sử dụng nó nhớ dùng namespace

using System.Threading;
using System.Threading.Tasks;
Ta sẽ làm một ví dụ đơn giản, sử dụng Task tạo ra các 2 tiến trình con chạy đồng thời:
public static void WriteLine(string s, ConsoleColor color) {
    Console.ForegroundColor = color;
    Console.WriteLine(s);
}

// Tạo và chạy Task, sử dụng delegate Func
static void Async1(string thamso1, string thamso2)
{
    Func<object, string> myfunc = (object thamso) => {
        dynamic ts = thamso;  // xem thêm kiểu  động - dynamic
        for (int i = 1; i <= 50; i++)
        {
            //  Thread.CurrentThread.ManagedThreadId  trả về ID của thread đạng chạy 
            WriteLine($"{Thread.CurrentThread.ManagedThreadId,3} {ts.x, 10} {i,5} {ts.y}", ConsoleColor.Green);
            Thread.Sleep(500);
        }
        return $"Kết thúc! {ts.x}";
    };
    Task<string> task = new  Task<string>(myfunc, new {x = thamso1, y = thamso2});
    task.Start();   // tạo và chạy thread
}

// Tạo và chạy Task, sử dụng delegate Action
static void Async2() {

    Action myaction = () => {
        for (int i = 1; i <= 50; i++)
        {
            WriteLine($"{Thread.CurrentThread.ManagedThreadId,3} {"myaction", 10} {i,5}", ConsoleColor.Yellow);
            Thread.Sleep(2000);
        }
    };
    Task task = new Task(myaction);
    task.Start();   // tạo và chạy thread
}

static void Main(string[] args)
{
    Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId,3} MainThread");

    Async1("myfunc  ", "...");          // chạy thread
    Async2();                           // chạy thread

    Console.ReadKey();                 // Thread chính chờ người dùng bấm phím để kết thúc, trong khi 2 thread trên  vẫn đang  chạy
                                       // Nếu bấm phím, hàm  Main  kết  thúc, thread chính kết  thúc dẫn đến 2 thread con trên cũng  kết  thúc
    Console.ResetColor();
}

Để khởi tạo một Task bạn cần tham số là một delegate (Func hoặc Action) - nên cần nắm rõ về delegate Func và Action trước.

Sử dụng Task<T>Func<T,V> : khi khởi tạo một task cần tham số là một delegate, khi task chạy xong thì có kết quả trả về nên dùng đến delegate dạng Func<T,V>, đầu tiên tạo Delegate dạng này với cú pháp như sau

Func<object, return_type> func = (object thamso) => {
    // code ...
    return ...;
};

Sau đó tạo Task với cú pháp

Task<return_type> task = new  Task<return_type>(func, thamso);

Khi đã có Task, gọi phương thức Start() của nó để bắt đầu chạy thread, một thread mới bất đồng bộ sẽ khởi chạy - nó sẽ không khóa thread gọi nó - các dòng code tiếp theo của thread chính vẫn chạy trong khi Task đang thi hành.

Khi Task chạy xong, kết quả delegate trả về lưu ở thuộc tính Result (task.Result)

Kết quả chạy code trên, hai thread con đang chạy song song, trong khi thread chính đàng chờ người người dùng bấm bàn phím.

Cũng để ý Async2 ở trên lại dùng Task chứ không phải Task<T>, áp dụng khi hàm bất đồng bộ không cần kết quả trả về, lúc đó lại dùng hàm delegate dạng Action chứ không dùng Func, cú pháp:

Action action = () => {};
Task task = new Task(action);

async và await

Mã trên bạn thấy, khi đối tượng Task khởi chạy bằng Start thì thread của Task chạy, và những dòng code sau Start() được xử lý mà không bị khóa lại. Như đã nói trên, thread chạy - và khi delegate hoàn thành kết quả trả về lưu ở task.Result

Vấn đề là khi truy cập task.Result để đọc kết quả trả về, thì dòng code đó sẽ chờ cho Task hoàn thành để đọc dẫn đến dòng code sau đó không được thực thi - do thread gọi lại bị block, ví dụ cập nhật hàm Async1 để thấy rõ điều đó.

static void Async1(string thamso1, string thamso2)
{
    Func<object, string> myfunc = (object thamso) => {
        dynamic ts = thamso;
        for (int i = 1; i <= 50; i++)
        {
            WriteLine($"{Thread.CurrentThread.ManagedThreadId,3} {ts.x, 10} {i,5} {ts.y}", ConsoleColor.Green);
            Thread.Sleep(500);
        }
        return $"Kết thúc! {ts.x}";
    };
    Task<string> task = new  Task<string>(myfunc, new {x = thamso1, y = thamso2});
    task.Start();

    // thread cha không bị khóa
    Thread.Sleep(2000);
    WriteLine("Làm gì đó khi task đang chạy ...", ConsoleColor.Red);
    
    string ketqua= task.Result;   // khóa (block) thread cha - chờ task hoàn thành

    Console.WriteLine("Làm gì đó khi task đã kết thúc");
}

Khi Async1 được gọi 1 từ thread chính, nó khác trường hợp trước - thread chạy nhưng hàm không trả về ngay lập tức - dẫn đến thread bị khóa - Async2 không được chạy cho đến khi thread trong Async1 kết thúc.

Bạn thấy thread trong Async1 kết thức 2, thì Async2 mới được gọi 3, lúc đó task trong nó mới chạy 4

Vậy 2 task trong ASync1 và Async2 không được chạy đồng thời, task này kết thúc mới chạy được task kia => Lợi ích của đa luồng, bất đồng bộ mất đi.


Giờ bạn mong muốn khi gọi Async1(), nó trả về ngay lập tức (nghĩa là không khóa thread gọi nó, mặc dù bắt đầu chạy một task) - trong khi bên trong Async1() vẫn đảm bảo, có những đoạn code chỉ được thi hành khi task trong nó kết thúc (đoạn code phía sau string ketqua = task.Result;)

Lúc này bạn cần sử dụng đến cặp từ khóa asyncawait. Tiến hành làm như sau:

Bước 1) Thêm vào khai báo tên hàm từ khóa async, nó cho trình biên dịch biết đây là hàm bất đồng bộ - khi gọi nó - nó trả về ngay lập tức

static async void Async1(string thamso1, string thamso2)

Bước 2) Trong thân của Async1, phải có đoạn code chờ task hoàn thành

await task;
Dòng code await này có ý nghĩa
  • Lời gọi hàm Async1 chuyển hướng về chỗ gọi nó khi gặp await (tạm dừng thi hành mã sau await)
  • Code trong Async1 phía sau await chỉ được chạy khi task chạy xong
  • Khi await hoàn thành thì nó chứa kết quả của Task nếu có
  • await chỉ viết được trong hàm có khai báo async
  • Nhớ là await phải dùng với Task

Vậy hàm Async sau khi chuyển nó là hàm bất đồng bộ với từ khóa asyncawait sẽ như sau:

static async void Async1(string thamso1, string thamso2)
{
    Func<object, string> myfunc = (object thamso) => {
        dynamic ts = thamso;
        for (int i = 1; i <= 15; i++)
        {
            WriteLine($"{Thread.CurrentThread.ManagedThreadId,3} {ts.x, 10} {i,5} {ts.y}", ConsoleColor.Green);
            Thread.Sleep(500);
        }
        return $"Kết thúc! {ts.x}";
    };
    Task<string> task = new  Task<string>(myfunc, new {x = thamso1, y = thamso2});
    task.Start();

    Thread.Sleep(1000);
    WriteLine("Làm gì đó khi task đang chạy ...", ConsoleColor.Red);

    await task;     // Gọi Async1 sẽ quay về chỗ gọi nó từ đây


    // Từ đây là code sau await (trong Async1) sẽ chỉ thi hành khi task kết thúc
    string ketqua= task.Result;       // Đọc kết quả trả về của task - không phải lo block thread gọi Async1
    Console.WriteLine("Làm gì đó khi task đã kết thúc");
    Console.WriteLine(ketqua);          // In kết quả trả về của task
}

Giờ chạy lại ví dụ, bạn sẽ thấy 2 task đã cùng chạy một lúc - và đoạn code sau await trong phương thức async chỉ thi hành khi task trong nó kết thúc.

Phương thức async trả về Task

Khi khai báo hàm với async ngoài kiểu void như phần trên, nó chỉ có thể trả về thêm kiểu Task hoặc Task<T>

Cú pháp

async Task<T> funtion_name<T>(/*tham số nếu có */)
{
    await ...  // phải có đoạn code await một Task nào đó

    return t;  // t phải có kiểu T, mặc dù viết return t; nhưng thực tế trình biên dịch tạo ra đối tượng Task<T> để  trả về
}

Ví dụ khai báo hàm async trả về kiểu Task<int> thì phải có return một giá trị hay biến kiểu int, khai báo async đảm bảo trình biên dịch trả về kiểu Task<int> mặc dù trong thân là return biểu thức kiểu int

Giờ sửa lại hàm Async1 trả về kiểu Task<string>

static async Task<string> Async1(string thamso1, string thamso2)
{
    Func<object, string> myfunc = (object thamso) => {
        dynamic ts = thamso;
        for (int i = 1; i <= 20; i++)
        {
            WriteLine($"{Thread.CurrentThread.ManagedThreadId,3} {ts.x, 10} {i,5} {ts.y}", ConsoleColor.Green);
            Thread.Sleep(500);
        }
        return $"Kết thúc! {ts.x}";
    };
    Task<string> task = new  Task<string>(myfunc, new {x = thamso1, y = thamso2});

    task.Start();  // chủ ý dòng này, để đảm bảo  task được kích hoạt

    await task;

    string ketqua= task.Result;
    Console.WriteLine(ketqua);
    return ketqua;  // trả về kiểu string
}

Do Async1 giờ trả về một Task, thì nó có thể lại được await ở một phương thức async khác, ví dụ chuyển hàm Main thành async để await Async1

static async Task Main(string[] args)
{
    var x = Async1("myfunc", "...");
    Console.ReadKey();

    await x;
    Console.WriteLine("Main " + x.Result);
}

Hãy chạy thử để xem kết quả

Áp dùng xây dựng hàm đồng bộ DownloadWebpage đầu tiên, thành dạng hỗ trợ async, tải trang web trên một task riêng

static async Task<string> DownloadWebpage(string url, bool showresult) {

    Func<object, string> download_func = (object thamso) => {
        using (var client = new WebClient())
        {
            Console.Write("Starting download ...");
            string content = client.DownloadString(url);
            if (showresult)
                WriteLine(content.Substring(0, 50), ConsoleColor.DarkBlue);
            return content;
        }
    };

    Task<string> task_download  = new Task<string>(download_func, null);
    task_download.Start();

    await task_download;
    return task_download.Result;
}

Hàm Main gọi tải thử

static async Task Main(string[] args)
{
    string url = "https://code.visualstudio.com/";

    var x    = Async1("myfunc", "...");
    var html = DownloadWebpage(url, true);

    Console.ReadKey();


    await x;
    await html;
    WriteLine(html.Result.Substring(0, 50), ConsoleColor.DarkMagenta);    // kết quả download
}

Hãy chạy thử để xem kết quả

Code mã nguồn ví dụ: ASync C#

Đăng ký theo dõi ủng hộ kênh