i ++가 스레드로부터 안전하지 않다고 들었습니다. ++ i는 스레드로부터 안전합니까?


90

나는 어셈블리에서 원래 값을 임시 어딘가에 저장하고 증가시킨 다음 컨텍스트 스위치에 의해 중단 될 수있는 대체하기 때문에 i ++가 스레드로부터 안전한 진술이 아니라고 들었습니다.

그러나 ++ i에 대해 궁금합니다. 내가 알 수있는 한, 이것은 'add r1, r1, 1'과 같은 단일 어셈블리 명령어로 축소되며 하나의 명령어 일 뿐이므로 컨텍스트 스위치에 의해 중단되지 않습니다.

누구든지 명확히 할 수 있습니까? x86 플랫폼이 사용되고 있다고 가정합니다.


질문입니다. 두 개 이상의 스레드가 이와 같은 변수에 액세스하려면 어떤 종류의 시나리오가 필요합니까? 나는 여기서 비판하는 것이 아니라 솔직하게 묻는 것이다. 이 시간에 내 머리는 아무것도 생각할 수 없습니다.
OscarRyz

5
개체 수를 유지하는 C ++ 클래스의 클래스 변수?
paxdiablo

1
다른 사람이 나에게 말했기 때문에 오늘 방금 본 문제에 대한 좋은 비디오 : youtube.com/watch?v=mrvAqvtWYb4
Johannes Schaub-litb

1
C / C ++로 재 지정됨; Java는 여기에서 고려되지 않고 C #도 비슷하지만 엄격하게 정의 된 메모리 의미 체계가 부족합니다.
Tim Williscroft

1
@Oscar Reyes i 변수를 사용하는 두 개의 스레드가 있다고 가정 해 보겠습니다. 스레드 하나가 특정 지점에있을 때만 스레드를 늘리고 다른 스레드가 다른 지점에있을 때만 스레드를 줄이면 스레드 안전성에 대해 걱정해야합니다.
samoz

답변:


158

당신은 잘못 들었습니다. 그것은 잘가있을 수 있습니다 "i++"특정 컴파일러와 특정 프로세서 아키텍처에 대한 스레드 안전하지만 모두의 기준에 위임 아니에요. 사실, 멀티 스레딩은 ISO C 또는 C ++ 표준 (a)의 일부가 아니기 때문에 컴파일 될 것이라고 생각하는 것에 따라 스레드로부터 안전한 것으로 간주 할 수 없습니다.

다음 ++i과 같은 임의의 시퀀스로 컴파일 할 수 있습니다.

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

메모리 증가 명령이없는 내 (가상) CPU에서는 스레드로부터 안전하지 않습니다. 또는 똑똑하고 다음과 같이 컴파일 할 수 있습니다.

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

여기서 인터럽트를 lock비활성화하고 unlock활성화 합니다 . 그러나 그럼에도 불구하고 메모리를 공유하는 이러한 CPU 중 두 개 이상이있는 아키텍처에서는 스레드로부터 안전하지 않을 수 있습니다 ( lock한 CPU에 대한 인터럽트 만 비활성화 할 수 있음).

언어 자체 (또는 해당 언어에 내장되지 않은 경우 라이브러리)는 스레드로부터 안전한 구조를 제공하므로 생성되는 기계 코드에 대한 이해 (또는 오해 가능성)에 의존하지 말고이를 사용해야합니다.

Java synchronizedpthread_mutex_lock()(일부 운영 체제에서 C / C ++에서 사용 가능) 와 같은 것들은 (a) .


(a) 이 질문은 C11 및 C ++ 11 표준이 완성되기 전에 질문되었습니다. 이러한 반복은 이제 원자 데이터 유형을 포함하여 언어 사양에 스레딩 지원을 도입했습니다 (하지만 일반적으로 스레드와 스레드 는 적어도 C에서는 선택 사항입니다 ).


8
이 명확한 답을 말할 것도없고, 플랫폼 별 문제 아니라고 강조하는 일 ...
RBerteig

2
C 실버 배지를 축하합니다 :)
Johannes Schaub-litb

난 당신이 더 현대적인 OS가 사용자 모드 인터럽트를 해제 프로그램 및에 pthread_mutex_lock ()를 C의 일부가 아닌 권한을 부여 없다고 정확해야한다고 생각
Bastien 레너드

@Bastien, 메모리 증분 명령이없는 CPU에서는 최신 OS가 실행되지 않습니다. :-)하지만 귀하의 요점은 C에 관한 것입니다.
paxdiablo

