본문 바로가기

Language & Framework/Spring

Spring 동시성 문제 때려잡기 (2) Application Lock (synchronized, semaphore, ReentrantLock)

1.이 글은 지난편 "Spring 동시성 문제 때려잡기(1) DB Lock"에서 이어지는 글로 전제조건에 대해서 다시 설명하지 않습니다.

2. 여러 문서를 참고하여 해당 포스팅에 미처 포함하지 못한 다양한 테스트코드를 작성해보고 작성한 글이지만 틀린 부분이 있을 수 있습니다. 되도록 본문 하단의 링크들을 참고하여 직접 학습하시는 것을 권고합니다.

 

https://7357.tistory.com/338

 

Spring 동시성 문제 때려잡기 (1) DB Lock

현재 진행 중인 프로젝트는 시간 대여 서비스로, 누군가가 주최한 모임의 정해진 시간에 참여를 신청하고 예약/결제하는 그런 로직을 가지고 있다. 근데 처음 어플리케이션을 설계하는 단계에

7357.tistory.com

 

지난 시간에는 DB Lock을 통한 분산환경에서의 동시성 문제 해결 방법들을 확인했다.

오늘은 단일 프로세스 서버에서 활용할 수 있는 Application Lock에 대해 알아볼 것이다.

Java에서 제공하는 상호 배제의 방법으로는 대표적으로 synchronized, Lock이 있다.

이번 글에서 Atomic시리즈(AtomicInteger, AtomicLong..)를 통한 동기화 해결은 다루지 않는다.

 

지난 번에 synchronized는 아주 무능하고 동시성 해결 같은 건 할 수 없는 친구처럼 묘사되어 억울했을 것 같다.

따라서 synchronized를 사용한 경우를 먼저 확인해보겠다.

 

 

 

지난번의 실패 예제와 다른 점은 Transacational에 속하지 않은 상태에서 synchronized를 걸고 있다는 것이다.

메서드가 끝나는 시점과 트랜잭션이 끝나는 시점이 다른 문제를 외부 호출로 해결했다.

 

전제조건 =  트랜잭션에서 엔티티를 saveAndFlush() 메서드로 저장해야함. 아래 링크 참고.

