Right now do !

[JAVA] concurrent programming - StampedLock

by 지금당장해

프롤로그

 JAVA7 이전 기준으로 동시성 제어(스레드 안정성)를 위해 Lock을 설정하는 방법은 소위 Monitor Lock이라고 하는 synchronized 블럭을 사용하는 방법과 재 진입성이 보장되는 명시적인 Lock인 ReentrantLock과 Reentrant-ReadWriteLock을 이용하여 Lock을 구현 하였다. 필자가 ReentrantReadWriteLock을 처음 봤을때 아 이쯤이면 뭐 됐지 Lock을 안걸고 Lock Free로 구현하면 되지 이 이상 뭐가 필요할까 했는데 JAVA8 부터 지원한는 StampedLock이 이라는 것이 아닌가? "그래 새로운것을 계속 무시하면 난 아직도 dos에서 GW-BASIC으로 개발하고 있을꺼야! 이제 이 놈이 뭔지 들춰봐야 할 때가 되었어...." 다짐을 하였으나 어렵다. 100% 이해 했다고 확신은 없으나 Oracle에서 제공하는 Document의 Sample수준에서 일단 정리하고 추가로 다시 정리 하는 것으로 해야겠다.

StampedLock은 재 진입이 불가능

 synchronized 블럭이나 ReentrantLock과 ReentrantReadWriteLock 소위 재 진입이 가능한 Lock이다. 풀어서 이야기 하면 같은 Thread에서 동일한 Lock객체를 공유하는 Lock구간에는 한번 획득한 Lock으로 무사 통과를 한다는 뜻이다. 그런데 StampedLock은 이것이 불가능하다는 말이다. 결국 임계영역 내에서 같은 Lock을 사용하고 있으나 또 따른 Lock영역에 진입하려 시도하면 해당 thread가 획득한 Lock 때문에 BLOCKING이 된다는 말이다. 아래와 같이 프로그램을 작성하면 안된다.

 

@Test
public void stampedLockBadCaseTest() {
    StampedLockBadCaseTest test = new StampedLockBadCaseTest();
    test.entryMethod();
}

