Không phải các hướng dẫn của async / đang chờ sử dụng trong C # có mâu thuẫn với các khái niệm về kiến ​​trúc tốt và phân lớp trừu tượng không?


103

Câu hỏi này liên quan đến ngôn ngữ C #, nhưng tôi hy vọng nó sẽ bao gồm các ngôn ngữ khác như Java hoặc TypeScript.

Microsoft khuyến nghị thực hành tốt nhất về việc sử dụng các cuộc gọi không đồng bộ trong .NET. Trong số các khuyến nghị này, chúng ta hãy chọn hai:

  • thay đổi chữ ký của các phương thức async để chúng trả về Nhiệm vụ hoặc Tác vụ <> (trong TypeScript, đó sẽ là một Lời hứa <>)
  • thay đổi tên của các phương thức async để kết thúc bằng xxxAsync ()

Bây giờ, khi thay thế một thành phần đồng bộ, cấp thấp bằng một thành phần không đồng bộ, điều này sẽ tác động đến toàn bộ ứng dụng. Vì async / await chỉ có tác động tích cực nếu được sử dụng "hết cách", điều đó có nghĩa là tên chữ ký và phương thức của mỗi lớp trong ứng dụng phải được thay đổi.

Một kiến ​​trúc tốt thường liên quan đến việc đặt trừu tượng giữa mỗi lớp, sao cho việc thay thế các thành phần cấp thấp bằng các thành phần khác không được nhìn thấy bởi các thành phần cấp trên. Trong C #, trừu tượng có dạng giao diện. Nếu chúng tôi giới thiệu một thành phần mới, mức độ thấp, không đồng bộ, mỗi giao diện trong ngăn xếp cuộc gọi cần phải được sửa đổi hoặc thay thế bằng một giao diện mới. Cách giải quyết vấn đề (không đồng bộ hoặc đồng bộ hóa) trong lớp triển khai không bị ẩn (trừu tượng hóa) đối với người gọi nữa. Người gọi phải biết đó là đồng bộ hóa hay không đồng bộ.

Không đồng bộ / chờ đợi các thực tiễn tốt nhất mâu thuẫn với các nguyên tắc "kiến trúc tốt"?

Điều đó có nghĩa là mỗi giao diện (giả sử IEnumerable, IDataAccessLayer) cần đối tác không đồng bộ của chúng (IAsyncEnumerable, IAsyncDataAccessLayer) sao cho chúng có thể được thay thế trong ngăn xếp khi chuyển sang phụ thuộc async?

Nếu chúng ta đẩy vấn đề đi xa hơn một chút, sẽ không đơn giản hơn khi giả sử mọi phương thức là không đồng bộ (để trả về Tác vụ <> hoặc Lời hứa <>) và cho các phương thức để đồng bộ hóa các cuộc gọi không đồng bộ khi chúng không thực sự không đồng bộ? Đây có phải là một cái gì đó được mong đợi từ các ngôn ngữ lập trình trong tương lai?


5
Trong khi điều này nghe có vẻ như một câu hỏi thảo luận tuyệt vời, tôi nghĩ rằng điều này quá dựa trên ý kiến ​​để được trả lời ở đây.
Euphoric

22
@Euphoric: Tôi nghĩ rằng vấn đề được tìm thấy ở đây đi sâu hơn các hướng dẫn của C #, đó chỉ là một triệu chứng của việc thay đổi các phần của ứng dụng thành hành vi không đồng bộ có thể có tác động không cục bộ trên toàn bộ hệ thống. Vì vậy, ruột của tôi nói với tôi rằng phải có một câu trả lời không có ý kiến ​​cho việc này, dựa trên các sự kiện kỹ thuật. Do đó, tôi khuyến khích mọi người ở đây không đóng câu hỏi này quá sớm, thay vào đó chúng ta hãy chờ đợi câu trả lời nào sẽ đến (và nếu họ quá quan tâm, chúng ta vẫn có thể bỏ phiếu cho việc đóng).
Doc Brown

25
@DocBrown Tôi nghĩ câu hỏi sâu hơn ở đây là "Có thể thay đổi một phần hệ thống từ đồng bộ sang không đồng bộ mà không có các bộ phận phụ thuộc vào nó cũng phải thay đổi không?" Tôi nghĩ rằng câu trả lời là rõ ràng "không". Trong trường hợp đó, tôi không thấy "các khái niệm về kiến ​​trúc tốt và phân lớp" áp dụng ở đây như thế nào.
Euphoric

