.NET: Công cụ để làm việc với đa luồng và không đồng bộ. Phần 1

Tôi đang xuất bản bài viết gốc về Habr, bản dịch của bài viết này được đăng trên trang web của công ty bài đăng trên blog.

Nhu cầu thực hiện một việc gì đó một cách không đồng bộ, không cần chờ đợi kết quả ngay tại chỗ, hoặc phân chia công việc lớn cho nhiều đơn vị thực hiện nó, đã tồn tại trước khi máy tính ra đời. Với sự ra đời của họ, nhu cầu này đã trở nên rất hữu hình. Bây giờ, vào năm 2019, tôi đang gõ bài viết này trên một máy tính xách tay có bộ xử lý Intel Core 8 nhân, trên đó có hơn một trăm quy trình đang chạy song song và thậm chí nhiều luồng hơn. Gần đó, có một chiếc điện thoại hơi tồi tàn, được mua cách đây vài năm, có bộ xử lý 8 nhân. Các tài nguyên chuyên đề có đầy đủ các bài báo và video trong đó tác giả ngưỡng mộ những chiếc điện thoại thông minh hàng đầu của năm nay có bộ xử lý 16 lõi. MS Azure cung cấp một máy ảo có bộ xử lý 20 lõi và RAM 128 TB với giá dưới 2 USD/giờ. Thật không may, không thể khai thác tối đa và khai thác sức mạnh này nếu không quản lý được sự tương tác của các luồng.

Thuật ngữ

Quá trình - Đối tượng hệ điều hành, không gian địa chỉ biệt lập, chứa các luồng.
Chủ đề - một đối tượng hệ điều hành, đơn vị thực thi nhỏ nhất, một phần của quy trình, các luồng chia sẻ bộ nhớ và các tài nguyên khác với nhau trong một quy trình.
Đa nhiệm - Thuộc tính hệ điều hành, khả năng chạy nhiều tiến trình cùng lúc
Đa lõi - một thuộc tính của bộ xử lý, khả năng sử dụng nhiều lõi để xử lý dữ liệu
Đa xử lý - một thuộc tính của máy tính, khả năng làm việc đồng thời với nhiều bộ xử lý về mặt vật lý
Đa luồng - một thuộc tính của một quy trình, khả năng phân phối xử lý dữ liệu giữa một số luồng.
Sự song song - thực hiện một số hành động vật lý đồng thời trên một đơn vị thời gian
không đồng bộ - thực hiện một thao tác mà không cần chờ quá trình xử lý này hoàn thành; kết quả của việc thực hiện có thể được xử lý sau.

Phép ẩn dụ

Không phải tất cả các định nghĩa đều tốt và một số cần giải thích thêm, vì vậy tôi sẽ thêm một phép ẩn dụ về việc nấu bữa sáng vào thuật ngữ được giới thiệu chính thức. Nấu bữa sáng trong phép ẩn dụ này là một quá trình.

Trong khi chuẩn bị bữa sáng vào buổi sáng tôi (CPU) Tôi vào bếp (Máy tính). Tôi có 2 tay (Lõi). Có một số thiết bị trong nhà bếp (IO): lò nướng, ấm đun nước, máy nướng bánh mì, tủ lạnh. Mình bật gas, đặt chảo lên rồi đổ dầu vào mà không đợi nóng (không đồng bộ, Không chặn-IO-Chờ), mình lấy trứng ra khỏi tủ lạnh và đập ra đĩa rồi đánh bằng một tay (Chủ đề số 1) và thứ hai (Chủ đề số 2) đang cầm đĩa (Tài nguyên được chia sẻ). Bây giờ tôi muốn bật ấm đun nước, nhưng tôi không có đủ tay (Chủ đề đói) Trong thời gian này, chảo rán nóng lên (Đang xử lý kết quả) mà tôi đổ những gì tôi đã đánh bông vào. Tôi với lấy ấm đun nước, bật nó lên và ngu ngốc nhìn nước sôi trong đó (Chặn-IO-Chờ), mặc dù trong thời gian này anh ấy có thể rửa đĩa nơi anh ấy đánh trứng tráng.

Tôi làm món trứng tráng chỉ bằng 2 tay, không có nhiều hơn, nhưng đồng thời, tại thời điểm đánh trứng, 3 thao tác diễn ra cùng lúc: đánh trứng, cầm đĩa, làm nóng chảo rán .CPU là bộ phận nhanh nhất của máy tính, IO là bộ phận thường làm mọi thứ chậm lại nhất, vì vậy giải pháp hiệu quả thường là chiếm CPU bằng thứ gì đó trong khi nhận dữ liệu từ IO.

Tiếp tục phép ẩn dụ:

  • Nếu trong quá trình chuẩn bị món trứng tráng, tôi cũng cố gắng thay quần áo thì đây sẽ là một ví dụ về đa nhiệm. Một sắc thái quan trọng: máy tính làm việc này tốt hơn nhiều so với con người.
  • Một nhà bếp với nhiều đầu bếp, chẳng hạn như trong một nhà hàng - một máy tính đa lõi.
  • Nhiều nhà hàng trong khu ẩm thực ở trung tâm mua sắm - trung tâm dữ liệu

