Làm thế nào để tránh vi phạm nguyên tắc DRY khi bạn phải có cả phiên bản mã không đồng bộ và đồng bộ hóa?


15

Tôi đang làm việc trên một dự án cần hỗ trợ cả phiên bản đồng bộ hóa và đồng bộ hóa của cùng một phương pháp / logic. Vì vậy, ví dụ tôi cần phải có:

public class Foo
{
   public bool IsIt()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return conn.Query<bool>("SELECT IsIt FROM SomeTable");
      }
   }

   public async Task<bool> IsItAsync()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return await conn.QueryAsync<bool>("SELECT IsIt FROM SomeTable");
      }
   }
}

Logic không đồng bộ và logic đồng bộ hóa cho các phương thức này giống hệt nhau về mọi khía cạnh ngoại trừ một phương thức không đồng bộ và một phương thức khác thì không. Có cách nào hợp pháp để tránh vi phạm nguyên tắc DRY trong loại kịch bản này không? Tôi thấy rằng mọi người nói rằng bạn có thể sử dụng GetAwaiter (). GetResult () trên phương thức async và gọi nó từ phương thức đồng bộ hóa của bạn? Là chủ đề đó an toàn trong tất cả các kịch bản? Có cách nào khác, tốt hơn để làm điều này hay tôi buộc phải sao chép logic?


1
Bạn có thể nói thêm một chút về những gì bạn đang làm ở đây? Cụ thể, làm thế nào bạn đạt được sự không đồng bộ trong phương pháp không đồng bộ của bạn? CPU có độ trễ cao bị ràng buộc hay IO bị ràng buộc?
Eric Lippert

"Một là không đồng bộ và một số khác thì không" là sự khác biệt trong mã của phương thức thực tế hay chỉ là giao diện? (Rõ ràng đối với chỉ giao diện bạn có thể return Task.FromResult(IsIt());)
Alexei Levenkov

Ngoài ra, có lẽ các phiên bản đồng bộ và không đồng bộ đều có độ trễ cao. Trong trường hợp nào người gọi sẽ sử dụng phiên bản đồng bộ, và người gọi đó sẽ làm thế nào để giảm thiểu thực tế rằng callee có thể mất hàng tỷ nano giây? Người gọi của phiên bản đồng bộ không quan tâm rằng họ đang treo UI? Có UI không? Hãy cho chúng tôi biết thêm về kịch bản.
Eric Lippert

@EricLippert Tôi đã thêm một ví dụ giả cụ thể chỉ để cho bạn biết làm thế nào 2 cơ sở mã giống hệt nhau ngoài thực tế là một mã không đồng bộ. Bạn có thể dễ dàng tưởng tượng ra một kịch bản trong đó phương thức này phức tạp hơn nhiều và các dòng và dòng mã phải được sao chép.
Marko

@AlexeiLevenkov Sự khác biệt thực sự nằm ở mã, tôi đã thêm một số mã giả để demo nó.
Marko

Câu trả lời:


15

Bạn đã hỏi một số câu hỏi trong câu hỏi của bạn. Tôi sẽ phá vỡ chúng một chút khác nhau so với bạn đã làm. Nhưng trước tiên hãy để tôi trả lời trực tiếp câu hỏi.

Tất cả chúng ta đều muốn một chiếc máy ảnh nhẹ, chất lượng cao và giá rẻ, nhưng giống như câu nói, bạn chỉ có thể nhận được tối đa hai trong số ba. Bạn đang ở trong tình trạng tương tự ở đây. Bạn muốn một giải pháp hiệu quả, an toàn và chia sẻ mã giữa các đường dẫn đồng bộ và không đồng bộ. Bạn sẽ chỉ nhận được hai trong số đó.

Hãy để tôi phá vỡ tại sao đó là. Chúng ta sẽ bắt đầu với câu hỏi này:


