Tại sao tất cả các hàm không được đồng bộ hóa theo mặc định?


107

Mô hình async-await của .net 4.5 đang thay đổi mô hình. Nó gần như quá tốt để trở thành sự thật.

Tôi đã chuyển một số mã IO-heavy sang async-await vì việc chặn đã là dĩ vãng.

Khá nhiều người đang so sánh sự chờ đợi không đồng bộ với sự phá hoại của zombie và tôi thấy nó khá chính xác. Mã không đồng bộ thích mã không đồng bộ khác (bạn cần một hàm không đồng bộ để chờ đợi một hàm không đồng bộ). Vì vậy, ngày càng nhiều hàm trở nên không đồng bộ và điều này tiếp tục phát triển trong cơ sở mã của bạn.

Thay đổi các chức năng thành không đồng bộ là công việc có phần lặp đi lặp lại và không thể tưởng tượng được. Ném một asynctừ khóa vào khai báo, bao gồm giá trị trả về Task<>và bạn đã hoàn thành khá nhiều việc. Thật đáng lo ngại về mức độ dễ dàng của toàn bộ quá trình, và khá sớm một tập lệnh thay thế văn bản sẽ tự động hóa hầu hết các "chuyển" cho tôi.

Và bây giờ câu hỏi là .. Nếu tất cả mã của tôi đang dần chuyển sang trạng thái không đồng bộ, tại sao không đặt tất cả mã không đồng bộ theo mặc định?

Lý do rõ ràng mà tôi cho là hiệu suất. Async-await có chi phí và mã không cần phải async, tốt nhất là không nên. Nhưng nếu hiệu suất là vấn đề duy nhất, chắc chắn một số tối ưu hóa thông minh có thể tự động loại bỏ chi phí khi không cần thiết. Tôi đã đọc về tối ưu hóa "đường dẫn nhanh" , và đối với tôi, có vẻ như chỉ riêng nó sẽ đảm nhận hầu hết việc đó.

Có thể điều này có thể so sánh với sự thay đổi mô hình do những người thu gom rác mang lại. Trong những ngày đầu tiên của GC, việc giải phóng bộ nhớ của chính bạn chắc chắn hiệu quả hơn. Nhưng số đông vẫn chọn thu thập tự động vì mã an toàn hơn, đơn giản hơn có thể kém hiệu quả hơn (và thậm chí điều đó được cho là không còn đúng nữa). Có lẽ đây là trường hợp ở đây? Tại sao không phải tất cả các chức năng đều không đồng bộ?


8
Cung cấp cho nhóm C # tín dụng để đánh dấu bản đồ. Giống như nó đã được thực hiện hàng trăm năm trước, "những con rồng nằm ở đây". Bạn có thể trang bị một con tàu và đến đó, rất có thể bạn sẽ sống sót qua nó khi có Mặt trời chiếu sáng và gió sau lưng. Và đôi khi không, họ không bao giờ quay trở lại. Giống như async / await, SO chứa đầy các câu hỏi từ người dùng không hiểu cách họ thoát khỏi bản đồ. Mặc dù họ đã nhận được một cảnh báo khá tốt. Bây giờ là vấn đề của họ, không phải vấn đề của đội C #. Họ đánh dấu những con rồng.
Hans Passant

1
@Sayse thậm chí nếu bạn loại bỏ sự phân biệt giữa chức năng đồng bộ và async, chức năng được thực hiện đồng bộ gọi vẫn sẽ được đồng bộ (như ví dụ WriteLine của bạn)
talkol

2
“Bọc giá trị trả về bằng Task <>” Trừ khi phương thức của bạn phải async(để thực hiện một số hợp đồng), đây có lẽ là một ý tưởng tồi. Bạn đang nhận được những nhược điểm của không đồng bộ (tăng chi phí của các cuộc gọi phương thức; sự cần thiết phải sử dụng awaittrong mã gọi), nhưng không có ưu điểm nào.
svick

1
Đây là một câu hỏi thực sự thú vị nhưng có lẽ không phù hợp lắm với SO. Chúng tôi có thể xem xét chuyển nó sang Lập trình viên.
Eric Lippert

