인메모리에서의 동시성 이슈

Ehigh·2024년 10월 13일

이전 프로젝트에서 대기열 기능을 개발하며 동시성 문제를 만났으나, 해결하지 못했다.
이를 해결하기 위해 공부한 내용을 정리한다.

문제 상황

대기열 정보를 저장하는 객체에 초당 1000번의 Read작업 & 1번의 Write작업이 발생하던 상황에서, 잘못된 값이 나오던 문제가 있었다.

이 상황을 최대한 간소화해서 재현해보았다.

재현 및 테스트

A.java

  • read/write 작업의 대상인 객체로, 동시성 문제가 발생하는 대상이다.
  • 멤버변수로 int a, long b, String c 가 있으며, 항상 같은 숫자값을 저장한다.
    ex) A.getA() = 1, A.getB() = 1L, A.getC() = "1"
@Getter @Setter
public class A {
    private int a;
    private long b;
    private String c;

    public A(int a, long b, String c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    public static A createWithZero() {
        return new A(0, 0L, "0");
    }

    public void increase() {
        this.a++;
        this.b++;
        this.c = Integer.parseInt(this.c) + 1 + "";
    }
}

TargetService.java

  • A 인스턴스를 멤버로 가지며, 요청에 따라 Read, Write 작업을 수행한다.
  • Read작업 중 시간이 걸리던 상황을 반영하기 위해, Read작업 중간중간에 1ns씩 대기시킨다.
public class TargetService {
    private A A;

    public TargetService(A a) {
        A = a;
    }

    public ReadResult read() {
        try {
            int a = A.getA();
            Thread.sleep(0, 1);
            long b = A.getB();
            Thread.sleep(0, 1);
            String c = A.getC();
            
            return new ReadResult(a, b, c);
        } catch (Exception e) {

        }
        return null;
    }

    /**
     * A 안의 값을 1씩 올린다.
     */
    public void update() {
        A.increase();
    }
}

테스트를 진행해보았다.

2000개의 쓰레드를 생성해 200번마다 1번씩, 총 10번의 Write작업과
1990번의 Read작업을 비동기로 실행했다.

문제가 발생한 경우(A.a, A.b, A.c의 숫자 값이 다른 경우)는 값을 출력하고,
총 횟수 / 성공 횟수 / 실패 횟수도 출력했다.

결과

동시성 문제 발생

  • 꽤 많은 실패 케이스가 발생했다.
  • Read작업이 실행 중 Thread.sleep()에 의해 대기 큐로 밀려났고, 그 사이에 Write작업이 일어났다고 짐작할 수 있다.

요구사항

