โปรแกรมมัลติเธรดติดอยู่ในโหมดปรับให้เหมาะสม แต่จะทำงานตามปกติใน -O0


68

ฉันเขียนโปรแกรมหลายเธรดแบบง่าย ๆ ดังนี้:

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

มันทำงานตามปกติในโหมดดีบั๊กในVisual Studioหรือ-O0ในgc c และพิมพ์ผลหลังจากนั้นไม่1กี่วินาที แต่มันติดอยู่และไม่ได้พิมพ์อะไรในรีลีส-O1 -O2 -O3หรือโหมด


ความคิดเห็นไม่ได้มีไว้สำหรับการอภิปรายเพิ่มเติม การสนทนานี้ได้รับการย้ายไปแชท
ซามูเอล Liew

คำตอบ:


100

สองเธรดที่เข้าถึงตัวแปรที่ไม่ได้เป็นอะตอมและไม่มีการป้องกันคือUBข้อกังวลfinishedนี้ คุณสามารถfinishedพิมพ์std::atomic<bool>เพื่อแก้ไขปัญหานี้ได้

การแก้ไขของฉัน:

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

เอาท์พุท:

result =1023045342
main thread id=140147660588864

การสาธิตสดบน coliru


บางคนอาจคิดว่า 'มันเป็นbool- อาจเป็นหนึ่งบิต สิ่งนี้จะไม่เป็นอะตอมได้อย่างไร ' (ฉันทำเมื่อฉันเริ่มต้นด้วยการใช้เธรดหลายตัวเอง)

แต่โปรดทราบว่าการขาดการฉีกขาดไม่ใช่สิ่งเดียวที่std::atomicให้คุณ นอกจากนี้ยังทำให้การเข้าถึงการอ่าน + เขียนพร้อมกันจากหลายเธรดที่กำหนดไว้อย่างดีการหยุดคอมไพเลอร์จากการสมมติว่าการอ่านตัวแปรอีกครั้งจะเห็นค่าเดียวกันเสมอ

การทำให้boolไม่ได้รับการป้องกันและไม่ใช่อะตอมอาจทำให้เกิดปัญหาเพิ่มเติม:

  • คอมไพเลอร์อาจตัดสินใจปรับตัวแปรให้เหมาะสมในรีจิสเตอร์หรือแม้กระทั่ง CSE หลาย ๆ การเข้าถึงเป็นหนึ่งเดียวและยกโหลดออกจากลูป
  • ตัวแปรอาจถูกแคชสำหรับ CPU core (ในชีวิตจริงซีพียูมีแคชเชื่อมโยงกัน . นี้ไม่ได้เป็นปัญหาที่แท้จริง แต่ c ++ มาตรฐานคือพอหลวมเพื่อให้ครอบคลุมสมมุติ C ++ การใช้งานหน่วยความจำที่ไม่สอดคล้องกันที่ใช้ร่วมกันที่atomic<bool>มีmemory_order_relaxedการจัดเก็บ / โหลดจะทำงาน แต่ที่volatileจะไม่. ใช้ ความผันผวนสำหรับสิ่งนี้จะเป็น UB แม้ว่ามันจะทำงานได้จริงในการใช้งาน C ++ จริง)

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


ฉันรู้สึกประหลาดใจเล็กน้อยเกี่ยวกับการอภิปรายที่มีวิวัฒนาการเกี่ยวกับความสัมพันธ์ที่อาจเกิดขึ้นvolatileกับปัญหานี้ ดังนั้นฉันต้องการใช้เงินสองเซ็นต์:


4
ฉันมองไปที่หนึ่งfunc()และคิดว่า "ฉันสามารถเพิ่มประสิทธิภาพให้ไกลออกไป" เครื่องมือเพิ่มประสิทธิภาพไม่สนใจเธรดเลยและจะตรวจจับลูปไม่สิ้นสุดและจะเปลี่ยนมันเป็น "ในขณะที่ (จริง)" ถ้าเราดูgodbolt .org / z / Tl44iNเราสามารถเห็นสิ่งนี้ ถ้าเสร็จแล้วTrueมันจะกลับมา หากไม่เป็นเช่นนั้นก็จะกระโดดกลับไปที่เงื่อนไขอย่างไม่มีเงื่อนไข (วนไม่สิ้นสุด) ที่ฉลาก.L5
Baldrickk


2
@val: มีพื้นเหตุผลที่จะละเมิดไม่มีvolatileใน C ++ 11 เพราะคุณจะได้รับเหมือนกันกับ asm และatomic<T> std::memory_order_relaxedมันใช้งานได้บนฮาร์ดแวร์จริง: แคชมีความสอดคล้องกันดังนั้นคำสั่งโหลดไม่สามารถอ่านค่าค้างเมื่อร้านค้าในคอร์อื่นมุ่งมั่นที่จะแคชที่นั่น (MESI)
Peter Cordes

5
@PeterCordes การใช้volatileยังคงเป็น UB คุณไม่ควรคาดเดาสิ่งที่แน่นอนและชัดเจน UB นั้นปลอดภัยเพราะคุณไม่สามารถคิดว่ามันจะผิดพลาดได้อย่างไรและมันทำงานได้ดีเมื่อคุณลอง นั่นทำให้ผู้คนเสียชีวิตไปแล้ว
David Schwartz

