Skip to content

Task.Factory.StartNew so với Task.Run trong C#: Sự khác biệt

Hãy sử dụng Task.Run. Đây là Task.Factory.StartNew với hai giá trị mặc định mà hầu như mọi người gọi đều muốn: TaskScheduler.Default thay vì bộ lập lịch hiện tại, và DenyChildAttach thay vì cho phép các tác vụ con đính kèm, cộng với việc...

00:00 / --:--

Read by your browser's built-in voice.

Hãy sử dụng Task.Run. Đây là Task.Factory.StartNew với hai giá trị mặc định mà hầu như mọi người gọi đều muốn: TaskScheduler.Default thay vì bộ lập lịch hiện tại, và DenyChildAttach thay vì cho phép các tác vụ con đính kèm, cộng với việc giải gói (unwrap) cho các đại biểu async. Task.Factory.StartNew là dạng tổng quát và nó có vị trí riêng trong bốn tình huống: một TaskScheduler tùy chỉnh, các tác vụ con được đính kèm, LongRunning và các tùy chọn tạo tác vụ khác, cùng với các quá tải (overload) đối tượng trạng thái giúp tránh việc cấp phát closure. Để tải xuống mã nguồn cho bài viết này, bạn có thể truy cập kho lưu trữ GitHub của chúng tôi. Chúng tôi sẽ sử dụng một dự án kiểm thử đơn vị và so sánh hành vi của chúng trong các tình huống khác nhau. Ngoài ra, chúng tôi sẽ thảo luận về các yếu tố chính quyết định phương thức mà chúng ta nên chọn trong một trường hợp sử dụng cụ thể. Cả hai đều khởi chạy một Task thay vì một luồng, một sự khác biệt mà bài viết của chúng tôi về so sánh tác vụ với luồng đã giải thích chi tiết. Để ngắn gọn, chúng tôi sẽ chỉ sử dụng StartNew thay vì Task.Factory.StartNew trong phần còn lại của bài viết. Hãy bắt đầu.

Khởi tạo tác vụ bằng Task.Run

Task.Run có một vài quá tải nhận tham số Action/Func, và/hoặc tham số cho CancellationToken. Để đơn giản, chúng ta sẽ bắt đầu với ví dụ cơ bản Task.Run(action):

void DoWork(int order, int durationMs)
{
    Thread.Sleep(durationMs);
    Console.WriteLine($"Task {order} executed");
}

var task1 = Task.Run(() => DoWork(1, 500));
var task2 = Task.Run(() => DoWork(2, 200));
var task3 = Task.Run(() => DoWork(3, 300));

Task.WaitAll(task1, task2, task3);

Chúng ta khởi tạo ba tác vụ chạy trong các khoảng thời gian khác nhau, mỗi tác vụ in ra một thông báo khi kết thúc. Ở đây, chúng ta sử dụng Thread.Sleep chỉ để mô phỏng một thao tác đang chạy:

// Approximate Output:
Task 2 executed
Task 3 executed
Task 1 executed

Mặc dù các tác vụ được xếp hàng lần lượt, chúng không chờ đợi lẫn nhau. Kết quả là, các thông báo hoàn thành xuất hiện theo một trình tự khác.

Khởi tạo tác vụ bằng StartNew

Bây giờ, hãy chuẩn bị một phiên bản của cùng ví dụ đó bằng cách sử dụng StartNew:

var task1 = Task.Factory.StartNew(() => DoWork(1, 500));
var task2 = Task.Factory.StartNew(() => DoWork(2, 200));
var task3 = Task.Factory.StartNew(() => DoWork(3, 300));

Task.WaitAll(task1, task2, task3);

Chúng ta có thể thấy cú pháp khá giống với phiên bản Task.Run và kết quả đầu ra cũng trông giống hệt:

// Approximate Output:
Task 2 executed
Task 3 executed
Task 1 executed

Task.Factory.StartNew trong C# là gì?