1
Có lẽ tôi đang thiếu một cái gì đó, nhưng tôi có cùng một câu hỏi. Nếu async / await luôn đi theo từng cặp và mã vẫn phải đợi nó thực thi xong, thì tại sao không cập nhật .NET framework hiện có và đặt các phương thức cần async - async theo mặc định mà KHÔNG tạo thêm từ khóa? Ngôn ngữ đã trở thành thứ mà nó được thiết kế để thoát ra - từ khóa mì Ý. Tôi nghĩ rằng họ nên ngừng làm điều đó sau khi "var" được đề xuất. Bây giờ chúng ta có "dynamic", asyn / await ... vv ... Tại sao các bạn không sử dụng .NET-ify javascript? ;))
monstro

Câu trả lời:


127

Trước tiên, cảm ơn bạn đã có những lời tốt đẹp. Nó thực sự là một tính năng tuyệt vời và tôi rất vui vì đã là một phần nhỏ của nó.

Nếu tất cả mã của tôi đang dần chuyển sang trạng thái không đồng bộ, tại sao không đặt tất cả mã không đồng bộ theo mặc định?

Chà, bạn đang phóng đại; tất cả mã của bạn không chuyển sang trạng thái không đồng bộ. Khi bạn cộng hai số nguyên "thuần túy" với nhau, bạn sẽ không phải chờ đợi kết quả. Khi bạn thêm hai số nguyên trong tương lai với nhau để có được số nguyên thứ ba trong tương lai - bởi vì đó Task<int>là số nguyên mà bạn sẽ có quyền truy cập trong tương lai - tất nhiên bạn có thể sẽ chờ kết quả.

Lý do chính để không làm cho mọi thứ không đồng bộ là vì mục đích của async / await là giúp viết mã dễ dàng hơn trong một thế giới có nhiều thao tác có độ trễ cao . Phần lớn các hoạt động của bạn có độ trễ không cao, vì vậy sẽ không có ý nghĩa gì nếu lấy hiệu suất làm giảm độ trễ đó. Thay vào đó, một vài thao tác chính của bạn có độ trễ cao và những thao tác đó đang gây ra sự xâm nhập không đồng bộ của thây ma trong toàn bộ mã.

nếu hiệu suất là vấn đề duy nhất, chắc chắn một số tối ưu hóa thông minh có thể tự động loại bỏ chi phí khi không cần thiết.

Về lý thuyết, lý thuyết và thực hành tương tự nhau. Trong thực tế, chúng không bao giờ như vậy.

Hãy để tôi cung cấp cho bạn ba điểm chống lại kiểu chuyển đổi này, theo sau là vượt qua tối ưu hóa.

Điểm đầu tiên một lần nữa là: async trong C # / VB / F # về cơ bản là một dạng giới hạn của việc truyền tiếp tục . Một số lượng lớn các nghiên cứu trong cộng đồng ngôn ngữ chức năng đã đi vào việc tìm ra các cách để xác định cách tối ưu hóa mã sử dụng nhiều kiểu truyền tiếp tục. Nhóm biên dịch có thể sẽ phải giải quyết các vấn đề rất giống nhau trong một thế giới mà "async" là mặc định và các phương thức không phải async phải được xác định và khử async-ified. Nhóm C # không thực sự quan tâm đến việc giải quyết các vấn đề nghiên cứu mở, vì vậy đó là điểm lớn chống lại ngay ở đó.

Điểm thứ hai chống lại là C # không có mức độ "minh bạch tham chiếu" làm cho các loại tối ưu hóa này dễ kiểm soát hơn. Theo "tính minh bạch tham chiếu", tôi có nghĩa là thuộc tính mà giá trị của một biểu thức không phụ thuộc vào thời điểm nó được đánh giá . Các biểu thức như 2 + 2là minh bạch về mặt tham chiếu; bạn có thể thực hiện đánh giá tại thời điểm biên dịch nếu bạn muốn, hoặc trì hoãn nó cho đến thời gian chạy và nhận được câu trả lời tương tự. Nhưng một biểu thức như x+ykhông thể di chuyển theo thời gian vì x và y có thể thay đổi theo thời gian .

Không đồng bộ làm cho việc suy luận về thời điểm một tác dụng phụ sẽ khó khăn hơn nhiều. Trước khi async, nếu bạn nói:

M();
N();

M()đã void M() { Q(); R(); }, và N()đã void N() { S(); T(); }, và RStạo ra tác dụng phụ, thì bạn biết rằng tác dụng phụ của R xảy ra trước tác dụng phụ của S. Nhưng nếu bạn có async void M() { await Q(); R(); }thì đột nhiên điều đó đi ra ngoài cửa sổ. Bạn không có gì đảm bảo R()sẽ xảy ra trước hay sau S()(trừ khi tất nhiên M()là đang chờ; nhưng tất nhiên Taskkhông cần phải đợi cho đến sau N().)

Bây giờ, hãy tưởng tượng rằng thuộc tính không còn biết thứ tự nào xảy ra tác dụng phụ áp dụng cho mọi đoạn mã trong chương trình của bạn ngoại trừ những đoạn mã mà trình tối ưu hóa quản lý để khử async-ify. Về cơ bản, bạn không còn manh mối nào nữa là các biểu thức sẽ được đánh giá theo thứ tự nào, có nghĩa là tất cả các biểu thức cần phải minh bạch về mặt tham chiếu, điều này rất khó trong một ngôn ngữ như C #.

Điểm thứ ba chống lại là bạn phải hỏi "tại sao async lại đặc biệt như vậy?" Nếu bạn định tranh luận rằng mọi hoạt động thực sự phải là một Task<T>thì bạn cần phải trả lời được câu hỏi "tại sao không Lazy<T>?" hoặc "tại sao không Nullable<T>?" hoặc "tại sao không IEnumerable<T>?" Bởi vì chúng tôi có thể dễ dàng làm điều đó. Tại sao không nên để mọi hoạt động được nâng lên thành nullable ? Hoặc mọi hoạt động được tính toán một cách lười biếng và kết quả được lưu vào bộ nhớ đệm để sử dụng sau này hoặc kết quả của mọi hoạt động là một chuỗi các giá trị thay vì chỉ một giá trị duy nhất . Sau đó, bạn phải cố gắng tối ưu hóa những tình huống mà bạn biết "ồ, điều này không bao giờ được để trống, vì vậy tôi có thể tạo mã tốt hơn", v.v.

Vấn đề là: tôi không rõ điều gì Task<T>thực sự là đặc biệt để đảm bảo cho nhiều công việc này.

Nếu những thứ này bạn quan tâm thì tôi khuyên bạn nên điều tra các ngôn ngữ chức năng như Haskell, ngôn ngữ này có tính minh bạch tham chiếu mạnh hơn nhiều và cho phép tất cả các loại đánh giá không theo thứ tự và thực hiện bộ nhớ đệm tự động. Haskell cũng hỗ trợ mạnh mẽ hơn nhiều trong hệ thống loại hình của nó đối với các loại "sự sống đơn nguyên" mà tôi đã ám chỉ.


Việc có các hàm không đồng bộ được gọi mà không chờ đợi không có ý nghĩa đối với tôi (trong trường hợp phổ biến). Nếu chúng tôi loại bỏ tính năng này, trình biên dịch có thể tự quyết định xem một hàm không đồng bộ hay không (nó có gọi là await không?). Sau đó, chúng ta có thể có cú pháp giống hệt nhau cho cả hai trường hợp (không đồng bộ và đồng bộ hóa) và chỉ sử dụng await trong các cuộc gọi làm dấu phân biệt. Zombie phá hoại giải quyết :)
talkol

Tôi đã tiếp tục các cuộc thảo luận trong lập trình theo yêu cầu của bạn: programmers.stackexchange.com/questions/209872/...
talkol

@EricLippert - Câu trả lời rất hay như mọi khi :) Tôi tò mò liệu bạn có thể làm rõ "độ trễ cao" không? Có phạm vi chung tính bằng mili giây ở đây không? Tôi chỉ đang cố gắng tìm ra đâu là đường ranh giới dưới để sử dụng không đồng bộ vì tôi không muốn lạm dụng nó.
Travis J

7
@TravisJ: Hướng dẫn là: không chặn chuỗi giao diện người dùng trong hơn 30 mili giây. Nhiều hơn thế nữa và bạn có nguy cơ bị người dùng chú ý đến việc tạm dừng.
Eric Lippert