2
@Damon Mutexes ได้เปิดตัว / รับซีแมนทิกส์ คอมไพเลอร์ไม่ได้รับอนุญาตให้ปรับการอ่านให้เหมาะสมหาก mutex ถูกล็อคมาก่อนดังนั้นการปกป้องfinishedด้วยstd::mutexงาน (โดยไม่มีvolatileหรือatomic) ในความเป็นจริงคุณสามารถแทนที่ atomics ทั้งหมดด้วยค่า "ง่าย" + รูปแบบ mutex; มันจะยังคงทำงานและจะช้าลง atomic<T>ได้รับอนุญาตให้ใช้ mutex ภายใน atomic_flagรับประกันเท่านั้นล็อคฟรี
Erlkoenig

42

คำตอบของ Scheff อธิบายวิธีแก้ไขรหัสของคุณ ฉันคิดว่าฉันจะเพิ่มข้อมูลเล็กน้อยเกี่ยวกับสิ่งที่เกิดขึ้นจริงในกรณีนี้

ฉันรวบรวมรหัสของคุณที่godboltโดยใช้ระดับการเพิ่มประสิทธิภาพ 1 ( -O1) ฟังก์ชั่นของคุณรวบรวมเช่น:

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

แล้วเกิดอะไรขึ้นที่นี่? อันดับแรกเรามีการเปรียบเทียบ: cmp BYTE PTR finished[rip], 0- การตรวจสอบนี้เพื่อดูว่าfinishedเป็นเท็จหรือไม่

หากไม่ใช่เท็จ (aka จริง) เราควรออกจากลูปในการเรียกใช้ครั้งแรก สิ่งนี้ทำได้โดยjne .L4ที่j umps เมื่อn ot e qual to label .L4ที่ค่าของi( 0) ถูกเก็บไว้ใน register สำหรับใช้ในภายหลังและฟังก์ชันจะคืนค่า

หากเป็นเท็จเราจะย้ายไปที่

.L5:
  jmp .L5

นี่คือการกระโดดแบบไม่มีเงื่อนไขเพื่อกำหนดป้ายกำกับ.L5ซึ่งเกิดขึ้นเป็นคำสั่งการกระโดดเอง

กล่าวอีกนัยหนึ่งเธรดจะถูกใส่เข้าในการวนรอบไม่ว่าง

แล้วทำไมสิ่งนี้ถึงเกิดขึ้น

เท่าที่เครื่องมือเพิ่มประสิทธิภาพเกี่ยวข้องเธรดอยู่นอกขอบเขต มันจะถือว่าเธรดอื่นไม่ได้อ่านหรือเขียนตัวแปรพร้อมกัน (เพราะจะเป็น data-race UB) คุณต้องบอกว่าไม่สามารถเพิ่มประสิทธิภาพการเข้าถึงได้ นี่คือคำตอบของ Scheff ที่เข้ามาฉันจะไม่สนใจที่จะทำซ้ำเขา

เนื่องจากเครื่องมือเพิ่มประสิทธิภาพไม่ได้บอกว่าfinishedตัวแปรอาจมีการเปลี่ยนแปลงระหว่างการดำเนินการของฟังก์ชั่นจึงเห็นfinishedว่าไม่ได้ทำการแก้ไขโดยฟังก์ชันเองและถือว่าเป็นค่าคงที่

โค้ดที่ได้รับการปรับปรุงให้มีเส้นทางรหัสสองเส้นทางที่จะเป็นผลมาจากการเข้าสู่ฟังก์ชันด้วยค่าบูลคงที่ มันจะวนรอบไม่สิ้นสุดหรือวนซ้ำไม่เคยวิ่ง

ที่-O0คอมไพเลอร์ (ตามที่คาดไว้) ไม่ได้เพิ่มประสิทธิภาพการวนลูปและเปรียบเทียบ:

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

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

ระบบที่ซับซ้อนมากขึ้นพร้อมโครงสร้างข้อมูลมีแนวโน้มที่จะส่งผลให้ข้อมูลเสียหายหรือการดำเนินการที่ไม่เหมาะสม


3
C ++ 11 ทำเธรดและแบบจำลองหน่วยความจำที่รู้จักเธรดเป็นส่วนหนึ่งของภาษานั้น ๆ ซึ่งหมายความว่าคอมไพเลอร์ไม่สามารถประดิษฐ์การเขียนแม้แต่กับatomicตัวแปรที่ไม่ใช่ในโค้ดที่ไม่ได้เขียนตัวแปรเหล่านั้น เช่นif (cond) foo=1;ไม่สามารถแปลงเป็น asm ที่เป็นเช่นfoo = cond ? 1 : foo;นั้นเพราะ load + store (ไม่ใช่ atomic RMW) สามารถเหยียบการเขียนจากเธรดอื่นได้ คอมไพเลอร์กำลังหลีกเลี่ยงสิ่งต่าง ๆ เช่นนั้นเพราะพวกเขาต้องการที่จะเป็นประโยชน์สำหรับการเขียนโปรแกรมแบบมัลติเธรด แต่ C ++ 11 ทำให้มันเป็นทางการที่คอมไพเลอร์ต้องไม่ทำลายโค้ดที่ 2 เธรดเขียนa[1]และa[2]
Peter Cordes