Tôi thấy rằng mọi người nói rằng bạn có thể sử dụng GetAwaiter().GetResult()phương thức async và gọi nó từ phương thức đồng bộ hóa của bạn? Là chủ đề đó an toàn trong tất cả các kịch bản?

Điểm của câu hỏi này là "tôi có thể chia sẻ các đường dẫn đồng bộ và không đồng bộ bằng cách thực hiện đường dẫn đồng bộ chỉ đơn giản là chờ đồng bộ trên phiên bản không đồng bộ không?"

Hãy để tôi siêu rõ về điểm này bởi vì nó rất quan trọng:

BẠN NÊN NGAY LẬP TỨC DỪNG LẠI BẤT CỨ LỜI KHUYÊN NÀO TỪ MỌI NGƯỜI .

Đó là lời khuyên cực kỳ tồi tệ. Sẽ rất nguy hiểm khi tìm nạp đồng bộ kết quả từ một tác vụ không đồng bộ trừ khi bạn có bằng chứng cho thấy nhiệm vụ đã hoàn thành bình thường hoặc bất thường .

Lý do đây là lời khuyên cực kỳ tồi tệ, tốt, hãy xem xét kịch bản này. Bạn muốn cắt cỏ, nhưng lưỡi cắt cỏ của bạn bị hỏng. Bạn quyết định làm theo quy trình này:

  • Đặt một lưỡi dao mới từ một trang web. Đây là một hoạt động không đồng bộ, độ trễ cao.
  • Chờ đợi đồng bộ - nghĩa là ngủ cho đến khi bạn có lưỡi dao trong tay .
  • Định kỳ kiểm tra hộp thư để xem lưỡi dao đã đến chưa.
  • Lấy lưỡi dao ra khỏi hộp. Bây giờ bạn có nó trong tay.
  • Lắp lưỡi cắt vào máy cắt.
  • Cắt cỏ.

Chuyện gì xảy ra Bạn ngủ mãi mãi vì hoạt động kiểm tra thư hiện được kiểm soát trên một cái gì đó xảy ra sau khi thư đến .

Rất dễ gặp phải tình huống này khi bạn chờ đợi một cách đồng bộ một nhiệm vụ tùy ý. Nhiệm vụ đó có thể có công việc được lên lịch trong tương lai của chuỗi hiện đang chờ và bây giờ tương lai đó sẽ không bao giờ đến vì bạn đang chờ nó.

Nếu bạn chờ đợi không đồng bộ thì mọi thứ đều ổn! Bạn định kỳ kiểm tra thư và trong khi chờ đợi, bạn làm bánh sandwich hoặc đóng thuế hoặc bất cứ thứ gì; bạn tiếp tục hoàn thành công việc trong khi chờ đợi

Không bao giờ chờ đợi đồng bộ. Nếu nhiệm vụ được thực hiện, nó là không cần thiết . Nếu tác vụ không được thực hiện nhưng được lên lịch để chạy luồng hiện tại, thì nó không hiệu quả vì luồng hiện tại có thể phục vụ công việc khác thay vì chờ đợi. Nếu tác vụ không được thực hiện và lên lịch chạy trên luồng hiện tại, nó sẽ bị treo để chờ đồng bộ. Không có lý do chính đáng để chờ đợi đồng bộ, một lần nữa, trừ khi bạn đã biết rằng nhiệm vụ đã hoàn thành .

Để đọc thêm về chủ đề này, xem

https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html

Stephen giải thích kịch bản trong thế giới thực tốt hơn nhiều so với tôi có thể.


Bây giờ hãy xem xét "hướng khác". Chúng ta có thể chia sẻ mã bằng cách tạo phiên bản không đồng bộ chỉ đơn giản là thực hiện phiên bản đồng bộ trên luồng công nhân không?