static class StampedLockBadCaseTest {
    private int cnt = 0;
    private final StampedLock sl = new StampedLock();
    void entryMethod() {
        long stamp = sl.writeLock();
        try {
            cnt++;
            System.out.println(String.format("Current count >>> %d", cnt));
            ThreadUtil.sleep(100);
            nextDeadLockMethod();
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    void nextDeadLockMethod() {
        long stamp = sl.writeLock();
        try {
            cnt++;
            System.out.println(String.format("Current count >>> %d", cnt));
            ThreadUtil.sleep(100);
        } finally {
            sl.unlockWrite(stamp);
        }
    }

}

StampedLock의 특징

 위 코드에서 처럼 Dead Lock의 위험을 감수하고 이 Class를 사용해야 하는 이유를 찾아보도록 하자. 참고로 아래 정리는 필자가 실제 이 Class를 실무에 적용해보고 얻은 결과가 아닌 그냥 그야말로 책으로 배운 지식이니 독자들은 좀 삐딱한 시각으로 봐주기 바란다.

 

  1. 낙관적인 읽기를 지원하여 읽기 thread 기아상태(starvation)를 회피 할 수 있다.
  2. 읽기 또는 쓰기 Lock모드에서 반대 모드 즉 쓰기 또는 읽기 모드로 Lock을 변경 할 수 있다. 이를 Upgrade라 한다.
  3. 위 두가지 기능으로 결국 효율적인 Lock 알고리즘 구현이 가능하다. 즉 빨리 치고 빠질 수 있다. 허나 잘 못 구현하면 죽는다.

StampedLock의 Stamp

 여태 필자가 봤던 JAVA의 Stamped라는 용어는 단일연산(원자성 연산) Class의 ABA 문제를 해결하기 위한 AtomicStampedReference 이후에 두 번째다. 의미론적으로도 두 Class에서 이 stamped는 비슷하다. stamp에 해당하는 값이 둘다 long형의 숫자고 사전 연산에서 받아놓은 값이 이후 연산에서 비교 해봤을 때 변경되었다면 이후 연산은 실패가 된다. 다만 Atomic- 에선는 그저 false를 리턴하여 다음에 다시해도 되고 다른 처리를 해도 되지만 StampedLock은 이런 경우 예외가 발생한다.

 

@Test
public void wrongStampUsageInStampedLockTest() {

    int cnt = 0;
    StampedLock sl = new StampedLock();
    long stamp = sl.writeLock();
    
    try {
        cnt++;
    } finally {
        try {
            sl.unlockWrite(stamp+1); // 일부러 다른 숫자를 넣어 봤다.
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

java.lang.IllegalMonitorStateException
at java.util.concurrent.locks.StampedLock.unlockWrite(StampedLock.java:544)
at com.platformfarm.cluster.StampedLockTest.wrongStampUsageInStampedLockTest(StampedLockTest.java:24)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

... 중략 ...

 

 본론으로 돌아가 StampedLock의 사용법을 살펴본다. writeLock이던 readLock이던 필요한 Lock을 얻었다가 할일이 다 끝나면 unlock을 한다. 이때 lock을 획득 했을때 받아놓은 숫자를 unlock에 인자로 넘긴다. 해당 lock이 내부적으로 가지고 있는 stamp와 다른 값이면 않된다. 값이 다르다는 것은 다른 Thread가 lock을 가져갔다는 의미인데 사실상 불가능 하다. 왜냐 하면 이 Lock은 재 진입이 불가능 하기 때문이다.  만약 이 stamp를 일부러 다른 숫자를 넘긴다던지 하면  IllegalMonitorStateException 예외가 발생한다.

 

@Test
public void wellStampUsageInStampedLockTest() {

    int cnt = 0;
    StampedLock sl = new StampedLock();
    long stamp = sl.writeLock();
    
    try {
        cnt++;
    } finally {
        sl.unlockWrite(stamp);
    }
}

낙관적인(Optimistic) 읽기 사용법

 이 특성이 필자가 보기인 StampedLock의 필요성의 절반 이상이기도 하다. ReentrantReadWriteLock이 결국 기아 상태를 걱정해야 하는 이유가 Write Lock이 타 thread에 의해 점유되어 있는 상황에서는 read를 할 수 없기 때문인데 StampedLock은 stamp를 이용하여 이를 보장 할 수 있도록 해준다. oracle java API사이트에서 발췌해온 예제를 통해 이런 특성을 이해 해보도록 하자.

 https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/StampedLock.html#unlockRead-long-

 

StampedLock (Java Platform SE 8 )

tryConvertToWriteLock public long tryConvertToWriteLock(long stamp) If the lock state matches the given stamp, performs one of the following actions. If the stamp represents holding a write lock, returns it. Or, if a read lock, if the write lock is availab

docs.oracle.com

 

static class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    void move(double deltaX, double deltaY) { // an exclusively locked method
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    double distanceFromOrigin() { // A read-only method
        long stamp = sl.tryOptimisticRead();
        double currentX = x, currentY = y;
        if (!sl.validate(stamp)) { // 그 사이에 누가 Lock을 가져가 stamp가 변했다.
            stamp = sl.readLock(); // 누군가의 nulock을 대기하면서 난 읽기 lock을 가져간다.
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        // ELSE... 변화가 없다면 낙관적인 읽기를 통해 얻은 값으로 연산을하여 리턴한다.
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    void moveIfAtOrigin(double newX, double newY) { // upgrade, 즉 읽기 락이 쓰기 락으로 변경
        // Could instead start with optimistic, not read mode
        // 읽기모드 대신 낙관적인 모드로 시작할 수 있다. (distanceFromOrigin 처럼)
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) {
                long ws = sl.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                }
                else { // lock을 획득하지 못함, 기다리지 않음 기존 읽기 Lock을 해제 하고
                       // 쓰기 Lock 획득
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);
        }
    }
}

 위 소스중 distanceFromOrigin 함수가 낙관적 읽기의 예제이다. 무조건 lock을 걸고 읽기를 하는 것이 아니고 tryOptimisticRead() 함수를 이용하여 stamp를 획득하고 멤버변수(공유객체)에서 값을 읽어둔다. validate(stamp)함수를 이용하여 그간 stamp의 값에 변화가 없는지 유효성 검사를 한다. 만약 변화가 있다면 그 사이에 누가(타 thread) lock을 가져 갔거나 이미 값을 변경한 것이다. 그런 상황이 아니라면 그대로 낙관적인 읽기가 유효해진다. 그럼 바로 이 값을 쓰면 된다. 이 stamp가 유효하지 않다면 이제는 비관적인 연산을 해야한다. thread는 readLock() 함수를 통해 Lock을 확보해야 한다. 만약 확보가 되면 최신 값을 읽어 사용하고 Lock을 해제시키다.

 

Lock 모드 변경(upgrade)

 이전 단원의 예제에서 moveIfAtOrigin 함수가 upgrade를 수행하는 예제이다. 해당 함수의 맥락은 이렇다. "먼저 안전하게 값을 읽어 해당 공유객체의 값이 처음 설정인 경우에만 해당 값을 변경한다." 그렇기 때문에 처음에는 readLock을 확보한다. 그래 봤더니 값을 설정해야하는 케이스가 확인된다면 tryConvertToWriteLock(stamp)함수를 이용하여 Lock의 종류를 바꾸고 공유자원 변경(쓰기) 연산을 진행한다.

 

에필로그

 Lock을 지원하는 Class들의 진화는 결국 성능/효율을 목적으로 해왔다. 개발자 손에는 Lock을 관리하는 아주 다양한 선택지가 놓여졌다. 하지만 아이러니 하게 동시성 제어에서 이 Lock은 최대한 배제되는 것이 좋다. 가급적이면 Lock-Free알고리즘을 구현하는 것이 좋다는 뜻이다.  

블로그의 정보

지금 당장 해!!!

지금당장해

활동하기