6
@Euphoric: nghe có vẻ là một cơ sở tốt cho câu trả lời không có ý kiến ​​;-)
Doc Brown

5
@Gherman: vì C #, giống như nhiều ngôn ngữ, không thể quá tải chỉ dựa trên loại trả về. Bạn sẽ kết thúc với các phương thức không đồng bộ có cùng chữ ký với các đối tác đồng bộ hóa của chúng (không phải tất cả đều có thể lấy CancellationTokenvà các phương thức có thể muốn cung cấp mặc định). Loại bỏ các phương thức đồng bộ hóa hiện có (và chủ động phá vỡ tất cả mã) là một cách không rõ ràng.
Jeroen Mostert

Câu trả lời:


111

Chức năng của bạn màu gì?

Bạn có thể quan tâm đến chức năng 1 của bạn của Bob Nystrom .

Trong bài viết này, ông mô tả một ngôn ngữ hư cấu trong đó:

  • Mỗi chức năng có một màu: xanh hoặc đỏ.
  • Một chức năng màu đỏ có thể gọi các chức năng màu xanh hoặc màu đỏ, không có vấn đề.
  • Một chức năng màu xanh chỉ có thể gọi các chức năng màu xanh.

Mặc dù hư cấu, điều này xảy ra khá thường xuyên trong các ngôn ngữ lập trình:

  • Trong C ++, phương thức "const" chỉ có thể gọi các phương thức "const" khác this.
  • Trong Haskell, chức năng không phải IO chỉ có thể gọi các chức năng không phải IO.
  • Trong C #, chức năng đồng bộ chỉ có thể gọi chức năng đồng bộ hóa 2 .

Như bạn đã nhận ra, vì các quy tắc này, các hàm màu đỏ có xu hướng lan truyền xung quanh cơ sở mã. Bạn chèn một, và từng chút một nó sẽ chiếm toàn bộ cơ sở mã.

1 Bob Nystrom, ngoài việc viết blog, còn là thành viên của nhóm Dart và đã viết ra serie Phiên dịch thủ công nhỏ này; rất khuyến khích cho bất kỳ ngôn ngữ lập trình / trình biên dịch afficionado.

2 Không hoàn toàn đúng, vì bạn có thể gọi một hàm async và chặn cho đến khi nó trả về, nhưng ...

Giới hạn ngôn ngữ

Về cơ bản, đây là một giới hạn về ngôn ngữ / thời gian chạy.

Ví dụ, ngôn ngữ có luồng M: N, chẳng hạn như Erlang và Go, không có asyncchức năng: mỗi chức năng có khả năng không đồng bộ và "sợi" của nó sẽ đơn giản bị treo, tráo đổi và đổi chỗ khi nó sẵn sàng trở lại.

C # đã đi với mô hình luồng 1: 1, và do đó quyết định hiển thị tính đồng bộ bề mặt trong ngôn ngữ để tránh vô tình chặn các luồng.

Khi có sự hạn chế về ngôn ngữ, các hướng dẫn mã hóa phải thích nghi.


4
Các hàm IO có xu hướng lan truyền, nhưng với sự chuyên cần, bạn hầu như có thể cách ly chúng với các hàm gần (trong ngăn xếp khi gọi) các điểm nhập mã của bạn. Bạn có thể làm điều này bằng cách yêu cầu các hàm đó gọi các hàm IO và sau đó các hàm khác xử lý đầu ra từ chúng và trả về bất kỳ kết quả nào cần thiết cho IO tiếp theo. Tôi thấy rằng phong cách này làm cho các cơ sở mã của tôi dễ dàng quản lý và làm việc hơn. Tôi tự hỏi nếu có một hệ luỵ với tính đồng bộ.
jpmc26

16
Bạn có ý nghĩa gì khi xâu chuỗi "M: N" và "1: 1"?
Thuyền trưởng Man

14
@CaptainMan: phân luồng 1: 1 có nghĩa là ánh xạ một luồng ứng dụng sang một luồng hệ điều hành, đây là trường hợp trong các ngôn ngữ như C, C ++, Java hoặc C #. Ngược lại, phân luồng M: N có nghĩa là ánh xạ các luồng ứng dụng M sang các luồng N OS; trong trường hợp của Go, một chuỗi ứng dụng được gọi là "goroutine", trong trường hợp của Erlang, nó được gọi là "diễn viên" và bạn cũng có thể đã nghe nói chúng là "sợi xanh" hoặc "sợi". Họ cung cấp đồng thời mà không yêu cầu song song. Thật không may, bài viết Wikipedia về chủ đề này khá thưa thớt.
Matthieu M.

