[java] Thread Synchronization (Monitor)

2021. 12. 28. 16:32[ 백엔드 개발 ]/[ Java,Kotlin ]

Race Condition

- 멀티 쓰레드 환경에서 발생할 수 있는 상황

- shared resource에 대해서 2개 이상의 task들이 동시에(concurrent, parrallel 모두 해당) 접근을 시도할 경우 이들 간의 순서로 인해 결과 값에 영향을 줄 수 있는 상태를 의미

- 이로 인해 shared resource에 대한 연산에 대해서 일관성이 깨진 상태의 결괏값이 도출될 수 있음

 

The degree of multiprogramming : single-processor가 수용할 수 있는 최대 thread 개수

 

Mutual exclusion

- Race Condition을 해결하기 위한 개념

- 한 시점에 하나의 task만이 shared resource에 접근할 수 있음 (상호 배제)

- 단, Mutual exclusio을 적용하더라도 deadlock은 해결되지 못함.(하지만 deadlock은 어느 운영체제에서도 해결하지 않음)

cf. deadlock: 2개 이상의 task들이 서로가 원하는 resource를 각자가 점유하고 있어 무한정 기다리는 상태(starvation)(https://jh-labs.tistory.com/83)

 

Thread Synchronization

- Race Codition으로 인해 발생할 수 있는 문제점을 해결하고자 등장

- Race Codition이 예상되는 곳에 Critical Section을 두고 쓰레드 간 동기화를 함

- 아래 Mutex와 Semaphore는 task간 상호 배제를 이용해 동기화를 하는 방식임

 

1. Mutex

- 위에서 말한 Mutual Exclusion을 적용한 해결책임.

- 하지만 Context Switching이 필요 없다는 것은 장점임

- lock을 건 task만 해당 lock을 해제할 수 있음

- acquire(), release()하는 과정 자체를 동기화해야 함.

- 이미 resource를 점유하고(lock) 있을 경우 어떠한 방식으로 기다릴지에 대해 아래와 같이 2가지 종류로 구분됨

 

* Mutex의 종류 2가지 : 

1) Busy Waiting(spinlock) : 반복문을 계속 돌면서 무한정 기다리는 방식으로 CPU를 낭비함 (context switching이 필요 없음)

2) block : 진행을 중지하고 waiting queue에 들어가서 기다리고 다시 스케줄링됨(context switching 필요)

 

2. Semaphore (by Dijkstra)

- Mutex와 다르게 '동시에' 최대 N개의 task가 critial section에 접근할 수 있도록 함

- Mutex와 다르게 lock을 건 task 외 다른 task가 signal을 보내 해당 lock을 해제할 수도 있음

- 다른 task가 semaphore의 count를 감소시킬 수 있기 때문에 Mutex보다 데드락 발생 가능성이 적음

- 동기에 접근 가능한 task의 개수인 count를 잘 보호하는 것이 매우 중요

- semaphore의 단점: acquire()하고 release()하는 과정 자체가 동기화되지 않음.

- semaphore 역시 lock 된 리소스를 어떠한 방식으로 기다릴지에 대해 Busy witing, block 두 가지 방식으로 구분됨

 

* semaphore의 단점

- 동시에 접근 가능한 task의 개수를 의미하는 변수를 어디에서든지 변경 가능

- 상황에 따라 block도 하고 waiting도 해서 명확하게 용도를 구분하기 어렵기 때문에 사용하기 어렵고 버그가 생기기 쉬움

- 따라서 프로그래밍 언어 레벨에서 Monitor라는 것을 지원함

 

Thread Synchronization In JAVA

보통 프레임워크나 라이브러리에서는 synchornization을 위한 도구를 지원하지만 C언어에서는 직접 관련 내용을 구현해야 한다. 자바에서는 Monitor라는 도구를 통해 객체에 Lock을 걸어 상호배제를 할 수 있다. Monitor는 Mutex의 Semaphore의 단점을 극복하기 위해 등장했으며 자바에서 사용하는 모든 동기화는 Monitor를 통해 이루어진다.

 

Monitor

- semaphore의 명확한 용도를 정하고자 프로그래밍 언어 차원에서 지원하는 동기화 구조임(SW 모듈)

- 동기화와 관련된 코드는 컴파일 시점에서 자동으로 삽입됨