Công cụ .NET

.NET hoạt động tốt với các luồng cũng như nhiều thứ khác. Với mỗi phiên bản mới, nó ngày càng giới thiệu nhiều công cụ mới để làm việc với chúng, các lớp trừu tượng mới trên các luồng hệ điều hành. Khi làm việc với việc xây dựng các phần trừu tượng, các nhà phát triển khung sử dụng một cách tiếp cận để lại cơ hội, khi sử dụng phần trừu tượng cấp cao, đi xuống một hoặc nhiều cấp độ bên dưới. Thông thường, điều này là không cần thiết, trên thực tế, nó có cơ hội tự bắn vào chân mình bằng một khẩu súng ngắn, nhưng đôi khi, trong những trường hợp hiếm hoi, đó có thể là cách duy nhất để giải quyết một vấn đề chưa được giải quyết ở mức độ trừu tượng hiện tại .

Khi nói đến các công cụ, ý tôi là cả giao diện lập trình ứng dụng (API) do khung và gói của bên thứ ba cung cấp, cũng như toàn bộ giải pháp phần mềm giúp đơn giản hóa việc tìm kiếm bất kỳ vấn đề nào liên quan đến mã đa luồng.

Bắt đầu một chủ đề

Lớp Thread là lớp cơ bản nhất trong .NET để làm việc với các luồng. Hàm tạo chấp nhận một trong hai đại biểu:

  • ThreadStart - Không có tham số
  • ParametrizedThreadStart - với một tham số thuộc loại đối tượng.

Đại biểu sẽ được thực thi trong luồng mới được tạo sau khi gọi phương thức Bắt đầu. Nếu một đại biểu thuộc loại ParametrizedThreadStart được truyền cho hàm tạo thì một đối tượng phải được truyền cho phương thức Bắt đầu. Cơ chế này là cần thiết để chuyển bất kỳ thông tin cục bộ nào sang luồng. Điều đáng chú ý là việc tạo một luồng là một hoạt động tốn kém và bản thân luồng đó là một đối tượng nặng, ít nhất là vì nó phân bổ 1MB bộ nhớ trên ngăn xếp và yêu cầu tương tác với API hệ điều hành.

new Thread(...).Start(...);

Lớp ThreadPool thể hiện khái niệm về một nhóm. Trong .NET, nhóm luồng là một phần của kỹ thuật và các nhà phát triển tại Microsoft đã nỗ lực rất nhiều để đảm bảo nó hoạt động tối ưu trong nhiều tình huống khác nhau.

Khái niệm chung:

Từ thời điểm ứng dụng khởi động, nó sẽ tạo ra một số luồng dự trữ ở chế độ nền và cung cấp khả năng sử dụng chúng. Nếu các luồng được sử dụng thường xuyên và với số lượng lớn, nhóm sẽ mở rộng để đáp ứng nhu cầu của người gọi. Khi không có luồng trống nào trong nhóm vào đúng thời điểm, nó sẽ đợi một trong các luồng quay trở lại hoặc tạo một luồng mới. Theo đó, nhóm luồng rất phù hợp cho một số hành động ngắn hạn và kém phù hợp cho các hoạt động chạy dưới dạng dịch vụ trong toàn bộ hoạt động của ứng dụng.

Để sử dụng một luồng từ nhóm, có một phương thức QueueUserWorkItem chấp nhận một đại biểu thuộc loại WaitCallback, có cùng chữ ký với ParametrizedThreadStart và tham số được truyền cho nó thực hiện chức năng tương tự.

ThreadPool.QueueUserWorkItem(...);

Phương thức nhóm luồng ít được biết đến hơn RegisterWaitForSingleObject được sử dụng để tổ chức các hoạt động IO không chặn. Đại biểu được truyền cho phương thức này sẽ được gọi khi WaitHandle được truyền cho phương thức này là “Đã phát hành”.

ThreadPool.RegisterWaitForSingleObject(...)

.NET có bộ đếm thời gian luồng và nó khác với bộ định thời WinForms/WPF ở chỗ trình xử lý của nó sẽ được gọi trên một luồng được lấy từ nhóm.

System.Threading.Timer

Ngoài ra còn có một cách khá kỳ lạ để gửi một đại biểu để thực thi một luồng từ nhóm - phương thức BeginInvoke.

DelegateInstance.BeginInvoke

Tôi muốn nói ngắn gọn về chức năng mà nhiều phương thức trên có thể được gọi - CreateThread từ Kernel32.dll Win32 API. Có một cách, nhờ cơ chế của các phương thức bên ngoài, để gọi hàm này. Tôi chỉ thấy lệnh gọi như vậy một lần trong một ví dụ khủng khiếp về mã kế thừa và động lực của tác giả thực hiện chính xác điều này vẫn còn là một bí ẩn đối với tôi.

Kernel32.dll CreateThread