Task.Factory.StartNew tạo ra một tác vụ và lập lịch để nó chạy ngay lập tức. Đây là trình khởi tạo đa năng trên TaskFactory, được truy cập thông qua thuộc tính tĩnh Task.Factory. Lệnh gọi tối thiểu nhận một đại biểu và trả về một Task. Một Func<TResult> trả về một Task<TResult>Result của nó mang giá trị đó. Các quá tải đầy đủ hơn là nơi nó khác biệt với mọi thứ khác. Chúng chấp nhận một CancellationToken, một giá trị TaskCreationOptions như LongRunning hoặc AttachedToParent, một TaskScheduler, và một đối tượng trạng thái được truyền cho đại biểu mà không cần nắm bắt closure. Hai giá trị mặc định thường gây nhầm lẫn cho mọi người. Nếu không có bộ lập lịch rõ ràng, nó sử dụng TaskScheduler.Current, không phải bộ lập lịch mặc định, vì vậy một lệnh gọi được thực hiện bên trong ngữ cảnh của bộ lập lịch khác sẽ vẫn nằm trong ngữ cảnh đó. Và nó không giải gói các đại biểu async, vì vậy một lambda async tạo ra một Task<Task<T>>. Task.Run tồn tại để cung cấp các giá trị mặc định tốt hơn cho trường hợp phổ biến đó, đó là lý do tại sao nó là lựa chọn nên dùng trừ khi có một quá tải cụ thể nào đó là lý do cần thiết.

Sự khác biệt giữa Task.Run và Task.Factory.StartNew

Cả hai đều khởi chạy một tác vụ, và Task.Run là một phím tắt cho một lệnh gọi StartNew cụ thể. Mọi sự khác biệt đều đến từ các đối số mà nó cố định. Task.Run(action) tương đương với StartNew(action, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default). StartNew(action) sử dụng TaskCreationOptions.NoneTaskScheduler.Current. Hai sự thay thế đó tạo ra ba hành vi có thể quan sát được. Các tác vụ con được đánh dấu AttachedToParent sẽ đính kèm trong StartNew và bị bỏ qua trong Task.Run, vì vậy việc chờ đợi tác vụ cha sẽ chờ đợi chúng trong trường hợp này nhưng không phải trường hợp kia. StartNew chạy trên bất kỳ bộ lập lịch nào đang hiện hành, điều này trên luồng UI có nghĩa là không có sự giảm tải nào cả. Và Task.Run giải gói một đại biểu async trong khi StartNew trả về một tác vụ lồng nhau. Sự khác biệt thứ tư là những gì StartNew có mà Task.Run không có: các quá tải cho tùy chọn tạo tác vụ, bộ lập lịch tùy chỉnh và đối tượng trạng thái. Không có sự khác biệt nào trong số đó liên quan đến tốc độ. Cả hai đều xếp hàng cùng một loại công việc, và chúng khác nhau ở nơi công việc đó được xếp hàng, những gì nó được phép làm khi đang chạy và những gì tác vụ được trả về đại diện cho.

Ngữ nghĩa khác nhau

Khi chúng ta gọi phương thức StartNew(action) cơ bản, nó giống như gọi quá tải này: Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Current); Ngược lại, khi chúng ta gọi Task.Run(action), điều đó gần như tương đương với: Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); Chúng ta gọi đó là sự tương đương gần đúng vì mọi thứ hơi khác một chút khi chúng ta sử dụng StartNew cho một đại biểu async. Chúng ta sẽ thảo luận thêm về điều này sau. Ngữ nghĩa được tiết lộ cho thấy rõ ràng rằng Task.Run(action)StartNew(action) khác nhau về chế độ TaskCreationOptions và ngữ cảnh TaskScheduler.

Phối hợp với các tác vụ con

Vì vậy, Task.Run cung cấp một tác vụ với hạn chế TaskCreationOptions.DenyChildAttach nhưng StartNew không áp đặt bất kỳ hạn chế

