본문 바로가기
Java/Java Basic

[Java Basic] 45 - Java Thread 4 - Lock 클래스, Condition 인터페이스

by Rosmary 2022. 9. 25.
728x90
반응형

 

 

 

지난 포스팅에서 예시로 들었던 뷔페 코드를 조금 변경해보았다(진상 손님은 구현하지 않았다). 우선 wait()과 notify() 매서드 호출 코드는 제외한 상태다.

 

Table 클래스 코드

 

Chef 클래스 코드

 

Customer 클래스 코드

 

이 상태에서 코드를 실행해보자. 아마 고객들이 첫 음식을 가지고 간 다음 Chef가 음식을 서빙하는 과정에서, 테이블에 음식이 5개가 되면 Chef Thread에서 lock이 해제가 되지 않음을 알 수 있을 것이다.

 

 

 

이러한 이유로 필자는 Synchronized 동기화 된 매서드 내부에 wait(), notify()로 lock의 강제 해제와 lock 보유 허용을 가능하게 하도록 코드를 작성했었다. 하지만, 이전 포스팅에서 언급했듯이 synchronized와 wait(), notify()만으로는 특정 Thread를 선별하여 lock을 걸거나 해제하는 것이 불가능하기 때문에 다른 방법으로 동기화를 진행해주어야 한다.

 

 

아니면 지금처럼 하나의 Thread(Customer3)가 계속 대기하는 상황을 보아야 한다.

 

이번 포스팅에서는 synchronized와 wait(), notify()와 같이 자동으로 lock 해제, 보유 허용을 진행하는 동기화 방식이 아닌, 수동 동기화 방식에 대해 알아보려 한다.

 

 

 

1. java.util.concurrent.locks 패키지

 

Java에서 제공하는 몇몇 클래스는 수동으로 Thread의 점유(lock)를 제어할 수 있도록 한다. 그리고 이들 클래스는 java.util.concurrent.locks 패키지 안에 정의되어 있다. 

 

 

 

 

클래스 명을 보면 크게 Lock으로 끝나는 클래스가 많이 보이고 그 외에 Condition이라는 클래스가 보인다. 이 두 클래스 범주가 오늘 포스팅에서 확인할 내용이다. 

 

 

 

2. Lock 클래스 종류

 

Lock 클래스는 여러 종류가 존재한다. 이들 클래스가 정의하는 매서드들은 거의 동일하기 때문에, 클래스 기능에 대한 성격을 파악하면 매서드 사용은 큰 문제가 되지 않는다.

 

* ReentrantLock:  읽기, 쓰기에 대해 동시에 lock을 제어하는 클래스. Lock 인터페이스를 구현하며 Lock 인터페이스의 추상 메서드인 lock(), unlock(), tryLock() 매서드를 구체적으로 정의한다.

 

*  ReentrantReadWriteLock: 읽기, 쓰기에 대해 별도로 lock을 제어하는 클래스. 클래스 기능 특성 상, lock() 매서드가 존재하지 않고 readLock(), writeLock()으로 분화된 매서드가 존재함.

 

*  StampedLock: 읽기, 쓰기 외에도 낙관적 읽기(Optimistic Read)에 대한 lock을 제어하는 클래스.

 

 

위에서 소개한 클래스를 모두  소개하기에는 중복되는 내용이 많기 때문에, 필자는 ReentrantLock에 대한 내용을 중점적으로 설명하려하며, 필요 시 다른 클래스의 매서드를 소개하려 한다. 

 

 

 

(1) ReentrantLock 클래스

 

Reentrant는 항공우주공학을 전공하셨던 분들이라면 많이 들어보았을 단어일 것이다. "재진입의"이라는 뜻인데, 항우공학에서는 우주로 나간 미사일이나 우주선이 지구로 돌아오는 과정을 의미한다. Java에서도 용어의 의미 자체는 크게 다르지 않은데, lock으로부터 해제(우주로 나감)된 Thread에게 다시 lock을 부여할 수 있는 기회(다시 돌아옴)를 주는 것과 모양새가 크게 다르지 않기 때문이다. 여기까지는 약간의 뻘소리고...

 

하여간 이 클래스는 synchronized 제어자에서 자동으로 lock 해제와 재진입을 진행하주는 것과 달리, 프로그래머가 수동으로 lock 해제와 재진입이 가능하도록 만들어준다. 이 클래스에는 Thread에 lock을 거는 lock(), 현재 Thread의 lock을 해재하는 unlock(), 그리고 특정 조건을 만족하는 경우에 lock을 획득하는 tryLock() 매서드 등이 정의되어 있다. 

 

 

