[Thread-safety] 동시성 문제와 shared mutable state 관리
스레드, 컨텍스트 스위칭에 대한 이해
- 스레드가 수행하는 더 이상 쪼개지지 않는 작업 최소 단위는 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 그림을 보면 뭔소린지 이해가 된다.
- 쓰기 스레드는 왼쪽 코어에서, 읽기 쓰레드는 오른쪽 코어에서 실행되는 상황이라고 가정하자.
- 쓰기 스레드가 쓰기 작업을 수행하고 나서 변경값을 저장하게 되면 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개 이상이 될 수 있을 때는 volatile이 괜찮을 수도 있다. (어차피 누가 쓰든 같은 값이고, overwrite 해도 문제가 되지 않는다면.)
- 쓰기를 수행하는 스레드가 2개 이상이면 [Mutual Exclusion, Visibility 보장] 모두 필요하므로 동기화가 필요하다.
읽기 / 쓰기 스레드 개수에 따라 발생하는 동시성 문제와 해결 전략
읽기 스레드 | 쓰기 스레드 | Race Condition | Visibility Problem | Solution |
n | 0 | X | X | - |
n | 1 | X | O | volatile |
n | n | O | O | 동기화 |
- 참고로 일반 변수와 Volatile 변수, Atomic 변수 사이에 성능 차이는 거의 없다고 봐도 무방하다.
읽기 - n / 쓰기 - n 인 경우 동기화 전략
- Atomic
- 보통 제일 빠르다. 사용할 수 있는 경우 사용하는 편이 좋음. CAS를 활용함.
- ConcurrentHashMap 같은 자료구조 사용도 고려
- 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 block
참고
- https://kotlinlang.org/docs/shared-mutable-state-and-concurrency.html
- [Effective Java] 11장 동시성 + collection 유틸 메서드
'Coding Note' 카테고리의 다른 글
좋은 개발자란 무엇일까? 개발을 잘한다는건? (0) | 2022.02.22 |
---|---|
나는 풀스택이 아닌데, 풀스택이란 뭘까 (0) | 2022.02.18 |
[Test] 통합 Test를 위한 DB는 어떻게 구성하면 좋을까? - Testcontainers (0) | 2021.05.03 |
Serverless computing platform의 장점 (0) | 2020.12.04 |
Promise / Future에 대한 개념 정리 (0) | 2020.03.20 |