Chúng ta khởi tạo một tác vụ bên trong (inner task) nằm trong phạm vi của tác vụ bên ngoài (outer task) với chỉ thị TaskCreationOptions.AttachedToParent. Ở đây, chúng ta sử dụng hàm khởi tạo tác vụ thông thường để tạo tác vụ bên trong nhằm minh họa ví dụ từ một góc nhìn trung lập.

Bằng cách gọi outerTask.Wait(), chúng ta giữ cho luồng chính chờ đợi tác vụ bên ngoài hoàn thành. Chúng ta chặn luồng tại đây vì các ví dụ này chạy bên ngoài một phương thức bất đồng bộ; bài viết của chúng tôi về sự khác biệt giữaawaitTask.Wait giải thích lý do tại sao dạng await là cách đúng đắn trong mã ứng dụng. Bản thân tác vụ bên ngoài không có nhiều mã để thực thi, nó chỉ bắt đầu tác vụ bên trong và in ngay thông báo hoàn thành. Tuy nhiên, vì tác vụ bên trong được gắn với tác vụ cha (tức là tác vụ bên ngoài), tác vụ bên ngoài sẽ không “hoàn thành” cho đến khi tác vụ bên trong kết thúc. Khi tác vụ bên trong kết thúc, luồng thực thi sẽ chuyển sang dòng tiếp theo của outerTask.Wait():

Outer task executed
Inner task executed
Inner task completed: True
Main thread exiting

Bây giờ, hãy xem điều gì xảy ra trong trường hợp của Task.Run:

Task? innerTask = null;

var outerTask = Task.Run(() =>
{
    innerTask = new Task(() =>
    {
        Thread.Sleep(300);
        Console.WriteLine("Inner task executed");
    }, TaskCreationOptions.AttachedToParent);

    innerTask.Start(TaskScheduler.Default);
    Console.WriteLine("Outer task executed");
});

outerTask.Wait();
Console.WriteLine($"Inner task completed: {innerTask?.IsCompleted ?? false}");
Console.WriteLine("Main thread exiting");

Không giống như ví dụ trước, lần này outerTask.Wait() không chờ tác vụ bên trong hoàn thành và dòng tiếp theo thực thi ngay lập tức sau khi tác vụ bên ngoài được thực thi. Điều này là do Task.Run khởi chạy tác vụ bên ngoài với hạn chế TaskCreationOptions.DenyChildAttach, vốn từ chối yêu cầu TaskCreationOptions.AttachedToParent từ tác vụ con. Vì dòng cuối cùng của mã đang thực thi trước khi tác vụ bên trong hoàn thành, chúng ta sẽ không nhận được thông báo từ tác vụ bên trong trong kết quả đầu ra:

Outer task executed
Inner task completed: False
Main thread exiting

Tóm lại, Task.Run StartNew hoạt động khác nhau khi có liên quan đến các tác vụ con.

Default vs Current TaskScheduler

Bây giờ, hãy nói về sự khác biệt từ ngữ cảnh TaskScheduler. Task.Run(action) sử dụng nội bộ TaskScheduler mặc định, nghĩa là nó luôn giảm tải tác vụ cho thread pool. StartNew(action), mặt khác, sử dụng bộ lập lịch của luồng hiện tại, vốn có thể không sử dụng thread pool chút nào!

Đây có thể là vấn đề đáng lo ngại đặc biệt là khi chúng ta làm việc với luồng UI! Nếu chúng ta khởi tạo một tác vụ bằng StartNew(action) trong một luồng UI, nó sẽ sử dụng bộ lập lịch của luồng UI và không có việc giảm tải nào xảy ra. Điều đó có nghĩa là, nếu tác vụ đó chạy lâu, UI sẽ sớm trở nên không phản hồi. Task.Run không gặp rủi ro này vì nó sẽ luôn giảm tải công việc cho thread pool bất kể nó được khởi tạo trong luồng nào. Vì vậy, Task.Run là lựa chọn an toàn hơn trong những trường hợp như vậy.