Xem và gỡ lỗi chủ đề

Bạn có thể xem các chủ đề do bạn tạo, tất cả các thành phần của bên thứ ba và nhóm .NET trong cửa sổ Chủ đề của Visual Studio. Cửa sổ này sẽ chỉ hiển thị thông tin luồng khi ứng dụng đang được gỡ lỗi và ở chế độ Break. Tại đây, bạn có thể xem tên ngăn xếp và mức độ ưu tiên của từng luồng một cách thuận tiện cũng như chuyển việc gỡ lỗi sang một luồng cụ thể. Bằng cách sử dụng thuộc tính Ưu tiên của lớp Thread, bạn có thể đặt mức độ ưu tiên của một luồng mà OC và CLR sẽ coi là đề xuất khi phân chia thời gian xử lý giữa các luồng.

.NET: Công cụ để làm việc với đa luồng và không đồng bộ. Phần 1

Thư viện song song nhiệm vụ

Thư viện song song tác vụ (TPL) đã được giới thiệu trong .NET 4.0. Bây giờ nó là tiêu chuẩn và công cụ chính để làm việc với tính không đồng bộ. Bất kỳ mã nào sử dụng cách tiếp cận cũ hơn đều được coi là cũ. Đơn vị cơ bản của TPL là lớp Task từ không gian tên System.Threading.Tasks. Một tác vụ là một sự trừu tượng hóa trên một luồng. Với phiên bản mới của ngôn ngữ C#, chúng tôi đã có một cách làm việc thông minh hơn với Tác vụ - các toán tử async/await. Những khái niệm này giúp bạn có thể viết mã không đồng bộ như thể nó đơn giản và đồng bộ, điều này giúp những người ít hiểu biết về hoạt động bên trong của các luồng có thể viết các ứng dụng sử dụng chúng, những ứng dụng không bị treo khi thực hiện các thao tác dài. Sử dụng async/await là chủ đề của một hoặc thậm chí một số bài viết, nhưng tôi sẽ cố gắng hiểu ý chính của nó trong một vài câu:

  • async là công cụ sửa đổi của phương thức trả về Tác vụ hoặc khoảng trống
  • và chờ đợi là toán tử chờ tác vụ không chặn.

Một lần nữa: toán tử chờ đợi, trong trường hợp chung (có ngoại lệ), sẽ tiếp tục giải phóng luồng thực thi hiện tại và khi Tác vụ hoàn thành quá trình thực thi của nó và luồng (trên thực tế, sẽ chính xác hơn nếu nói bối cảnh , nhưng sẽ nói thêm về điều đó sau) sẽ tiếp tục thực thi phương thức này. Bên trong .NET, cơ chế này được triển khai theo cách tương tự như lợi nhuận, khi phương thức được viết chuyển thành cả một lớp, là một máy trạng thái và có thể được thực thi thành từng phần riêng biệt tùy thuộc vào các trạng thái này. Bất kỳ ai quan tâm đều có thể viết bất kỳ mã đơn giản nào bằng cách sử dụng asynс/await, biên dịch và xem tập hợp bằng cách sử dụng JetBrains dotPeek khi bật Mã tạo trình biên dịch.

Hãy xem xét các tùy chọn để khởi chạy và sử dụng Task. Trong ví dụ mã bên dưới, chúng tôi tạo một tác vụ mới không có gì hữu ích (Chủ đề.Sleep(10000)), nhưng trong thực tế, đây sẽ là một công việc phức tạp đòi hỏi nhiều CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Một tác vụ được tạo với một số tùy chọn:

  • LongRunning là một gợi ý rằng nhiệm vụ sẽ không được hoàn thành nhanh chóng, điều đó có nghĩa là có thể cân nhắc việc không lấy một chuỗi từ nhóm mà tạo một chuỗi riêng cho Nhiệm vụ này để không làm hại người khác.
  • AttachedToParent - Nhiệm vụ có thể được sắp xếp theo thứ bậc. Nếu tùy chọn này được sử dụng thì Tác vụ có thể ở trạng thái mà bản thân nó đã hoàn thành và đang chờ thực thi các tác vụ con của nó.
  • Ưu tiên Công bằng - có nghĩa là sẽ tốt hơn nếu thực thi các Nhiệm vụ được gửi để thực thi sớm hơn trước những Nhiệm vụ được gửi sau. Nhưng đây chỉ là khuyến nghị và kết quả không được đảm bảo.

Tham số thứ hai được truyền cho phương thức là CancellationToken. Để xử lý chính xác việc hủy một thao tác sau khi nó đã bắt đầu, mã đang được thực thi phải được lấp đầy bằng các kiểm tra về trạng thái CancellationToken. Nếu không có kiểm tra nào thì phương thức Hủy được gọi trên đối tượng CancellationTokenSource sẽ chỉ có thể dừng việc thực thi Tác vụ trước khi nó bắt đầu.