[ lock(), unlock() ]

 

서두 예시의 synchronized 제어자는 모두 삭제하고, lock()과 unlock()  매서드에 대해 알아보자. 

 

 

 

Thread가 공유하는 자료에 대한 동기화가 되어 있지 않다면, 위와 같이 이상한 값이 공유 자료 내에 포함되거나, 기타 이상한 에러들이 발생할 수 있음은 이미 이전의 포스팅에서 언급했었다. Table 클래스 내에서 ReentrantLock 객체를 만들어 동기화가 필요한 코드에 수동으로 동기화를 진행해보자. 

 

하나의 동기화 lock을 제어하기 위해서는 ReentrantLock 클래스의 인스턴스를 하나 생성해야 한다. 필자는 이 인스턴스 변수명을 lock으로 선언하고 생성자를 사용하여 객체를 생성했다.

 

 

먼저, Chef Thread에서 Table.add() 매서드를 사용하는 상황을 보자. Chef에서 음식을 올리는 와중에 Customer Thread에서 음식을 가져간다면 위에서 본 것과 같이 null 값이 들어가는 경우가 발생하기 때문에, add()와 remove() 매서드 내부의 가장 첫 줄에, lock.lock() 매서드를 적용하여 add(), remove() 매서드를 사용하는 Thread가 lock을 보유할 수 있도록 한다. 그러나 이렇게 코드를 작성하면, Customer Thread에서 음식을 가져가지 못하고 Chef Thread가 while 문의 무한 루프에 빠지게 된다.

 

 

원인은, lock() 매서드가 적용된 Thread는 특정 Queue에 Thread 정보가 저장된다. 지금은 lock() 매서드만 적용되기 때문에 다른 Thread에게 lock을 줄 수 있는 환경이 나타나지 않는 상황이다. 

 

ReentrantLock 클래스는 getHoldsCount()라는 매서드가 있는데, 현재 ReentrantLock 객체에 존재하는 Lock이 걸린 Thread의 수를 반환한다. lock.lock() 매서드 전후에 이 매서드를 적용해보자.

 

 

 

Customer에서 remove() 매서드를 호출할 무렵, ReentrantLock 인스턴스 객체의 add()에 걸린 lock Thread 수량이 4개다. lock이 걸린 Thread가 4개씩이나 존재하니, remove()를 호출한 Thread에 lock을 주고싶어도 줄 수 가 없다. 따라서 Customer1, 2, 3 Thread는 remove() 호출 시, lock.lock()이 실행되지 않게 된 것이다. 그리고 Chef Thread에 지속적으로 lock이 걸린 상태에서 테이블의 음식 수가 5개에 도달하니, add() 매서드 while 문의 무한 루프에 빠지게 된 것이다. 

 

정리하자면, 위 문제의 원인은 lock을 해제할 unlock() 매서드의 부재다. unlock() 매서드를 각 매서드 마지막 부분과 while 문 내부의 마지막에 넣고 다시 실행해보자.

 

 

 

 

 

locked Thread의 수량은 매서드를 벗어나는 순간 - 정상 종료든, 예외로 인한 종료든 - 모두 0으로 표시되는 것이 보인다. Locked Thread 수량이 0이기 때문에 lock.lock() 적용이 가능하여 다른 Thread 들도 add(), remove()를 점유하여 사용할 수 있게 된다.

 

 

 

lock()과 unlock() 매서드는 synchronized 블록을 대체할 수 있다. 다음과 같이 synchronized 블록을 lock(), unlock()으로 변경할 수 있다.

 

-------------------------------------------------------------------------

[ synchronized ] 

synchronized { 동기화 코드; }

 

[ ReentrantLock.lock(), ReentrantLock.unlock() ]

ReentrantLock.lock();

try { 동기화 코드; }

finally { ReentrantLock.unlock();  }

-------------------------------------------------------------------------

 

참고로, ReentrantLock 클래스 인스턴스 생성 시, 생성자 매개변수로 boolean 값을 대입할 수 있다. true가 대입되면, 두 Thread가 경쟁 상태에 놓일 때, 오래 lock을 기다린 Thread에 우선적으로 lock을 부여할 수 있게 된다. 하지만 어떤 Thread가 오래 기다렸는지에 대해 별도의 연산을 진행해야하므로 성능은 떨어지게 된다.

 

필자가 작성한 코드에서는 이 내용에 대한 구현이 쉽지 않은데, Customer Thread는 자신의 코드 수행에 sleep() 코드가 1초 간격으로 작성되어 있기 때문에 경쟁상태로 겹쳐질 일이 발생하지 않기 때문이다. 그래서 Pizza를 가져가는 Customer를 하나 더 생성하더라도 오래 기다린 Thread가 Pizza를 가져가는 경우가 잘 나타나지 않는다.

 

 