1
Điều thách thức tôi là việc một cái gì đó được thực hiện đồng bộ hay không đồng bộ hay không là một chi tiết thực hiện có thể thay đổi. Nhưng sự thay đổi trong cách triển khai đó tạo ra hiệu ứng gợn sóng thông qua mã gọi nó, mã gọi nó, v.v. Cuối cùng chúng ta phải thay đổi mã vì mã đó phụ thuộc vào, đó là điều mà chúng ta thường rất tránh. Hoặc chúng tôi sử dụng async/awaitvì một cái gì đó ẩn bên dưới các lớp trừu tượng thể không đồng bộ.
Scott Hannen

23

Tại sao không phải tất cả các chức năng đều không đồng bộ?

Hiệu suất là một lý do, như bạn đã đề cập. Lưu ý rằng tùy chọn "đường dẫn nhanh" mà bạn đã liên kết sẽ cải thiện hiệu suất trong trường hợp Nhiệm vụ đã hoàn thành, nhưng nó vẫn yêu cầu nhiều hướng dẫn và chi phí hơn so với lệnh gọi phương thức đơn lẻ. Do đó, ngay cả khi có sẵn "đường dẫn nhanh", bạn đang thêm rất nhiều phức tạp và chi phí với mỗi lần gọi phương thức không đồng bộ.

Khả năng tương thích ngược, cũng như khả năng tương thích với các ngôn ngữ khác (bao gồm cả các kịch bản tương tác), cũng sẽ trở nên có vấn đề.

Vấn đề còn lại là sự phức tạp và mục đích. Các hoạt động không đồng bộ làm tăng thêm độ phức tạp - trong nhiều trường hợp, các tính năng ngôn ngữ ẩn điều này, nhưng có nhiều trường hợp việc tạo các phương pháp asyncchắc chắn làm tăng thêm độ phức tạp cho việc sử dụng chúng. Điều này đặc biệt đúng nếu bạn không có ngữ cảnh đồng bộ hóa, vì các phương thức không đồng bộ sau đó có thể dễ dàng gây ra các vấn đề phân luồng không mong muốn.

Ngoài ra, có rất nhiều quy trình không đồng bộ, về bản chất của chúng. Những điều đó có ý nghĩa hơn khi hoạt động đồng bộ. Ví dụ, bắt buộc Math.Sqrtphải là Task<double> Math.SqrtAsyncvô lý, vì không có lý do gì để điều đó là không đồng bộ. Thay vì asyncđẩy thông qua ứng dụng của bạn, bạn muốn kết thúc với awaitpropogating ở khắp mọi nơi .

Điều này cũng sẽ phá vỡ hoàn toàn mô hình hiện tại, cũng như gây ra các vấn đề với các thuộc tính (thực chất chỉ là các cặp phương thức .. liệu chúng có đi không đồng bộ không?), Và có các tác động khác trong suốt quá trình thiết kế khung và ngôn ngữ.

Nếu bạn đang thực hiện nhiều công việc ràng buộc IO, bạn sẽ có xu hướng thấy rằng sử dụng asynctràn lan là một bổ sung tuyệt vời, nhiều thói quen của bạn sẽ như vậy async. Tuy nhiên, khi bạn bắt đầu thực hiện công việc ràng buộc CPU, nói chung, việc làm cho mọi thứ asyncthực sự không tốt - nó đang che giấu thực tế rằng bạn đang sử dụng các chu kỳ CPU dưới một API có vẻ như là không đồng bộ, nhưng thực sự không nhất thiết phải thực sự không đồng bộ.


Chính xác những gì tôi sẽ viết (hiệu suất), khả năng tương thích ngược có thể là điều khác, dll là để được sử dụng với các ngôn ngữ cũ không hỗ trợ async / chờ đợi cũng
Sayse

Làm sqrt async là không vô lý nếu chúng ta chỉ cần loại bỏ sự phân biệt giữa chức năng đồng bộ và async
talkol

@talkol Tôi đoán tôi sẽ quay lại đây - tại sao nên mọi chức năng cuộc gọi mất vào sự phức tạp của sự không đồng bộ?
Reed Copsey