Tham số cuối cùng là một đối tượng lập lịch kiểu TaskScheduler. Lớp này và các lớp con của nó được thiết kế để kiểm soát các chiến lược phân phối Nhiệm vụ trên các luồng; theo mặc định, Nhiệm vụ sẽ được thực thi trên một luồng ngẫu nhiên từ nhóm.

Toán tử chờ đợi được áp dụng cho Tác vụ đã tạo, có nghĩa là mã được viết sau nó, nếu có, sẽ được thực thi trong cùng một ngữ cảnh (thường điều này có nghĩa là trên cùng một luồng) như mã trước chờ đợi.

Phương thức này được đánh dấu là async void, có nghĩa là nó có thể sử dụng toán tử chờ, nhưng mã gọi sẽ không thể chờ thực thi. Nếu tính năng đó là cần thiết thì phương thức đó phải trả về Task. Các phương thức được đánh dấu là async void khá phổ biến: theo quy tắc, đây là các trình xử lý sự kiện hoặc các phương thức khác hoạt động dựa trên nguyên tắc fire-and-forget. Nếu bạn không chỉ cần tạo cơ hội đợi cho đến khi kết thúc quá trình thực thi mà còn trả về kết quả, thì bạn cần sử dụng Task.

Trên Tác vụ mà phương thức StartNew trả về, cũng như trên bất kỳ tác vụ nào khác, bạn có thể gọi phương thức Cấu hìnhAwait với tham số sai, khi đó việc thực thi sau khi chờ đợi sẽ tiếp tục không phải trên ngữ cảnh đã chụp mà trên một ngữ cảnh tùy ý. Điều này phải luôn được thực hiện khi bối cảnh thực thi không quan trọng đối với mã sau khi chờ đợi. Đây cũng là khuyến nghị của MS khi viết mã sẽ được đóng gói trong thư viện.

Hãy tìm hiểu thêm một chút về cách bạn có thể đợi hoàn thành Nhiệm vụ. Dưới đây là một ví dụ về mã, với các nhận xét về khi nào kỳ vọng được thực hiện tốt có điều kiện và khi nào nó được thực hiện kém có điều kiện.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

Trong ví dụ đầu tiên, chúng tôi đợi Tác vụ hoàn thành mà không chặn luồng gọi; chúng tôi sẽ quay lại xử lý kết quả chỉ khi nó đã ở đó; cho đến lúc đó, luồng gọi được để lại cho các thiết bị của chính nó.

Trong tùy chọn thứ hai, chúng tôi chặn luồng gọi cho đến khi tính được kết quả của phương thức. Điều này tệ không chỉ vì chúng ta đã chiếm một luồng, một tài nguyên quý giá của chương trình, với trạng thái nhàn rỗi đơn giản, mà còn bởi vì nếu mã của phương thức mà chúng ta gọi có chứa chờ đợi và bối cảnh đồng bộ hóa yêu cầu quay lại luồng đang gọi sau đang chờ, khi đó chúng ta sẽ gặp bế tắc: Luồng gọi chờ trong khi kết quả của phương thức không đồng bộ được tính toán, phương thức không đồng bộ cố gắng tiếp tục thực thi trong luồng gọi một cách vô ích.

Một nhược điểm khác của phương pháp này là việc xử lý lỗi phức tạp. Thực tế là các lỗi trong mã không đồng bộ khi sử dụng async/await rất dễ xử lý - chúng hoạt động giống như khi mã được đồng bộ. Mặc dù nếu chúng ta áp dụng phép trừ tà chờ đợi đồng bộ cho một Tác vụ, thì ngoại lệ ban đầu sẽ biến thành AggregateException, tức là. Để xử lý ngoại lệ, bạn sẽ phải kiểm tra loại InnerException và tự viết một chuỗi if bên trong một khối bắt hoặc sử dụng cấu trúc bắt khi, thay vì chuỗi khối bắt quen thuộc hơn trong thế giới C#.

Ví dụ thứ ba và cuối cùng cũng bị đánh dấu là xấu vì lý do tương tự và chứa tất cả các vấn đề tương tự.

Các phương thức WhenAny và WhenAll cực kỳ thuận tiện cho việc chờ đợi một nhóm Nhiệm vụ; chúng gói một nhóm Nhiệm vụ thành một, nhiệm vụ này sẽ kích hoạt khi một Nhiệm vụ trong nhóm được kích hoạt lần đầu tiên hoặc khi tất cả chúng đã hoàn thành việc thực thi.

Đang dừng chủ đề