5
@Bastien : 황소. RISC 프로세서에는 일반적으로 메모리 증분 명령이 없습니다. 로드 / 추가 / 저장 트리플렛은 예를 들어 PowerPC에서 수행하는 방법입니다.
derobert

42

++ i 또는 i ++에 대해 포괄적 인 진술을 할 수 없습니다. 왜? 32 비트 시스템에서 64 비트 정수를 증가시키는 것을 고려하십시오. 기본 머신에 "로드, 증가, 저장"명령이 쿼드 단어 인 경우를 제외하고, 해당 값을 증가 시키려면 여러 명령이 필요합니다.이 명령 중 하나는 스레드 컨텍스트 스위치에 의해 중단 될 수 있습니다.

또한 ++i항상 "가치에 하나를 추가"하는 것은 아닙니다. C와 같은 언어에서 포인터를 증가 시키면 실제로 가리키는 물체의 크기가 추가됩니다. 즉, i32 바이트 구조에 대한 포인터 인 경우 ++i32 바이트를 추가합니다. 거의 모든 플랫폼이 원자적인 "메모리 주소에서 값 증가"명령을 가지고있는 반면, 모든 플랫폼이 원자 "메모리 주소에서 값에 임의의 값 추가"명령을 가지고있는 것은 아닙니다.


35
물론 C ++과 같은 언어에서 지루한 32 비트 정수로 제한하지 않는다면 ++ i는 실제로 데이터베이스의 값을 업데이트하는 웹 서비스에 대한 호출이 될 수 있습니다.
Eclipse

16

둘 다 스레드에 안전하지 않습니다.

CPU는 메모리로 직접 수학을 할 수 없습니다. 메모리에서 값을로드하고 CPU 레지스터로 수학을 수행하여 간접적으로 수행합니다.

i ++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ i

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

두 경우 모두 예측할 수없는 i 값을 발생시키는 경쟁 조건이 있습니다.

예를 들어, 각각 레지스터 a1, b1을 사용하는 두 개의 동시 ++ i 스레드가 있다고 가정합니다. 그리고 컨텍스트 전환은 다음과 같이 실행됩니다.

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

결과적으로 나는 i + 2가되지 않고 i + 1이되는데 이는 잘못된 것입니다.

이를 해결하기 위해 모드 CPU는 컨텍스트 전환이 비활성화되는 간격 동안 일종의 LOCK, UNLOCK cpu 명령을 제공합니다.

Win32에서는 InterlockedIncrement ()를 사용하여 스레드 안전성을 위해 i ++를 수행합니다. 뮤텍스에 의존하는 것보다 훨씬 빠릅니다.


6
"CPU는 메모리로 직접 수학을 할 수 없습니다."-이것은 정확하지 않습니다. 레지스터에 먼저로드 할 필요없이 메모리 요소에 대해 "직접"수학을 수행 할 수있는 CPU가 있습니다. 예 : MC68000
darklon 2011

1
LOCK 및 UNLOCK CPU 명령은 컨텍스트 전환과 관련이 없습니다. 캐시 라인을 잠급니다.
David Schwartz

11

멀티 코어 환경에서 스레드간에 int를 공유하는 경우 적절한 메모리 장벽이 필요합니다. 이는 연동 된 명령어 (예 : win32의 InterlockedIncrement 참조)를 사용하거나 특정 스레드 안전을 보장하는 언어 (또는 컴파일러)를 사용하는 것을 의미 할 수 있습니다. CPU 수준의 명령 재정렬 및 ​​캐시 및 기타 문제를 통해 보장되지 않는 한 스레드간에 공유되는 항목이 안전하다고 가정하지 마십시오.

편집 : 대부분의 아키텍처에서 가정 할 수있는 한 가지는 적절하게 정렬 된 단일 단어를 처리하는 경우 함께 으 깨진 두 값의 조합을 포함하는 단일 단어로 끝나지 않는다는 것입니다. 두 번의 쓰기가 서로 겹쳐서 발생하면 하나는 이기고 다른 하나는 버려집니다. 주의를 기울이면이를 활용하여 ++ i 또는 i ++가 단일 작성기 / 다중 판독기 상황에서 스레드로부터 안전하다는 것을 확인할 수 있습니다.


int 액세스 (읽기 / 쓰기)가 원자적인 환경에서는 실제로 잘못되었습니다. 이러한 환경에서 작동 할 수있는 알고리즘이 있습니다. 메모리 장벽이 없다는 것은 때때로 오래된 데이터로 작업하고 있음을 의미 할 수 있습니다.
MSalters

