เหตุใดโค้ดที่กลายพันธุ์ของตัวแปรที่ใช้ร่วมกันระหว่างเธรดจึงไม่ได้รับผลกระทบจากสภาวะการแข่งขัน


107

ฉันใช้ Cygwin GCC และเรียกใช้รหัสนี้:

#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;
}

g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.oรวบรวมกับเส้น:

มันพิมพ์ 1,000 ซึ่งถูกต้อง อย่างไรก็ตามฉันคาดว่าจะมีจำนวนน้อยกว่าเนื่องจากเธรดเขียนทับค่าที่เพิ่มขึ้นก่อนหน้านี้ เหตุใดรหัสนี้จึงไม่ได้รับผลกระทบจากการเข้าถึงซึ่งกันและกัน

เครื่องทดสอบของฉันมี 4 คอร์และฉันไม่มีข้อ จำกัด ในโปรแกรมที่ฉันรู้จัก

ปัญหายังคงมีอยู่เมื่อเปลี่ยนเนื้อหาของการแชร์fooด้วยสิ่งที่ซับซ้อนกว่าเช่น

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

66
ซีพียู Intel มีตรรกะ "ยิงลง" ภายในที่น่าทึ่งเพื่อรักษาความเข้ากันได้กับซีพียู x86 รุ่นแรก ๆ ที่ใช้ในระบบ SMP (เช่นเครื่อง Pentium Pro คู่) เงื่อนไขความล้มเหลวมากมายที่เราได้รับการสอนนั้นแทบจะไม่เคยเกิดขึ้นจริงบนเครื่อง x86 ดังนั้นแกนกลางจะเขียนuกลับไปที่หน่วยความจำ CPU จะทำสิ่งที่น่าอัศจรรย์เช่นสังเกตว่าสายหน่วยความจำสำหรับuไม่ได้อยู่ในแคชของ CPU และจะเริ่มการทำงานที่เพิ่มขึ้นใหม่ นี่คือเหตุผลว่าทำไมการเปลี่ยนจาก x86 ไปยังสถาปัตยกรรมอื่น ๆ อาจเป็นประสบการณ์เปิดหูเปิดตา!
David Schwartz

1
อาจจะยังเร็วเกินไป คุณต้องเพิ่มโค้ดเพื่อให้แน่ใจว่าเธรดให้ผลก่อนที่จะดำเนินการใด ๆ เพื่อให้แน่ใจว่าเธรดอื่น ๆ จะเริ่มทำงานก่อนที่จะเสร็จสมบูรณ์
ร็อบเค

