본문 바로가기

개발

Spring 예제 코드로 알아보는 semaphore, mutex 동기화 이해

동기화부터 알아야 하는데 동기화란 

프로세스들 사이의 수행시기를 맞추는것을 말한다.

  • 실행 순서 제어를 위한 동기화
  • 상호 배제를 위한 동기화

위 두가지 케이스로 나뉠수 있다. 

 

대표적으로 생산자 소비자 문제가 있는데

생성자는 데이터를 추가하면 변수 1을 증가시키고 

소비자는 데이터를 삭제하면 변수 1을 감소시킨다.

총합 == 10

생산자(){
	데이터 추가
	총합 변수 1 증가
}
소비자(){
	데이터 삭제
	총합 변수 1 감소
}

 

생산자를 1000번 소비자를 1000번 실행 시키면 결과값은 항상 0이 나오지 않는다.

생산자와 소비자가 동기화 되있지 않아서 생기는 문제이다. 

 

소비자 작업이 끝나기 전에 생산자가 수정되어 공유자원에 문제가 생긴다.

공유자원(shared resource)이란 동시에 접근해서는 안되는 자원이다.

임계구역(critical section) 동시에 실행하면 문제가 발생하는 자원에 접근하는 코드 영역

레이스컨디션(race condition) 잘못된 실행으로 인해 여러 프로세스가 동시 다발적으로 임계 구역 코드를 실행하여 문제가 발생되는 부분

총합 변수 1 증가는 간단해 보이지만 내부는 간단하지 않다

변수 = 총합     // 1.1 변수에 총합을 저장
변수 = 변수 + 1 // 1.2 변수에 1을 더함  
총합 = 변수     // 1.3 총합에 변수를 저장
변수 = 총합     // 2.1 변수에 총합을 저장
변수 = 변수 - 1 // 2.2 변수에 1을 뺌
총합 = 변수     // 2.3 총합에 변수를 저장

생성자 소비자가 동시에 일어난다고 했을때

1.1 생산자 변수 10, 총합 10

1.2 생산자 변수 11, 총합 10

2.1 소비자 변수 10, 총합 10 —— 변수는 지역변수이기 때문에 공유되지 않는다

2.2 소비자 변수 9, 총합 10

1.3 생산자 변수 11, 총합 11

2.3 소비자 변수 9, 총합 9

총합이 11 → 10 으로 바뀌어야 하는데 2.3에서는 변수가 이미 9로 세팅되었기 때문에 9로 변하게 된다.

이것을 방지하기 위해서 3가지 원칙이 지켜져야한다.

  1. 상호배제(mutual exclusion) : 한 프로세스가 임계 구역에 진입하면 다른 프로세스는 임계 구역에 들어올 수 없다.
  2. 진행(progress) : 임계지역에 어떤 프로세스도 들어가지 않았으면 임계지역에 진입하고자 하는 프로세스는 들어갈수 있어야한다 - deadlock
  3. 유한대기(bounded watiting) : 프로세스가 임계 구역에 진입하고 싶다면 그 프로세스는 언젠가 임계 구역에 들어올수 있어야 한다.

 

사실 운영체제를 공부 해야겟다 한게 한줄의 코드 때문이다.

private AtomicBoolean semaphore = new AtomicBoolean(true);

@Bean
public Supplier<String> sendTestData() {
	return () -> this.semaphore.getAndSet(!this.**semaphore.get(**)) ? "foo" : "bar";
}

spring mq 예제에서 제공하는 소스 코드중 하나인데

운영체제를 어렴풋이 공부하면서 들었던 semaphore 를 멋들어지게 구현해서

이번기회에 확실하게 익히기 위해 정리를 했다.

 

동기화 기법에는 3가지가 있는데 제일 처음 알아야하는 뮤텍스락이다

Mutual Exclusion Lock

뮤텍스락은 동시에 접근해서는 안되는 자원에 동시에 접근하지 않도록 만드는 도구

상호 배제를 위한 동기화 도구

임계지역에 진입하는 프로세스는 뮤텍스 락을 이용해 자물쇠를 걸어둘수 있고 다른 프로세스는 잠겨있지 않으면 임계 구역에 진입할수 있다. (공중화장실에 사용중, 비었음 표시 처럼)

  • 자물쇠 역할 : 프로세스들이 공유하는 전역 변수 lock
  • 임계 구역을 잠그는 역할(사용중) : acquire
  • 잠금 해제(비었음) : release
acquire(){
	while(lock == true){
		lock = true;
  }  
}

release() {
	lock = false;
}

---

acquire()
// 임계구역
release()

이렇게 되면 프로세스는

  • 락을 획득할 수 없으면 무작정 기다림
  • 락을 획득할 수 있으면 임계구역 잠근 뒤 임계 구역에서 작업진행
  • 임계구역 빠져 나올때는 임계구역 잠금 해제