[ tryLock()]

 

다음으로 tryLock()을 보자. lock() 대신 tryLock()을 사용하여 Thread에 Lock을 부여하는 것이 가능한데, tryLock()은 현재 Thread가 lock을 얻을 수 있다면 얻고, 그렇지 않다면 기다리는 기능을 하는 매서드다. 우선 Chef Thread만 사용하는 add() 매서드의 첫 줄에 tryLock() 매서드를 적용해보자. Chef Thread에서 처음 음식을 올리는 과정은 문제없이 진행되지만, 고객 Thread가 음식을 가져가는 순간 예외가 발생하면서 Chef Thread는 종료될 것이다.

 

 

 

원인이 뭘까? 우선 Main Thread에서 각 Thread를 실행시키면, chef가 가장 먼저 실행된다. 따라서 이 때는 lock을 보유한 Thread가 없기 때문에 add() 매서드 안의 tryLock()으로도 lock 획득이 가능해진다. 그렇기 때문에 첫 고객들이 음식을 가져가기 전에는 Customer Thread에서 lock을 획득하지 않기 때문에(Customer.run() 코드를 다시 보자. 5초 뒤에 remove() 함수를 호출한다),  아무 문제 없이 음식을 테이블에 올릴 수 있다. 

 

그런데 고객 Thread에서 음식을 가져갈 때, 4개의 Thread가 한 번에 lock을 얻으려고 대기하고 있다. 이 때 Chef Thread 역시 tryLock()으로 lock을 얻으려고 대기중이나, 20%의 확률을 뚫고 lock을 얻기는 매우 어렵다. lock을 얻지 못한 상태에서 add() 코드가 진행되는데, add() 내부에 존재하는 unlock() 코드에 다다르면 Java 컴파일러가,

 

"add() 매서드에 대해서는 lock을 획득한 Thread가 없는데 어떻게 unlock()을 하냐? 이거 에러다!!"

 

라고 예외를 뿜어내는 것이다.

 

그럼, 여러 Thread가 존재하면 tryLock()은 못쓰지 않을까? 그렇지는 않다. tryLock()은 lock을 획득하면 true를, lock을 획득받지 못하면 false를 반환하기 때문에, 보통 while 문의 조건으로 넣어 많이 사용한다. 따라서 위의 코드는 아래와 같이 변경할 수 있다.

 

lock.tryLock() 반환값 true를 사용하는 경우

 

lock.tryLock() 반환값 false를 사용하는 경우

 

 

이제 remove() 매서드도 위와 동일하게 while 문을 사용하여 tryLock() 매서드를 적용해보자. 아마 이전과 달리 모든 Customer Thread가 동시에 lock을 얻어 실행하는 것이 아니라, 일부 Customer Thread가 lock 획득에 실패하여 다시 5초를 기다리는 상황을 확인할 수 있다.

 

 

 

tryLock()은 반드시 실행이 필요한 Thread가 아닌 경우, 그러니까 프로그램을 실행함에 있어서 크게 문제가 되지 않을만한 때에 사용하는 것이 좋다. 예를 들어 주기적으로 문서를 자동으로 저장해준다던가(저장을 한 번 실패한다고 크게 문제가 되지 않는다), 크게 중요하지 않은 정보를 갱신하여 화면에 표시한다는 것 등이 예시다. 만약 반드시 실행해야하는 Thread라면 lock을 못받는 경우도 발생할 수 있기 때문이다.

 

 

(2) ReentrantReadWriteLock 클래스

 

ReentrantReadWriteLock 클래스는, lock과 unlock을 조금 더 세분화한다. ReentrantLock이 읽기와 쓰기에 대해서 모두 lock, unlock을 진행하는데, 반해 이 클래스의 매서드들은 공유 자원에 대해 읽기, 쓰기의 lock을 별도로 제공한다.

 

읽기와 쓰기의 Lock을 부여하기 위해 ReentrantReadWriteLock 클래스는 ReentrantLock 객체를 제공하는 매서드를 가지고 있다. 이 매서드들이 ReentrantReadWriteLock.readLock(), ReentrantReadWriteLock.writeLock()이다. 

 

 

 

이렇게 생성된 Lock 객체에 대해 lock(), unlock() 매서드를 적용하여 각 Thread가 읽기/쓰기 lock을 얻거나 반환할 수 있도록 한다. 그리고 읽기 lock은 쓰기 lock에 의해 영향이 없기 때문에(자료를 읽는 것은 쓰기에 의해 자료가 변하더라도 크게 상관이 없다) 사실상 쓰기 lock()에 영향을 받지 않고 Thread를 실행할 수 있게 된다. 

 