1
ดังที่ได้มีการระบุไว้ในที่อื่น ๆ รหัสเธรดสั้นมากจึงอาจถูกเรียกใช้งานได้ดีก่อนที่เธรดถัดไปจะเข้าคิว วิธีการเกี่ยวกับ 10 เธรดที่วาง u ++ ในลูปนับ 100 และการหน่วงเวลาสั้น ๆ ภายในก่อนเริ่มลูป (หรือแฟ
ล็ก

5
อันที่จริงการวางไข่โปรแกรมซ้ำ ๆ ในลูปในที่สุดก็แสดงให้เห็นว่ามันแตก: บางอย่างเช่นwhile true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;พิมพ์ 999 หรือ 998 ในระบบของฉัน
แดเนียลคามิลโคซาร์

คำตอบ:


266

foo()สั้นมากจนแต่ละเธรดอาจจะเสร็จสิ้นก่อนที่เธรดถัดไปจะเกิด หากคุณเพิ่มการนอนหลับเป็นเวลาสุ่มfoo()ก่อนหน้าu++นั้นคุณอาจเริ่มเห็นสิ่งที่คุณคาดหวัง


51
สิ่งนี้เปลี่ยนผลลัพธ์ไปในทางที่คาดหวัง
mafu

49
ฉันจะทราบว่านี่เป็นกลยุทธ์ที่ค่อนข้างดีในการแสดงสภาพการแข่งขัน คุณควรจะสามารถหยุดการทำงานชั่วคราวระหว่างสองการดำเนินการใด ๆ ถ้าไม่มีแสดงว่ามีสภาพการแข่งขัน
Matthieu M.

เราเพิ่งมีปัญหากับ C # เมื่อเร็ว ๆ นี้ โดยปกติแล้วโค้ดแทบจะไม่เคยล้มเหลว แต่การเพิ่มล่าสุดของการเรียก API ในระหว่างนั้นมีความล่าช้าเพียงพอที่จะทำให้มีการเปลี่ยนแปลงอย่างต่อเนื่อง
Obsidian Phoenix

@MatthieuM. Microsoft ไม่มีเครื่องมืออัตโนมัติที่ทำเช่นนั้นได้อย่างแน่นอนเนื่องจากเป็นวิธีการตรวจจับสภาพการแข่งขันและทำให้สามารถทำซ้ำได้อย่างน่าเชื่อถือหรือไม่?
Mason Wheeler

1
@MasonWheeler: ฉันทำงานใกล้ลินุกซ์โดยเฉพาะดังนั้น ... dunno :(
Matthieu M.

59

สิ่งสำคัญคือต้องเข้าใจสภาพการแข่งขันไม่ได้รับประกันว่าโค้ดจะทำงานไม่ถูกต้องเพียง แต่สามารถทำอะไรก็ได้เนื่องจากเป็นพฤติกรรมที่ไม่ได้กำหนดไว้ รวมถึงการวิ่งตามที่คาดหวัง.

โดยเฉพาะอย่างยิ่งในสภาพการแข่งขันของเครื่อง X86 และ AMD64 ในบางกรณีแทบจะไม่ก่อให้เกิดปัญหาเนื่องจากคำแนะนำส่วนใหญ่เป็นแบบอะตอมและการรับประกันการเชื่อมโยงกันนั้นสูงมาก การรับประกันเหล่านี้ค่อนข้างลดลงในระบบประมวลผลหลายตัวซึ่งจำเป็นต้องใช้คำนำหน้าล็อคเพื่อให้คำแนะนำหลาย ๆ อย่างเป็นแบบอะตอม

หากการเพิ่มขึ้นของเครื่องของคุณเป็นแบบอะตอมสิ่งนี้จะทำงานได้อย่างถูกต้องแม้ว่าตามมาตรฐานภาษาจะเป็นพฤติกรรมที่ไม่ได้กำหนด

โดยเฉพาะอย่างยิ่งฉันคาดหวังว่าในกรณีนี้รหัสอาจถูกคอมไพล์ไปยัง atomic Fetch and Addคำสั่ง (ADD หรือ XADD ในชุดประกอบ X86) ซึ่งแน่นอนว่าเป็นอะตอมในระบบโปรเซสเซอร์เดี่ยวอย่างไรก็ตามในระบบมัลติโปรเซสเซอร์ไม่รับประกันว่าจะเป็นอะตอมและตัวล็อค จะต้องทำเช่นนั้น หากคุณกำลังรันบนระบบมัลติโปรเซสเซอร์จะมีหน้าต่างที่เธรดอาจรบกวนและให้ผลลัพธ์ที่ไม่ถูกต้อง

โดยเฉพาะฉันรวบรวมรหัสของคุณเพื่อประกอบโดยใช้https://godbolt.org/และfoo()รวบรวมไปที่:

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

ซึ่งหมายความว่ามันกำลังดำเนินการคำสั่งเพิ่มซึ่งสำหรับโปรเซสเซอร์ตัวเดียวจะเป็นแบบอะตอม (แม้ว่าตามที่กล่าวไว้ข้างต้นจะไม่ใช่สำหรับระบบมัลติโปรเซสเซอร์)


41
สิ่งสำคัญคือต้องจำไว้ว่า "การทำงานตามที่ตั้งใจไว้" เป็นผลลัพธ์ที่ยอมรับได้จากพฤติกรรมที่ไม่ได้กำหนด
มาร์ค

3
ตามที่คุณระบุคำสั่งนี้ไม่ได้เป็น ปรมาณูบนเครื่อง SMP (ซึ่งเป็นระบบที่ทันสมัยทั้งหมด) แม้inc [u]ไม่ใช่ปรมาณู LOCKคำนำหน้าเป็นสิ่งจำเป็นที่จะทำให้การเรียนการสอนอย่างแท้จริงอะตอม OP กำลังโชคดี จำไว้ว่าแม้ว่าคุณจะบอกให้ CPU "เพิ่ม 1 ในคำตามที่อยู่นี้" แต่ CPU ก็ยังต้องดึงเพิ่มขึ้นจัดเก็บค่านั้นและ CPU อีกตัวสามารถทำสิ่งเดียวกันพร้อมกันทำให้ผลลัพธ์ไม่ถูกต้อง
Jonathon Reinhart

2
ฉันลงคะแนน แต่จากนั้นฉันก็อ่านคำถามของคุณอีกครั้งและตระหนักว่าคำแถลงปรมาณูของคุณสมมติว่ามี CPU ตัวเดียว หากคุณแก้ไขคำถามของคุณเพื่อให้ชัดเจนยิ่งขึ้น (เมื่อคุณพูดว่า "ปรมาณู" ให้ชัดเจนว่านี่เป็นเพียงกรณีของ CPU ตัวเดียวเท่านั้น) ฉันจะสามารถลบการลงคะแนนของฉันได้
Jonathon Reinhart

3
โหวตลงฉันพบว่าการอ้างสิทธิ์นี้ค่อนข้างแย่"โดยเฉพาะอย่างยิ่งในสภาพการแข่งขันของเครื่อง X86 และ AMD64 ในบางกรณีแทบจะไม่ก่อให้เกิดปัญหาเนื่องจากคำแนะนำส่วนใหญ่เป็นแบบอะตอมและการรับประกันการเชื่อมโยงกันนั้นสูงมาก" ย่อหน้าควรเริ่มสร้างสมมติฐานที่ชัดเจนว่าคุณกำลังมุ่งเน้นไปที่แกนเดี่ยว ถึงกระนั้นสถาปัตยกรรมแบบมัลติคอร์ก็เป็นมาตรฐานโดยพฤตินัยในอุปกรณ์ของผู้บริโภคในปัจจุบันซึ่งฉันคิดว่านี่เป็นกรณีมุมที่จะอธิบายครั้งสุดท้ายแทนที่จะเป็นแบบแรก
Patrick Trentin

3
โอ้แน่นอน x86 มีความเข้ากันได้แบบย้อนกลับมากมาย…สิ่งต่างๆเพื่อให้แน่ใจว่าโค้ดที่เขียนไม่ถูกต้องทำงานในขอบเขตที่เป็นไปได้ มันเป็นเรื่องใหญ่มากเมื่อ Pentium Pro เปิดตัวการดำเนินการนอกคำสั่ง Intel ต้องการตรวจสอบให้แน่ใจว่าฐานของโค้ดที่ติดตั้งใช้งานได้โดยไม่จำเป็นต้องคอมไพล์ใหม่โดยเฉพาะสำหรับชิปใหม่ของพวกเขา x86 เริ่มต้นจากการเป็นแกน CISC แต่ได้พัฒนาภายในเป็นแกน RISC แม้ว่าจะยังคงนำเสนอและทำงานได้หลายอย่างเช่นเดียวกับ CISC จากมุมมองของโปรแกรมเมอร์ สำหรับข้อมูลเพิ่มเติมโปรดดูที่คำตอบปีเตอร์ Cordes ของที่นี่
โคดี้เกรย์

20

u++ฉันคิดว่ามันเป็นไม่มากสิ่งที่ถ้าคุณใส่นอนหลับก่อนหรือหลัง ค่อนข้างที่การดำเนินการu++จะแปลเป็นรหัสที่เทียบกับค่าใช้จ่ายของเธรดการวางไข่ที่เรียกใช้foo- ดำเนินการอย่างรวดเร็วมากจนไม่น่าจะถูกดักฟัง อย่างไรก็ตามหากคุณ "ยืดเวลา" การดำเนินu++การสภาพการแข่งขันจะมีแนวโน้มมากขึ้น:

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

ผลลัพธ์: 694


BTW: ฉันลองแล้วด้วย

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

และมันทำให้ผมมีเวลาส่วนใหญ่แต่บางครั้ง19971995


1
ฉันคาดหวังในคอมไพเลอร์ที่มีเหตุผลคลุมเครือว่าฟังก์ชันทั้งหมดจะได้รับการปรับให้เหมาะสมกับสิ่งเดียวกัน ฉันไม่แปลกใจเลย ขอบคุณสำหรับผลลัพธ์ที่น่าสนใจ
Vality

นี่คือความถูกต้อง คำสั่งหลายพันคำต้องทำงานก่อนที่เธรดถัดไปจะเริ่มเรียกใช้ฟังก์ชันเล็ก ๆ ที่เป็นปัญหา เมื่อคุณทำให้เวลาดำเนินการในฟังก์ชันใกล้กับค่าโสหุ้ยการสร้างเธรดมากขึ้นคุณจะเห็นผลกระทบของเงื่อนไขการแข่งขัน
Jonathon Reinhart

@Vality: ฉันยังคาดหวังว่ามันจะลบ for-loop ปลอมภายใต้การเพิ่มประสิทธิภาพ O3 มันไม่?
user21820

จะelse u -= 1ถูกประหารชีวิตได้อย่างไร? แม้ในสภาพแวดล้อมคู่ขนานค่าก็ไม่ควรพอดี%2ใช่หรือไม่?
mafu

2
จากผลลัพธ์ดูเหมือนว่าelse u -= 1จะถูกดำเนินการหนึ่งครั้งครั้งแรก foo () ถูกเรียกเมื่อ u == 0 ส่วนที่เหลือ 999 ครั้ง u เป็นเลขคี่และu += 2ถูกดำเนินการเป็นผลให้ u = -1 + 999 * 2 = 1997; คือผลลัพธ์ที่ถูกต้อง บางครั้งสภาวะการแข่งขันทำให้หนึ่งใน + = 2 ถูกเขียนทับด้วยเธรดคู่ขนานและคุณจะได้รับ 1995
ลุค

7

มันต้องทนทุกข์ทรมานจากสภาพการแข่งขัน ใส่usleep(1000);ก่อนu++;ในfooและฉันเห็นผลลัพธ์ที่แตกต่างกัน (<1000) ในแต่ละครั้ง


6
  1. คำตอบที่เป็นไปได้ว่าทำไมสภาพการแข่งขันจึงไม่ปรากฏให้คุณเห็นแม้ว่าจะมีอยู่จริง แต่ก็foo()เร็วมากเมื่อเทียบกับเวลาที่ใช้ในการเริ่มเธรดซึ่งแต่ละเธรดจะเสร็จสิ้นก่อนที่จะเริ่มต้นครั้งต่อไป แต่...

  2. แม้จะเป็นเวอร์ชันดั้งเดิมของคุณผลลัพธ์ก็จะแตกต่างกันไปตามระบบ: ฉันลองใช้ Macbook (ควอดคอร์) ในแบบของคุณและในการวิ่ง 10 ครั้งฉันได้รับ 1,000 ครั้งสามครั้ง 999 หกครั้งและ 998 ครั้ง ดังนั้นการแข่งขันจึงค่อนข้างหายาก แต่มีอยู่อย่างชัดเจน

  3. คุณรวบรวมด้วย'-g'ซึ่งมีวิธีทำให้จุดบกพร่องหายไป ฉันคอมไพล์โค้ดของคุณอีกครั้งยังคงไม่เปลี่ยนแปลง แต่ไม่มีไฟล์'-g'แข่งขันและการแข่งขันก็เด่นชัดขึ้นมาก: ฉันได้รับ 1,000 ครั้ง, 999 สามครั้ง, 998 สองครั้ง, 997 สองครั้ง, 996 ครั้งและ 992 ครั้ง

  4. เรื่อง ข้อเสนอแนะในการเพิ่มการนอนหลับซึ่งช่วยได้ แต่ (ก) เวลาพักเครื่องคงที่ทำให้เธรดยังคงเบ้ตามเวลาเริ่มต้น (ขึ้นอยู่กับความละเอียดของตัวจับเวลา) และ (ข) การนอนหลับแบบสุ่มจะกระจายออกไปเมื่อสิ่งที่เราต้องการคือ ดึงพวกเขาเข้ามาใกล้กัน แต่ฉันจะเขียนโค้ดให้พวกเขารอสัญญาณเริ่มต้นดังนั้นฉันจึงสามารถสร้างมันทั้งหมดก่อนที่จะให้พวกเขาทำงาน ด้วยเวอร์ชันนี้ (มีหรือไม่มี'-g') ฉันได้ผลลัพธ์ทุกที่ต่ำถึง 974 และไม่สูงกว่า 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;
    }

เพียงแค่ทราบ -gธงไม่ได้ในทางใดทางหนึ่ง "ข้อบกพร่องให้หายไป." -gธงทั้ง GNU และเสียงดังกราวคอมไพเลอร์เป็นเพียงการเพิ่มสัญลักษณ์การแก้ปัญหากับไบนารีรวบรวม สิ่งนี้ช่วยให้คุณสามารถเรียกใช้เครื่องมือวินิจฉัยเช่น GDB และ Memcheck ในโปรแกรมของคุณด้วยเอาต์พุตที่มนุษย์อ่านได้ ตัวอย่างเช่นเมื่อ Memcheck ทำงานบนโปรแกรมที่มีการรั่วไหลของหน่วยความจำจะไม่บอกหมายเลขบรรทัดให้คุณทราบเว้นแต่โปรแกรมจะถูกสร้างขึ้นโดยใช้-gแฟล็ก
MS-DDOS

จริงอยู่ที่ข้อบกพร่องที่ซ่อนจากดีบักเกอร์มักจะเป็นเรื่องของการเพิ่มประสิทธิภาพคอมไพเลอร์มากกว่า ฉันควรจะได้พยายามและกล่าวว่า "ใช้-O2 แทนของ-g" แต่ที่กล่าวมาหากคุณไม่เคยมีความสุขในการล่าจุดบกพร่องที่จะปรากฏเฉพาะเมื่อรวบรวมโดยไม่มี -gให้ถือว่าตัวเองโชคดี มันสามารถเกิดขึ้นได้ด้วยข้อบกพร่องของนามแฝงที่บอบบางที่สุดบางส่วน ฉันได้เห็นมันแล้วแม้ว่าจะไม่เร็ว ๆ นี้และฉันก็เชื่อได้ว่ามันอาจจะเป็นเรื่องแปลกของคอมไพเลอร์ที่เป็นกรรมสิทธิ์เก่าดังนั้นฉันจะเชื่อคุณชั่วคราวเกี่ยวกับ GNU และ Clang เวอร์ชันใหม่ที่ทันสมัย
dgould

-gไม่ได้หยุดคุณจากการใช้การเพิ่มประสิทธิภาพ เช่นgcc -O3 -gทำให้ asm เหมือนกับgcc -O3แต่มีข้อมูลเมตาการแก้ไขข้อบกพร่อง gdb จะพูดว่า "optimized out" หากคุณพยายามพิมพ์ตัวแปรบางตัว -gอาจเปลี่ยนตำแหน่งสัมพัทธ์ของบางสิ่งในหน่วยความจำหากมีสิ่งใดที่เพิ่มเข้ามาเป็นส่วนหนึ่งของ.textส่วนนี้ แน่นอนต้องใช้พื้นที่ในไฟล์ออบเจ็กต์ แต่ฉันคิดว่าหลังจากการเชื่อมโยงแล้วทั้งหมดจะจบลงที่ปลายด้านหนึ่งของส่วนข้อความ (ไม่ใช่ส่วน) หรือไม่ก็เป็นส่วนหนึ่งของเซ็กเมนต์เลย อาจส่งผลต่อตำแหน่งที่แมปสิ่งต่างๆสำหรับไลบรารีไดนามิก
Peter Cordes
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.