Tại sao mã thay đổi một biến được chia sẻ trên các chuỗi dường như KHÔNG bị điều kiện chủng tộc?


107

Tôi đang sử dụng Cygwin GCC và chạy mã này:

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

unsigned u = 0;

void foo()
{
    u++;
}

int main()
{
    vector<thread> threads;
    for(int i = 0; i < 1000; i++) {
        threads.push_back (thread (foo));
    }
    for (auto& t : threads) t.join();

    cout << u << endl;
    return 0;
}

Biên soạn với dòng: g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o.

Nó in 1000, đó là chính xác. Tuy nhiên, tôi mong đợi một số lượng ít hơn do các luồng ghi đè lên một giá trị đã tăng trước đó. Tại sao mã này không bị truy cập lẫn nhau?

Máy thử nghiệm của tôi có 4 lõi và tôi không đặt bất kỳ hạn chế nào đối với chương trình mà tôi biết.

Sự cố vẫn tiếp diễn khi thay thế nội dung được chia sẻ foobằng nội dung phức tạp hơn, ví dụ:

if (u % 3 == 0) {
    u += 4;
} else {
    u -= 1;
}

66
CPU Intel có một số logic "bắn hạ" bên trong tuyệt vời để duy trì khả năng tương thích với các CPU x86 rất sớm được sử dụng trong hệ thống SMP (như máy Pentium Pro kép). Rất nhiều điều kiện lỗi mà chúng tôi được dạy là có thể xảy ra hầu như không bao giờ thực sự xảy ra trên máy x86. Vì vậy, nói rằng một lõi sẽ ghi ulại vào bộ nhớ. CPU sẽ thực sự làm những điều đáng kinh ngạc như thông báo rằng dòng bộ nhớ ukhông có trong bộ nhớ cache của CPU và nó sẽ khởi động lại hoạt động gia tăng. Đây là lý do tại sao chuyển từ x86 sang các kiến ​​trúc khác có thể là một trải nghiệm mở rộng tầm mắt!
David Schwartz

1
Có lẽ vẫn còn quá nhanh. Bạn cần thêm mã để đảm bảo rằng luồng sinh ra trước khi nó thực hiện bất cứ điều gì để đảm bảo rằng các luồng khác được khởi chạy trước khi hoàn thành.
Rob K

1
Như đã được lưu ý ở nơi khác, mã luồng quá ngắn nên nó có thể được thực thi trước khi luồng tiếp theo được xếp hàng đợi. Làm thế nào về 10 chủ đề đặt u ++ trong một vòng lặp 100 đếm. Và một sự chậm trễ ngắn trong vòng cho trước khi bắt đầu vòng lặp (hoặc toàn cầu "đi" lá cờ để bắt đầu tất cả chúng cùng một lúc)
RufusVS

5
Trên thực tế, việc tạo chương trình lặp đi lặp lại trong một vòng lặp cuối cùng cho thấy nó bị hỏng: một cái gì đó như while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;in 999 hoặc 998 trên hệ thống của tôi.
Daniel Kamil Kozar,

Câu trả lời:


266

foo()quá ngắn nên mỗi luồng có thể kết thúc trước khi luồng tiếp theo được tạo ra. Nếu bạn thêm một giấc ngủ vào một khoảng thời gian ngẫu nhiên foo()trước đó u++, bạn có thể bắt đầu thấy những gì bạn mong đợi.


51
Điều này thực sự đã thay đổi đầu ra theo cách mong đợi.
mafu

49
Tôi xin lưu ý rằng đây nói chung là một chiến lược khá tốt để trưng bày các điều kiện chủng tộc. Bạn sẽ có thể tạm dừng giữa hai thao tác bất kỳ; nếu không, có một điều kiện chủng tộc.
Matthieu M.

Chúng tôi vừa gặp sự cố này với C # gần đây. Thông thường, mã hầu như không bao giờ bị lỗi, nhưng việc bổ sung lệnh gọi API gần đây vào giữa đã giới thiệu đủ độ trễ để khiến nó liên tục thay đổi.
Obsidian Phoenix