2
원자 성은 스레드 안전성을 보장하지 않는다는 것입니다. 잠금없는 데이터 구조 또는 알고리즘을 설계 할만큼 똑똑하다면 계속 진행하십시오. 그러나 여전히 컴파일러가 제공 할 보장이 무엇인지 알아야합니다.
Eclipse

10

C ++에서 원자 적 증가를 원하면 C ++ 0x 라이브러리 ( std::atomic데이터 유형) 또는 TBB와 같은 것을 사용할 수 있습니다 .

GNU 코딩 가이드 라인에서 한 단어에 맞는 데이터 유형을 업데이트하는 것이 "보통 안전"하다고 말한 적이 있었지만 SMP 시스템에는 잘못 되었고 일부 아키텍처에서는 잘못되었으며 최적화 컴파일러를 사용할 때는 잘못되었습니다.


"한 단어로 된 데이터 유형 업데이트"주석을 명확히하려면 :

SMP 시스템의 두 CPU가 동일한주기에서 동일한 메모리 위치에 쓴 다음 변경 사항을 다른 CPU와 캐시에 전파 할 수 있습니다. 한 단어의 데이터 만 기록되어 쓰기가 완료되는 데 한 주기만 걸리더라도 동시에 발생하므로 어떤 쓰기가 성공했는지 보장 할 수 없습니다. 부분적으로 업데이트 된 데이터는 얻지 못하지만이 경우를 처리 할 다른 방법이 없기 때문에 하나의 쓰기가 사라집니다.

비교 및 교체는 여러 CPU간에 적절하게 조정되지만 한 단어 데이터 유형의 모든 변수 할당이 비교 및 ​​교체를 사용할 것이라고 믿을 이유가 없습니다.

그리고 최적화 컴파일러는 영향을 미치지 않지만 로드 / 저장이 컴파일되는 방식 에 로드 / 저장이 발생할 변경 수 있으므로 읽기 및 쓰기가 소스 코드에 나타나는 것과 동일한 순서로 발생할 것으로 예상되는 경우 심각한 문제를 일으킬 수 있습니다 ( 가장 유명한 이중 검사 잠금은 바닐라 C ++에서 작동하지 않습니다.)

참고 나의 원래 대답은 또한 Intel 64 비트 아키텍처가 64 비트 데이터를 처리 할 때 손상되었다고 말했습니다. 그것은 사실이 아니기 때문에 답을 편집했지만 내 편집은 PowerPC 칩이 고장 났다고 주장했습니다. 즉 치값 (즉, 상수)을 레지스터로 읽을 때도 마찬가지입니다 (목록 2 및 목록 4 아래의 "포인터로드"라는 두 섹션 참조). 그러나 한 사이클 ( lmw) 에서 메모리에서 데이터를로드하는 지침이 있으므로 내 대답의 해당 부분을 제거했습니다.


SMP 및 최적화 컴파일러를 사용하더라도 데이터가 자연스럽게 정렬되고 올바른 크기 인 경우 읽기 및 쓰기는 대부분의 최신 CPU에서 원자 적입니다. 그러나 특히 64 비트 머신의 경우에는 많은주의 사항이 있으므로 데이터가 모든 머신의 요구 사항을 충족하는지 확인하는 것이 번거로울 수 있습니다.
Dan Olson

업데이트 해 주셔서 감사합니다. 정확하고, 읽기와 쓰기는 반쯤 완료 될 수 없다고 말하면서 원자 적이지만, 귀하의 의견은 실제로이 사실에 접근하는 방법을 강조합니다. 메모리 장벽과 동일하게 작동의 원자 적 특성에 영향을주지 않지만 실제로 접근하는 방식에 영향을줍니다.
Dan Olson


4

프로그래밍 언어가 스레드에 대해 아무 말도하지 않지만 다중 스레드 플랫폼에서 실행되는 경우 어떻게 모든 언어 구조가 스레드로부터 안전 할 수 있습니까?

다른 사람들이 지적했듯이 플랫폼 별 호출을 통해 변수에 대한 다중 스레드 액세스를 보호해야합니다.

플랫폼 특이성을 추상화하는 라이브러리가 있으며 다가오는 C ++ 표준은 스레드에 대처하기 위해 메모리 모델을 조정했습니다 (따라서 스레드 안전성을 보장 할 수 있음).


4

단일 어셈블리 명령어로 축소되어 메모리에서 직접 값을 증가시키는 경우에도 여전히 스레드로부터 안전하지 않습니다.

