(Thread-safety) 동시성 문제와 shared mutable state 관리
스레드, 컨텍스트 스위칭에 대한 이해
- 스레드가 수행하는 더 이상 쪼개지지 않는 작업 최소 단위는 CPU instruction 이다. 즉. 어셈블리.
- interrupt가 들어오면, 지금 하고 있는 인스트럭션 까지 마치고 나서 컨텍스트 스위칭 하게 된다.
- 스레드 context switching 시, 스레드 컨텍스트(레지스터 등등)가 저장되었다가, 재시작 시 불러와서 이어서 실행하게 된다.
- e.g., CPU instruction 어디까지 수행했는지는 EIP 레지스터. 직전에 더하려던 값은 EAX 레지스터 등등.
- 무언가에 1을 더하는 작업을 CPU instruction으로 나누어 보면 최소 아래 3가지로 구성된다. (CAS는 예외)
1
2
3
4
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)
1
2
3
4
5
6
7
8
9
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개 이상이 될 수 있을 때는 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
참고
This post is licensed under CC BY 4.0 by the author.