필자는 위에서 작성한 뷔폐 코드에 ReentrantReadWriteLock() 클래스를 적용하여 Thread를 제어하려 한다. 대신에, 기존에 add() 매서드에 존재하던 Table 위에 서빙된 음식 종류를 출력하는 코드를 삭제하고, 이 코드를 printTable()이라는 새 매서드에 넣어 5초마다 출력이 되도록 하려 한다. 이 매서드는 별도의 Thread를 만들어 실행하려한다.

 

Table 클래스의 코드.

 

printTable() 매서드 실행을 위한 Manager 클래스. Thread 구현을 하려한다.

 

Chef와 Customer 클래스. 기존의 코드를 최적화했다.

 

정상적으로 잘 동작함을 알 수 있다.

 

lock을 단순히 읽기와 쓰기로 나눈다는 것만 제외한다면, 사용에 있어서 ReentrantLock 클래스와 큰 차이는 없다.

 

 

 

2. Condition 클래스

 

이제 Thread를 선별적으로 중지, 재진입 할 수 있도록 만드는 방법에 대해 알아보자. 여전히 필자가 만든 코드는 두 고객이 하나의 음식을 경쟁하는 상태가 되는 경우, lock을 오래 획득하지 못한 Thread가 lock을 몇 차례 획득하지 못하는 상황이 발생하고 있다. 

 

 

 

그런다고 음식을 못 받은 경우에 lock을 반환하지 않도록 코드를 작성하게 되면, 고객이 아니라 지난 포스팅에 작성한 진상 손놈이 다시 튀어나오게 된다. 심한 경우 Customer4 Thread는 기아상태(진짜 뷔폐에서 굶는...)에 빠지게 된다.

 

음식을 못받은 경우에 wait()을 Thread에서 사용하면 어떻게 될까? 

 

 

 

Customer에서 Pizza가 없을 때에만 wait()으로 일시 대기중인 Thread가 생성되는데, main 매서드에서는 Chef가 먼저 코드를 실행하게되므로 wait() Thread가 존재하지 않는 상태에서 notify() 매서드가 호출되어 에러가 발생한다. 즉, wait()과 notify()를 적용하려면 Pizza가 없는 경우(Condition)에 대해서만 wait(), notify()를 걸 수 있는 별도의 기능이 필요하다.

 

Java에서는 이 기능을 Condition이라는 인터페이스를 통해 제공한다. 

 

 

Condition은 인터페이스이기 때문에 직접 인스턴스를 생성할 수 없고, 대신 Lock 계열 클래스 객체를 통해 newCondition() 매서드를 호출하여 Condition 객체를 생성한다. 즉, 하나의 Lock 계열 클래스에 대해 여러 Condition을 생성하는 것이 가능하며, 이 Condition을 통해 특정 조건에서 특정 Thread를 제어할 수 있게 된다.

 

우선 방금전에 ReentrantReadWriteLock 클래스를 적용한 내용을 다시 ReentrantLock 클래스 내용으로 변경하고,  기존의 wait()과 notify() 코드를 각각 Condition 클래스의 await(), signal() 매서드로 변경해보자.

 

 

 

 

이 코드를 실행시켜보자. 아마 Pizza가 없어 음식을 못 가져간 Customer Thread는 await()으로 일시 정지되도록 만드나, 추후 add() 매서드에서 signal() 매서드로 await() 된 Thread를 깨워 재진입이 가능하도록 만들기 때문에 바로 Pizza를 가져갈 수 있게 된다. 

 


 

 

최근의 프로그래밍에서 Multi Threading은 절대로 빼어놓고 진행할 수 없을 정도로 중요한 개념이다. Threading의 경우 이론적인 내용을 눈으로만 읽으면 이해된다고 착각하기가 매우 쉬운데, 실제로 코드를 작성하다보면 생각처럼 잘 동작하지 않는 경우가 많다. 필자의 경우에도 앞선 포스팅부터 이 포스팅을 완성하기까지 테스트만 수십번 시도한 듯 하다. Thread는 반드시 직접 코드를 실행하면서 동작 과정을 세세하게 익힐 필요가 있다고 본다.

 

다음 포스팅에서는 fork & join 프레임워크에 대해서 작성하려 하는데, 불필요하다면 Thread에 대한 내용은 여기서 종료하고 람다식에 대한 내용을 작성하려 한다.

 

 

 

Fin.

 

반응형

댓글