Không đồng bộ (khởi chạy :: async) trong C ++ 11 có làm cho nhóm luồng lỗi thời để tránh tạo luồng tốn kém không?


117

Nó có liên quan lỏng lẻo đến câu hỏi này: Std :: thread có được gộp chung trong C ++ 11 không? . Mặc dù câu hỏi khác nhau, mục đích là giống nhau:

Câu hỏi 1: Sử dụng nhóm luồng của riêng bạn (hoặc thư viện bên thứ 3) để tránh tạo luồng tốn kém có còn hợp lý không?

Kết luận trong câu hỏi khác là bạn không thể dựa vào std::threadđể được gộp (có thể có hoặc có thể không). Tuy nhiên, std::async(launch::async)có vẻ như cơ hội được gộp cao hơn nhiều.

Nó không nghĩ rằng nó bị buộc bởi tiêu chuẩn, nhưng IMHO, tôi hy vọng rằng tất cả các triển khai C ++ 11 tốt sẽ sử dụng tổng hợp luồng nếu quá trình tạo luồng chậm. Chỉ trên các nền tảng không tốn kém để tạo một luồng mới, tôi mong rằng chúng luôn sinh ra một luồng mới.

Câu hỏi 2: Đây chỉ là những gì tôi nghĩ, nhưng tôi không có dữ kiện để chứng minh điều đó. Tôi rất có thể đã nhầm. Nó có phải là một phỏng đoán có học không?

Cuối cùng, ở đây tôi đã cung cấp một số mã mẫu đầu tiên cho thấy cách tôi nghĩ rằng việc tạo luồng có thể được thể hiện bằng async(launch::async):

Ví dụ 1:

 thread t([]{ f(); });
 // ...
 t.join();

trở thành

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

Ví dụ 2: Cháy và quên chuỗi

 thread([]{ f(); }).detach();

trở thành

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

Câu hỏi 3: Bạn thích asyncphiên threadbản nào hơn phiên bản?


Phần còn lại không còn là một phần của câu hỏi, mà chỉ để làm rõ:

Tại sao giá trị trả về phải được gán cho một biến giả?

Thật không may, tiêu chuẩn C ++ 11 hiện tại buộc bạn phải nắm bắt giá trị trả về std::async, vì nếu không, trình hủy được thực thi, sẽ chặn cho đến khi hành động kết thúc. Nó được một số người coi là một lỗi trong tiêu chuẩn (ví dụ, bởi Herb Sutter).

Ví dụ này từ cppreference.com minh họa điều đó một cách độc đáo:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

Làm rõ khác:

Tôi biết rằng nhóm luồng có thể có những cách sử dụng hợp pháp khác nhưng trong câu hỏi này tôi chỉ quan tâm đến khía cạnh tránh chi phí tạo luồng đắt đỏ .

Tôi nghĩ rằng vẫn có những tình huống mà nhóm luồng rất hữu ích, đặc biệt nếu bạn cần kiểm soát nhiều hơn đối với tài nguyên. Ví dụ: máy chủ có thể quyết định chỉ xử lý đồng thời một số lượng yêu cầu cố định để đảm bảo thời gian phản hồi nhanh và tăng khả năng dự đoán của việc sử dụng bộ nhớ. Nhóm chủ đề sẽ ổn, ở đây.

Biến cục bộ của luồng cũng có thể là một đối số cho nhóm luồng của riêng bạn, nhưng tôi không chắc liệu nó có liên quan trong thực tế hay không:

  • Tạo một luồng mới với sự std::threadbắt đầu mà không có biến cục bộ của luồng được khởi tạo. Có thể đây không phải là điều bạn muốn.
  • Trong các chủ đề được sinh ra bởi async, tôi hơi không rõ ràng vì chủ đề đó có thể đã được sử dụng lại. Theo hiểu biết của tôi, các biến cục bộ của luồng không được đảm bảo sẽ được đặt lại, nhưng tôi có thể nhầm.
  • Mặt khác, việc sử dụng nhóm luồng (kích thước cố định) của riêng bạn, cho phép bạn toàn quyền kiểm soát nếu bạn thực sự cần.

8
"Tuy nhiên, std::async(launch::async)có vẻ như cơ hội được gộp cao hơn nhiều." Không, tôi tin rằng nó std::async(launch::async | launch::deferred)có thể được gộp lại. Chỉ launch::asyncvới nhiệm vụ được cho là được khởi chạy trên một luồng mới bất kể những tác vụ khác đang chạy. Với chính sách launch::async | launch::deferredthì việc triển khai sẽ phải chọn chính sách nào, nhưng quan trọng hơn là phải trì hoãn việc lựa chọn chính sách nào. Nghĩa là, nó có thể đợi cho đến khi một luồng trong nhóm luồng khả dụng và sau đó chọn chính sách không đồng bộ.
bames53