Đó là có thể và thực sự có thể là một ý tưởng tồi, vì những lý do sau đây.

  • Sẽ không hiệu quả nếu hoạt động đồng bộ là công việc IO có độ trễ cao. Điều này về cơ bản thuê một công nhân và làm cho công nhân đó ngủ cho đến khi một nhiệm vụ được thực hiện. Chủ đề là cực kỳ tốn kém . Theo mặc định, chúng tiêu thụ một triệu byte không gian địa chỉ, chúng mất thời gian, chúng chiếm tài nguyên hệ điều hành; bạn không muốn đốt cháy một chủ đề làm công việc vô ích.

  • Hoạt động đồng bộ có thể không được viết là luồng an toàn.

  • Đây một kỹ thuật hợp lý hơn nếu công việc có độ trễ cao bị ràng buộc bởi bộ xử lý, nhưng nếu có thì có lẽ bạn không muốn đơn giản chuyển nó cho một luồng công nhân. Bạn có thể muốn sử dụng thư viện song song tác vụ để song song nó với càng nhiều CPU càng tốt, bạn có thể muốn logic hủy bỏ và bạn không thể đơn giản làm cho phiên bản đồng bộ làm tất cả điều đó, vì đó đã là phiên bản không đồng bộ .

Đọc thêm; một lần nữa, Stephen giải thích rất rõ ràng:

Tại sao không sử dụng Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-USE.html

Nhiều kịch bản "làm và không" cho Nhiệm vụ.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html


Điều đó sau đó để lại cho chúng tôi những gì? Cả hai kỹ thuật để chia sẻ mã đều dẫn đến bế tắc hoặc thiếu hiệu quả. Kết luận mà chúng tôi đạt được là bạn phải đưa ra lựa chọn. Bạn có muốn một chương trình hiệu quả và chính xác và làm hài lòng người gọi hay bạn muốn lưu một vài tổ hợp phím được yêu cầu bằng cách sao chép một lượng nhỏ mã giữa các đường dẫn đồng bộ và không đồng bộ? Bạn không nhận được cả hai, tôi sợ.


Bạn có thể giải thích tại sao việc trở lại Task.Run(() => SynchronousMethod())trong phiên bản async không phải là điều OP muốn không?
Guillaume Sasdy

2
@GuillaumeSasdy: Người đăng ban đầu đã trả lời câu hỏi công việc là gì: đó là IO bị ràng buộc. Thật lãng phí khi chạy các tác vụ ràng buộc IO trên luồng công nhân!
Eric Lippert

Được rồi tôi hiểu. Tôi nghĩ bạn đã đúng, và câu trả lời @StriplingWar Warrior còn giải thích điều đó nhiều hơn nữa.
Guillaume Sasdy

2
@Marko gần như bất kỳ cách giải quyết nào chặn luồng cuối cùng (sẽ chịu tải cao) sẽ tiêu thụ tất cả các luồng xử lý luồng (thường được sử dụng để hoàn thành các hoạt động không đồng bộ). Kết quả là tất cả các luồng sẽ chờ và không có luồng nào để cho phép các hoạt động chạy phần "hoàn thành thao tác async" của mã. Hầu hết các cách giải quyết đều ổn trong các tình huống tải thấp (vì có rất nhiều luồng thậm chí 2-3 bị chặn như một phần của từng thao tác). Và nếu bạn có thể đảm bảo rằng việc hoàn thành hoạt động async sẽ chạy trên luồng hệ điều hành mới (không phải nhóm luồng) thậm chí có thể làm việc cho tất cả các trường hợp (bạn phải trả giá cao như vậy)
Alexei Levenkov

2
Đôi khi thực sự không có cách nào khác ngoài "chỉ đơn giản là thực hiện phiên bản đồng bộ trên luồng công nhân". Ví dụ: đó là cách Dns.GetHostEntryAsynctriển khai trong .NET và FileStream.ReadAsyncđối với một số loại tệp nhất định. HĐH chỉ đơn giản là không cung cấp giao diện không đồng bộ, do đó, bộ thực thi phải giả mạo nó (và nó không phải là ngôn ngữ cụ thể mà nói, thời gian chạy Erlang chạy toàn bộ cây quy trình công nhân , với nhiều luồng trong mỗi luồng, để cung cấp I / O không chặn đĩa độ phân giải).
Joker_vD