2
แต่ใช่นอกเหนือจากการคุยโวเกี่ยวกับวิธีที่คอมไพเลอร์ไม่ทราบหัวข้อเลยคำตอบของคุณถูกต้อง Data-race UB คือสิ่งที่ช่วยให้สามารถทำการโหลดตัวแปรที่ไม่ใช่อะตอมรวมถึง globals และการเพิ่มประสิทธิภาพเชิงรุกอื่น ๆ ที่เราต้องการสำหรับโค้ดแบบเธรดเดี่ยว การเขียนโปรแกรม MCU - การเพิ่มประสิทธิภาพ C ++ O2 จะหยุดลงขณะที่วนรอบอุปกรณ์อิเล็กทรอนิกส์ SE เป็นเวอร์ชันของคำอธิบายนี้
Peter Cordes

1
@PeterCordes: ข้อดีอย่างหนึ่งของ Java ที่ใช้ GC คือหน่วยความจำสำหรับวัตถุจะไม่ถูกนำกลับมาใช้ใหม่หากไม่มีอุปสรรคหน่วยความจำระดับโลกระหว่างการใช้งานเก่าและใหม่ซึ่งหมายความว่าแกนใด ๆ ที่ตรวจสอบวัตถุจะเห็นคุณค่าบางอย่าง จัดขึ้นในบางครั้งหลังจากการอ้างอิงถูกตีพิมพ์ครั้งแรก ในขณะที่อุปสรรคหน่วยความจำทั่วโลกอาจมีราคาแพงมากหากพวกเขาใช้บ่อยพวกเขาสามารถลดความต้องการอุปสรรคหน่วยความจำที่อื่นแม้เมื่อใช้เท่าที่จำเป็น
supercat

1
ใช่ฉันรู้ว่านั่นคือสิ่งที่คุณพยายามจะพูด แต่ฉันไม่คิดว่าถ้อยคำของคุณ 100% หมายความว่าอย่างนั้น การบอกว่าเครื่องมือเพิ่มประสิทธิภาพ "ไม่สนใจมัน" ไม่ถูกต้อง: เป็นที่ทราบกันดีว่าการไม่ทำเกลียวเมื่อการปรับให้เหมาะสมสามารถเกี่ยวข้องกับสิ่งต่าง ๆ เช่นการโหลดคำ / แก้ไขไบต์ใน word / word store ซึ่งในทางปฏิบัติทำให้เกิดข้อบกพร่องซึ่งการเข้าถึงเธรดหนึ่งขั้นตอน เขียนถึงสมาชิก struct ที่อยู่ติดกัน ดูlwn.net/Articles/478657สำหรับเรื่องราวทั้งหมดและแบบจำลองหน่วยความจำ C11 / C ++ 11 เท่านั้นที่ทำให้การเพิ่มประสิทธิภาพผิดกฎหมายไม่ใช่แค่ในทางปฏิบัติที่ไม่พึงประสงค์
Peter Cordes

1
ไม่เป็นสิ่งที่ดี .. ขอบคุณ @PeterCordes ฉันขอขอบคุณการปรับปรุง
Baldrickk

5

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

นี่คือตัวอย่าง:

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

สดบนwandbox


1
ยังสามารถประกาศfinishedว่าstaticภายในบล็อกฟังก์ชั่น มันจะยังคงได้รับการเริ่มต้นเพียงครั้งเดียวและถ้ามันเริ่มต้นเป็นค่าคงที่นี้ไม่จำเป็นต้องล็อค
Davislor

การเข้าถึงยังfinishedสามารถใช้std::memory_order_relaxedโหลดและร้านค้าที่ถูกกว่า ไม่จำเป็นต้องมีการสั่งซื้อ wrt ตัวแปรอื่น ๆ ในทั้งสองหัวข้อ ฉันไม่แน่ใจว่าคำแนะนำของ @ Davislor staticเหมาะสมหรือไม่; หากคุณมีหลายกระทู้ปั่นนับคุณไม่จำเป็นต้องหยุดพวกเขาทั้งหมดด้วยธงเดียวกัน คุณต้องการที่จะเขียนการเริ่มต้นของfinishedในลักษณะที่รวบรวมเป็นเพียงการเริ่มต้นไม่ใช่ร้านค้าอะตอม (เช่นคุณกำลังทำกับfinished = false;ไวยากรณ์เริ่มต้น C ++ 17 godbolt.org/z/EjoKgq )
Peter Cordes

@PeterCordes การวางธงในวัตถุจะทำให้มีมากกว่าหนึ่งสำหรับเธรดพูลที่แตกต่างกันตามที่คุณพูด อย่างไรก็ตามการออกแบบดั้งเดิมมีการตั้งค่าสถานะเดียวสำหรับเธรดทั้งหมดอย่างไรก็ตาม
Davislor
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.