Việc giảm tải cho pool cũng là nơi hai mô hình tư duy gặp nhau, và bài viết của chúng tôi về lập trình bất đồng bộ so với đa luồng vạch ra ranh giới giữa việc chờ đợi I/O và chiếm dụng một luồng.

Sự nhận thức về async

Không giống như StartNew, Task.Run có nhận thức về async. Điều đó thực sự có nghĩa là gì?

asyncawait là hai bổ sung tuyệt vời cho thế giới lập trình bất đồng bộ. Giờ đây, chúng ta có thể viết khối mã bất đồng bộ của mình bằng cách sử dụng các cấu trúc luồng điều khiển của ngôn ngữ giống như cách chúng ta viết luồng mã đồng bộ, và trình biên dịch thực hiện phần còn lại của các chuyển đổi cho chúng ta. Chúng ta không cần phải lo lắng về các cấu trúc Task tường minh khi trả về một kết quả (hoặc không có kết quả) từ quy trình bất đồng bộ. Tuy nhiên, sự chuyển đổi do trình biên dịch điều khiển này có thể dẫn đến những kết quả không mong muốn (từ góc độ của nhà phát triển) khi chúng ta làm việc với StartNew.

Một lưu ý trước khi xem mã. Chúng ta đọc thuộc tính Result trực tiếp ở đây để buộc hoàn thành, điều này ổn trong một ví dụ minh họa nhưng rủi ro trong mã ứng dụng, nơi Result có thể gây ra deadlock. Chúng tôi đã nói về điều đó trong bài viết Lập trình bất đồng bộ với Async và Await trong ASP.NET Core. Bây giờ là phiên bản StartNew với một delegate async:

var task = Task.Factory.StartNew(async () =>
{
    await Task.Delay(500);
    return "Calculated Value";
});

Console.WriteLine(task.GetType()); // System.Threading.Tasks.Task`1[System.Threading.Tasks.Task`1[System.String]]

var innerTask = task.Unwrap();
Console.WriteLine(innerTask.GetType()); // System.Threading.Tasks.UnwrapPromise`1[System.String]

Console.WriteLine(innerTask.Result); // Calculated Value

Chúng ta khởi tạo một tác vụ xếp hàng đợi một quy trình bất đồng bộ được ủy quyền. Do từ khóa async, trình biên dịch ánh xạ delegate này thành Func<Task<string>>, từ đó trả về một Task<string> khi được gọi. Trên hết, StartNew bao bọc điều này trong một cấu trúc Task. Cuối cùng, chúng ta nhận được một thực thể Task<Task<string>>, đây không phải là điều chúng ta mong muốn. Chúng ta phải gọi phương thức mở rộng Unwrap để truy cập vào thực thể tác vụ bên trong mà chúng ta dự định. Tất nhiên đây không phải là vấn đề của StartNew, nó chỉ không được thiết kế với nhận thức về async. Nhưng, Task.Run được thiết kế với kịch bản này trong tâm trí, vốn thực hiện việc unwrapping này ở bên trong:

var task = Task.Run(async () =>
{
    await Task.Delay(500);
    return "Calculated Value";
});

Console.WriteLine(task.GetType()); // System.Threading.Tasks.UnwrapPromise`1[System.String]

Console.WriteLine(task.Result); // Calculated Value

Như chúng ta mong đợi, chúng ta không cần lệnh gọi Unwrap bổ sung trong trường hợp của Task.Run.

Sự khác biệt giữa Task.Run và Task.Factory.StartNew với trạng thái đối tượng

Bất cứ khi nào chúng ta xử lý một quy trình bất đồng bộ, chúng ta cần nhận thức được “sự thay đổi trạng thái”. Hãy nghĩ về việc bắt đầu một loạt các tác vụ trong một vòng lặp:

var tasks = new List<

Điều này là do, vào thời điểm các tác vụ bắt đầu thực thi, trạng thái của biến i (vốn có phạm vi nằm ngoài khối lặp) đã bị thay đổi và đạt đến giá trị cuối cùng là 4. Điều này áp dụng cho các vòng lặp for chứ không áp dụng cho các vòng lặp foreach, vốn có biến lặp không còn bị chia sẻ giữa các lần lặp trong C# 5. Một cách để giải quyết vấn đề này là lưu trữ giá trị của i vào một biến cục bộ bên trong khối lặp:

var tasks = new List<Task>();
for (var i = 1; i < 4; i++)
{
    var iteration = i;
    var task = Task.Run(async () =>
    {
        await Task.Delay(100);
        Console.WriteLine($"Iteration {iteration}");
    });

    tasks.Add(task);
}

Task.WaitAll(tasks.ToArray());

Bây giờ, chúng ta nhận được kết quả mong muốn:

Iteration 3
Iteration 1
Iteration 2

Việc phân tán công việc trên một tập hợp có các hình thức gọn gàng hơn so với vòng lặp thủ công, và bài viết của chúng tôi về Parallel.ForEachAsync() và Task.Run với WhenAll sẽ so sánh chúng.

Tuy nhiên, có một mối lo ngại về hiệu năng. Do việc bắt biến lambda, có một sự phân bổ bộ nhớ bổ sung cho biến iteration đó. Mặc dù đây không phải là chi phí đáng kể trong ví dụ đơn giản nhất này, nhưng đây có thể là một mối lo ngại lớn trong các quy trình phức tạp nơi có nhiều biến liên quan. Task.Run không cung cấp bất kỳ giải pháp nào cho việc này nhưng StartNew thì có! StartNew cung cấp một số quá tải chấp nhận một đối tượng trạng thái, một trong số đó là:

public Task StartNew (Action<object> action, object state);

Điều này cung cấp một cách tốt hơn để vượt qua vấn đề biến đổi trạng thái mà không làm tăng chi phí phân bổ bộ nhớ bổ sung:

var tasks = new List<Task>();
for (var i = 1; i < 4; i++)
{
    var task = Task.Factory.StartNew(async (iteration) =>
    {
        await Task.Delay(100);
        Console.WriteLine($"Iteration {iteration}");
    }, i)
    .Unwrap();

    tasks.Add(task);
}

Task.WaitAll(tasks.ToArray());

Như chúng ta có thể thấy, StartNew nắm bắt giá trị hiện tại của i và truyền trạng thái bất biến này vào hành động được ủy quyền. Chúng ta không cần bản sao cục bộ nữa:

// Approximate Output:
Iteration 1
Iteration 3
Iteration 2

Nhìn chung, StartNew cung cấp một phương tiện để tránh các closure và phân bổ bộ nhớ do việc bắt biến lambda trong các delegate, do đó có thể mang lại một số lợi ích về hiệu năng. Tuy nhiên, lợi ích hiệu năng này không được đảm bảo và có thể không đủ đáng kể để tạo ra bất kỳ sự khác biệt nào. Vì vậy, nếu việc phân tích bộ nhớ của một tác vụ cụ thể cho thấy rằng việc truyền một đối tượng trạng thái mang lại lợi ích đáng kể, chúng ta nên sử dụng StartNew ở đó.

Lập lịch tác vụ nâng cao

Chúng ta hiện biết rằng Task.Run luôn sử dụng bộ lập lịch tác vụ mặc định. Bộ lập lịch mặc định sử dụng ThreadPool, cung cấp một số tính năng tối ưu hóa mạnh mẽ bao gồm đánh cắp công việc (work-stealing) để cân bằng tải và chèn/nghỉ luồng. Nhìn chung, nó tạo điều kiện cho thông lượng tối đa và hiệu năng tốt.