6

Thật khó để đưa ra câu trả lời một kích cỡ phù hợp cho câu hỏi này. Thật không may, không có cách đơn giản, hoàn hảo để sử dụng lại giữa mã không đồng bộ và mã đồng bộ. Nhưng đây là một vài nguyên tắc để xem xét:

  1. Mã không đồng bộ và mã đồng bộ thường khác nhau về cơ bản. Ví dụ, mã không đồng bộ nên bao gồm mã thông báo hủy. Và thường thì nó kết thúc bằng cách gọi các phương thức khác nhau (như ví dụ của bạn gọi Query()theo cách này và QueryAsync()cách khác) hoặc thiết lập kết nối với các cài đặt khác nhau. Vì vậy, ngay cả khi nó có cấu trúc tương tự nhau, thường có đủ sự khác biệt trong hành vi để xứng đáng với việc chúng được coi là mã riêng biệt với các yêu cầu khác nhau. Lưu ý sự khác biệt giữa triển khai AsyncSync của các phương thức trong lớp Tệp, ví dụ: không có nỗ lực nào được thực hiện để làm cho chúng sử dụng cùng một mã
  2. Nếu bạn đang cung cấp chữ ký phương thức không đồng bộ để triển khai giao diện, nhưng bạn tình cờ có một triển khai đồng bộ (nghĩa là không có gì không đồng bộ về phương thức của bạn), bạn có thể chỉ cần trả về Task.FromResult(...).
  3. Bất kỳ mảnh đồng bộ của logic mà giống nhau giữa hai phương pháp có thể được trích xuất đến một phương pháp helper riêng biệt và đòn bẩy trong cả hai phương pháp.

Chúc may mắn.


-2

Dễ dàng; có một cuộc gọi đồng bộ một cuộc gọi không đồng bộ. Thậm chí còn có một phương pháp hữu ích Task<T>để làm việc đó:

public class Foo
{
   public bool IsIt()
   {
      var task = IsItAsync(); //no await here; we want the Task

      //Some tasks end up scheduled to run before you get them;
      //don't try to run them a second time
      if((int)task.Status > (int)TaskStatus.Created)
          //this call will block the current thread,
          //and unlike Run()/Wait() will prefer the current 
          //thread's TaskScheduler instead of a new thread.
          task.RunSynchronously(); 

      //if IsItAsync() can throw exceptions,
      //you still need a Wait() call to bring those back from the Task
      try{ 
          task.Wait();
          return task.Result;
      }
      catch(Exception ex) 
      { 
          //Handle IsItAsync() exceptions here;
          //remember to return something if you don't rethrow              
      }
   }

   public async Task<bool> IsItAsync()
   {
      // Some async logic
   }
}

1
Xem câu trả lời của tôi để biết tại sao đây là một thực hành tồi tệ nhất mà bạn không bao giờ phải làm.
Eric Lippert

1
Câu trả lời của bạn thỏa thuận với GetAwaiter (). GetResult (). Tôi tránh điều đó. Thực tế là đôi khi bạn phải tạo một phương thức chạy đồng bộ để sử dụng nó trong ngăn xếp cuộc gọi không thể thực hiện không đồng bộ. Nếu không bao giờ chờ đồng bộ hóa, thì với tư cách là thành viên (cũ) của nhóm C #, vui lòng cho tôi biết lý do tại sao bạn đặt không chỉ một mà hai cách để chờ đồng bộ vào một nhiệm vụ không đồng bộ vào TPL.
KeithS

Người đặt câu hỏi đó sẽ là Stephen Toub chứ không phải tôi. Tôi chỉ làm việc về mặt ngôn ngữ.
Eric Lippert
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.