2
@talkol Tôi muốn lập luận rằng không nhất thiết phải đúng sự thật - sự không đồng bộ có thể thêm lỗi bản thân mà là tồi tệ hơn chặn ...
Reed Copsey

1
@talkol Làm thế nào là await FooAsync()đơn giản hơn Foo()? Và thay vì hiệu ứng domino nhỏ đôi khi, bạn có hiệu ứng domino lớn mọi lúc và bạn gọi đó là một sự cải tiến?
svick

4

Hiệu suất sang một bên - không đồng bộ có thể có chi phí năng suất. Trên máy khách (WinForms, WPF, Windows Phone), đó là một lợi ích cho năng suất. Nhưng trên máy chủ hoặc trong các tình huống không phải giao diện người dùng khác, bạn phải trả năng suất. Bạn chắc chắn không muốn chuyển sang chế độ không đồng bộ theo mặc định ở đó. Sử dụng nó khi bạn cần những lợi thế về khả năng mở rộng.

Sử dụng nó khi ở điểm ngọt ngào. Trong các trường hợp khác, không.


2
+1 - Nỗ lực đơn giản để có bản đồ tinh thần của mã chạy 5 hoạt động không đồng bộ song song với chuỗi hoàn thành ngẫu nhiên sẽ cho hầu hết mọi người cung cấp đủ đau cho một ngày. Lý luận về hành vi của async (và do đó vốn song song) đang khó khăn hơn nhiều hơn so với mã đồng bộ tốt cũ ...
Alexei Levenkov

2

Tôi tin rằng có một lý do chính đáng để làm cho tất cả các phương thức không đồng bộ nếu chúng không cần thiết - khả năng mở rộng. Các phương thức không đồng bộ có chọn lọc chỉ hoạt động nếu mã của bạn không bao giờ phát triển và bạn biết rằng phương thức A () luôn ràng buộc CPU (bạn giữ nó đồng bộ) và phương thức B () luôn bị ràng buộc I / O (bạn đánh dấu nó là không đồng bộ).

Nhưng nếu mọi thứ thay đổi thì sao? Có, A () đang thực hiện tính toán nhưng tại một thời điểm nào đó trong tương lai, bạn phải thêm ghi nhật ký vào đó hoặc báo cáo, hoặc lệnh gọi lại do người dùng xác định với triển khai không thể dự đoán hoặc thuật toán đã được mở rộng và bây giờ không chỉ bao gồm các phép tính CPU mà còn cũng có một số I / O? Bạn sẽ cần phải chuyển đổi phương thức thành không đồng bộ nhưng điều này sẽ phá vỡ API và tất cả các trình gọi trong ngăn xếp cũng cần được cập nhật (và chúng thậm chí có thể là các ứng dụng khác nhau từ các nhà cung cấp khác nhau). Hoặc bạn sẽ cần thêm phiên bản không đồng bộ cùng với phiên bản đồng bộ nhưng điều này không tạo ra nhiều khác biệt - việc sử dụng phiên bản đồng bộ sẽ chặn và do đó khó có thể chấp nhận được.

Sẽ thật tuyệt nếu có thể làm cho phương thức đồng bộ hiện có không đồng bộ mà không cần thay đổi API. Nhưng trong thực tế, chúng tôi không có tùy chọn như vậy, tôi tin rằng, và sử dụng phiên bản không đồng bộ ngay cả khi nó hiện không cần thiết là cách duy nhất để đảm bảo rằng bạn sẽ không bao giờ gặp phải vấn đề tương thích trong tương lai.


Mặc dù đây có vẻ như là một nhận xét cực đoan, nhưng có rất nhiều sự thật trong đó. Đầu tiên, do vấn đề này: "điều này sẽ phá vỡ API và tất cả các trình gọi lên ngăn xếp" async / await làm cho một ứng dụng được kết hợp chặt chẽ hơn. Ví dụ, nó có thể dễ dàng phá vỡ nguyên tắc Thay thế Liskov nếu một lớp con muốn sử dụng async / await. Ngoài ra, thật khó để tưởng tượng một kiến ​​trúc microservices mà hầu hết các phương thức không cần async / await.
ItsAllABadJoke
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.