Vì nhiều lý do khác nhau, có thể cần phải dừng dòng chảy sau khi nó đã bắt đầu. Có một số cách để làm điều này. Lớp Thread có hai phương thức được đặt tên thích hợp: Huỷ bỏ и Làm gián đoạn. Cái đầu tiên rất không được khuyến khích sử dụng, bởi vì sau khi gọi nó vào bất kỳ thời điểm ngẫu nhiên nào, trong quá trình xử lý bất kỳ lệnh nào, một ngoại lệ sẽ được đưa ra Chủ đề bị hủy bỏ ngoại lệ. Bạn không mong đợi một ngoại lệ như vậy sẽ được đưa ra khi tăng bất kỳ biến số nguyên nào, phải không? Và khi sử dụng phương pháp này, đây là một tình huống rất thực tế. Nếu bạn cần ngăn CLR tạo ra ngoại lệ như vậy trong một phần mã nhất định, bạn có thể gói nó trong các cuộc gọi Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Bất kỳ mã nào được viết trong khối cuối cùng đều được gói trong các lệnh gọi như vậy. Vì lý do này, trong chiều sâu của mã khung, bạn có thể tìm thấy các khối có lần thử trống nhưng cuối cùng không trống. Microsoft không khuyến khích phương pháp này đến mức họ không đưa nó vào lõi .net.

Phương pháp Ngắt hoạt động dễ dự đoán hơn. Nó có thể làm gián đoạn thread với một ngoại lệ Chủ đề bị gián đoạnngoại lệ chỉ trong những thời điểm khi luồng ở trạng thái chờ. Nó chuyển sang trạng thái này trong khi treo trong khi chờ WaitHandle, khóa hoặc sau khi gọi Thread.Sleep.

Cả hai lựa chọn được mô tả ở trên đều xấu do tính khó đoán của chúng. Giải pháp là sử dụng cấu trúc CancelToken và lớp học CancelTokenSource. Vấn đề là thế này: một thể hiện của lớp CancellationTokenSource được tạo và chỉ người sở hữu nó mới có thể dừng hoạt động bằng cách gọi phương thức Hủy bỏ. Chỉ CancellationToken mới được chuyển cho chính hoạt động đó. Chủ sở hữu CancellingToken không thể tự hủy thao tác mà chỉ có thể kiểm tra xem thao tác đã bị hủy hay chưa. Có một thuộc tính Boolean cho việc này Đã yêu cầu hủy bỏ và phương pháp Ném Nếu HủyYêu cầu. Cái sau sẽ ném một ngoại lệ Nhiệm vụ đã hủyNgoại lệ nếu phương thức Hủy được gọi trên phiên bản CancellingToken đang được ghi lại. Và đây là phương pháp tôi khuyên bạn nên sử dụng. Đây là một cải tiến so với các tùy chọn trước đó bằng cách giành được toàn quyền kiểm soát tại thời điểm nào một hoạt động ngoại lệ có thể bị hủy bỏ.

Tùy chọn tàn bạo nhất để dừng một luồng là gọi hàm Win32 API TerminateThread. Hành vi của CLR sau khi gọi hàm này có thể không thể đoán trước được. Trên MSDN phần sau đây được viết về chức năng này: “TerminateThread là một chức năng nguy hiểm chỉ nên được sử dụng trong những trường hợp nghiêm trọng nhất. “

Chuyển đổi API kế thừa sang Dựa trên tác vụ bằng phương pháp FromAsync

Nếu bạn đủ may mắn để làm việc trong một dự án được bắt đầu sau khi Nhiệm vụ được giới thiệu và không còn gây ra nỗi kinh hoàng thầm lặng cho hầu hết các nhà phát triển, thì bạn sẽ không phải đối mặt với nhiều API cũ, cả API của bên thứ ba và nhóm của bạn đã từng hành hạ trong quá khứ. May mắn thay, nhóm .NET Framework đã quan tâm đến chúng tôi, mặc dù có lẽ mục tiêu là chăm sóc chính chúng tôi. Dù vậy, .NET có một số công cụ để chuyển đổi mã được viết theo phương pháp lập trình không đồng bộ cũ sang mã mới một cách dễ dàng. Một trong số đó là phương thức FromAsync của TaskFactory. Trong ví dụ về mã bên dưới, tôi gói các phương thức không đồng bộ cũ của lớp WebRequest vào một Tác vụ bằng phương thức này.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Đây chỉ là một ví dụ và bạn khó có thể phải làm điều này với các kiểu dựng sẵn, nhưng bất kỳ dự án cũ nào cũng chỉ đơn giản là chứa đầy các phương thức BeginDoSomething trả về các phương thức IAsyncResult và EndDoSomething nhận nó.

Chuyển đổi API kế thừa sang Dựa trên nhiệm vụ bằng cách sử dụng lớp TaskCompletionSource

Một công cụ quan trọng khác cần xem xét là lớp Hoàn thành nhiệm vụNguồn. Về chức năng, mục đích và nguyên lý hoạt động, nó có thể phần nào gợi nhớ đến phương thức RegisterWaitForSingleObject của lớp ThreadPool mà tôi đã viết ở trên. Bằng cách sử dụng lớp này, bạn có thể gói các API không đồng bộ cũ vào Nhiệm vụ một cách dễ dàng và thuận tiện.