@MatthieuM. Không phải Microsoft có một công cụ tự động thực hiện chính xác điều đó, như một phương pháp vừa phát hiện điều kiện chủng tộc vừa làm cho chúng có thể tái tạo một cách đáng tin cậy?
Mason Wheeler

1
@MasonWheeler: Tôi làm việc độc quyền trên Linux, vì vậy ... dunno :(
Matthieu M.

59

Điều quan trọng là phải hiểu một điều kiện chủng tộc không đảm bảo mã sẽ chạy không chính xác, chỉ đơn thuần là nó có thể làm bất cứ điều gì, vì nó là một hành vi không xác định. Bao gồm cả việc chạy như mong đợi.

Đặc biệt trên các máy X86 và AMD64, các điều kiện chạy đua trong một số trường hợp hiếm khi gây ra vấn đề vì nhiều lệnh là nguyên tử và đảm bảo đồng tiền rất cao. Những đảm bảo này phần nào bị giảm bớt trên các hệ thống đa bộ xử lý nơi cần tiền tố khóa để nhiều lệnh trở thành nguyên tử.

Nếu gia số trên máy của bạn là op nguyên tử, điều này có thể sẽ chạy chính xác mặc dù theo tiêu chuẩn ngôn ngữ, đó là Hành vi không xác định.

Cụ thể, tôi mong đợi trong trường hợp này, mã có thể được biên dịch thành lệnh Tìm nạp và Thêm nguyên tử (ADD hoặc XADD trong hợp ngữ X86) thực sự là nguyên tử trong các hệ thống bộ xử lý đơn lẻ, tuy nhiên trên các hệ thống đa xử lý, điều này không được đảm bảo là nguyên tử và khóa sẽ được yêu cầu để làm cho nó như vậy. Nếu bạn đang chạy trên một hệ thống đa xử lý, sẽ có một cửa sổ nơi các luồng có thể can thiệp và tạo ra kết quả không chính xác.

Cụ thể, tôi đã biên dịch mã của bạn thành assembly bằng https://godbolt.org/foo()biên dịch thành:

foo():
        add     DWORD PTR u[rip], 1
        ret

Điều này có nghĩa là nó chỉ thực hiện một lệnh bổ sung đối với một bộ xử lý đơn lẻ sẽ là nguyên tử (mặc dù như đã đề cập ở trên không phải như vậy đối với hệ thống nhiều bộ xử lý).


41
Điều quan trọng cần nhớ là "chạy như dự định" là một kết quả được phép của hành vi không xác định.
Đánh dấu

3
Như bạn đã chỉ ra, hướng dẫn này không phải là nguyên tử trên máy SMP (mà tất cả các hệ thống hiện đại đều có). Thậm chí inc [u]không phải là nguyên tử. Các LOCKtiền tố được yêu cầu thực hiện một lệnh thực sự nguyên tử. OP chỉ đơn giản là gặp may. Hãy nhớ lại rằng mặc dù bạn đang nói với CPU "thêm 1 vào từ tại địa chỉ này", CPU vẫn phải tìm nạp, tăng, lưu trữ giá trị đó và một CPU khác có thể làm điều tương tự đồng thời, khiến kết quả không chính xác.
Jonathon Reinhart

2
Tôi đã bỏ phiếu thấp, nhưng sau đó tôi đọc lại câu hỏi của bạn và nhận ra rằng các tuyên bố về tính nguyên tử của bạn đang giả định một CPU duy nhất. Nếu bạn chỉnh sửa câu hỏi của mình để làm rõ hơn điều này (khi bạn nói "nguyên tử", hãy rõ ràng rằng đây chỉ là trường hợp trên một CPU duy nhất), thì tôi sẽ có thể loại bỏ phiếu phản đối của mình.
Jonathon Reinhart

3
Bị phản đối, tôi thấy tuyên bố này hơi lớn "Đặc biệt trên các máy X86 và AMD64, điều kiện chạy đua trong một số trường hợp hiếm khi gây ra sự cố vì nhiều lệnh là nguyên tử và đảm bảo đồng tiền rất cao." Đoạn văn sẽ bắt đầu đưa ra giả định rõ ràng rằng bạn đang tập trung vào lõi đơn. Mặc dù vậy, kiến ​​trúc đa lõi ngày nay đã trở thành tiêu chuẩn thực tế trong các thiết bị tiêu dùng mà tôi sẽ coi đây là một trường hợp góc cạnh để giải thích cuối cùng, thay vì đầu tiên.
Patrick Trentin

3
Ồ, chắc chắn rồi. x86 có rất nhiều khả năng tương thích ngược… thứ để đảm bảo rằng mã bị viết sai hoạt động trong phạm vi có thể. Đó thực sự là một vấn đề lớn khi Pentium Pro giới thiệu việc thực thi không theo thứ tự. Intel muốn đảm bảo rằng cơ sở mã đã cài đặt hoạt động mà không cần phải biên dịch lại đặc biệt cho chip mới của họ. x86 bắt đầu như một lõi CISC, nhưng đã phát triển nội bộ thành một lõi RISC, mặc dù nó vẫn trình bày và hoạt động theo nhiều cách như CISC từ góc nhìn của một lập trình viên. Để biết thêm, hãy xem câu trả lời của Peter Cordes tại đây .
Cody Grey

20

Tôi nghĩ rằng nó không phải là quá nhiều điều nếu bạn đặt một giấc ngủ trước hoặc sau u++. Đúng hơn là hoạt động u++chuyển thành mã - so với chi phí của các luồng sinh sản gọi foo- được thực hiện rất nhanh chóng đến mức nó không có khả năng bị chặn. Tuy nhiên, nếu bạn "kéo dài" hoạt động u++, thì tình trạng cuộc đua sẽ trở nên nhiều khả năng:

void foo()
{
    unsigned i = u;
    for (int s=0;s<10000;s++);
    u = i+1;
}

kết quả: 694


BTW: Tôi cũng đã thử

if (u % 2) {
    u += 2;
} else {
    u -= 1;
}

và nó đã cho tôi hầu hết các lần 1997, nhưng đôi khi 1995.


1
Tôi mong đợi trên bất kỳ trình biên dịch mơ hồ nào lành mạnh rằng toàn bộ chức năng sẽ được tối ưu hóa cho cùng một thứ. Tôi ngạc nhiên là không phải vậy. Cảm ơn bạn vì kết quả thú vị.
Vality

Điều này hoàn toàn chính xác. Nhiều nghìn hướng dẫn cần phải chạy trước khi luồng tiếp theo bắt đầu thực hiện hàm nhỏ được đề cập. Khi bạn đặt thời gian thực thi trong hàm gần với chi phí tạo luồng, bạn sẽ thấy tác động của điều kiện chủng tộc.
Jonathon Reinhart

@Vality: Tôi cũng mong đợi nó sẽ xóa vòng lặp giả mạo theo tối ưu hóa O3. Nó không?
user21820,

Làm thế nào có thể else u -= 1được thực hiện? Ngay cả trong một môi trường song song, giá trị không bao giờ không phù hợp %2, phải không?
mafu

2
từ đầu ra, có vẻ như else u -= 1được thực thi một lần, lần đầu tiên foo () được gọi, khi u == 0. 999 lần còn lại u là số lẻ và u += 2được thực thi dẫn đến u = -1 + 999 * 2 = 1997; tức là đầu ra chính xác. Điều kiện cuộc đua đôi khi khiến một trong các dấu + = 2 bị ghi đè bởi một chuỗi song song và bạn nhận được 1995.
Luke

7

Nó phải chịu một tình trạng chủng tộc. Đặt usleep(1000);trước u++;vào foovà tôi thấy đầu ra khác nhau (<1000) mỗi lần.


6
  1. Câu trả lời có khả năng lý do tại sao tình trạng chủng tộc không biểu hiện cho bạn, mặc dù nó không tồn tại, đó là foo()rất nhanh, so với thời gian cần thiết để bắt đầu một chủ đề, mỗi chủ đề kết thúc trước khi lon tiếp theo thậm chí bắt đầu. Nhưng...

  2. Ngay cả với phiên bản gốc của bạn, kết quả khác nhau tùy theo hệ thống: Tôi đã thử theo cách của bạn trên Macbook (lõi tứ) và trong mười lần chạy, tôi nhận được 1000 ba lần, 999 sáu lần và 998 một lần. Vì vậy, cuộc đua là hơi hiếm, nhưng hiện diện rõ ràng.

  3. Bạn đã biên dịch với '-g', có cách làm cho lỗi biến mất. Tôi đã biên dịch lại mã của bạn, vẫn không thay đổi nhưng không có '-g', và cuộc đua trở nên rõ rệt hơn nhiều: Tôi nhận được 1000 một lần, 999 ba lần, 998 hai lần, 997 hai lần, 996 một lần và 992 một lần.

  4. Re. gợi ý về việc thêm chế độ ngủ - điều đó sẽ hữu ích, nhưng (a) thời gian ngủ cố định khiến các chuỗi vẫn bị lệch theo thời gian bắt đầu (tùy thuộc vào độ phân giải của bộ đếm thời gian) và (b) chế độ ngủ ngẫu nhiên sẽ trải chúng ra khi những gì chúng ta muốn kéo chúng lại gần nhau hơn. Thay vào đó, tôi sẽ viết mã chúng để chờ tín hiệu bắt đầu, vì vậy tôi có thể tạo tất cả chúng trước khi để chúng hoạt động. Với phiên bản này (có hoặc không '-g'), tôi nhận được kết quả ở khắp nơi, thấp nhất là 974 và không cao hơn 998:

    #include <iostream>
    #include <thread>
    #include <vector>
    using namespace std;
    
    unsigned u = 0;
    bool start = false;
    
    void foo()
    {
        while (!start) {
            std::this_thread::yield();
        }
        u++;
    }
    
    int main()
    {
        vector<thread> threads;
        for(int i = 0; i < 1000; i++) {
            threads.push_back (thread (foo));
        }
        start = true;
        for (auto& t : threads) t.join();
    
        cout << u << endl;
        return 0;
    }

Chỉ là một ghi chú. Các -gcờ không trong bất kỳ cách "làm lỗi biến mất." Các -glá cờ trên cả hai trình biên dịch GNU và Clang chỉ cần thêm ký hiệu gỡ lỗi với nhị phân được biên dịch. Điều này cho phép bạn chạy các công cụ chẩn đoán như GDB và Memcheck trên các chương trình của mình với một số đầu ra con người có thể đọc được. Ví dụ: khi Memcheck được chạy trên một chương trình bị rò rỉ bộ nhớ, nó sẽ không cho bạn biết số dòng trừ khi chương trình được tạo bằng -gcờ.
MS-DDOS

Cấp, lỗi ẩn khỏi trình gỡ lỗi thường là vấn đề tối ưu hóa trình biên dịch; Lẽ ra tôi nên cố gắng, và nói, "sử dụng -O2 thay vì của -g". Nhưng điều đó nói lên rằng, nếu bạn chưa bao giờ có được niềm vui khi săn được một lỗi chỉ xuất hiện khi được biên dịch mà không có -g , hãy coi mình là người may mắn. Nó có thể xảy ra, với một số lỗi răng cưa tinh vi nhất. Tôi đã nhìn thấy nó, mặc dù không phải gần đây, và tôi có thể tin rằng có lẽ đó là lỗi của một trình biên dịch độc quyền cũ, nên tạm thời tôi sẽ tin bạn về các phiên bản hiện đại của GNU và Clang.
dgould

-gkhông ngăn bạn sử dụng tối ưu hóa. Ví dụ: gcc -O3 -glàm cho asm giống như gcc -O3, nhưng với siêu dữ liệu gỡ lỗi. Tuy nhiên, gdb sẽ thông báo "đã tối ưu hóa" nếu bạn cố gắng in một số biến. -gcó thể thay đổi vị trí tương đối của một số thứ trong bộ nhớ, nếu bất kỳ thứ nào nó thêm vào là một phần của .textphần. Nó chắc chắn chiếm không gian trong tệp đối tượng, nhưng tôi nghĩ sau khi liên kết tất cả sẽ kết thúc ở một đầu của đoạn văn bản (không phải phần), hoặc không phải là một phần của phân đoạn. Có thể có thể ảnh hưởng đến nơi mọi thứ được ánh xạ cho các thư viện động.
Peter Cordes
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.