본문 바로가기
Spring

멀티 스레드 환경에서의 동시성 이슈와 Blocking, Non-blocking 알고리즘을 이용한 동기화

by 초보개발자96 2023. 12. 19.

개발을 하다보면 하나의 공유 자원을 여러 곳에서 동시에 접근하면서 race condition 이 발생하여 예상하지 못한 결과를 만들어 내는 경우가 있습니다. 웹 백엔드 서버를 개발한다면 보통 DB 와 같은 저장소에 접근할 때 동시성 이슈를 많이 만나게 되는데요, 이 글에서는 자바 코드 내의 특정한 객체(변수)에 접근할 때 발생하는 동시성 이슈를 해결하는 방법에 대해 다루어 보겠습니다.

 


 

멀티 스레드 환경에서 코드를 작성할 때에도 동시성 이슈는 언제든지 발생할 수 있습니다.

이를 살펴보기 위해 Int 형 멤버 변수 하나와 멤버 변수에 1을 더하는 메서드를 가진 간단한 SimpleNumber 클래스를 만들었습니다.

 

class SimpleNumber (
    var value: Int = 0
) {

    fun plusOne() {
        ++value
    }
}

 

아래는 해당 클래스의 인스턴스를 생성하고 plusOne 메서드를 1000번 호출하는 테스트입니다.

 

object Tester {

    /**
     * poolSize 만큼 동시에 fn 메서드를 count 번 호출합니다.
     * @param fn
     */
    fun request(poolSize: Int, count: Int, fn: () -> Unit) {
        val es = Executors.newFixedThreadPool(poolSize)
        val latch = CountDownLatch(count)
        for (i in 1..count) {
            try {
                es.submit {
                    fn()
                }
            } finally {
                latch.countDown()
            }
        }

        latch.await()
        es.awaitTermination(50, TimeUnit.MILLISECONDS)
        es.shutdown()
    }
}

@Test
fun `멀티 스레드에서는 동시성 이슈가 발생할 수 있으므로 1000번보다 적게 더해진다`() {
    // given
    val number = SimpleNumber()

    // when
    Tester.request(20, 1000) {
        number.plusOne()
    }

    // then
    assertNotEquals(number.value, 1000)
}

 

 

plusOne 메서드를 1000번 호출했으니 value 값이 1000임을 예상하고 테스트를 작성했지만, 위 테스트는 아래 경우를 커버하지 못하고 실패합니다.

 

  1. 스레드 A 가 value 값이 10 일때 읽고,
  2. A 가 plusOne 메서드를 호출하기 전, 스레드 B 가 value 값이 10 일때 읽고,
  3. 스레드 A 와 B 가 각각 plusOne 메서드를 호출한다면 결과는 11 이 됩니다.

 

이와 같이, 멀티 스레드 환경에서는 여러 스레드가 공유하여 사용하는 변수에 대해 Race conditoin 이 발생할 수 있음을 인지하고, Thread safe 한 코드를 작성해야 합니다.

동시성 이슈를 처리하는 여러 방법이 있겠지만, 여기서는 간단히 프로그래밍 언어 레벨에서 아래 두 방식을 확인해보겠습니다.

 

  • Blocking 알고리즘
  • Non-blocking 알고리즘
 

Blocking 알고리즘

Blocking 알고리즘은 오로지 Lock 을 획득한 스레드만 Critical Section(임계구역) 에 접근할 수 있도록 허용합니다.

 

임계 구역 또는 공유변수 영역은 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하는 코드의 일부를 의미합니다.

 

먼저 임계구역에 들어간 스레드는 해당 구역에 대한 Lock 을 획득하고, 다른 스레드가 접근하지 못하도록 합니다.

 

출처:   https://jenkov.com/tutorials/java-concurrency/non-blocking-algorithms.html

 

위 그림을 요약하면,

  1. 스레드 B 가 먼저 자원에 접근하면 스레드 B 는 Lock 을 획득하고,
  2. 스레드 A 는 스레드 B 가 Lock 을 풀고 나올때까지 대기합니다.

 

당연하게도 대기하다가 스레드 B 가 Lock 을 획득한 상태여도, 만약 CPU 를 다른 스레드가 사용하고 있다면 CPU 를 스케줄링 받기 전까지 대기해야합니다.