메모리에서 값을 증가시킬 때 하드웨어는 "읽기-수정-쓰기"작업을 수행합니다. 즉, 메모리에서 값을 읽고 증가한 다음 메모리에 다시 씁니다. x86 하드웨어는 메모리에서 직접 증가 할 수 없습니다. RAM (및 캐시)은 값을 읽고 수정할 수만 있고 저장할 수 있습니다.

이제 별도의 소켓에 있거나 단일 소켓을 공유하는 (공유 캐시 유무에 관계없이) 두 개의 개별 코어가 있다고 가정합니다. 첫 번째 프로세서는 값을 읽고 업데이트 된 값을 다시 쓰기 전에 두 번째 프로세서가 값을 읽습니다. 두 프로세서 모두 값을 다시 쓴 후에는 두 번이 아닌 한 번만 증가합니다.

이 문제를 피할 수있는 방법이 있습니다. x86 프로세서 (및 대부분의 멀티 코어 프로세서)는 하드웨어에서 이러한 종류의 충돌을 감지하고이를 시퀀싱 할 수 있으므로 전체 읽기-수정-쓰기 시퀀스가 ​​원자 적으로 표시됩니다. 그러나 이것은 비용이 많이 들기 때문에 x86에서 일반적으로 LOCK접두사 를 통해 코드에서 요청한 경우에만 수행됩니다 . 다른 아키텍처는 유사한 결과를내는 다른 방식으로이를 수행 할 수 있습니다. 예를 들어로드 링크 / 저장 조건부 및 원자 비교 및 ​​스왑 (최근 x86 프로세서에도이 마지막 프로세서가 있습니다).

사용 volatile은 여기서 도움이되지 않습니다. 변수가 외부에서 수정되었을 수 있으며 해당 변수에 대한 읽기가 레지스터에 캐시되거나 최적화되지 않아야한다는 것만 컴파일러에 알립니다. 컴파일러가 원자 적 프리미티브를 사용하도록 만들지는 않습니다.

가장 좋은 방법은 원자 적 프리미티브를 사용하거나 (컴파일러 또는 라이브러리에있는 경우) 어셈블리에서 직접 증가 (올바른 원자 적 명령어 사용)하는 것입니다.


2

증분이 원자 적 연산으로 컴파일된다고 가정하지 마십시오. InterlockedIncrement 또는 대상 플랫폼에 존재하는 유사한 함수를 사용하십시오.

편집 : 나는이 특정 질문을 찾았고 X86의 증가는 단일 프로세서 시스템에서는 원자 적이지만 다중 프로세서 시스템에서는 아닙니다. 잠금 접두사를 사용하면 원자 적으로 만들 수 있지만 InterlockedIncrement를 사용하는 것이 훨씬 더 이식 가능합니다.


1
InterlockedIncrement ()는 Windows 함수입니다. 내 모든 Linux 상자와 최신 OS X 컴퓨터는 x64 기반이므로 InterlockedIncrement ()가 x86 코드보다 '훨씬 더 이식 가능'하다고 말하는 것은 다소 가짜입니다.
Pete Kirkham

C가 어셈블리보다 훨씬 더 이식성있는 것과 같은 의미에서 훨씬 더 이식성이 뛰어납니다. 여기서 목표는 특정 프로세서에 대해 생성 된 특정 어셈블리에 의존하지 않도록 보호하는 것입니다. 다른 운영 체제가 염려된다면 InterlockedIncrement는 쉽게 래핑됩니다.
Dan Olson

2

x86 에 대한이 어셈블리 강의 에 따르면 레지스터를 메모리 위치에 원자 적으로 추가 할 수 있으므로 잠재적으로 코드가 '++ i'ou 'i ++'를 원자 적으로 실행할 수 있습니다. 그러나 다른 게시물에서 언급했듯이 C ansi는 '++'연산에 원 자성을 적용하지 않으므로 컴파일러가 생성 할 내용을 확신 할 수 없습니다.


1

1998 년 C ++ 표준은 쓰레드에 대해 아무 말도하지 않지만, 다음 표준 (올해 또는 내년에 예정)은 그렇습니다. 따라서 구현을 참조하지 않고는 작업의 스레드 안전성에 대해 지적 할 수 없습니다. 사용되는 프로세서뿐만 아니라 컴파일러, OS 및 스레드 모델의 조합입니다.

반대로 문서가 없다면, 특히 멀티 코어 프로세서 (또는 멀티 프로세서 시스템)에서 어떤 작업도 스레드로부터 안전하다고 생각하지 않습니다. 스레드 동기화 문제는 우연히 발생할 가능성이 있기 때문에 테스트를 신뢰하지도 않습니다.

사용중인 특정 시스템에 대한 문서가 없으면 스레드로부터 안전한 것은 없습니다.