- shared resource에 대해 안전하게 동기화가 이루어질 수 있도록 지원

- 프로그램이 언어 수준에서 공유자원(field, method 등)에 대한 Mutual Exclusion을 지원함

- 즉, 오직 하나의 task만이 하나의 monitor에 접근할 수 있음(암묵적으로 thread synchronization을 지원)

- 만약 두 번째 task가 monitor에 접근할 경우, 첫 번째 task가 monitor를 떠나기 전까지 block 됨

- monitor를 사용하면 자유도는 낮아지지만 안정성이 높아지고 쉽게 구현 가능 (semaphore보다 제약점임)

 

Monitor의 Condition Variables

- resource를 보호하거나 접근을 제한하는 개념이 아니라, 특정 event를 기다리는 메커니즘(예, IO이벤트 등)

- wait() : 특정 이벤트가 발생하기를 기다림

- signal() 또는 notify() : waiting 하는 task 중 하나를 깨움

- broadcast() 또는 notifyAll() : waiting 하는 task를 모든 쓰레드에게 알리지만 하나의 thread만 lock이 풀림

- condition variable은 자바의 최상위 객체인 Object에서 제공된다(wait(), notify(), notifyAll())

- semaphore의 흐름을 제어하는 특징

 

JAVA에서의 Monitor

1) Synchronization 키워드

- OS에서 제공하는 Mutex를 JVM에서 쉽게 사용할 수 있도록 추상화해 둔 키워드이다.

- field, method, block단위로 가능

 

2) HW측면에서의 해결책: atomic

- atomic이란? critical section에서 로직들이 모두 실행되든가 그렇지 않든가(all-or-nothing) 중에 하나여야만 함.

- critical section에서 수행 중이던 task가 preemption 당해서 스케줄러가 불리고, critical section에서 빠져나가야만 한다면 atomic이 성립되지 않은 경우임

- 이러한 경우 스케줄러가 불리는 상황을 방지해야 함. 따라서 critical section에 들어온 task에 대해서는 timer를 세팅하지 않는 방식을 통해 preemption 당하지 않도록 하고 atomic을 보장함

- 하지만 이 경우 커널쓰레드에서 해당 쓰레드에 인터럽트를 보낼 수 없다는 문제점이 있음

- 보통 critical section에는 읽고/쓰는 instruction인 경우가 많다는 특징을 이용해 이러한 연산을 한 번에 이루어질 수 있도록 하는 방식으로 해결

 

[java의 atomic 키워드]

- java.concurrent.atomic 패키지에서 제공됨

- Atomic 클래스들은 CAS(compare-and-swap) 기반으로 되어 있어 thead-safety 함

- 예를 들어 semaphore에서 wait 하고 aquire 하는 연산 자체가 atomic 한 연산이 아니므로 적용되어야 함

 

cf. CAS: 서로가 보고 있는 값이 같으면 원래 값으로 변경시키고 다르면 그대로

 

3) HW측면에서의 해결책: volatile

- 컴파일러가 기계어를 만드는 과정에서 프로세스가 멀티쓰레드인지 고민하지 않음(즉, single-thread기반으로 컴파일함)

- 프로세서는 보통 register에서 먼저 데이터를 찾는데 이 경우 일관성이 깨질 수 있음

- 만약 멀티쓰레드 환경에서 두 CPU가 각각 레지스터에 특정 값을 로드해 둔 상황일 때, 다른 쓰레드가 해당 값을 바꿔 메모리 상에 변경이 이뤄진다면 또 다른 쓰레드는 레지스터에서 갱신되지 않은 값을 읽을 수 있다는 문제점이 있음.

cf. 레지스터에서 읽기 약 1 cycle, 메모리에서 읽기 약 1000 cycle

- 이러한 멀티쓰레드 환경에서 일관성이 깨진 값을 읽을 수 있다는 문제점을 해결하고자 HW측면에서 컴파일 시점에 해당 값을 레지스터가 아닌 메모리에서 읽도록 강제하는 것이 volatile 키워드임

- volatile 키워드를 변수에 명시하면 해당 변수를 읽는 instruction을 생성할 때, 레지스터에서 읽는 것이 아니라 메모리에서 읽어오도록 컴파일함

 

 

 

Reference

https://tecoble.techcourse.co.kr/post/2021-10-23-java-synchronize/