반응형

 

 

c++ 의 atomic 은 두가지를 해결해 준다 원자성(명령어 쪼개져서 여러쓰래드의 간섭 방지), 가시성(캐쉬 연산 얽힘 방지)

=? 양자역학의 얽힘을 말하는 것이 아님 ㅋ

캐쉬 연산 얽힘 이란 용어는 있는것이 아니지만 뭔가 한단어로 표현할게 없을까 생각해봤을때 떠오르는 것이 이단어면 괜찮을것 같다는..

 

 Atomic 변수를 다른 스레드에서 같은 변수를 사용한다고 하면 한번에 하나만 실행 되는건 맞는데, 약간 병목현상이 발생 하게 됨


C++

std::atmoic

원자성 : 

atomic은 원자라는 의미를 가지며 원자적 연산(더 이상 쪼개질 수 없는 연산)을 진행한다. 

원자적 연산은 처리하는 중간에 다른 스레드가 끼어들 여지를 주지 않으며 전부 처리하거나 or 아무것도 하지 못했다. 이 두 가지 상황만이 존재하는 연산. 즉 한번에 일어나는 연산.

이것은 싱글 쓰레드 환경에서는 중요치 않지만, 멀티 쓰레드 환경에서는 중요한 개념.

 -> 둘 이상의 스레드가 공유 자원에 접근할 때 발생할 수 있는 문제를 경쟁 상태(Race condition)이라 한다.

 -> 경쟁 상태는 mutex와 같은 상호 배제 객체로도 해결이 가능하지만, atomic연산을 통해서도 위의 문제를 방지할 수 있다.

 

 -> 경쟁 상태가 문제가 되는 이유는 운영체제의 콘텍스트 스위칭(Context Swiching, 문맥 교환)때문.

 -> C++11이전에는 Interlocked(인터락) 함수를 사용하였음.

 -> std::mutex와 같이 c++11에 추가된 상호 배제 객체는 유저 모드 동기화. 내부적으로 원자적 연산(CAS)을 통해 처리된 결과를 반환.

 

가시성 :

또한 atomic연산은 메모리 가시성 문제또한 해결한다. 가시성이란 멀티 코어의 캐시(Cache) 간 불일치의 문제를 말하는데, 하나의 스레드에서 특정한 변수의 값을 수정하였는데, 다른 스레드에서 그 수정된 값을 제대로 읽어 들인다는 보장이 없다.

(참고로 C++의 volatile은 JAVA와는 달리 가시성 문제를 해결해주지 않는다. 단순히 컴파일러 최적화를 막기만 함)

CPU에서 메모리에 선언된 변수를 읽어 들일 때 최적화를 위해 해당 메모리와 주위의 메모리를 가져가 캐시에 저장한다.

 

 

std::atomic at; 은 이 걸 사용하는 명령어 한줄이 원자적으로 실행된다

at.load()

at.store()

 

이런 명령 외에 앞뒤로 명령어들이 atomic 이 아닌것들이 있다면 이들 사이의 명령어 순서의 보장은 보장하지 못한다

 

 

 


Java 

(참고로 C++의 volatile은 JAVA와는 달리 가시성 문제를 해결해주지 않는다. 단순히 컴파일러 최적화를 막기만 함)

CPU에서 메모리에 선언된 변수를 읽어 들일 때 최적화를 위해 해당 메모리와 주위의 메모리를 가져가 캐시에 저장한다.

 

C++ 과 votaile 이 하는 역할이 다르긴 하지만 개념 설명이 좋아서 덧붙인다

 

 

 

 


 

멀티 스레드를 다루는 과정의 기초가 되는 가시성과 원자성을 정의해볼 것

사실 가시성과 원자성이라고 하는 단어는 문제를 해결하기 위한 원칙이다.

바꿔 말해 Multi Thread를 구성하다보니, 비 가시성, 비 원자성 문제가 발생했고, Multi Thread를 문제없이 사용하려면 가시성과 원자성을 확보해야한다.

여기서 ~해야한다는 비단 개발자의 노력만으로 이루어질 수 없음을 말한다. (CPU, OS, JVM 등이 지원해야한다.)

참고로 가시성과 원자성은 여러 Thread가 동시에 접근 가능한 공유 변수에 대한 이야기다.

공유변수에 대한 Thread간 경합 (race condition)

Thread 내에서 생성된 변수 또는 로직적으로 잘 격리된 멤버변수 접근 (비경합)

 

 

 

 

프롤로그

가시성과 이번에 다루려는 원자성은 Multi Thread 환경에서 Thread간 공유 메모리 이슈를 발생시킨다는 점에서 공통분모를 가지고 있고, 서로간의 상호작용을 잘 염두에 두어야 한다는 것은 사실이다.

