스레드, 컨텍스트 스위칭에 대한 이해

  • 스레드가 수행하는 더 이상 쪼개지지 않는 작업 최소 단위는 CPU instruction 이다. 즉. 어셈블리.
  • interrupt가 들어오면, 지금 하고 있는 인스트럭션 까지 마치고 나서 컨텍스트 스위칭 하게 된다.
  • 스레드 context switching 시, 스레드 컨텍스트(레지스터 등등)가 저장되었다가, 재시작 시 불러와서 이어서 실행하게 된다.
    • e.g., CPU instruction 어디까지 수행했는지는 EIP 레지스터. 직전에 더하려던 값은 EAX 레지스터 등등.
  • 무언가에 1을 더하는 작업을 CPU instruction으로 나누어 보면 최소 아래 3가지로 구성된다. (CAS는 예외)

```assembly

load (from mem to reg)

add reg, 1

save (from reg to mem)

```

동시성 문제는 일반적으로 CPU instruction 사이의 단절로 발생한다.

프로그래밍 언어로 추상화된 layer에서는 하나의 덧셈 연산자로 보여, 이 것이 하나의 atomic 연산 일 것이라고 기대하게 되지만,

실제로 작업을 수행하는 과정은 3가지 CPU instruction으로 구성되어 atomicity를 보장할 수 없고

3가지 instruction을 수행하는 도중에 context switching이 발생하면 의도치 않은 결과로 이어질 수 있는 것이다.

 

동시성 문제는 크게 두 가지다.

  • 경쟁 조건 (Race Condition)
  • Visibility 문제

 

동시성 문제 1: 경쟁 조건 (Race Condition)

```assembly

    T1                              T2

load (from mem to reg)      
                            load (from mem to reg)
add reg, 1                        
                            add reg, 1
save (from reg to mem)                            
                            save (from reg to mem)

```

shared mutable state에 쓰기 작업 (3가지 CPU instruction)을 수행하는 thread가 2개라고 가정해보면.

T1의 수행 결과는 T2의 수행 결과로 덮어써진다.

 

동시성 문제 2: Visibility 문제

  • ' 이게 뭔 소리냐? 어차피 객체는 Heap에 저장될테고, Heap은 스레드들이 공유하는 영역이니까 한 스레드가 객체를 바꾸면, 다른 스레드에서 그 객체에 접근해도 항상 변경된 값이 나와야 하는거 아니냐? ' 라고 생각할 수 있다.
  • 하지만 CPU에는 cache가 존재하기 때문에... 아래 cache 그림을 보면 뭔소린지 이해가 된다.

https://www.baeldung.com/java-volatile

  • 쓰기 스레드는 왼쪽 코어에서, 읽기 쓰레드는 오른쪽 코어에서 실행되는 상황이라고 가정하자.
  • 쓰기 스레드가 쓰기 작업을 수행하고 나서 변경값을 저장하게 되면 L1 - L2 - L3 - RAM 순으로 변경 전파가 발생해야 한다.
  • 하지만 변경 전파가 곧바로 발생하지 않는다. 효율을 위해 일단 cache에 썼다가 하위 cache, ram에는 나중에 flush 하기 때문이다. (이는 별도의 캐시 동기화 전략에 따른다)
  • 즉, flush가 발생하기 전 까지는 오른쪽 코어에서 실행하는 스레드는 new_value가 아니라 old_value가 보이게 된다.

 

그래서 동기화의 기능도 크게 다음 두 가지다.

  • Race condition에 대한 해결책 : Mutual Exclusion
    • 임계 영역 지정을 통한 상호 배제 등
    • atomic하게 수행하거나, 임계 영역을 진행할 때 lock을 필요로 하게 되면 다른 스레드를 배제하고 혼자 수행하게 됨 (배타적 수행)
    • 배타적으로 수행하면 읽고 쓰는 중 데이터가 훼손되거나 쓰기가 반영되지 않는 문제가 없음.
  • Visibility 보장
    • 한 스레드가 만든 변화를 다른 스레드가 즉각 확인 할 수 있도록 적용 = 가장 최근에 기록된 값을 읽게끔 보장해준다.
    • 어떻게?  => 변수를 Write 하는 시점에 RAM 까지 쓰고, Read 하는 시점에 RAM으로부터 읽어오도록 한다.
    • `` volatile`` 키워드
      • 사용하면 visibility만 보장할 수 있음
      • 그러나 이는 배타적 수행을 보장하지는 않는다.
      • 그래서 쓰기를 수행하는 스레드가 2개 이상이면 `` volatile``을 쓰면 안된다.
        • 좀 더 정확히는, 같은 결과 쓰기를 수행하는 스레드가 2개 이상이 될 수 있을 때는 volatile이 괜찮을 수도 있다. (어차피 누가 쓰든 같은 값이고, overwrite 해도 문제가 되지 않는다면.)
          • e.g., Class.java의 enumConstantDictionary
        • 반면 쓰기 결과가 매번 달라지거나, 이전 결과값에 덧셈을 한다거나 하는 경우 쓰기 스레드가 2개 이상이면 volatile 만으로는 부족하다.
      • 쓰기를 수행하는 스레드가 2개 이상이면 [Mutual Exclusion, Visibility 보장] 모두 필요하므로 동기화가 필요하다.

 

읽기 / 쓰기 스레드 개수에 따라 발생하는 동시성 문제와 해결 전략

읽기 스레드 쓰기 스레드 Race Condition Visibility Problem Solution
n 0 X X -
n 1 X O volatile
n n O O 동기화

 

읽기 - n / 쓰기 - n 인 경우 동기화 전략

  • Atomic
    • 보통 제일 빠르다. 사용할 수 있는 경우 사용하는 편이 좋음.
  • Thread confinement
    • shared mutable state에 쓰는 부분의 코드는 항상 단일 스레드에서 실행되도록 하는 방법.
    • 단일 스레드에서 실행되도록 한다는건 그 부분을 실행할 때 지정 스레드로 넘어가게끔 컨텍스트 스위칭이 일어나도록 한다는 의미인데... 이를 어느 지점에서 할지도 고민해야 한다.
    • 항상 최소 영역으로 지정한다고(fine-grained thread confinement) 장땡이 아니다. 최소 영역으로 지정했다가 컨텍스트 스위칭이 과하게 발생하는 경우 이 때문에 더 느려질 수도 있기 때문임.
      • 극단적으로 해당 부분 코드를 수행하는데 0.1초, 컨텍스트 스위칭에 드는 시간이 1초 라고 가정해보면, 그냥 싱글 스레드에서 수행하는 편이 낫다.
    • Single Writer Priciple을 지키는 방법이지만, 득실을 잘 계산해보아야 하는 방법.
  • Coroutine confinement
    • 언어 차원의 지원이 필요함. (e.g., 코틀린의 Actor)
    • 한 코루틴은 어떤 스레드 위에서 실행되든 순차적으로 실행된다. 는 특성을 이용한 것으로, shared mutable state를 단일 코루틴에 두어 confinement 하는 방법.
    • 보통 단순 lock 보다 효율적인데, lock은 스레드가 blocking에 들어갈 수 있지만(=낭비) 이 방법은 아니기 때문.
  • 임계 영역(Critical Section) 지정을 통한 상호 배제 (Mutual Exclusion)
    • Mutex, Semaphore, lock, synchronized

 

참고