즉, Blocking 알고리즘은 Lock 에 대한 경쟁이 심해질수록, 실제로 필요한 작업을 처리하는 시간 대비 동기화 작업에 필요한 시간의 비율이 높아지면서 성능이 떨어질 수 있습니다.

 

Synchronized 키워드

처음 언급한 SimpleNumber 예제를 다시 살펴보면, plusOne 메서드를 호출하여 value 에 대해 read - update - write 연산을 연속적으로 수행하고 있습니다. 즉, plusOne 메서드는 원자성이 보장되지 않으므로 다수의 스레드가 plusOne 메서드를 호출하면 Race condition 이 발생할 수 있습니다.

 

보통 read 만 한다면 Race condition 으로 인한 동시성 이슈가 발생하지 않습니다.

 

이를 Blocking 알고리즘으로 해결하려면 plusOne 메서드를 처리할 때 Lock 관련 처리를 해주어, 하나의 스레드만 plusOne 메서드를 처리할 수 있도록 해야 합니다.

코틀린에서는 @Synchronized 어노테이션을 이용하면 해당 작업을 JVM 에게 위임하여 쉽게 구현할 수 있습니다.

 

class PessimisticLockNumber(
    @Volatile
    var value: Int = 0
) {

    @Synchronized
    fun plusOne() {
        ++value
    }
}
@Test
fun `동시성 이슈가 발생하지 않는다`() {
    // given
    val number = PessimisticLockNumber()

    // when
    Tester.request(20, 1000) {
        number.plusOne()
    }

    // then
    assertEquals(number.value, 1000)
}

 

@Synchronized 가 붙은 plusWithSync 메서드를 1000번 호출하면 count 값이 1000이 되는 것을 볼 수 있습니다.

이렇게 처리하는 방식을 Pessimistic Lock (비관적 락) 이라고도 합니다.

 

 

Non-blocking 알고리즘

Non-blocking 알고리즘이란 Blocking 과 다르게 Lock 이 없는 알고리즘으로, Lock 에 의한 어떠한 오버헤드도 존재하지 않습니다.

Lock-Free 알고리즘이라고도 불립니다.

 

출처:   https://jenkov.com/tutorials/java-concurrency/non-blocking-algorithms.html

 

위 그림을 요약하면,

  1. 스레드 B 가 먼저 자원에 접근하여 먼저 작업을 하고 있으면,
  2. 스레드 A 는 임계구역에 작업을 할 수 없음 을 즉시 통지받습니다.

즉, 스레드가 어떤 행위를 요청했을 때 행위를 현재 수행할 수 없다면 수행할 수 없다고 즉시 통지해 주고, 수행할 수 있을때까지 대기하지 않습니다.

그럼 스레드 A 는 요청받은 행위를 포기할까요? 위 그림으론 그래보이지만 실제로는 다르게 동작합니다.

아래는 자바에서 Non-blocking 을 활용하여 원자성을 제공하는 AtomicInteger 의 addAndGet 내부입니다.

 

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!weakCompareAndSetInt(o, offset, v, v + delta));
    return v;
}

 

코드를 보면 while 문을 돌며 성공할 때까지 행위를 시도합니다.

다만, 위 코드를 정확히 이해하기 위해선 weakCompareAndSetInt 메서드(CAS 연산)에 대해 알아봐야 합니다.

 

CAS 연산과 Atomic 패키지

Non-blocking 알고리즘에서는 데이터의 안정성을 보장하기 위해 Compare-And-Swap 등 하드웨어에서 제공하는 명령을 사용합니다.

 

Compare-And-Swap (CAS)는 Compare 와 Swap 을 한번에 수행하는 기계어 입니다. Machine Instruction 이므로 가시성, 원자성을 확보할 수 있고, 실행 중에 다른 자원에 의해 interrupt 를 받지도, preemption 되지도 않습니다.

 

CAS 연산은 일종의 함수라 생각하면 됩니다.

매개변수로 작업할 대상 위치, 현재 예상되는 값, 교체할 값을 받습니다. 만약 현재 예상되는 값과 동일하면 교체할 값으로 변경하고, 동일하지 않으면 변경하지 않습니다.

아래는 jdk.internal.misc 의 Unsafe 클래스에 있는 compareAndSetInt 메서드로, 만약 현재 값이 expected 와 동일하다면 x 로 값을 수정할 수 있도록 아토믹하게 보장해줍니다.

 