1

i를 스레드 로컬 저장소에 넣습니다. 그것은 원자 적이지는 않지만 중요하지 않습니다.


1

AFAIK, C ++ 표준에 따르면에 대한 읽기 / 쓰기 int는 원자 적입니다.

그러나 이것이하는 일은 데이터 경쟁과 관련된 정의되지 않은 동작을 제거하는 것입니다.

그러나 두 스레드가 모두 증가하려고하면 데이터 경쟁이 계속됩니다 i.

다음 시나리오를 상상해보십시오.

i = 0처음에 보자 :

스레드 A는 메모리에서 값을 읽고 자체 캐시에 저장합니다. 스레드 A는 값을 1 씩 증가시킵니다.

스레드 B는 메모리에서 값을 읽고 자체 캐시에 저장합니다. 스레드 B는 값을 1 씩 증가시킵니다.

이것이 모두 단일 스레드이면 i = 2메모리에 저장됩니다.

그러나 두 스레드 모두에서 각 스레드는 변경 사항을 기록하므로 스레드 A는 i = 1메모리에 다시 쓰고 스레드 B는 i = 1메모리에 기록 합니다.

잘 정의되어 있고 부분적인 파괴 나 구성이나 어떤 종류의 객체 찢어짐도 없지만 여전히 데이터 경쟁입니다.

원자 적으로 증가 i하려면 다음을 사용할 수 있습니다.

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

이 작업이 어디에서 발생하는지 신경 쓰지 않기 때문에 완화 된 순서를 사용할 수 있습니다. 우리가 신경 쓰는 것은 증가 작업이 원자 적이라는 것입니다.


0

"단지 하나의 명령 일 뿐이며 컨텍스트 전환에 의해 중단되지 않을 것입니다."라고 말합니다. -단일 CPU에는 모두 좋고 좋지만 듀얼 코어 CPU는 어떻습니까? 그러면 컨텍스트 전환없이 두 개의 스레드가 동시에 동일한 변수에 액세스 할 수 있습니다.

언어를 모르면 답은 그 언어를 테스트하는 것입니다.


4
테스트를 통해 어떤 것이 스레드로부터 안전한지 알 수 없습니다. 스레딩 문제는 백만 분의 일이 될 수 있습니다. 문서에서 찾아보세요. 문서에 의해 스레드 안전성이 보장되지 않으면 그렇지 않습니다.
Eclipse

2
여기에서 @Josh와 동의하십시오. 기본 코드 분석을 통해 수학적으로 입증 될 수있는 경우에만 스레드로부터 안전합니다. 아무리 많은 테스트도 그것에 접근하기 시작할 수 없습니다.
Rex M

마지막 문장까지 훌륭한 대답이었습니다.
Rob K

0

"i ++"라는 표현이 문장에서 유일한 경우, "++ i"와 동등하다면 컴파일러는 시간적 값 등을 유지하지 않을만큼 똑똑하다고 생각합니다. 따라서 서로 바꿔서 사용할 수 있다면 (그렇지 않으면 어느 것을 사용할 것인지 묻지 마십시오), 거의 동일하므로 (미학을 제외하고) 어느 것을 사용하든 상관 없습니다.

어쨌든 증분 연산자가 원자 적이라 할지라도 올바른 잠금을 사용하지 않으면 나머지 계산이 일관된다는 보장은 없습니다.

혼자서 실험하고 싶다면 N 개의 스레드가 동시에 공유 변수를 M 번 증가시키는 프로그램을 작성하십시오. 값이 N * M보다 작 으면 일부 증가를 덮어 씁니다. 사전 증가 및 사후 증가로 시도하고 알려주십시오 ;-)


0

카운터의 경우 비 잠금 및 스레드로부터 안전한 비교 및 ​​스왑 관용구를 사용하는 것이 좋습니다.

다음은 Java입니다.

public class IntCompareAndSwap {
    private int value = 0;

    public synchronized int get(){return value;}

    public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
        int oldValue = value;

        if (oldValue == p_expectedValue)
            value = p_newValue;

        return oldValue;
    }
}

public class IntCASCounter {

    public IntCASCounter(){
        m_value = new IntCompareAndSwap();
    }

    private IntCompareAndSwap m_value;

    public int getValue(){return m_value.get();}

    public void increment(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp != m_value.compareAndSwap(temp, temp + 1));

    }

    public void decrement(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));

    }
}

test_and_set 함수와 유사합니다.
samoz 09-06-10

1
"비 잠금"이라고 썼지 만 "동기화 됨"이 잠금을 의미하지 않습니까?
Corey Trager
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.