  • Read작업과 Write작업은 서로 간섭하면 안 된다. (Read작업 중엔 Write작업을, Write작업 중엔 Read작업을 block해야 한다.
  • Read작업끼리는 서로 block하지 않아야 한다. 간섭해도 문제가 생기지 않고, 오히려 block하면 성능이 떨어진다.
  • Write작업끼리도 간섭하면 안 된다. 요청이 많아지고 작업이 느려지면 write작업끼리 겹칠 가능성이 있고, 처리가 복잡해질 수 있다.

해결 방안

크게 4가지를 생각했다.

  • synchronized 키워드
  • Atomic 변수 활용
  • ReadWriteLock
  • StampedLock

1. synchronized 키워드 사용

  • 자바의 모든 객체는 각각 락을 가지고 있다. 이를 고유 락(Instrinsic Lock)이라고 한다.
  • synchronized 키워드는 이 고유 락을 사용한다. 특정 스레드가 synchronized 블럭에 진입할 때 lock을 얻어야 진입할 수 있다. 재진입을 지원한다.

    재진입
    예를 들어, 다음과 같은 상황이다.

    public class Sample {
       
       public synchronized void methodA() {
           methodB();
       }
       
       public synchronized void methodB() {
           System.out.println(1);
           return;
       }
    }
    1. 특정 쓰레드가 methodA()를 호출하면, 락을 획득한다.
    2. 그 상태로 methodB()를 호출한다.
    3. Sample객체의 락이 이미 점유된 상태에서 또다시 락 획득을 시도하지만, 같은 쓰레드라면 이를 허용한다.

    재진입이 불가능하다라는 경우는, 위의 경우에 같은 쓰레드라도 methodB()에 진입이 허용되지 않는 경우를 말한다.

현재는 Read작업과 Write작업을 다르게 처리하는 것이 필요하므로, 이 방식은 적합하지 않았다.

2. Atomic 변수 활용

Atomic변수는 연산에 원자성이 보장된다.

private AtomicInteger count; // 현재 Read작업중인 쓰레드 수
private AtomicBoolean isLocked;

위 2가지 변수를 활용하여 구현하는 방식을 생각했다.
대략 아래와 같은 방식이다.

LockUsingAtomic.java

public class LockUsingAtomic {
    
    private AtomicInteger count;
    private AtomicBoolean isLocked;
    
    public int read() {
        if (isLocked) wait();
        count++;
        // read
        count--;
    }
    
    public void write() {
        if (count != 0) wait();
        isLocked = true;
        // write
        isLocked = false;
    }
}

Atomic변수는, CAS알고리즘을 통해 non-blocking 방식으로 연산의 원자성을 보장한다.

CAS(Compare And Swap) 알고리즘

count + 1 하는 경우를 예시로 설명해보면,

  1. 힙에서 count값을 복사하여, CPU 캐시에 저장
  2. CPU에서 + 1 연산 후, 연산 전의 값 == 힙의 count값인지 비교
    a. 같으면 힙에 결과값을 쓰고, true 반환
    b. 다르면 false를 반환하고, 1번 단계로 돌아감

그러나 결과적으로 동시성 문제를 완전히 해결하지 못하고, 효율성 면에서도 문제가 있어 선택하지 않았다. 자세한 내용은 뒤에서 설명한다.

3. ReadWriteLock

Read락과 Write락, 2가지를 구분지어 관리하는 락이다.

  • Read락이 필요한 상황끼리는, 동시에 실행될 수 있다.

  • Write락이 포함되는 상황에서는, 단일 Write락의 점유만 가능하다.

    • Read락과 Write락이 동시에 점유될 수 없다
    • Write락은 1개의 쓰레드만 점유할 수 있다.
  • 후술될 StampedLock과 비교했을 때, (필요한 경우)매번 Read락을 걸게 되어, 일반적으로 효율이 떨어진다.

  • 자바에서, 구현체인 ReentrantReadWriteLockUnfair 모드(default)와 Fair모드를 지원한다.

    • Unfair모드에서는 특정 쓰레드의 기아 현상이 발생할 수 있다.
    • Fair모드에서는 작업들의 순서를 보장한다. 다만 Unfair모드보다 효율성이 떨어진다.

사용 예시

Sample.java

public class Sample {

    private ReadWriteLock rwLock = new ReentrantReadWriteLock(); // Unfair 모드
//    private ReadWriteLock rwLock = new ReentrantReadWriteLock(true); // Fair 모드
    private Lock readLock = rwLock.readLock();
    private Lock writeLock = rwLock.writeLock();

    public void read() {
        readLock.lock();
        // read작업 수행
        readLock.unlock();
    }

    public void write() {
        writeLock.lock();
        // write작업 수행
        writeLock.unlock();
    }
}

4. StampedLock

  • ReadWriteLock과 유사한 기능에, 추가 기능을 제공한다.(상속한 객체는 아니다.)

  • 낙관적 읽기(락을 걸지 않는 읽기)를 지원한다.

  • 쓰기 작업과의 충돌이 빈번하지 않은 경우, 즉 읽기 작업이 많은 경우에 적합하다.

  • Unfair하게 처리된다.(기아 현상이 발생할 수 있다.)

낙관적 읽기
우선 락을 걸지 않은 채 읽기를 시도하고, 필요한 경우에만 락을 건다. 대략 아래 과정처럼 진행된다.

  1. 락 객체에서 Stamp값(수정이 일어날 때마다 변하는 값)을 가져온다.
  2. 데이터를 읽는다.
  3. Stamp값을 사용해, 읽는 중에 데이터 수정이 있었는지 체크한다.
  4. 있었다면, 이번엔 읽기 락을 걸고 데이터를 읽는다.

사용 예시

Sample.java

public class Sample {


    private StampedLock stampedLock = new StampedLock();
    private int data = 0;

    public int read() {
        long stamp = stampedLock.tryOptimisticRead();
        int readData = this.data;
        if (!stampedLock.validate(stamp)) {
            // 읽기 중 수정이 발생하여 stamp가 변하면, 락을 걸고 읽는다.
            stamp = stampedLock.readLock();
            try {
                readData = this.data;
            } finally {
                stampedLock.unlockRead(stamp);
            }
        }
        return readData;
    }

그래서, 어떤 게 가장 적합할까?

Atomic변수를 활용하는 방식은 크게 2가지 문제점이 있다.

첫째로, 수많은 읽기 쓰레드들이 count++,-- 연산을 하는 과정에서 CAS방식으로 인한 오버헤드가 발생한다. 특히 여타 경우와는 다르게 Read작업을 하는 모든 쓰레드가 count연산을 시도하므로, CAS방식의 오버헤드가 특히 더 많다.

둘째로, 로직 내에서 또다른 동시성 문제가 발생할 수 있다.
위의 내용을 다시 보자.

LockUsingAtomic.java

public class LockUsingAtomic {
    
    private AtomicInteger count;
    private AtomicBoolean isLocked;
    
    public int read() {
        if (isLocked) wait();
        count++;
        // read
        count--;
    }
    
    public void write() {
        if (count != 0) wait();
        isLocked = true;
        // write
        isLocked = false;
    }
}

여기서 read()와 write()가 같이 수행된다면,
Write 쓰레드가 조건문을 통과하고 isLocked = true로 변경하기 전에 Read쓰레드가 조건문을 통과할 수 있다.

이런 경우에 대한 처리 로직을 포함하면 차라리 Lock객체들을 쓰는게 더 간단할 것이라 생각했고,
CAS방식의 오버헤드도 생각했을 때 Atomic변수 방식은 적합하지 않다고 생각했다.

StampedLock 방식은 Unfair모드의 문제점때문에 선택하지 않았다.
속도 면에서, 낙관적 읽기와 Unfair모드로 인한 장점이 있겠지만,
Write작업이 지연되거나, 특정 Read작업이 오래 지연되는 것의 비용이 더 큰 상황에서 이를 막아줄 수 있느냐가 우선이었다.

이러한 이유로, 우선 ReadWriteLock(ReentrantReadWriteLock)Fair모드로 사용해보고, 효율성 면에서 문제가 크다면 StampedLock을 상속한 클래스를 만들거나, Atomic변수 방식을 사용하기로 했다.

적용

실제 문제 상황이었던 프로젝트 코드는 현재 관심 대상인 로직만 실행하기엔 어려운 관계로, 앞에서 재현했던 테스트코드에 ReadWriteLock을 적용해보았다.

TargetService.java

  • lock 관련 코드가 추가되었다.
  • A.increase()를 호출하는 다른 부분은 없다고 가정한다.
public class TargetService {

    private A A;
    // 공정성 유지; 특정 작업이 오래 밀리면 안됨
    private ReadWriteLock lock = new ReentrantReadWriteLock(true);
    private Lock readLock = lock.readLock();
    private Lock writeLock = lock.writeLock();

    public TargetService(A a) {
        A = a;
    }

    public ReadResult read() {
        readLock.lock();
        try {
            int a = A.getA();
            Thread.sleep(0, 1);
            long b = A.getB();
            Thread.sleep(0, 1);
            String c = A.getC();
            return new ReadResult(a, b, c);
        } catch (InterruptedException e) {

        } finally {
            readLock.unlock();
        }
        return null;
    }

    /**
     * A 안의 값을 1씩 올린다.
     */
    public void update() {
        writeLock.lock();
        A.increase();
        writeLock.unlock();
    }
}

이전과 마찬가지로 2000개의 쓰레드(Write 10 + Read 1990)로 작업을 실행했고, 잘못된 값과 총 성공/실패 작업 수를 출력했다.

결과

락 적용 후 결과

모두 문제 없이 처리된 것을 확인할 수 있었다.




참고 링크

0개의 댓글