public final native boolean compareAndSetInt(Object o, long offset,
                                             int expected,
                                             int x);

 

Non-blocking 으로 알려진 Atomic 패키지에 구현된 클래스는 내부적으로 이러한 CAS 연산을 이용하고 있습니다. 즉, 원자성이 보장됩니다.

 

위에서 확인했던 AtomicInteger 의 addAndGet 메서드를 다시 보겠습니다.

 

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!weakCompareAndSetInt(o, offset, v, v + delta));
    return v;
}

 

getIntVolatile 로 v 값을 가져오면서 weakCompareAndSetInt (CAS 연산) 메서드가 성공할 때 까지 반복합니다.

언젠가 weakCompareAndSetInt 는 성공하게 되고, weakCompareAndSetInt 자체가 원자성을 제공하기 때문에 Race condition 은 발생하지 않습니다.

비록 무한루프를 반복하긴하지만 공유자원에 접근하는 모든 스레드가 블로킹되지 않아서 Context switching 과 같은 자원 낭비가 없고, 빠르게 자원을 획득하고 반환할 수 있습니다.

 

class OptimisticLockNumber(
    private val value: Int = 0
) {

    private val atomicValue = AtomicInteger(value)

    fun plusOne() {
        atomicValue.addAndGet(1)
    }

    fun getValue(): Int {
        return atomicValue.get()
    }
}
@Test
fun `동시성 이슈가 발생하지 않는다`() {
    // given
    val number = OptimisticLockNumber()

    // when
    Tester.request(20, 1000) {
        number.plusOne()
    }

    // then
    assertEquals(number.getValue(), 1000)
}

 

테스트가 정상적으로 통과하는 것을 확인할 수 있습니다.

이렇게 처리하는 방식을 Optimistic Lock (낙관적 락) 이라고도 합니다.

 

추가- volatile

CPU 내에는 성능 향상을 위해서 L1 Cache가 내장되어 있어, CPU 코어는 메모리에서 읽어온 값을 캐시에 저장하고, 캐시에서 값을 읽어서 작업합니다. 스레드는 값을 읽어올 때 자신이 실행되는 CPU 캐시에 해당 값이 있는지 확인하고 없는 경우에만 메인 메모리에서 읽어옵니다.

 

출처:   https://javaplant.tistory.com/23

 

아래는 첫번째 스레드는 running 값이 false 인 경우 루프를 종료하고, 두번째 스레드는 1초 후에 running 값을 false 로 수정하는 코드입니다. 

 

@Test
fun `두번째 스레드는 자신의 CPU cache 의 running 값을 false 로 수정하므로 첫번째 스레드는 종료되지 않는다`() {
    var running = true
    Thread {
        var count = 0
        while (running) {
            count++
        }
        println("Thread 1 finished. Counted up to $count")
    }.start()
    Thread {
        Thread.sleep(100)
        println("Thread 2 finishing")
        running = false
    }.start()
}

 

스레드 2 로 인해 1초 후에 스레드 1이 종료되는 것을 예상하지만 실제로는 종료되지 않습니다. 왜냐하면 스레드 2가 수정한 running 값은 자신이 실행되는 CPU cache 에 있는 running 값이고, 스레드 1은 평생 자신의 CPU cache 에서 running 을 읽고있기 때문입니다.

이를 해결하기 위해 volatile 키워드를 이용할 수 있습니다. volatile 로 선언된 변수는 사용될때마다 cache 를 무시하고 메모리에 직접 접근하여 변수의 값을 확인하고 수정해주어 가시성을 확보해줍니다. 

 

@Volatile
private var running = true

@Test
fun `정상적으로 종료됩니다`() {
    Thread {
        var count = 0
        while (running) {
            count++
        }
        println("Thread 1 finished. Counted up to $count")
    }.start()
    Thread {
        Thread.sleep(100)
        println("Thread 2 finishing")
        running = false
    }.start()
}

 

volatile 변수는 변수의 write 연산은 원자성을 보장합니다. 하지만 value++ 처럼 연속적으로 read - update - write 와 같은 연속적인 연산에 있어서 원자성을 보장하진 않습니다.

이를 개선하려면 Atomic 으로 선언하거나, 행위에 대해선 synchronized 로 Lock 을 걸어야 합니다.