Chương trình đa luồng bị kẹt trong chế độ tối ưu hóa nhưng chạy bình thường trong -O0


68

Tôi đã viết một chương trình đa luồng đơn giản như sau:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Nó hoạt động bình thường trong chế độ gỡ lỗi trong Visual studio hoặc -O0trong gc c và in ra kết quả sau 1vài giây. Nhưng nó bị kẹt và không in bất cứ điều gì trong chế độ Phát hành hoặc -O1 -O2 -O3.


Bình luận không dành cho thảo luận mở rộng; cuộc trò chuyện này đã được chuyển sang trò chuyện .
Samuel Liew

Câu trả lời:


100

Hai luồng, truy cập vào một biến không nguyên tử, không được bảo vệ là UB Mối quan tâm này finished. Bạn có thể làm cho finishedloại std::atomic<bool>để sửa lỗi này.

Sửa chữa của tôi:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Đầu ra:

result =1023045342
main thread id=140147660588864

Demo trực tiếp trên coliru


Ai đó có thể nghĩ 'Đó là một bool- có thể là một chút. Làm thế nào điều này có thể là phi nguyên tử? ' (Tôi đã làm khi tôi bắt đầu với đa luồng.)

Nhưng lưu ý rằng việc thiếu nước mắt không phải là điều duy nhất std::atomicmang lại cho bạn. Nó cũng làm cho truy cập đọc + ghi đồng thời từ nhiều luồng được xác định rõ, ngăn trình biên dịch giả định rằng đọc lại biến sẽ luôn thấy cùng một giá trị.

Làm cho một người boolkhông được bảo vệ, không nguyên tử có thể gây ra các vấn đề bổ sung:

  • Trình biên dịch có thể quyết định tối ưu hóa biến thành một thanh ghi hoặc thậm chí nhiều CSE truy cập vào một và kéo tải ra khỏi một vòng lặp.
  • Biến có thể được lưu trữ cho lõi CPU. (Trong cuộc sống thực, CPU có bộ nhớ kết hợp . Đây không phải là vấn đề thực sự, nhưng tiêu chuẩn C ++ đủ lỏng để bao gồm các triển khai C ++ giả định trên bộ nhớ chia sẻ không kết hợp trong đó atomic<bool>vớimemory_order_relaxed cửa hàng / tải sẽ làm việc, nhưng mà volatilesẽ không được. Sử dụng không ổn định cho điều này sẽ là UB, mặc dù nó hoạt động trên thực tế trên các triển khai C ++ thực sự.)

Để ngăn chặn điều này xảy ra, trình biên dịch phải được thông báo rõ ràng không làm.


Tôi hơi ngạc nhiên về cuộc thảo luận đang phát triển liên quan đến mối quan hệ tiềm năng của volatile vấn đề này. Vì vậy, tôi muốn tiêu hai xu của mình:


4
Tôi đã nhìn func()và nghĩ rằng "Tôi có thể tối ưu hóa điều đó" Trình tối ưu hóa hoàn toàn không quan tâm đến các luồng, và sẽ phát hiện vòng lặp vô hạn, và sẽ vui vẻ biến nó thành "trong khi (Đúng)" Nếu chúng ta nhìn vào godbolt .org / z / Tl44iN chúng ta có thể thấy điều này. Nếu hoàn thành là Truenó trở lại. Nếu không, nó đi vào một bước nhảy vô điều kiện trở lại chính nó (một vòng lặp vô hạn) tại nhãn.L5
Baldrickk


2
@val: về cơ bản không có lý do để lạm dụng volatiletrong C ++ 11 vì bạn có thể nhận được asm giống hệt với atomic<T>std::memory_order_relaxed. Nó hoạt động mặc dù trên phần cứng thực: bộ nhớ cache được kết hợp để một lệnh tải không thể tiếp tục đọc một giá trị cũ khi một cửa hàng trên lõi khác cam kết lưu trữ ở đó. (MESI)
Peter Cordes

5
@PeterCordes Sử dụng volatilevẫn là UB. Bạn thực sự không bao giờ nên cho rằng một cái gì đó chắc chắn và rõ ràng UB an toàn chỉ vì bạn không thể nghĩ ra một cách nó có thể sai và nó đã hoạt động khi bạn thử nó. Điều đó đã khiến mọi người bị đốt cháy nhiều lần.
David Schwartz