busy wait 바쁜 대기 : 임계구역이 잠겨 있으면, 잠겨 있는지를 반복적으로 확인

쉽게 이해 하자면 공중 화장실이 있는데 자물쇠(lock)를 보고

비었음 표시(lock == false)를 보고

들어가서 문을 잠궈서(acquire) 사용중(lock == true) 표시

다른 사람이 들어올려고 하면 사용중(lock == true) 표시를 보고 못들어오는데

잠겨 있는지를 반복적으로 확인 한다.

두번째 semaphore

mutex는 화장실이 하나

semaphore 는 화장실이 여러개


semaphore 를 보다가….. 충격적인걸 발견했는데 이걸 처음 개발한 사람이

네덜란드의 에츠허르 다익스트라이다.

저 사람이 누구인가… 하면

다익스트라 알고리즘을 만든사람이다

다익스트라 알고리즘은 최단경로를 알려주는 알고리즘인데

카카오 인턴십 알고리즘 문제로 나왔던걸 본 기억이 있다..

2021 카카오 인턴십 for Tech developers 코딩 테스트 해설


다시 돌아와서 semaphore 는 하나의 변수와 두개의 함수로 구현할수 있다.

변수 s - 임계 구역에 진입할수 있는 프로세스 개수(화장실 개수)

함수 wait - 임계 구역에 들어가도 좋은지 기다려야 할지 알려주는 함수(신호확인)

함수 signal - 임계 구역 앞에서 기다리는 프로세스에게 ‘가도 좋다’ 신호주는 함수(초록불)

semaphore 도 mutex 처럼 임계구역이 사이에 있어야 함

wait ()
// 임계구역
signal ()

Wait 함수

  1. 임계 구역에 진입할 수 잇는 프로세스 개수가 0 이하라면
  2. 사용할 수 있는 자원이 있는지 반복적으로 확인
  3. 임계 구역에 진입할 수 있는 프로세스 개수가 하나 이상이면 S를 1 감소시키고 임계 구역 진입
wait () {
	while( S <= 0){
		S--;
	}
}

signal 함수

  1. 임계구역에서 작업 마치고 1 증가
signal(){
	S++;
}

mutex와 semaphore는 공유 자원이 없는 경우 프로세스는 무한히 확인해야한다.

busy wait 은 cpu 주기를 낭비한다는 점에서 손해

semaphore는 우아한 방법을 사용하는데

  1. wait 함수는 자원이 없을 경우 해당 프로세스를 대기로 만든다
  2. 프로세스를 semaphore 위한 대기 큐에 넣는다
  3. 다른 프로세스 작업의 임계작업이 끝나고 signal 함수를 호출한다
  4. signal 함수는 대기중인 프로세스를 대기 큐에 제거
  5. 프로세스를 준비 상태로 변경한 뒤 준비 큐로 옮긴다

해당 로직이 추가된 코드

wait () {
	S--;
	if( S < 0){
		add this process to queue;
	  sleep();
	}
}
signal(){
	S++;
	if( S <=0 ) {
		remove a process p from queue;
	  wakeup(p);
	}
}

아래 3가지는 동기화를 정의하는건데 3가지를 만족해야 동기화다.

위에 까지 설명한것은 mutual exclusion을 위한 동기화

실행 순서 제어를 위한 동기화

올바른 순서로 실행하기 위해서는

  1. 변수 S를 0으로 두고
  2. 처음으로 실행할 프로세스 뒤에 signal
  3. 두번째로 실행할 프로세스 앞에 wait

이제 소스를 보자………

Atomic 이라는 단어가 나오는데 여기서 더 나아가서 정리를 해보자면

자바에서 동기화문제는 synchronzied, Atomic, volatile 세가지 키워드로 해결된다.

일단 synchronzied 부터 살펴보자

 


synchronzied 하니깐 갑자기 생각나는 경험이 있다.

예전에 카카오페이 면접 볼 기회가 있었는데

Stringbuilder 와 Stringbuffer 의 차이를 물어봤다.

이건 준비가 되어있었기 때문에

Stringbuilder - thread safe 하지 않음

Stringbuffer - thread safe

으로 답변을 했는데 Stringbuffer 구현이 어떻게 되있을까를 물어봤다.

저기 까지는 공부가 되었는데.. 구현까지는 예상 범위 밖이었다.

그래서 답변을 했을때

builder 에서 append 같은 메소드가 있다.

volatile은 변수에 쓰는거니 동기화에 맞지 않는다.

그렇다면 synchronzied를 써서 해줄것 같다. 라고 했는데

면접관이 다른 방법은 없을까요? 라고 또 생각지도 못한 질문을 했다.

고민 좀 해보다가 동기화에 대해 다른게 생각나지 않아서 잘 모르겟다라고 하니