Bạn sẽ nói rằng tôi đã nói về phương thức FromAsync của lớp TaskFactory dành cho những mục đích này. Ở đây chúng ta sẽ phải nhớ lại toàn bộ lịch sử phát triển các mô hình không đồng bộ trong .net mà Microsoft đã cung cấp trong 15 năm qua: trước Mẫu không đồng bộ dựa trên nhiệm vụ (TAP), đã có Mẫu lập trình không đồng bộ (APP), trong đó là về các phương pháp Bắt đầuDoS Something quay trở lại IAsyncResult và phương pháp Kết thúcDoSomething chấp nhận nó và trong di sản của những năm này, phương thức FromAsync hoàn hảo, nhưng theo thời gian, nó đã được thay thế bằng Mẫu không đồng bộ dựa trên sự kiện (EAP), giả định rằng một sự kiện sẽ được phát sinh khi hoạt động không đồng bộ hoàn tất.

TaskCompletionSource hoàn hảo để gói Nhiệm vụ và API kế thừa được xây dựng xung quanh mô hình sự kiện. Bản chất công việc của nó như sau: một đối tượng của lớp này có một thuộc tính công khai thuộc loại Nhiệm vụ, trạng thái của thuộc tính này có thể được kiểm soát thông qua các phương thức SetResult, SetException, v.v. của lớp TaskCompletionSource. Ở những nơi mà toán tử chờ được áp dụng cho Tác vụ này, nó sẽ được thực thi hoặc thất bại với một ngoại lệ tùy thuộc vào phương thức được áp dụng cho TaskCompletionSource. Nếu vẫn chưa rõ, hãy xem ví dụ về mã này, trong đó một số API EAP cũ được gói trong Tác vụ bằng cách sử dụng TaskCompletionSource: khi sự kiện kích hoạt, Tác vụ sẽ được chuyển sang trạng thái Đã hoàn thành và phương thức áp dụng toán tử chờ đợi Nhiệm vụ này sẽ tiếp tục thực thi nó sau khi nhận được đối tượng kết quả.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

Nhiệm vụHoàn thànhMẹo & thủ thuật nguồn

Việc gói các API cũ không phải là tất cả những gì có thể thực hiện được bằng TaskCompletionSource. Việc sử dụng lớp này mở ra một khả năng thú vị trong việc thiết kế các API khác nhau trên các Tác vụ không chiếm các luồng. Và luồng, như chúng ta nhớ, là một nguồn tài nguyên đắt tiền và số lượng của chúng bị giới hạn (chủ yếu là do dung lượng RAM). Hạn chế này có thể dễ dàng đạt được bằng cách phát triển, ví dụ, một ứng dụng web được tải với logic nghiệp vụ phức tạp. Hãy xem xét các khả năng mà tôi đang nói đến khi thực hiện một thủ thuật như Bỏ phiếu dài.

Nói tóm lại, bản chất của thủ thuật là thế này: bạn cần nhận thông tin từ API về một số sự kiện xảy ra từ phía nó, trong khi API vì lý do nào đó không thể báo cáo sự kiện mà chỉ có thể trả về trạng thái. Một ví dụ về những điều này là tất cả các API được xây dựng dựa trên HTTP trước thời của WebSocket hoặc khi vì lý do nào đó không thể sử dụng công nghệ này. Máy khách có thể hỏi máy chủ HTTP. Máy chủ HTTP không thể tự bắt đầu liên lạc với máy khách. Một giải pháp đơn giản là thăm dò máy chủ bằng cách sử dụng bộ hẹn giờ, nhưng điều này tạo ra tải bổ sung trên máy chủ và tăng thêm độ trễ trung bình cho TimeInterval / 2. Để giải quyết vấn đề này, một thủ thuật có tên Long Polling đã được phát minh, bao gồm việc trì hoãn phản hồi từ máy chủ cho đến khi Hết thời gian chờ hoặc một sự kiện sẽ xảy ra. Nếu sự kiện đã xảy ra thì nó sẽ được xử lý, nếu không thì yêu cầu sẽ được gửi lại.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Nhưng giải pháp như vậy sẽ trở nên khủng khiếp ngay khi số lượng khách hàng chờ đợi sự kiện tăng lên, bởi vì... Mỗi client như vậy chiếm toàn bộ một thread đang chờ một sự kiện. Có, và chúng tôi nhận được thêm độ trễ 1ms khi sự kiện được kích hoạt, điều này thường không đáng kể, nhưng tại sao lại khiến phần mềm trở nên tồi tệ hơn mức có thể? Nếu chúng ta loại bỏ Thread.Sleep(1), thì chúng ta sẽ tải một lõi bộ xử lý ở trạng thái không hoạt động 100%, quay trong một chu kỳ vô ích. Sử dụng TaskCompletionSource bạn có thể dễ dàng làm lại mã này và giải quyết tất cả các vấn đề được xác định ở trên:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Mã này chưa sẵn sàng để sản xuất mà chỉ là bản demo. Để sử dụng nó trong các trường hợp thực tế, tối thiểu bạn cũng cần xử lý tình huống khi một tin nhắn đến vào thời điểm không ai mong đợi nó: trong trường hợp này, phương thức AsseptMessageAsync sẽ trả về một Tác vụ đã hoàn thành. Nếu đây là trường hợp phổ biến nhất thì bạn có thể nghĩ đến việc sử dụng ValueTask.

