스레드에서 공유 변수를 변경하는 코드가 경쟁 조건을 겪지 않는 이유는 무엇입니까?


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.

1000을 인쇄합니다. 그러나 이전에 증가한 값을 덮어 쓰는 스레드로 인해 더 적은 수를 예상했습니다. 이 코드가 상호 액세스의 영향을받지 않는 이유는 무엇입니까?

내 테스트 머신에는 4 개의 코어가 있으며 내가 아는 프로그램에 제한을 두지 않습니다.

공유 내용을 foo더 복잡한 것으로 대체 할 때 문제가 지속됩니다.

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

66
Intel CPU에는 SMP 시스템 (예 : 듀얼 Pentium Pro 시스템)에 사용 된 초기 x86 CPU와의 호환성을 유지하기 위해 몇 가지 놀라운 내부 "정지"로직이 있습니다. 우리가 배운 많은 실패 조건은 x86 시스템에서 거의 실제로 발생하지 않습니다. 따라서 코어가 u메모리에 다시 쓰기 위해 이동한다고 가정합니다 . CPU는 실제로 메모리 라인 u이 CPU의 캐시에 없다는 사실을 알아 채고 증분 작업을 다시 시작하는 것과 같은 놀라운 작업을 수행합니다. 이것이 x86에서 다른 아키텍처로 전환하는 것이 눈을 뜨게하는 경험이 될 수있는 이유입니다!
David Schwartz

1
여전히 너무 빠르다. 스레드가 완료되기 전에 다른 스레드가 시작되도록하기 위해 어떤 작업을 수행하기 전에 스레드가 양보하도록 코드를 추가해야합니다.
Rob K

1
다른 곳에서 언급했듯이 스레드 코드는 너무 짧아 다음 스레드가 대기열에 추가되기 전에 실행될 수 있습니다. 100 카운트 루프에 u ++를 배치하는 스레드 10 개는 어떻습니까? 루프가 시작되기 전에 for 내 짧은 지연 (또는 동시에 시작하는 전역 "go"플래그)
RufusVS

5
사실, 반복적으로 프로그램을 반복해서 생성하면 결국에는 그것이 깨지는 것을 보여줍니다 : while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;내 시스템에서 999 또는 998을 인쇄하는 것과 같습니다 .
Daniel Kamil Kozar

답변:


266

foo()너무 짧아서 각 스레드는 아마도 다음 스레드가 생성되기 전에 끝날 것입니다. foo()이전에 임의의 시간 동안 수면을 추가 u++하면 예상 한 것을 볼 수 있습니다.


51
이것은 실제로 예상되는 방식으로 출력을 변경했습니다.
mafu

49
나는 이것이 일반적으로 경쟁 조건을 보여주기위한 다소 좋은 전략이라는 점에 주목하고 싶습니다. 두 작업 사이에 일시 중지를 삽입 할 수 있어야합니다. 그렇지 않다면 경쟁 조건이 있습니다.
Matthieu M.

최근 C #에서이 문제가 발생했습니다. 일반적으로 코드는 거의 실패하지 않지만 최근에 추가 된 API 호출로 인해 지속적으로 변경 될 수있는 충분한 지연이 발생했습니다.
Obsidian Phoenix

@MatthieuM. Microsoft는 경쟁 조건을 감지하고이를 안정적으로 재현 할 수 있도록하는 방법으로 정확하게이를 수행하는 자동화 도구를 가지고 있지 않습니까?
메이슨 휠러