2
Điều này có phần liên quan nhưng tôi cũng nghĩ ý tưởng "màu chức năng" này cũng áp dụng cho các chức năng chặn đầu vào từ người dùng, ví dụ: hộp thoại phương thức, hộp thông báo, một số dạng I / O của bảng điều khiển, v.v., là những công cụ khuôn khổ đã có từ đầu.
jrh

2
@MatthieuM. C # không có một luồng ứng dụng cho mỗi luồng HĐH và không bao giờ có. Điều này rất rõ ràng khi bạn tương tác với mã gốc và đặc biệt rõ ràng khi chạy trong MS SQL. Và tất nhiên, các thói quen hợp tác luôn luôn có thể (và thậm chí còn đơn giản hơn async); thật vậy, đó là một mô hình khá phổ biến để xây dựng giao diện người dùng đáp ứng. Nó có đẹp như Erlang không? Không. Nhưng đó vẫn là một khoảng cách xa so với C :)
Luaan

82

Bạn đúng là có một mâu thuẫn ở đây, nhưng nó không phải là "thực hành tốt nhất" là xấu. Đó là bởi vì chức năng không đồng bộ về cơ bản không có gì khác với chức năng đồng bộ. Thay vì chờ kết quả từ các phụ thuộc của nó (thường là một số IO), nó tạo ra một tác vụ được xử lý bởi vòng lặp sự kiện chính. Đây không phải là một sự khác biệt có thể được ẩn giấu dưới sự trừu tượng.


27
Câu trả lời đơn giản như IMO này. Sự khác biệt giữa một quy trình đồng bộ và không đồng bộ không phải là một chi tiết triển khai - đó là một hợp đồng khác nhau về mặt ngữ nghĩa.
Ant P

11
@AntP: Tôi không đồng ý rằng nó đơn giản; nó xuất hiện trong ngôn ngữ C #, nhưng không phải bằng ngôn ngữ Go chẳng hạn. Vì vậy, đây không phải là một thuộc tính vốn có của các quy trình không đồng bộ, vấn đề là các quy trình không đồng bộ được mô hình hóa như thế nào trong ngôn ngữ đã cho.
Matthieu M.

1
@MatthieuM. Có, nhưng bạn cũng có thể sử dụng asynccác phương thức trong C # để cung cấp các hợp đồng đồng bộ, nếu bạn muốn. Sự khác biệt duy nhất là Go không đồng bộ theo mặc định trong khi C # theo mặc định là đồng bộ. asynccung cấp cho bạn mô hình lập trình thứ hai - async là sự trừu tượng hóa (những gì nó thực sự phụ thuộc vào thời gian chạy, lập lịch tác vụ, bối cảnh đồng bộ hóa, triển khai trình chờ ...).
Luaan

6

Một phương thức không đồng bộ hoạt động khác với một phương thức đồng bộ, vì tôi chắc chắn rằng bạn biết. Trong thời gian chạy, để chuyển đổi một cuộc gọi không đồng bộ thành một cuộc gọi đồng bộ là chuyện nhỏ, nhưng điều ngược lại không thể nói. Vì vậy, logic sau đó trở thành, tại sao chúng ta không tạo ra các phương thức không đồng bộ của mọi phương thức có thể yêu cầu nó và để người gọi "chuyển đổi" khi cần thiết thành một phương thức đồng bộ?

Theo một nghĩa nào đó, nó giống như có một phương pháp đưa ra các ngoại lệ và một phương pháp khác là "an toàn" và sẽ không ném ngay cả trong trường hợp có lỗi. Tại thời điểm nào thì lập trình viên quá mức để cung cấp các phương thức này mà nếu không thì có thể chuyển đổi cái này sang cái khác?

Trong đó có hai trường phái tư duy: một là tạo ra nhiều phương thức, mỗi phương thức gọi một phương thức riêng tư khác có thể cho phép khả năng cung cấp các tham số tùy chọn hoặc thay đổi nhỏ cho hành vi như không đồng bộ. Cách khác là tối thiểu hóa các phương thức giao diện đến các yếu tố cần thiết để cho người gọi tự thực hiện các sửa đổi cần thiết.