Khi chúng tôi nhận được yêu cầu về tin nhắn, chúng tôi tạo và đặt TaskCompletionSource vào từ điển, sau đó đợi điều gì xảy ra trước: khoảng thời gian đã chỉ định hết hạn hoặc nhận được tin nhắn.

ValueTask: tại sao và như thế nào

Các toán tử async/await, như toán tử trả về lợi nhuận, tạo ra một máy trạng thái từ phương thức và đây là việc tạo một đối tượng mới, điều này hầu như không quan trọng, nhưng trong một số ít trường hợp, nó có thể tạo ra sự cố. Trường hợp này có thể là một phương pháp được gọi thực sự thường xuyên, chúng ta đang nói về hàng chục, hàng trăm nghìn cuộc gọi mỗi giây. Nếu một phương thức như vậy được viết theo cách mà trong hầu hết các trường hợp, nó trả về kết quả bỏ qua tất cả các phương thức đang chờ, thì .NET sẽ cung cấp một công cụ để tối ưu hóa điều này - cấu trúc ValueTask. Để làm rõ, chúng ta hãy xem một ví dụ về cách sử dụng nó: có một bộ nhớ đệm mà chúng ta rất thường xuyên truy cập. Có một số giá trị trong đó và sau đó chúng tôi chỉ cần trả lại chúng; nếu không, chúng tôi sẽ đi đến IO chậm nào đó để lấy chúng. Tôi muốn thực hiện việc sau một cách không đồng bộ, có nghĩa là toàn bộ phương thức hóa ra không đồng bộ. Vì vậy, cách rõ ràng để viết phương pháp này như sau:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Vì mong muốn tối ưu hóa một chút và hơi lo ngại về những gì Roslyn sẽ tạo ra khi biên dịch mã này, bạn có thể viết lại ví dụ này như sau:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

Thật vậy, giải pháp tối ưu trong trường hợp này là tối ưu hóa đường dẫn nóng, cụ thể là lấy giá trị từ từ điển mà không có bất kỳ phân bổ và tải không cần thiết nào trên GC, trong khi trong những trường hợp hiếm hoi đó khi chúng ta vẫn cần truy cập IO để lấy dữ liệu , mọi thứ sẽ vẫn là điểm cộng/trừ theo cách cũ:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Chúng ta hãy xem xét kỹ hơn đoạn mã này: nếu có một giá trị trong bộ đệm, chúng ta sẽ tạo một cấu trúc, nếu không thì tác vụ thực sự sẽ được gói gọn trong một cấu trúc có ý nghĩa. Mã gọi không quan tâm mã này được thực thi trong đường dẫn nào: ValueTask, từ quan điểm cú pháp C#, sẽ hoạt động giống như một Tác vụ thông thường trong trường hợp này.

TaskSchedulers: quản lý các chiến lược khởi động nhiệm vụ

API tiếp theo mà tôi muốn xem xét là lớp Task Scheduler và các dẫn xuất của nó. Tôi đã đề cập ở trên rằng TPL có khả năng quản lý các chiến lược phân phối Nhiệm vụ trên các luồng. Các chiến lược như vậy được xác định trong lớp con của lớp TaskScheduler. Hầu hết mọi chiến lược bạn cần đều có thể tìm thấy trong thư viện. Tiện ích mở rộng song songExtras, được phát triển bởi Microsoft, nhưng không phải là một phần của .NET mà được cung cấp dưới dạng gói Nuget. Chúng ta hãy xem xét ngắn gọn một số trong số họ:

  • CurrentThreadTaskScheduler - thực thi các tác vụ trên luồng hiện tại
  • LimitedConcurrencyLevelTaskScheduler - giới hạn số lượng Tác vụ được thực thi đồng thời bởi tham số N, được chấp nhận trong hàm tạo
  • Trình lập lịch tác vụ đã đặt hàng — được định nghĩa là LimitedConcurrencyLevelTaskScheduler(1), do đó các tác vụ sẽ được thực hiện tuần tự.
  • Công việcTrộm cắpTaskScheduler - dụng cụ ăn cắp công việc phương pháp phân bổ nhiệm vụ. Về cơ bản nó là một ThreadPool riêng biệt. Giải quyết vấn đề trong .NET ThreadPool là một lớp tĩnh, một lớp dành cho tất cả các ứng dụng, có nghĩa là việc quá tải hoặc sử dụng không đúng cách trong một phần của chương trình có thể dẫn đến tác dụng phụ ở phần khác. Hơn nữa, việc tìm hiểu nguyên nhân của những khiếm khuyết đó là điều vô cùng khó khăn. Cái đó. Có thể cần phải sử dụng WorkStealingTaskScheduler riêng biệt trong các phần của chương trình mà việc sử dụng ThreadPool có thể phức tạp và không thể đoán trước.
  • Trình lập lịch tác vụ được xếp hàng đợi — cho phép bạn thực hiện các nhiệm vụ theo quy tắc xếp hàng ưu tiên
  • Trình lập lịch ThreadPerTask — tạo một luồng riêng biệt cho mỗi Tác vụ được thực thi trên đó. Có thể hữu ích cho những nhiệm vụ mất nhiều thời gian để hoàn thành.

