Task là một đối tượng đại diện cho công việc đang diễn ra. Nó có thể đang chạy trên một luồng của thread pool, đang chờ một bộ hẹn giờ, hoặc đang chờ phản hồi qua mạng; dù thế nào bạn cũng có thể hỏi nó đã xong chưa, lấy kết quả, chờ nó, hoặc gắn thêm việc vào sau nó. async và await là cú pháp của ngôn ngữ xây trên task. Trang này trình bày chính API Task, cùng với luồng (thread).
Task.Run: chạy công việc trên thread pool
Task.Run nhận một lambda và đưa nó vào hàng đợi của thread pool, một tập các luồng mà .NET giữ sẵn để không phải tạo luồng mới cho mỗi việc. Nó trả về một Task, hoặc Task<T> nếu lambda trả về giá trị.
Đầu ra:
Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion
Result chặn luồng gọi cho đến khi task xong. Điều đó chấp nhận được trong Main của một chương trình console, nhưng trong code bất đồng bộ bạn sẽ viết int primes = await task;, cách này chờ mà không chặn luồng. Tương tự với task.Wait() cho một Task không có kết quả.
Task.Run dành cho công việc nặng về CPU: những phép tính giữ một nhân CPU luôn bận. Với I/O, như đọc tệp hay gọi web API, hãy dùng phương thức bất đồng bộ mà thư viện đã có sẵn (File.ReadAllTextAsync, HttpClient.GetStringAsync) và await trực tiếp. Trong lúc I/O đang chờ, một lời gọi bất đồng bộ được await không giữ luồng nào cả, nên bọc nó trong Task.Run chẳng được gì mà chỉ thêm một bước qua thread pool. Trường hợp thực sự chiếm một luồng của pool là Task.Run bọc quanh một lời gọi chặn đồng bộ như File.ReadAllText: luồng đó ngồi không cho đến khi ổ đĩa hoặc mạng trả lời.
Nhiều task cùng lúc: WhenAll
Chia công việc độc lập thành các task, khởi động tất cả, rồi gộp kết quả bằng Task.WhenAll. Trên máy có nhiều nhân, các phần chạy song song.
Đầu ra:
Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997
Mỗi phần làm việc trên khoảng riêng và trả về tổng riêng, nên các task không chia sẻ gì và không cần khóa. WhenAll trả về kết quả theo thứ tự của các task, bất kể các luồng xong theo thứ tự nào. Khi các task cần cập nhật trạng thái dùng chung, xem lock.
WhenAny và timeout
Task.WhenAny hoàn thành khi task đầu tiên trong số các task của nó hoàn thành, và trả về task đó. Cho công việc thật chạy đua với Task.Delay là có ngay một timeout:
Đầu ra:
fast mirror answered after 100 ms
Timed out after 300 ms
WhenAny trả về chính task thắng cuộc, không phải kết quả của nó, nên bạn await nó thêm lần nữa để lấy giá trị (hoặc ngoại lệ, nếu nó thất bại). Lưu ý rằng các task thua cuộc vẫn tiếp tục chạy; WhenAny không dừng chúng. Để thực sự dừng công việc chậm, hãy hủy nó bằng token như bên dưới. Từ .NET 6 trở đi, await work.WaitAsync(TimeSpan.FromMilliseconds(300)) diễn đạt cùng timeout đó trong một lời gọi.
Continuation với ContinueWith
Trước khi có await, cách để chạy code sau khi một task xong là ContinueWith. Bạn vẫn sẽ gặp nó trong code cũ:
Đầu ra:
Report total: 42
Mỗi continuation nhận task trước đó và chạy khi task đó hoàn thành. await làm điều tương tự với ít thủ tục hơn nhiều, xử lý ngoại lệ một cách tự nhiên, và quay về đúng ngữ cảnh, nên hãy ưu tiên nó trong code mới. ContinueWith vẫn chạy cả khi task trước thất bại hoặc bị hủy, nên code cũ phải kiểm tra t.IsFaulted hoặc truyền TaskContinuationOptions để tránh gọi .Result trên một task đã thất bại.
Hủy bằng CancellationToken
Task được hủy theo kiểu hợp tác. Một CancellationTokenSource phát ra token; code đang làm việc kiểm tra token và tự dừng. Không có gì bị cưỡng ép dừng.
Đầu ra:
Cancelled before finishing all 10 orders
Processed 3
Token được truyền xuống qua mọi tầng, và các API bất đồng bộ như Task.Delay, HttpClient hay các lời gọi cơ sở dữ liệu cũng nhận nó, nên việc hủy dừng chúng ngay giữa lúc chờ. Hãy bắt OperationCanceledException; TaskCanceledException mà một số API ném ra kế thừa từ nó. CancellationTokenSource cài đặt IDisposable, vì vậy có using.
Thread và Task
Thread là một luồng của hệ điều hành do bạn tự tạo. Nó nặng hơn: mỗi luồng có stack riêng (mặc định 1 MB trên Windows), khởi động một luồng tốn thời gian đáng kể, và nó không cho bạn kết quả hay cơ chế xử lý ngoại lệ.
Đầu ra:
Thread started
Result: 42
Thread | Task | |
|---|---|---|
| Chạy trên | Một luồng hệ điều hành mới, dành riêng | Một luồng của pool (hoặc không cần luồng, với I/O) |
| Chi phí khởi động | Cao | Thấp |
| Giá trị kết quả | Không, phải dùng chung một biến | Task<T>.Result / await |
| Ngoại lệ | Ngoại lệ không được xử lý làm sập tiến trình | Lưu trong task, ném lại khi await |
| Hủy | Cờ tự quản lý | Có sẵn CancellationToken |
| Chờ hoàn thành | Join() | await, Wait(), WhenAll |
Chỉ dùng Thread khi bạn cần điều mà pool không cung cấp: một vòng lặp chạy rất lâu không nên mượn luồng của pool, một mức ưu tiên cụ thể, hoặc single-threaded apartment cho COM. Với các task chạy lâu, Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) có được một luồng riêng mà vẫn giữ API của task.
Parallel.For và Parallel.ForEach
Với kiểu "làm việc này cho mọi phần tử, dùng hết các nhân", System.Threading.Tasks.Parallel đơn giản hơn việc tự tạo task. Nó chia khoảng lặp cho các luồng và trả về khi mọi lần lặp đã xong:
Đầu ra ví dụ:
image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425
Các dòng của từng ảnh xuất hiện theo bất kỳ thứ tự nào mà các luồng chạy, và ID luồng thay đổi giữa các lần chạy; chỉ có tổng là cố định. Đó là bản chất của vòng lặp song song: các lần lặp không được phụ thuộc vào thứ tự của nhau, và mọi trạng thái dùng chung đều cần đồng bộ hóa (ở đây là Interlocked.Add). Parallel.ForEach làm điều tương tự trên mọi collection, và .NET 6 bổ sung Parallel.ForEachAsync cho phần thân bất đồng bộ.
Lỗi thường gặp
.Resulthoặc.Wait()bên trong code bất đồng bộ. Chặn một luồng và có thể gây deadlock trong ứng dụng có giao diện; hãy dùngawait.- Bắt kiểu ngoại lệ gốc quanh
.Result. Lỗi đến dưới dạng được bọc trongAggregateException;awaitmới mở lớp bọc. - Chạy rồi bỏ mặc (fire and forget).
Task.Run(...)mà không await hay lưu lại task sẽ làm mất các ngoại lệ của nó. Task.Runbọc quanh I/O. Bọc quanh một phương thức bất đồng bộ thì chỉ thêm một bước qua thread pool; bọc quanh một phương thức chặn nhưFile.ReadAllTextthì giữ một luồng của pool suốt thời gian chờ. Hãy await trực tiếp phương thức I/O bất đồng bộ.- Bỏ qua token. Truyền
CancellationTokenvào chẳng có tác dụng gì nếu công việc không kiểm tra nó. - Trạng thái dùng chung không được đồng bộ trong code song song. Dùng
Interlocked,lock, hoặc cho mỗi task dữ liệu riêng.
Câu hỏi thường gặp
Task trong C# là gì?
Task đại diện cho một thao tác có thể hoàn thành trong tương lai: chạy code trên thread pool, một bộ hẹn giờ, một yêu cầu I/O. Bạn có thể await nó, nó cho biết đã hoàn thành, thất bại hay bị hủy, và Task<T> còn mang theo kết quả. Task là nền tảng bên dưới async/await.
Task và Thread trong C# khác nhau thế nào?
Thread là một luồng của hệ điều hành mà bạn tự tạo và quản lý, có stack riêng; khởi động tốn kém và không trả về giá trị. Task mô tả công việc, và Task.Run lên lịch chạy nó trên một nhóm luồng dùng chung có thể tái sử dụng; task có thể trả về kết quả, truyền ngoại lệ, bị hủy và được await. Hãy dùng task, trừ khi bạn cần một luồng riêng sống lâu với thiết lập đặc biệt.
Khi nào nên dùng Task.Run?
Cho công việc nặng về CPU mà bạn muốn đưa ra khỏi luồng hiện tại: xử lý ảnh, một phép tính lớn, phân tích một tệp lớn trong bộ nhớ, đặc biệt để giữ giao diện luôn phản hồi. Đừng bọc I/O trong Task.Run: các phương thức I/O bất đồng bộ như ReadAllTextAsync vốn đã giải phóng luồng trong lúc chờ.
Làm sao hủy một Task trong C#?
Tạo một CancellationTokenSource, truyền Token của nó vào công việc, rồi gọi Cancel() (hoặc CancelAfter(timeout)) trên source. Công việc phải hợp tác: kiểm tra token.IsCancellationRequested hoặc gọi token.ThrowIfCancellationRequested() trong vòng lặp, và truyền token tiếp cho các API bất đồng bộ như Task.Delay. Await một task đã bị hủy sẽ ném OperationCanceledException.
Task.WhenAll và Task.WhenAny khác nhau thế nào?
Task.WhenAll hoàn thành khi mọi task đã hoàn thành và trả về tất cả kết quả. Task.WhenAny hoàn thành ngay khi task đầu tiên hoàn thành và trả về chính task đó. WhenAny là cách thông dụng để thêm thời gian chờ tối đa (timeout): cho công việc chạy đua với Task.Delay.
Vì sao Task.Result ném AggregateException?
.Result và .Wait() bọc mọi lỗi trong một AggregateException, vì về tổng quát một task có thể chứa nhiều ngoại lệ. await mở lớp bọc và ném lại trực tiếp ngoại lệ bên trong đầu tiên, đây là một lý do nên ưu tiên await. Nếu buộc phải chặn luồng, .GetAwaiter().GetResult() cũng ném ra ngoại lệ gốc.