2
Theo như tôi biết chỉ có VC ++ sử dụng một nhóm luồng với std::async(). Tôi vẫn tò mò muốn biết cách họ hỗ trợ các trình hủy thread_local không tầm thường trong nhóm luồng.
bames53

2
@ bames53 Tôi đã xem qua libstdc ++ đi kèm với gcc 4.7.2 và nhận thấy rằng nếu chính sách khởi chạy không chính xác launch::async thì nó sẽ xử lý nó như thể nó duy nhất launch::deferredvà không bao giờ thực thi nó một cách không đồng bộ - vì vậy, trên thực tế, phiên bản đó của libstdc ++ "chọn" luôn sử dụng hoãn lại trừ khi bị ép buộc khác.
doug65536

3
@ doug65536 Quan điểm của tôi về các trình hủy thread_local là sự phá hủy khi thoát luồng không hoàn toàn chính xác khi sử dụng nhóm luồng. Khi một tác vụ được chạy không đồng bộ, nó sẽ chạy 'như thể trên một chuỗi mới', theo thông số, có nghĩa là mọi tác vụ không đồng bộ đều có các đối tượng thread_local của riêng nó. Việc triển khai dựa trên nhóm luồng phải đặc biệt chú ý để đảm bảo rằng các tác vụ chia sẻ cùng một luồng sao lưu vẫn hoạt động như thể chúng có các đối tượng thread_local của riêng chúng. Hãy xem xét chương trình này: pastebin.com/9nWUT40h
bames53

2
@ bames53 Sử dụng "as if on a new thread" trong thông số kỹ thuật là một sai lầm lớn theo quan điểm của tôi. std::asynccó thể là một điều tuyệt vời cho hiệu suất - nó có thể là hệ thống thực thi nhiệm vụ ngắn hạn tiêu chuẩn, được hỗ trợ tự nhiên bởi một nhóm luồng. Ngay bây giờ, nó chỉ là một std::threadvới một số thứ vớ vẩn để làm cho hàm luồng có thể trả về một giá trị. Ồ, và họ đã thêm chức năng "hoãn lại" dư thừa chồng chéo công việc của std::functionhoàn toàn.
doug65536

Câu trả lời:


54

Câu hỏi 1 :

Tôi đã thay đổi điều này từ bản gốc vì bản gốc đã sai. Tôi có ấn tượng rằng việc tạo luồng Linux rất rẻ và sau khi thử nghiệm, tôi xác định rằng chi phí gọi hàm trong một luồng mới so với luồng bình thường là rất lớn. Chi phí để tạo một luồng để xử lý một lệnh gọi hàm chậm hơn 10000 lần trở lên so với một lệnh gọi hàm thông thường. Vì vậy, nếu bạn đang phát hành nhiều lệnh gọi hàm nhỏ, một nhóm luồng có thể là một ý tưởng hay.

Rõ ràng là thư viện C ++ chuẩn đi kèm với g ++ không có nhóm luồng. Nhưng tôi chắc chắn có thể thấy một trường hợp cho họ. Ngay cả khi phải chuyển cuộc gọi thông qua một số loại hàng đợi liên luồng, nó có thể sẽ rẻ hơn so với việc bắt đầu một luồng mới. Và tiêu chuẩn cho phép điều này.

IMHO, nhân Linux mọi người nên làm việc để tạo luồng rẻ hơn hiện tại. Tuy nhiên, thư viện C ++ chuẩn cũng nên xem xét sử dụng pool để triển khai launch::async | launch::deferred.

Và OP đã chính xác, việc sử dụng ::std::threadđể khởi chạy một luồng tất nhiên buộc phải tạo một luồng mới thay vì sử dụng một luồng từ một nhóm. Vì vậy, ::std::async(::std::launch::async, ...)được ưu tiên.

Câu hỏi 2 :

Có, về cơ bản điều này 'ngầm' khởi chạy một chủ đề. Nhưng thực sự, nó vẫn khá rõ ràng những gì đang xảy ra. Vì vậy, tôi không thực sự nghĩ rằng từ ngầm là một từ đặc biệt tốt.