(https://www.baeldung.com/spring-data-jpa-save-saveandflush)

 

결과는 성공적이다.

이런 식으로면 어찌 사용할 수는 있겠으나 synchronized에는 한계점이 있다.

한 블럭 안에서 synchronized를 진행해야 함으로써 유연성 저하와 공정성(paireness)을 보장하지 않는다.

 

유연성 저하는 상황에 따라 필요할 수도, 필요하지 않을 수도 있지만 공정성을 보장하지 않는다는 것은 문제가 될 우려가 있다.

기아상태(Stravation)를 유발할 가능성이 있기 때문이다.

 

이를 해결하기 위해서는 Lock + Condition 혹은 Semaphore를 사용하면 된다.

친숙한 이름을 가진 세마포어부터 확인해보자.

 

 

 

세마포어는 생성 시 permits와 fair 여부를 인자로 받는다.

permits는 세마포어의 기본 특성인 N개까지의 쓰레드를 동시에 임계구역에 입장할 수 있도록 설정하는 것이고, fiar는 Thread가 입장 순서에 맞춰서 처리되는 것을 보장해줄지 말지에 대한 여부이다.

 

 

 

우리에게 필요한 것은 "입장 가능하거나" or "입장 불가능하거나"를 결정하는 이진 세마포어이기 때문에 인자는 1, true로 주었다.

 

Mutex와 Binary Semaphore는 다르다. 아래 링크 참고.

참고 : (https://stackoverflow.com/questions/62814/difference-between-binary-semaphore-and-mutex)

 

 

 

당연히 테스트는 성공하지만, 진짜로 쓰레드가 "공정하게" 처리되고 있는지에 대해서는 내 눈으로 확인하지 못했기 때문에 확신할 수 없다.

이를 확인하려 다양한 테스트 코드를 작성해보고 구글링도 해봤지만 어떻게 작성해야 unfairness한 상황과 fairness한 상황을 내 눈으로 직접 확인할 수 있는지 찾을 수 없었다.

 

테스트 코드로 확인할 수 없다면 소스코드라도 뜯어보자.

 

 

만약 fairness가 false라면 NonfairSync, true라면 FairSync를 반환하여 sync에 세팅한다.

 

 

 

이후 acquire()를 호출하면 내부적으로 acquireSharedInteruuptibly()를 호출하는데, 여기서 Thread의 interrupted를 체크하고 세마포 값을 확인한다.

 

 

 

당장 이 코드만 보고 모든 걸 이해할 수는 없지만, 주석과 코드를 통해 대략적으로 끼어들기 현상을 방지하여 쓰레드의 동작 순서를 보장한다는 사실을 파악할 수 있다.

 

조금 더 깊게 파고들고 싶지만, 토끼굴에 너무 깊이 들어왔다는 생각이 든다. 오늘은 여기까지만 보자.

아무튼 Semaphore 클래스를 활용해서 쓰레드의 공평성을 보장받을 수 있다는 사실을 파악했다.

 

다음은 Lock의 구현체인 ReentrantLock을 활용하여 공정성을 보장 받는 방법을 확인해보자.

 

 

마찬가지로 ReeentrantLock 또한 두 가지의 생성자를 가지고 있으며, fair를 true로 설정할 경우 sync에 FairSync() 인스턴스를 가지게 되어 공정성을 보장한다.

 

 

그런데 구현해놓은 코드만 보면 Semaphore와 ReentrantLock의 차이를 알 수가 없다.

예시가 적합하지 않았기 때문이다. 따라서 글로 설명하도록 하겠다.

 

우선 세마포어는 Binary Semaphore 뿐 아니라 인자의 값을 다르게 지정하면 더 많은 쓰레드가 동시에 임계구역에 진입할 수 있다.

또한, 세마포어는 wait() / signal()을 이용해 작동하는 OS의 세마포어와 마찬가지로 시그널(신호) 방식으로 동작한다.

반면 ReentrantLock의 경우 락의 제어권을 쓰레드가 자체적으로 소유하며, 한 번에 하나의 쓰레드만 락을 사용할 수 있다.

어.. 이거 완전.. Mutex네?

 

맞다. Semaphore는 이름 그대로 세마포어고, ReentrantLock은 뮤텍스라고 생각하면 된다.

사실 이렇게 짧게 설명하고 끝날 부분은 아닌데, 아쉽게도 내가 작성한 코드가 Lock들을 설명하기에 적합하지 않다.

 

이 부분에 대해 궁금증이 있다면 Lock, StampedLock(낙관적 락), Condition(wait(), notify()의 진보형이다.)의 키워드를 검색해서 추가적으로 학습하면 도움이 될것이다.

 

마지막으로 오해의 여지가 있을 수 있어서 추가적으로 언급하자면, 쓰레드의 순서를 공정하게 보장한다는 것은 어플리케이션 영역에서의 공정성 보장이고 OS의 스케줄링은 어플리케이션에서 간섭할 수 없다.

이에 따라 공정성을 보장하기 위해 오버헤드가 발생할 우려가 있으나, 내 테스트 환경에서는 유의미한 차이를 보이지 않았다.

 

https://stackoverflow.com/questions/70216266/what-is-the-purpose-of-fairness-parameter-in-reentrant-lock-in-java

 The constructor for this class accepts an optional fairness parameter. When set true, under contention, locks favor granting access to the longest-waiting thread. Otherwise this lock does not guarantee any particular access order. Programs using fair locks accessed by many threads may display lower overall throughput (i.e., are slower; often much slower) than those using the default setting, but have smaller variances in times to obtain locks and guarantee lack of starvation. Note however, that fairness of locks does not guarantee fairness of thread scheduling. Thus, one of many threads using a fair lock may obtain it multiple times in succession while other active threads are not progressing and not currently holding the lock. Also note that the untimed tryLock method does not honor the fairness setting. It will succeed if the lock is available even if other threads are waiting.
(https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/locks/ReentrantLock.html#ReentrantLock)

 

 

 

Reference

- Javadoc : AbstractQueuedSynchronizer

(https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/AbstractQueuedSynchronizer.html#hasQueuedPredecessors--)

- Javadoc : ReentrantLock

(https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/locks/ReentrantLock.html#ReentrantLock)

- Javadoc : Condition

(https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/locks/Condition.html)

- Javadoc : StampedLock

(https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/StampedLock.html)

- Jenkov : starvation and fairness

(https://jenkov.com/tutorials/java-concurrency/starvation-and-fairness.html)

- Baeldung : Guide to java.util.concurrent.Locks

(https://www.baeldung.com/java-concurrent-locks)

- Baeldung : Binary Semaphore vs Reentrant Lock

(https://www.baeldung.com/java-binary-semaphore-vs-reentrant-lock)

- Stackoverflow : Why use a ReentrantLock if one can use synchronized(this)?

(https://stackoverflow.com/questions/11821801/why-use-a-reentrantlock-if-one-can-use-synchronizedthis)

- Stackoverflow : What is the purpose of fairness parameter in REENTRANT LOCK in JAVA?

(https://stackoverflow.com/questions/70216266/what-is-the-purpose-of-fairness-parameter-in-reentrant-lock-in-java)