면접관님이 자기도 모르겟다며 구현되어있는 Stringbuffer를 봤는데

메소드마다 synchronized가 붙어 있는걸 보고 ‘와 다행이다’ 합격하겠다 했는데

떨어졌다….


synchronzied

  • 임계영역에서는 동시에 한 쓰레드만 접근해서 사용할 수 있도록 다른쓰레드의 접근을 막는다(lock)
  • synchronzied 를 건 메소드가 화장실(임계영역)이 되어서 다른 사람들(쓰레드)의 접근을 막음

java 에서는 이것을 더 우아하게 해결하기 위해 concurrent 패키지를 추가 했다.

concurrent 패키지

  • ReentrantLock 클래스 같이 더 유연한 락 메커니즘을 제공한다.
  • **ConcurrentHashMap**은 동시적인 해시 맵 구현으로서 **HashMap**보다 스레드 안전성을 보장

 

volatile

자바에서는 변수를 저장할때 성능을 향상시키기 위해 각 CPU 코어의 캐시에 저장하여 사용한다.

Main Memory 에 아직 저장되기 전에 캐시에 있는 메모리가 먼저 저장되면

불일치 현상이 발생한다.

 

volatile 이라는 걸 쓰면 자바 변수를 CPU 캐시가 아닌 '메인 메모리에 저장'할 것을 명시해 변수의 Visibility(가시성)를 보장한다.

Atomic

private AtomicBoolean semaphore = new AtomicBoolean(true);

@Bean
public Supplier<String> sendTestData() {
	return () -> this.semaphore.getAndSet(!this.semaphore.get()) ? "foo" : "bar";
}

AtomicBoolean 에 대해 공식 문서 설명을 보면