Tôi cũng không tin rằng buộc bạn phải đợi trở lại trước khi bị phá hủy nhất thiết là một lỗi. Tôi không biết rằng bạn nên sử dụng lệnh asyncgọi để tạo các luồng 'daemon' không mong đợi trả về. Và nếu họ được mong đợi sẽ quay trở lại, thì không nên bỏ qua các trường hợp ngoại lệ.

Câu hỏi 3 :

Cá nhân tôi thích khởi chạy chuỗi phải rõ ràng. Tôi đặt rất nhiều giá trị trên các hòn đảo nơi bạn có thể đảm bảo quyền truy cập nối tiếp. Nếu không, bạn sẽ gặp phải trạng thái có thể thay đổi rằng bạn luôn phải quấn một mutex xung quanh một nơi nào đó và nhớ sử dụng nó.

Tôi thích mô hình hàng đợi công việc tốt hơn rất nhiều so với mô hình 'tương lai' vì có 'các hòn đảo nối tiếp' nằm xung quanh để bạn có thể xử lý hiệu quả hơn trạng thái có thể thay đổi.

Nhưng thực sự, nó phụ thuộc vào chính xác những gì bạn đang làm.

Kiểm tra hiệu suất

Vì vậy, tôi đã kiểm tra hiệu suất của nhiều phương thức gọi mọi thứ khác nhau và đưa ra những con số này trên hệ thống 8 lõi (AMD Ryzen 7 2700X) chạy Fedora 29 được biên dịch với phiên bản clang 7.0.1 và libc ++ (không phải libstdc ++):

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

Và nguyên bản, trên MacBook Pro 15 "(CPU Intel (R) Core (TM) i7-7820HQ @ 2.90GHz) với Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6, tôi nhận được điều này:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

Đối với chuỗi công nhân, tôi bắt đầu một chuỗi, sau đó sử dụng một hàng đợi không khóa để gửi yêu cầu đến một chuỗi khác và sau đó chờ phản hồi "Đã xong" được gửi lại.

"Không làm gì cả" chỉ là để kiểm tra chi phí của dây nịt kiểm tra.

Rõ ràng rằng chi phí khởi chạy một chủ đề là rất lớn. Và ngay cả luồng công nhân với hàng đợi liên luồng cũng làm chậm mọi thứ theo hệ số 20 hoặc hơn đối với Fedora 25 trong máy ảo và khoảng 8 trên OS X gốc.

Tôi đã tạo một dự án Bitbucket có mã mà tôi đã sử dụng để kiểm tra hiệu suất. Nó có thể được tìm thấy ở đây: https://bitbucket.org/omnifarious/launch_thread_performance


3
Tôi đồng tình với mô hình hàng đợi công việc, tuy nhiên điều này đòi hỏi phải có một mô hình "đường ống" có thể không áp dụng được cho mọi việc sử dụng truy cập đồng thời.
Matthieu M.

1
Đối với tôi, có vẻ như các mẫu biểu thức (cho toán tử) có thể được sử dụng để soạn kết quả, đối với các lệnh gọi hàm, tôi đoán là bạn sẽ cần một phương thức gọi nhưng do quá tải nên có thể hơi khó hơn.
Matthieu M.

3
"rất rẻ" là tương đối với kinh nghiệm của bạn. Tôi thấy chi phí tạo luồng Linux là rất quan trọng cho việc sử dụng của tôi.
Jeff

1
@Jeff - Tôi nghĩ nó rẻ hơn rất nhiều so với hiện tại. Tôi đã cập nhật câu trả lời của mình một lúc trước để phản ánh một bài kiểm tra tôi đã thực hiện để tìm ra chi phí thực tế.
Omnifarious

4
Trong phần đầu tiên, bạn có phần đánh giá thấp việc phải thực hiện bao nhiêu để tạo ra một mối đe dọa và ít phải thực hiện như thế nào để gọi một hàm. Một lệnh gọi và trả về hàm là một vài lệnh CPU thao tác một vài byte trên đầu ngăn xếp. Việc tạo ra mối đe dọa có nghĩa là: 1. phân bổ một ngăn xếp, 2. thực hiện cuộc gọi tổng hợp, 3. tạo cấu trúc dữ liệu trong hạt nhân và liên kết chúng với nhau, ghép các ổ khóa trên đường đi, 4. chờ bộ lập lịch thực thi luồng, 5. chuyển đổi ngữ cảnh cho chủ đề. Bản thân mỗi bước này mất nhiều thời gian hơn các lệnh gọi hàm phức tạp nhất.
cmaster - phục hồi monica
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.