Vì vậy, chắc chắn là chúng ta muốn sử dụng bộ lập lịch mặc định trong hầu hết các trường hợp. Tuy nhiên, trong các ứng dụng thực tế, tình hình kinh doanh có thể đòi hỏi các thuật toán phân phối công việc phức tạp yêu cầu cơ chế lập lịch tác vụ của riêng chúng ta. Ví dụ, chúng ta có thể muốn giới hạn số lượng tác vụ đồng thời. Hoặc, chúng ta có thể nghĩ về một công cụ yêu cầu hỗ trợ có thể cần ưu tiên các yêu cầu khẩn cấp và lập lịch lại các yêu cầu đang chờ xử lý không quan trọng. Chúng ta cần triển khai bộ lập lịch tùy chỉnh trong những trường hợp như vậy. Vì không có quá tải nào của Task.Run chấp nhận tham số TaskScheduler, StartNew là lựa chọn khả thi ở đây.

Khi nào chúng ta nên sử dụng Task.Run và khi nào sử dụng StartNew?

Task.Run dùng để giảm tải công việc cho thread pool, đây gần như luôn là những gì chúng ta đang làm. StartNew khi một trong bốn nhu cầu cụ thể được áp dụng.

Thứ nhất là TaskScheduler tùy chỉnh: giới hạn tính đồng thời, ưu tiên công việc, ghim vào một luồng duy nhất. Không có quá tải Task.Run nào nhận bộ lập lịch, vì vậy StartNew là lựa chọn duy nhất.

Thứ hai là TaskCreationOptions, thường là LongRunning cho công việc mà nếu không sẽ chiếm dụng một luồng pool trong nhiều phút.

Thứ ba là các tác vụ con đính kèm, nơi tác vụ cha không nên hoàn thành cho đến khi các tác vụ con hoàn thành. Task.Run từ chối việc đính kèm theo thiết kế.

Thứ tư là các quá tải đối tượng trạng thái, truyền một giá trị cho delegate mà không cần bắt một closure. Hãy đo lường trước khi chọn StartNew cho việc này: khoản tiết kiệm là một lần phân bổ cho mỗi tác vụ, điều này chỉ quan trọng trong một vòng lặp chặt chẽ và không ở đâu khác.

Ngoài bốn trường hợp đó, Task.Run là mặc định an toàn hơn vì các mặc định của nó là những gì hầu hết mọi người gọi đều muốn.

Task.Run(action)

Task.Factory.StartNew(action)

Tương đương với

StartNew(action, None, DenyChildAttach, TaskScheduler.Default)

StartNew(action, None, None, TaskScheduler.Current)

Bộ lập lịch được sử dụng

Luôn là mặc định (thread pool)

Bộ lập lịch hiện tại, có thể không phải là thread pool

Tác vụ con có thể đính kèm

Không; DenyChildAttach từ chối AttachedToParent

async delegate

Được mở gói (unwrapped), trả về Task<T>

Trong bài viết này, chúng ta đã tìm hiểu về sự khác biệt giữa Task.Run và Task.Factory.StartNew. Chúng ta đã thảo luận về một số trường hợp sử dụng nâng cao mà StartNew là tùy chọn khả thi, nếu không thì Task.Run là phương thức được khuyến nghị nói chung. Cả hai đều nằm trong một nhóm rộng hơn, và tổng quan của chúng tôi về các mẫu lập trình bất đồng bộ trong .NET đặt chúng cùng với các lựa chọn thay thế khác.

Đã kiểm thử với .NET 10.0.10.

Source: Code Maze

Original article: Task.Factory.StartNew vs Task.Run in C#: The Difference

Original author: Ahsan Ullah

Rate this article

Rate this article out of 5

No ratings yet
Be the first to rate this article.

Selecting a star will ask you to sign in.
HV
Huy Vũ
Contributor at Selectgo

Shares practical engineering notes on this blog.