A boolean value that may be updated atomically. See the [java.util.concurrent.atomic](<https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/package-summary.html>) package specification for description of the properties of atomic variables. An AtomicBoolean is used in applications such as atomically updated flags, and cannot be used as a replacement for a [Boolean](<https://docs.oracle.com/javase/8/docs/api/java/lang/Boolean.html>).

참고 링크 : AtomicBoolean (Java Platform SE 8 )

atomic  package를 보면 atomic 변수에 대한 특성을 알 수 있고 CAS 알고리즘을 사용해 Atomic하게 값을 변경해준다.

CAS는 compareAndSet(boolean expect, boolean update) 인데

  • 변수의 값을 변경하기 전에 기존에 가지고 있던 값
  • 내가 예상하던 값과 같을 경우에만 새로운 값으로 할당하는 방법

하드웨어(CPU)의 도움을 받아 한 번에 단 하나의 스레드만 변수의 값을 변경할 수 있도록 제공한다

이제 소스를 하나하나 뜯어보자

@Bean
public Supplier<String> sendTestData() {
	return () -> this.semaphore.getAndSet(!this.semaphore.get()) ? "foo" : "bar";
}

semaphore.getAndSet 부터 보면

public final boolean getAndSet(boolean newValue) {
        return (int)VALUE.getAndSet(this, (newValue ? 1 : 0)) != 0;
   }

여기서 뒤는 삼항연산자 이고

모르는게 3개가 나온다.

  1. VALUE
  2. getAndSet

VarHandle VALUE 으로 선언되어있는데 몰라서 찾아보니 VarHandle은 java 9 부터 추가 된 클래스이다.

근데 내가 알기로는 atomic은 자바 5부터 추가 되었는데 왜 VarHandle이 나오는거지?

jdk에 따라서 구현이 다른가 하고 자바 8로 바꾸고 메소드 구현을 다시 봤는데

// java 8 
public final boolean getAndSet(boolean newValue) {
        boolean prev;
        do {
            prev = get();
        } while (!compareAndSet(prev, newValue));
        return prev;
    }

public final boolean compareAndSet(boolean expect, boolean update) {
        int e = expect ? 1 : 0;
        int u = update ? 1 : 0;
        return unsafe.compareAndSwapInt(this, valueOffset, e, u);
    }

대박 달라졌다…..

이래서 버전 올려야 하는거구나….

원래는 자바 11로 세팅하고 보고 있었다.

//azul zulu java 11
public final native
    @MethodHandle.PolymorphicSignature
    @HotSpotIntrinsicCandidate
    Object getAndSet(Object... args);

getAndSet 에서 HotSpotIntrinsicCandidate 을 몰라서 찾아보니

‘OpenJDK의 HotSpot 가상 머신에서 고유 기능으로 처리되도록 지정된 메서드에 적용되는 어노테이션’

어떤 jdk를 쓰는지도 중요한건가?!!!!!

속도가 획기적으로 증가한다고 듣기만한 GrallVm

원래 쓰던 zulu, 이 사실을 발견하고 나서 정리 해봤다.

//GrallVm java 17
public final native
    @MethodHandle.PolymorphicSignature
    @IntrinsicCandidate
    Object getAndSet(Object... args);

//azul zulu java 17
public final native
    @MethodHandle.PolymorphicSignature
    @IntrinsicCandidate
    Object getAndSet(Object... args);

//azul zulu java 11
public final native
    @MethodHandle.PolymorphicSignature
    @HotSpotIntrinsicCandidate
    Object getAndSet(Object... args);

11까지는 @HotSpotIntrinsicCandidate 을 쓰다가

17부터는 @IntrinsicCandidate 을 쓰는데 저건 또 뭔가 봤더니 16버전에 추가된 인터페이스다.

난 자바 개발자가 아닌것 같다. 모르는게 너무 많다.

VarHandle 이거 확인 하다가 여기까지 왔는데

VarHandle → 리플렉션(reflection)과 비슷한 방식으로 변수에 대한 원자적인 연산을 수행할 수 있도록 지원한다.

→ 원자적인 연산을 더 다양하게 수행할 수 있다

VarHandle 설명을 보면 AtomicBoolean 과 같은 클래스보다 더욱 유연한 기능을 제공한다.

기존(자바8 이전)에는 이런 식으로 내부에서 VarHandle을 쓰지 않았는데

// java 8 
public final boolean getAndSet(boolean newValue) {
        boolean prev;
        do {
            prev = get();
        } while (!compareAndSet(prev, newValue));
        return prev;
    }

public final boolean compareAndSet(boolean expect, boolean update) {
        int e = expect ? 1 : 0;
        int u = update ? 1 : 0;
        return unsafe.compareAndSwapInt(this, valueOffset, e, u);
    }

자바 9 이상부터는 AtomicBoolean 을 구현할때 VarHandle 을 사용해서 구현하고 있다.

이런걸 이제서야 알았다…

VarHandle에 getAndSet 메소드는

  • 변수의 값을 읽고 지정된 값을 설정하는 원자적인 연산을 수행한다
  • 해당 변수의 값을 읽은 후에 새로운 값을 설정하고, 이전 값(읽기 이전에 변수에 저장된 값)을 반환
  • 다른 스레드가 동시에 변수에 대한 연산을 수행할 때 발생할 수 있는 경쟁 상태(레이스 컨디션)를 방지
  • 안전한 변수 처리를 보장

getAndSet 상세는 다음과 같은데 어노테이션까지 설명하기에는

너무 길어져서….. 패스

public final native
    @MethodHandle.PolymorphicSignature
    @IntrinsicCandidate
    Object getAndSet(Object... args);

다행히 semaphore.get()은 쉽다

private volatile int value;

public AtomicBoolean(boolean initialValue) {
        if (initialValue)
            value = 1;
    }

public final boolean get() {
        return value != 0;
    }

--------

private AtomicBoolean semaphore = new AtomicBoolean(true);

@Bean
public Supplier<String> sendTestData() {
	return () -> this.semaphore.getAndSet(!this.semaphore.get()) ? "foo" : "bar";
}

volatile int value;

volatile는 위에서 설명했으니깐 패스!

sendTestData 로직을 자세하게 다시 보면

  1. new AtomicBoolean(true) 이면 value = 1 로 세팅된다.
  2. semaphore.get 을 하면 value 가 1이기 때문에 false 가 나온다.
  3. !semaphore.get 이니깐 false 가 true 로 된다.
  4. getAndSet() 으로 값이 true 에서 false 로 변경된다.
  5. getAndSet 은 현재의 value를 리턴하고, 인자로 전달된 값으로 업데이트

그래서 foo, bar 가 번갈아가면서 나온다

private AtomicBoolean semaphore = new AtomicBoolean(true);

@Bean
public Supplier<String> sendTestData() {
	return () -> this.semaphore.getAndSet(!this.**semaphore.get(**)) ? "foo" : "bar";
}

위 소스 코드에 대해 다시 한번 정리해보자면

  • spring cloud stream 에서 publisher를 간단하게 구현 했다
  • AtomicBoolean을 통해 semaphore를 구현했다
  • semaphore는 멀티프로그래밍 환경에서 공유 자원에 대한 접근을 제한하는 방법이다
  • AtomicBoolean 에서는 get 을 할때 volatile 을 통해 변수의 critical section 을 구분했다
  • AtomicBoolean 에서는 getAndSet 을 할때는 java 8 이하에서는 compareAndSet java 9 이상에서는 VarHandle 로 critical section 을 구분했다

VarHandle 설명할려면 또 이만 큼 써야 되니 알아서 알아본다.

Mutex, semaphore 말고 Monitor 도 있는데 이것도 알아서 알아본다.

 

 

출처 : 혼자공부하는 컴퓨터 구조 + 운영체제

반응형