2
@Damon Mutexes đã phát hành / thu nhận ngữ nghĩa. Trình biên dịch không được phép tối ưu hóa việc đọc đi nếu một mutex đã bị khóa trước đó, vì vậy bảo vệ finishedbằng một std::mutextác phẩm (không có volatilehoặc atomic). Trong thực tế, bạn có thể thay thế tất cả các nguyên tử bằng sơ đồ "giá trị" đơn giản + mutex; nó vẫn hoạt động và chậm hơn atomic<T>được phép sử dụng một mutex nội bộ; chỉ atomic_flagđược đảm bảo khóa miễn phí.
Erlkoenig

42

Câu trả lời của Scheff mô tả cách sửa mã của bạn. Tôi nghĩ rằng tôi sẽ thêm một chút thông tin về những gì đang thực sự xảy ra trong trường hợp này.

Tôi đã biên dịch mã của bạn tại godbolt bằng cách sử dụng tối ưu hóa mức 1 ( -O1). Hàm của bạn biên dịch như vậy:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

Vậy thì chuyện gì đã xảy ra ở đây? Đầu tiên, chúng tôi có một so sánh: cmp BYTE PTR finished[rip], 0- kiểm tra này để xem liệu finishedcó sai hay không.

Nếu nó không sai (aka đúng), chúng ta nên thoát khỏi vòng lặp trong lần chạy đầu tiên. Đây thực hiện bằng cách jne .L4đó j umps khi n ot e qual để nhãn .L4trong đó giá trị của i( 0) được lưu trữ trong một thanh ghi để sử dụng sau và trở về chức năng.

Nếu đó sai, tuy nhiên, chúng tôi chuyển đến

.L5:
  jmp .L5

Đây là một bước nhảy vô điều kiện, để gắn nhãn .L5mà chính là lệnh nhảy.

Nói cách khác, chuỗi được đưa vào một vòng lặp bận rộn vô hạn.

Vậy tại sao điều này đã xảy ra?

Theo như trình tối ưu hóa có liên quan, các chủ đề nằm ngoài phạm vi của nó. Nó giả định các luồng khác không đọc hoặc ghi các biến đồng thời (vì đó sẽ là cuộc đua dữ liệu UB). Bạn cần nói với nó rằng nó không thể tối ưu hóa truy cập đi. Đây là lúc câu trả lời của Scheff đến. Tôi sẽ không nhắc lại anh ta.

Bởi vì trình tối ưu hóa không được thông báo rằng finishedbiến có thể có khả năng thay đổi trong quá trình thực thi hàm, nó thấy rằng finishednó không bị sửa đổi bởi chính hàm đó và cho rằng nó là hằng số.

Mã được tối ưu hóa cung cấp hai đường dẫn mã sẽ dẫn đến việc nhập hàm với giá trị bool không đổi; hoặc nó chạy vòng lặp vô hạn, hoặc vòng lặp không bao giờ chạy.

tại -O0trình biên dịch (như mong đợi) không tối ưu hóa thân vòng lặp và so sánh đi:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

do đó, chức năng, khi không được tối ưu hóa hoạt động, việc thiếu tính nguyên tử ở đây thường không phải là vấn đề, bởi vì mã và kiểu dữ liệu là đơn giản. Có lẽ điều tồi tệ nhất chúng ta có thể gặp phải ở đây là giá trị của inó bị giảm đi so với những gì nó nên có.

Một hệ thống phức tạp hơn với cấu trúc dữ liệu có nhiều khả năng dẫn đến dữ liệu bị hỏng hoặc thực thi không đúng.


3
C ++ 11 không tạo ra các luồng và một mô hình bộ nhớ nhận biết luồng của chính ngôn ngữ đó. Điều này có nghĩa là trình biên dịch không thể phát minh ra ghi ngay cả các atomicbiến không trong mã không ghi các biến đó. ví dụ như if (cond) foo=1;không thể được chuyển thành asm đó là như foo = cond ? 1 : foo;vì đó tải + cửa hàng (không phải là một RMW nguyên tử) có thể bước vào một ghi từ thread khác. Trình biên dịch đã tránh những thứ như vậy bởi vì chúng muốn hữu ích cho việc viết các chương trình đa luồng, nhưng C ++ 11 đã chính thức rằng trình biên dịch phải không phá vỡ mã trong đó 2 luồng viết a[1]a[2]
Peter Cordes