하지만 시스템 관점에서 보면 이 두 개념은 조금 다른 곳에 존재한다.

  • 가시성 : CPU - Cache - Memory 관계상의 개념.
  • 원자성 : 한줄의 프로그램 statement(문장)가 컴파일러에 의해 기계어로 변경되면서, 이를 기계가 순차적으로 처리하기 위한 여러 개의 machine instruction이 만들어져 실행되기 때문에 일어나는 현상을 설명하는 용어.

 

 

 

원자성이 뭔가 간단히 알고 먼저 가시성을 보도록 한다

원자단위 연산의 이해

원자성 즉, 연산의 원자 단위를 이해하기 위해, 이전에 다루었던 i++를 원자 연산으로 분해해보도록 하겠다.

프로그램 언어 문장 -> machine instruction 변환

i++이 위 그림과 같이 캐싱을 배제하더라도 읽고(READ) > 연산하고(MODIFY) > 저장(WRITE)하는 총 3가지의 instruction이 수행된다.

이 원자단위의 연산을, 수행중에는 다른 Thread에 의해서 컨트롤되는 타 CPU의 개입이 있을 수 없는 최소 단위의 연산이라고 이해하면 된다.

 

 

 

 

가시성(Visibility)

가시성, 그러니까 잘 보임의 대상은 무엇일까?

위에서 이미 거론했지만, 우리는 공유하는 변수에 대해 다루고 있다.

이 변수가 HW에서는 Main Memoruy에 적재된다고 알고있다. 맞다. 헌데 int i = 10;이렇게 하면 그냥 메모리에 딱 하고 들어가면 좋겠는데, 성능이니 뭐니 하면서 시스템은 아주 복잡한 설계를 해놨다.

Thread는 동작하는 시점에 하나의 CPU(요즘에는 core라고 해야 맞다)를 점유하고 동작을 한다.

선언한 변수의 값이 Memory에만 존재하는 것이 아니라, CPU cahce라고 하는 영역에도 존재한다. 이는 CPU가 Memory에서 값을 읽어들여오고 다시 쓰고 하는 시간을 아끼기 위함이다. 더 큰 문제는 CPU cache에 값이 Memory에 언제 옮겨갈지도 모른다는 것이다. 이를 해결하는 것을 가시성이라고 한다.

각 Thread가 각기 바라보는 CPU의 cache

CPU1은 7까지 증가했으나, CPU2의 cache는 아직도 0으로 알고있다.

 

가시성과 원자성 두가지를 이해하면 Multi Thread 프로그램을 작성할 때 무엇을 주의해야하는지 명확해진다.

또 다른 표현으로 설명하자면 단일 Thread 프로그램에서는 가시성과 원자성을 고려하지 않아도 프로그램 작성하는데 문제가 없다.

그렇다고 가시성과 원자성이 Multi Thread를 할 때만 나타나는 개념은 아니라는 점은 명확히 밝혀둔다. 이는 원래 하드웨어를 설계한 사람들에 의해 만들어진 컴퓨터 내의 구조에 관한 이야기이다.

비 가시성 (가시성 이슈)

각기 다른 Thread 2개는 CPU1과 CPU2를 할당 받아 공유자원에 해당하는 변수를 연산한다. 이때 메인 메모리에서 값을 읽어들여서 연산에 사용하는 것이 아니라, CPU에 존재하는 Cache에 옮겨놓고 연산을 한다.

그 사이에 다른 쓰레드에서 같은 변수를 대상으로 연산을 한다. 이때 Thread는 타 Thread가 사용하고 있는 CPU의 Cache값을 알지 못한다. 언제 Cache의 값이 메인 메모리로 쓰여질지도 모른다. 그래서 아래 코드를 돌려보면 각각의 Thread가 100회씩 변수를 증가연산을 실시했는데, 200에 훨씬 못미치는 103~105쯤이 나타난다. (이 실험 결과는 하드웨어 성능에 따라 상이할 수 있다.)

만약 동시에 시작하는 Thread가 아닌 순차처리라면 200이 나와야하는 현상이다.

Cache에 담아서 연산을 하더라도 바로바로 메모리에 적용했으면 200이 아니더라도 180~190 정도는 나올 것 같은데 너무나도 100에 가까운 결과에 놀라는 독자도 있을 것이다.

 

 

 

Java  - Volatile을 이용한 가시성 확보

visibility(가시성)에 이어 volatile(변덕스러운) 용어가 나왔다.

가시성은 이제 좀 와닿는데 아직도 왜 volatile이라는 단어를 사용하는지 이해를 못한다고 원글 쓰신분은 말씀하신다.

암튼 Java에서 위에서 설명한 비 가시성 이슈를 해결하기 위해 Java 1.4부터 volatile을 지원하기 시작했다.

변수를 선언할 때 해당 단어를 앞에 써주기만 하면 된다. 이렇게만해도 테스트 코드가 "거의" 200을 반환한다. (사실 이 결과는 미신같은 이야기다. 정확히 이야기하면 200이거나 200보다 작은 값을 반환한다.)

원리는 이렇다. volatile로 선언된 변수를 CPU에서 연산을 하면 바로 메모리에 쓴다. (Cache에서 메모리로 값이 이동하는 것을 다른 책이나 문서에서는 flush라고 표현한다.)