Nếu bạn thuộc trường đầu tiên, sẽ có một logic nhất định để dành một lớp cho các cuộc gọi đồng bộ và không đồng bộ để tránh nhân đôi mỗi cuộc gọi. Microsoft có xu hướng ủng hộ trường phái tư tưởng này và theo quy ước, để phù hợp với phong cách mà Microsoft ưa thích, bạn cũng sẽ phải có phiên bản Async, giống như cách mà giao diện hầu như luôn bắt đầu bằng chữ "I". Hãy để tôi nhấn mạnh rằng điều đó không sai , vì sẽ tốt hơn nếu giữ một phong cách nhất quán trong một dự án thay vì làm "đúng cách" và thay đổi hoàn toàn phong cách cho sự phát triển mà bạn thêm vào một dự án.

Điều đó nói rằng, tôi có xu hướng ủng hộ trường thứ hai, đó là giảm thiểu các phương thức giao diện. Nếu tôi nghĩ một phương thức có thể được gọi theo cách không đồng bộ, thì phương thức đó đối với tôi là không đồng bộ. Người gọi có thể quyết định có chờ đợi nhiệm vụ đó kết thúc hay không trước khi tiếp tục. Nếu giao diện này là giao diện cho thư viện, sẽ hợp lý hơn khi thực hiện theo cách này để giảm thiểu số lượng phương thức bạn cần để loại bỏ hoặc điều chỉnh. Nếu giao diện được sử dụng nội bộ trong dự án của tôi, tôi sẽ thêm một phương thức cho mọi cuộc gọi cần thiết trong suốt dự án của mình cho các tham số được cung cấp và không có phương thức "phụ", và thậm chí sau đó, chỉ khi hành vi của phương thức chưa được bao phủ bằng một phương pháp hiện có.

Tuy nhiên, giống như nhiều thứ trong lĩnh vực này, nó chủ yếu là chủ quan. Cả hai phương pháp đều có ưu và nhược điểm của chúng. Microsoft cũng bắt đầu quy ước thêm các chữ cái chỉ loại ở đầu tên biến và "m_" để chỉ ra nó là thành viên, dẫn đến tên biến như thế nào m_pUser. Quan điểm của tôi là thậm chí Microsoft không thể sai lầm và cũng có thể mắc lỗi.

Điều đó nói rằng, nếu dự án của bạn tuân theo quy ước Async này, tôi sẽ khuyên bạn nên tôn trọng nó và tiếp tục phong cách. Và chỉ khi bạn được giao một dự án của riêng bạn, bạn có thể viết nó theo cách tốt nhất mà bạn thấy phù hợp.


6
"Trong thời gian chạy, để chuyển đổi một cuộc gọi không đồng bộ thành một cuộc gọi đồng bộ là chuyện nhỏ" Tôi không chắc nó chính xác là như vậy. Trong .NET, sử dụng .Wait()phương thức và thích có thể gây ra hậu quả tiêu cực, và trong js, theo như tôi biết, điều đó là không thể.
max630

2
@ max630 Tôi không nói rằng không có vấn đề đồng thời để xem xét, nhưng nếu ban đầu nó là một nhiệm vụ đồng bộ , thì rất có thể nó không tạo ra bế tắc. Điều đó nói rằng, tầm thường không có nghĩa là "nhấp đúp vào đây để chuyển đổi thành đồng bộ". Trong js, bạn trả về một cá thể Promise và gọi giải quyết nó.
Neil

2
vâng, nó hoàn toàn là một nỗi đau ở mông để chuyển đổi không đồng bộ trở lại để đồng bộ hóa
Ewan

4
@Neil Trong javascript, ngay cả khi bạn gọi Promise.resolve(x)và sau đó thêm các cuộc gọi lại vào nó, những cuộc gọi lại đó sẽ không được thực thi ngay lập tức.
NickL

1
@Neil nếu một giao diện hiển thị một phương thức không đồng bộ, hy vọng rằng việc chờ đợi trên Tác vụ sẽ không tạo ra bế tắc không phải là một giả định tốt. Giao diện sẽ tốt hơn nhiều khi cho thấy nó thực sự đồng bộ trong chữ ký phương thức, hơn là một lời hứa trong tài liệu có thể thay đổi trong phiên bản mới hơn.
Carl Walsh

2

Hãy tưởng tượng có một cách để cho phép bạn gọi các chức năng theo cách không đồng bộ mà không thay đổi chữ ký của chúng.