2
Nhưng có, khác hơn so với quá lời về cách trình biên dịch không nhận thức được bài nào cả , câu trả lời của bạn là đúng. Cuộc đua dữ liệu UB là những gì cho phép tích trữ vô số biến phi nguyên tử bao gồm toàn cầu và các tối ưu hóa tích cực khác mà chúng tôi muốn cho mã đơn luồng. Lập trình MCU - Tối ưu hóa C ++ O2 bị phá vỡ trong khi vòng lặp trên thiết bị điện tử. E là phiên bản giải thích này của tôi.
Peter Cordes

1
@PeterCordes: Một lợi thế của Java khi sử dụng GC là bộ nhớ cho các đối tượng sẽ không được tái chế mà không có rào cản bộ nhớ toàn cầu can thiệp giữa việc sử dụng cũ và mới, điều đó có nghĩa là bất kỳ lõi nào kiểm tra đối tượng sẽ luôn thấy một số giá trị mà nó có được tổ chức tại một thời điểm sau khi tài liệu tham khảo được công bố lần đầu tiên. Mặc dù các rào cản bộ nhớ toàn cầu có thể rất tốn kém nếu chúng được sử dụng thường xuyên, chúng có thể làm giảm đáng kể nhu cầu về các rào cản bộ nhớ ở nơi khác ngay cả khi được sử dụng một cách tiết kiệm.
supercat

1
Vâng, tôi biết đó là những gì bạn đang cố nói, nhưng tôi không nghĩ từ ngữ của bạn 100% có nghĩa như vậy. Nói rằng trình tối ưu hóa "hoàn toàn bỏ qua chúng." không hoàn toàn đúng: người ta biết rằng thực sự bỏ qua luồng khi tối ưu hóa có thể liên quan đến những thứ như tải từ / sửa đổi một byte trong kho từ / từ, trong thực tế đã gây ra lỗi khi một luồng truy cập vào char hoặc bitfield bước trên một viết cho một thành viên cấu trúc liền kề. Xem lwn.net/Articles/478657 để biết toàn bộ câu chuyện và làm thế nào chỉ mô hình bộ nhớ C11 / C ++ 11 làm cho việc tối ưu hóa như vậy là bất hợp pháp, không chỉ là không mong muốn trong thực tế.
Peter Cordes

1
Không, đó là tốt .. Cảm ơn @PeterCordes. Tôi đánh giá cao sự cải thiện.
Baldrickk

5

Vì lợi ích của sự hoàn thiện trong đường cong học tập; bạn nên tránh sử dụng các biến toàn cục. Bạn đã làm một công việc tốt mặc dù bằng cách làm cho nó tĩnh, vì vậy nó sẽ là cục bộ cho đơn vị dịch thuật.

Đây là một ví dụ:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Sống trên Wandbox


1
Cũng có thể khai báo finishednhư statictrong khối chức năng. Nó vẫn sẽ chỉ được khởi tạo một lần và nếu nó được khởi tạo thành một hằng số, điều này không yêu cầu khóa.
Davislor

Các truy cập finishedcũng có thể sử dụng std::memory_order_relaxedtải và cửa hàng rẻ hơn ; không có yêu cầu đặt hàng wrt. các biến khác trong một trong hai chủ đề. Tuy nhiên, tôi không chắc chắn đề xuất của @ Davislor staticcó ý nghĩa; nếu bạn có nhiều luồng đếm vòng quay, bạn sẽ không muốn dừng tất cả chúng với cùng một cờ. Tuy nhiên, bạn muốn viết phần khởi tạo finishedtheo cách biên dịch thành phần khởi tạo, chứ không phải là một cửa hàng nguyên tử. (Giống như bạn đang làm với finished = false;cú pháp khởi tạo mặc định C ++ 17. godbolt.org/z/EjoKgq ).
Peter Cordes

@PeterCordes Đặt cờ trong một đối tượng sẽ cho phép có nhiều hơn một đối với các nhóm luồng khác nhau, như bạn nói. Thiết kế ban đầu có một cờ duy nhất cho tất cả các chủ đề, tuy nhiên.
Davislor
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.