그러니 운이 좋게 Thread 두개가 주거니 받거니 하면서 증가를 시키면 200에 가까운 결과를 얻어내는 것이다.

하지만 개발자라면 이 미신같은 결과에 흡족해하면 안된다.

원글 작성자의 컴퓨터 기준으로 각 Thread당 100회가 아닌 1000회 정도 연산을 시키면 2000이 아닌 1998같은 결과를 얻어낸다.

이 이야기인 즉, 가시성이 확보되더라도 원자성 문제 (동시에 같은 값을 읽어다 증가시키고 flush하는...)로 인해 이와 같은 문제가 생긴다.

 

 

 

 

 

 

 

 

가시성을 확보하더라도 원자성이 문제되는 경우

 

 

 

 

 

 

 

원자성 (Atomicity)

가시성이라는 용어도 그렇지만, 원자성이라는 이 단어 역시 상당히 은유적이다.

가시성 역시 메모리 가시성이라고 하면 좀더 쉽게 와닿을 것이고, 원자성 역시 연산의 원자성이라고 하면 좀 더 이해가 쉽다.

관련 도서나 자료에서는 이를 연산의 단일성 혹은 단일 연산이라고 부르기도 한다.

공유되는 변수를 변경할 때, 기존의 값을 기반으로 하여 새로운 값이 결정되는 과정에서 여러 Thread가 이를 동시에 수행할 때 생기는 이슈를 부르는 명칭이기도 하다.

다수의 책이나 자료에서 다루는 예가 i++연산이다.

자연어 입장에서는 하나의 문장이지만, 이를 CPU가 수행하기 위해서는 총 3가지 instruction이 발생한다.

  • i의 기존 값을 읽는다. (READ)
  • i에 1을 더한다. (MODIFY)
  • i의 값을 변수에 할당한다. (WRITE)

이를 두개 Thread가 동시에 100회 수행한다고 했을때, 만약 i++이 원자성을 가지고 있는 연산이라고 하면 결과가 200이어야 하겠지만, 실제로는 200보다 작은 값이 도출된다.

원인은 다시한번 이야기하지만, i++이 3개의 instruction(READ-MODITY-WRITE)로 구성되어있기 때문이다. Thread-1이 값을 읽어 i+1을 하기 직전에 Thread-2가 i를 읽어 i+1을 수행하고 반영하는 동작을 수행한다면 후자의 연산은 무효가 되는 현상이 발생한다.

가시성 문제를 해결하더라도 원자성이 확보되지 못해 원치 않는 결과가 도출

 

 

원자단위 연산의 이해

원자성 즉, 연산의 원자 단위를 이해하기 위해, 이전에 다루었던 i++를 원자 연산으로 분해해보도록 하겠다.

프로그램 언어 문장 -> machine instruction 변환

i++이 위 그림과 같이 캐싱을 배제하더라도 읽고(READ) > 연산하고(MODIFY) > 저장(WRITE)하는 총 3가지의 instruction이 수행된다.

이 원자단위의 연산을, 수행중에는 다른 Thread에 의해서 컨트롤되는 타 CPU의 개입이 있을 수 없는 최소 단위의 연산이라고 이해하면 된다.

여기에 Multi Thread를 개입시켜보자. 각 instruction이 수행되는 사이에는 다른 Thread가 공유 메모리(변수)에 접근이 가능하여, 소위 값이 꼬이는 현상이 생기게 된다. 이러한 점을 예방하는 방법을 알아보자.

 

Java 동기화 처리를 통한 Thread 안정성 확보

하나의 원자 연산이 이루어지는 동안, 이 연산은 다른 Thread - CPU에 의해 간섭받을 수 없다. 이것은 시스템이 이렇게 구현되어있는 것이다.

헌데, 우리가 다루고자하는 연산은 원자단위로 하기에는 너무 복잡하다. 각종 조건, 반복, 보조장치로부터의 데이터 읽기와 쓰기 등이 복합적으로 이루어지는 연산이 섞여있다. 이러한 복잡한 연산을 i++도 한번에 못하는 시스템한테 한번에 시킨다는 것은 불가능하다. (시스템의 발달로 i++를 원자단위로 할 방법은 있다.)

개발자가 이를 쉽게 해결할 수 있는 방법이 바로 임계영역(Critical Section) 지정이다. 동시에 처리되면 문제가 되어 상호 배타적인(Mutual Exclusion) 영역을 설정하는 것이다. 여기서 말하는 영역은 공간적인 영역이 아닌 statement(명령문)의 블럭이다.

 

 

 

 

 

 

ref : https://velog.io/@syleemk/Java-Concurrent-Programming-%EA%B0%80%EC%8B%9C%EC%84%B1%EA%B3%BC-%EC%9B%90%EC%9E%90%EC%84%B1

ref : https://marmelo12.tistory.com/320

반응형

+ Recent posts