Có một chi tiết tốt bài viết về TaskSchedulers trên blog của Microsoft.

Để thuận tiện cho việc gỡ lỗi mọi thứ liên quan đến Nhiệm vụ, Visual Studio có cửa sổ Nhiệm vụ. Trong cửa sổ này, bạn có thể xem trạng thái hiện tại của tác vụ và chuyển đến dòng mã hiện đang thực thi.

.NET: Công cụ để làm việc với đa luồng và không đồng bộ. Phần 1

PLinq và lớp Parallel

Ngoài Nhiệm vụ và mọi điều đã nói về chúng, còn có hai công cụ thú vị hơn trong .NET: PLinq (Linq2Parallel) và lớp Parallel. Lời hứa đầu tiên hứa hẹn thực hiện song song tất cả các hoạt động Linq trên nhiều luồng. Số lượng luồng có thể được cấu hình bằng phương thức mở rộng WithDegreeOfParallelism. Thật không may, PLinq thường ở chế độ mặc định không có đủ thông tin về phần bên trong nguồn dữ liệu của bạn để mang lại tốc độ tăng đáng kể, mặt khác, chi phí thử rất thấp: bạn chỉ cần gọi phương thức AsParallel trước chuỗi các phương pháp Linq và chạy thử nghiệm hiệu suất. Hơn nữa, có thể chuyển thông tin bổ sung tới PLinq về bản chất nguồn dữ liệu của bạn bằng cơ chế Phân vùng. Bạn có thể đọc thêm đây и đây.

Lớp tĩnh Parallel cung cấp các phương thức để lặp song song thông qua bộ sưu tập Foreach, thực thi vòng lặp For và thực thi nhiều đại biểu trong lệnh gọi song song. Việc thực thi luồng hiện tại sẽ bị dừng cho đến khi quá trình tính toán hoàn tất. Số lượng luồng có thể được cấu hình bằng cách chuyển ParallelOptions làm đối số cuối cùng. Bạn cũng có thể chỉ định TaskScheduler và CancellationToken bằng các tùy chọn.

Những phát hiện

Khi tôi bắt đầu viết bài này dựa trên những tài liệu trong báo cáo của mình và những thông tin tôi thu thập được trong quá trình làm việc sau đó, tôi không ngờ rằng sẽ có nhiều thông tin như vậy. Bây giờ, khi trình soạn thảo văn bản nơi tôi đang gõ bài viết này trách móc tôi rằng trang 15 đã biến mất, tôi sẽ tóm tắt kết quả tạm thời. Các thủ thuật, API, công cụ trực quan và cạm bẫy khác sẽ được đề cập trong bài viết tiếp theo.

Kết luận:

  • Bạn cần biết các công cụ làm việc với luồng, tính không đồng bộ và tính song song để sử dụng tài nguyên của PC hiện đại.
  • .NET có nhiều công cụ khác nhau cho những mục đích này
  • Không phải tất cả chúng đều xuất hiện cùng một lúc, vì vậy bạn thường có thể tìm thấy những cái cũ, tuy nhiên, có nhiều cách để chuyển đổi API cũ mà không cần tốn nhiều công sức.
  • Làm việc với các luồng trong .NET được biểu diễn bằng các lớp Thread và ThreadPool
  • Các phương thức Thread.Abort, Thread.Interrupt và Win32 API TerminateThread rất nguy hiểm và không được khuyến khích sử dụng. Thay vào đó, tốt hơn nên sử dụng cơ chế CancellationToken
  • Dòng chảy là một nguồn tài nguyên có giá trị và nguồn cung của nó bị hạn chế. Nên tránh các tình huống mà luồng đang bận chờ sự kiện. Để làm được điều này, thật thuận tiện khi sử dụng lớp TaskCompletionSource
  • Các công cụ .NET mạnh mẽ và tiên tiến nhất để làm việc với tính song song và không đồng bộ là Nhiệm vụ.
  • Toán tử async/await trong C# triển khai khái niệm chờ không chặn
  • Bạn có thể kiểm soát việc phân phối Nhiệm vụ trên các luồng bằng cách sử dụng các lớp có nguồn gốc từ TaskScheduler
  • Cấu trúc ValueTask có thể hữu ích trong việc tối ưu hóa các đường dẫn nóng và lưu lượng bộ nhớ
  • Cửa sổ Nhiệm vụ và Chủ đề của Visual Studio cung cấp nhiều thông tin hữu ích để gỡ lỗi mã đa luồng hoặc không đồng bộ
  • PLinq là một công cụ thú vị nhưng nó có thể không có đủ thông tin về nguồn dữ liệu của bạn, nhưng điều này có thể được khắc phục bằng cơ chế phân vùng
  • Để được tiếp tục ...

Nguồn: www.habr.com

Thêm một lời nhận xét