1
@MasonWheeler : 독점적으로 리눅스에 내가 작업 가까이, 그래서 ... 몰라 :(
마티유 M.

59

경쟁 조건이 정의되지 않은 동작이기 때문에 코드가 잘못 실행된다는 것을 보장하는 것은 아니며 단지 어떤 일도 할 수 있다는 것을 이해하는 것이 중요합니다. 예상대로 실행 포함.

특히 X86 및 AMD64 시스템에서 경합 상태는 경우에 따라 많은 명령이 원자적이고 일관성 보장이 매우 높기 때문에 문제를 거의 일으키지 않습니다. 이러한 보장은 많은 명령어가 원자 적이기 위해 잠금 접두사가 필요한 다중 프로세서 시스템에서 다소 감소합니다.

컴퓨터에서 증가가 원자 연산 인 경우 언어 표준에 따라 정의되지 않은 동작이지만 올바르게 실행될 수 있습니다.

특히이 경우 코드가 단일 프로세서 시스템에서 실제로 원자 적 인 원자 Fetch and Add 명령 (X86 어셈블리의 ADD 또는 XADD) 으로 컴파일 될 수 있지만 다중 프로세서 시스템에서는 원자 및 잠금이 보장되지 않습니다. 그렇게하려면 필요합니다. 다중 프로세서 시스템에서 실행중인 경우 스레드가 방해하여 잘못된 결과를 생성 할 수있는 창이 나타납니다.

특히 https://godbolt.org/를 사용하여 코드를 어셈블리로 컴파일했습니다.foo() 컴파일하고 다음 과 같이 컴파일합니다.

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

즉, 단일 프로세서에 대해 원 자성이되는 추가 명령 만 수행한다는 것을 의미합니다 (위에서 언급했듯이 다중 프로세서 시스템에서는 그렇지 않음).


41
"의도 한대로 실행"은 정의되지 않은 동작의 허용 가능한 결과임을 기억하는 것이 중요합니다.
Mark

3
언급 했듯이이 명령어는 SMP 시스템 (모든 최신 시스템)에서 원 자성 이 아닙니다 . 심지어 inc [u]원자가 아닙니다. LOCK접두사는 명령이 진정으로 원자하기 위해 필요합니다. OP는 단순히 운이 좋아지고 있습니다. CPU에 "이 주소의 단어에 1 추가"라고 말하더라도 CPU는 여전히 해당 값을 가져 와서 증가하고 저장해야하며 다른 CPU가 동시에 동일한 작업을 수행하여 결과가 잘못 될 수 있습니다.
조나단 라인 하트

2
나는 반대표를 던졌지 만 귀하의 질문을 다시 읽고 원 자성 진술이 단일 CPU를 가정하고 있음을 깨달았습니다. 더 명확하게 질문을 편집하면 ( "원자"라고 말할 때 이것이 단일 CPU에서만 해당된다는 점을 분명히하십시오) 내 다운 투표를 제거 할 수 있습니다.
Jonathon Reinhart

3
나는이 주장이 "특히 X86 및 AMD64 시스템에서 어떤 경우에는 많은 명령이 원자적이고 일관성 보장이 매우 높기 때문에 어떤 경우에는 경합 상태에서 거의 문제를 일으키지 않습니다." 단락은 단일 코어에 초점을 맞추고 있다는 명시적인 가정을 시작해야합니다. 그럼에도 불구하고 멀티 코어 아키텍처는 오늘날 소비자 기기에서 사실상의 표준이며,이를 처음이 아닌 마지막에 설명 할 코너 사례라고 생각합니다.
Patrick Trentin

3
아, 물론입니다. x86에는 수많은 하위 호환성이 있습니다. 잘못 작성된 코드가 가능한 한 작동하도록하는 것입니다. Pentium Pro가 비 순차적 실행을 도입했을 때는 정말 큰 일이었습니다. 인텔은 새 칩을 위해 특별히 재 컴파일 할 필요 없이 설치된 코드 기반이 작동하는지 확인하기를 원했습니다 . x86은 CISC 코어로 시작했지만 내부적으로 RISC 코어로 발전했지만 프로그래머의 관점에서 CISC로 여러 가지 방식으로 표시되고 작동합니다. 자세한 내용은 Peter Cordes의 답변을 참조 하십시오 .
Cody Gray

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

그리고 그것은 나에게 대부분의 시간을 1997주었지만 때로는 1995.


1
나는 모호하게 정상적인 컴파일러에서 전체 기능이 같은 것에 최적화 될 것이라고 기대합니다. 그렇지 않다는 것이 놀랍습니다. 흥미로운 결과에 감사드립니다.
Vality

이것은 정확합니다. 다음 스레드가 문제의 작은 함수를 실행하기 전에 수천 개의 명령을 실행해야합니다. 함수의 실행 시간을 스레드 생성 오버 헤드에 더 가깝게 만들면 경쟁 조건의 영향을 확인할 수 있습니다.
조나단 라인 하트

@Vality : 또한 O3 최적화에서 가짜 for 루프를 삭제할 것으로 예상했습니다. 그렇지 않습니까?
user21820

어떻게 else u -= 1처형 될 수 있습니까? 병렬 환경에서도 값은 맞지 %2않아야합니다. 그렇지 않습니까?
mafu

2
출력에서 else u -= 1한 번 실행되고 u == 0 일 때 foo ()가 처음 호출 된 것처럼 보입니다 . 나머지 999 번 u는 홀수이고 u += 2실행되어 u = -1 + 999 * 2 = 1997이됩니다. 즉, 올바른 출력. 경합 조건으로 인해 + = 2 중 하나가 병렬 스레드에 의해 덮어 쓰여지고 1995이됩니다.
Luke

7

경쟁 조건으로 고통받습니다. 넣어 usleep(1000);전에 u++;에서 foo나는 다른 출력 (<1000) 때마다 참조하십시오.


6
  1. 경쟁 조건 존재 하더라도 왜 당신에게 나타나지 않았는 지에 대한 대답 foo()은 스레드를 시작하는 데 걸리는 시간에 비해 너무 빠르기 때문에 각 스레드가 다음 스레드가 시작되기 전에 완료됩니다. 그러나...

  2. 원래 버전을 사용해도 결과는 시스템에 따라 다릅니다. (쿼드 코어) Macbook에서 원하는 방식으로 시도했으며 10 회 실행 한 결과 1000 회 3 회, 999 회 6 회, 998 회를 1 회 받았습니다. 따라서 경주는 다소 드물지만 분명히 존재합니다.

  3. '-g'버그를 사라지게하는 방법이있는로 컴파일했습니다 . 나는 당신의 코드를 다시 컴파일했지만 여전히 변경되지 않았지만'-g' 했는데 이 없었고, 경주는 훨씬 더 뚜렷해졌습니다 : 나는 한 번 1000, 999 세 번, 998 두 번, 997 두 번, 996 한 번, 992 한 번을 얻었습니다.

  4. 레. 수면 추가 제안-도움이되지만 (a) 고정 된 수면 시간은 스레드가 시작 시간 (타이머 해상도에 따라 다름)에 의해 여전히 왜곡 된 상태로 남고 (b) 임의 수면은 우리가 원할 때 스레드를 분산시킵니다. 그들을 더 가깝게 당깁니다. 대신 시작 신호를 기다리도록 코드를 작성하여 작업을 시작하기 전에 모두 생성 할 수 있습니다. 이 버전 (포함 또는 포함하지 않음 '-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플래그는 어떤 방식으로하지 않습니다 "메이크업 버그가 사라집니다." -gGNU 및 Clang 컴파일러 의 플래그는 단순히 컴파일 된 바이너리에 디버그 기호를 추가합니다. 이를 통해 사람이 읽을 수있는 출력으로 프로그램에서 GDB 및 Memcheck와 같은 진단 도구를 실행할 수 있습니다. 예를 들어 메모리 누수가있는 프로그램에서 Memcheck가 실행될 때 프로그램이 -g플래그를 사용하여 빌드되지 않는 한 행 번호를 알려주지 않습니다 .
MS-DDOS

물론 디버거에 숨어있는 버그는 일반적으로 컴파일러 최적화의 문제입니다. 나는 시도하고, "사용했다 했어야 -O2 대신-g". 그러나 .NET 없이 컴파일되었을 때만 나타나는 버그를 사냥하는 즐거움을 경험 한 적이 없다면 자신을 다행이라고 생각하십시오. 그것은 미묘한 앨리어싱 버그의 매우 힘겨운의 일부, 일. 나는 나는 당신을 믿을거야 그래서 GNU와 연타의 현대 버전에 대해 잠정적으로, 오래된 독점 컴파일러의 특질이었다 아니지만 최근에는 본, 나는 어쩌면 믿을 수 있습니다. -g
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.