Điều đó sẽ rất tuyệt và không ai khuyên bạn nên thay đổi tên của họ.

Nhưng, các hàm không đồng bộ thực tế, không chỉ các hàm đang chờ một hàm async khác, mà ở mức thấp nhất có một số cấu trúc để chúng đặc trưng cho bản chất không đồng bộ của chúng. ví dụ

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Đó là hai bit thông tin mà mã gọi cần để chạy mã theo cách không đồng bộ mà mọi thứ thích Taskasyncgói gọn.

Có nhiều hơn một cách để thực hiện các hàm không đồng bộ, async Taskchỉ là một mẫu được tích hợp trong trình biên dịch thông qua các kiểu trả về để bạn không phải liên kết thủ công các sự kiện


0

Tôi sẽ giải quyết vấn đề chính theo cách ít C # ness và chung chung hơn:

Không đồng bộ / chờ đợi các thực tiễn tốt nhất mâu thuẫn với các nguyên tắc "kiến trúc tốt"?

Tôi sẽ nói rằng nó chỉ phụ thuộc vào lựa chọn bạn đưa ra trong thiết kế API của bạn và những gì bạn cho người dùng.

Nếu bạn muốn một chức năng của API của mình chỉ là không đồng bộ thì sẽ không có nhiều hứng thú để tuân theo quy ước đặt tên. Chỉ cần luôn trả về Nhiệm vụ <> / Lời hứa <> / Tương lai <> / ... là loại trả về, đó là tài liệu tự. Nếu muốn có một câu trả lời đồng bộ, anh ta vẫn có thể làm điều đó bằng cách chờ đợi, nhưng nếu anh ta luôn làm điều đó, nó sẽ tạo ra một chút sôi nổi.

Tuy nhiên, nếu bạn chỉ thực hiện đồng bộ hóa API, điều đó có nghĩa là nếu người dùng muốn nó không đồng bộ, anh ta sẽ phải tự quản lý phần không đồng bộ của nó.

Điều này có thể tạo ra khá nhiều công việc phụ, tuy nhiên nó cũng có thể cung cấp thêm quyền kiểm soát cho người dùng về số lượng cuộc gọi đồng thời mà anh ta cho phép, đặt thời gian chờ, retrys, v.v.

Trong một hệ thống lớn với API khổng lồ, việc triển khai hầu hết chúng để được đồng bộ hóa theo mặc định có thể dễ dàng và hiệu quả hơn so với việc quản lý độc lập từng phần của API của bạn, đặc biệt nếu chúng chia sẻ các nguồn tài nguyên (hệ thống tệp, CPU, cơ sở dữ liệu, ...).

Trong thực tế đối với các phần phức tạp nhất, bạn hoàn toàn có thể có hai triển khai cùng một phần API của mình, một phần đồng bộ thực hiện các công cụ tiện dụng, một phần không đồng bộ dựa trên phần đồng bộ thực hiện xử lý công cụ và chỉ quản lý đồng thời, tải, hết thời gian và thử lại .

Có lẽ ai đó khác có thể chia sẻ kinh nghiệm của mình với điều đó vì tôi thiếu kinh nghiệm với các hệ thống như vậy.


2
@Mirus Bạn đã sử dụng "gọi phương thức async từ phương thức đồng bộ hóa" trong cả hai khả năng.
Adrian Wragg

@AdrianWragg Tôi cũng vậy; bộ não của tôi phải có một điều kiện chủng tộc. Tôi sẽ sửa chữa nó.
Miral

Đó là cách khác; Việc gọi một phương thức không đồng bộ từ một phương thức đồng bộ là chuyện nhỏ, nhưng không thể gọi phương thức đồng bộ hóa từ phương thức không đồng bộ. . Thật không may, đó cũng là lựa chọn khó khăn hơn, bởi vì việc triển khai async chỉ có thể gọi các phương thức async.
Miral

(Và theo cách này, tất nhiên tôi có nghĩa là một phương thức đồng bộ hóa chặn . Bạn có thể gọi một cái gì đó thực hiện tính toán ràng buộc CPU thuần túy từ một phương thức không đồng bộ - mặc dù bạn nên cố gắng tránh thực hiện trừ khi bạn biết bạn đang ở trong bối cảnh công nhân thay vì bối cảnh UI - nhưng chặn các cuộc gọi chờ ở chế độ khóa hoặc cho I / O hoặc cho một hoạt động khác là một ý tưởng tồi.)
Miral
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.