간단하게 자바에서 동시성 제어를 하는 방법으로는 'synchronized' 가 있다.
그러나 synchronized 는 다음과 같은 문제점들이 존재한다:
- Lock 을 얻기 위해서 'BLOCKED' 상태에 있는 스레드를 멈출 수 있는 방법이 없다.
- Lock 을 얻는데 지정한 시간이 지나면 Timeout 이 나도록 할 수 없다.
- 좀 더 세밀한 범위에서 Lock 을 사용할 수 없다.
- 특정 조건이 되었을 때만 Lock 을 사용할 수 없다.
'ReentrantLock' 은 'synchronized' 의 이런 한계점을 극복할 수 있다.
이 글에서는 ReentrantLock 에 대해서 살펴보고, 더 나은 동시성 수준을 지원하는 다른 솔루션에 대해서도 간략하게 소개한다.
1. ReentrantLock
ReentrantLock 은 synchronized 처럼 Lock 을 사용해서 동시성 제어를 하는 방식이며 가장 주요한 특징은 Lock 을 명시적으로 얻을 수 있고, 반납할 수도 있다.
먼저 사용방법을 보자:
lock()
를 통해서 Lock 을 얻을 때까지 대기할 수 있다.unlock()
을 통해서 Lock 을 반납해야하며, 주의해야 할 건 finally 블록에서 사용해서 반드시 Lock 을 반납하도록 해야한다.- try 블록 이전에
lock()
메소드가 있는 이유로는 이 메소드에서 Exception 이 발생할 수 있기 때문이다. 이게 try 블록안에 있다면 Lock 을 얻지도 못했는데,unlock()
메소드가 호출되는 일이 생길 것이다.
class X {
private final ReentrantLock lock = new ReentrantLock();
// ... public void m() {
lock.lock(); // block until condition holds
try {
// ... method body }
finally { lock.unlock() }
}
}
ReentranctLock 의 특징으로는 다음과 같다:
- Condition 객체를 이용해서 Lock 을 얻을 조건이 되지 않았다면 Lock 을 반납하고 기다릴 수 있다
- 명시적으로 lock() 메소드로 Lock 을 잡는 방식이다 보니까, 좀 더 세밀하게 Lock 을 이용할 수 있다.
- Lock 을 스레드에 균등하게 Lock 을 배분시킬 수 있는 fairness 설정을 할 수 있다.
- Interrupt 를 받으면 Lock 을 얻으려는 시도를 중지시킬 수 있다.
tryLock()
메소드로 Lock 을 잡을 때 timeout 설정을 할 수 있다.
이것들에 대해서 하나씩 살펴보자.
1.1 특정 조건이 충족되었을 경우에만 Lock 을 사용
ReentrantLock 을 이용할 경우에 Condition 객체를 이용해서 특정 조건이 충족되었을 경우에만 Lock 을 이용하도록 할 수 있다.
예시는 다음과 같다:
- 누군가가 조건이 충족되었으니 Lock 을 사용하라고 condition 객체에 시그널을 보내주면 condition.await() 부분에서 차단이 풀리며 실행된다.
- 그리고 조건이 진짜로 충족되었는지 한번 더 확인해보고, 공유자원에 대해처 처리한다.
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
lock.lock();
try {
while (<<조건이 참이 아닌 경우>>)
condition.await();
<<공유 자원에 대한 로직 처리>>
} finally { lock.unlock(); }
while 문을 통해서 조건이 '진짜로' 충족되었는 지를 확인하는 이유로는 조건이 충족되지 않았는데 condition.await() 차단이 풀리는 경우가 발생하기 때문이다.
이런 현상을 spurious wakeup 이라고 한다.
이 현상이 발생하는 이유는 OS 가 시그널을 보내는 내부 동작 때문인데, 시그널을 받고 스레드를 깨울 때 broadcast 방식으로 깨우는 경우가 있기 때문이다.
1.2 Lock 을 좀 더 세밀하게 제어
ReentrantLock 을 통해서 Lock 은 필요한 부분에만 걸면 되니까 좀 더 세밀한 제어를 할 수 있다.
다음 예제는 연결리스트를 통해서 노드를 삽입할 때, 노드 전체에 대해서 Lock 을 거는 것이 아니라, 삽입을 하는 부분의 앞 뒤 노드만 Lock 을 걸어서 넣는다.
public class Node {
int value;
Node prev;
Node next;
ReentrantLock lock = new ReentrantLock();
private final Node head;
private final Node tail;
Node() {}
Node(int value, Node prev, Node next) {
this.value = value;
this.prev = prev;
this.next = next;
}
public void insert(int value) {
Node current = head;
current.lock.lock();
Node next = current.next;
try {
while (true) {
next.lock.lock();
try {
if (next == tail || next.value < value) {
Node node = new Node(value, current, next);
next.prev = node;
current.next = node;
return;
}
} finally { current.lock.unlock();}
current = next;
next = current.next;
}
} finally {
next.lock.unlock();
}
}
}
1.3 fairness
ReentrantLock(boolean fair) 로 ReentrantLock 을 생성할 경우 fairness 설정을 할 수 있다.
fairness 설정을 하게되면 이 Lock 을 가장 오래 기다린 스레드가 얻게 되면서 starvation 문제를 해결해줄 수 있다.
그러나 이런 fairness 설정은 Thread Schedulling 때문에 Lock 을 공정하게 배분하는게 힘들다.
분명 ReentranctLock 을 이용하게 되면 내부적으로 Queue 에 'BLOCKED' 당한 Thread 순서대로 관리되고, Lock 이 반납될 경우 가장 오래 기다린 Thread 가 깨어날 것이다.
그러나 READY 상태의 또 다른 스레드가 있다면, OS Scheduler 는 내부적인 알고리즘을 통해서 다음에 실행될 스레드를 선택할 것이기 때문에 fairness 를 항상 보장할 수 없다.
1.4 Interrupt 를 통해 Lock 을 얻으려는 시도 중지
ReentrantLock 에서 lockInterruptibly()
를 호출해서 Lock 을 얻으려고 시도하는 동안에 thread.interrupt()
를 통해서 Interrupt 를 받을 경우에는 Lock 을 얻으려는 시도가 중지된다.
예제를 통해서 살펴보자.
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockInterruptExample {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new Task());
Thread thread2 = new Thread(new Task());
// thread1을 시작합니다.
thread1.start();
// thread2를 시작합니다.
thread2.start();
// 잠시 후 thread2를 중단합니다.
Thread.sleep(1000);
thread2.interrupt();
}
static class Task implements Runnable {
@Override
public void run() {
try {
// lockInterruptibly()를 사용하여 Lock을 얻습니다.
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + ": Lock 획득");
// 장시간 작업을 시뮬레이션하기 위해 Thread를 잠시 멈춥니다.
Thread.sleep(2000);
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + ": Lock 해제");
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + ": 중단됨");
}
}
}
}
1.5 tryLock 를 통해서 Lock 을 얻기 위해서 대기하는 Timeout 설정
tryLock(long timeout, TimeUnit unit)
를 통해서 락을 얻을 때 대기하는 시간을 지정할 수 있다.
2. ReentrantLock 보다 더 나은 솔루션은 있을까?
'ReentrantReadWriteLock' 이라는 것도 있다:
- 이는 ReentrantLock 락보다 동시성 수준을 올리기 위해서 등장한 Lock 이며, readLock 과 writeLock 을 분리해서 사용할 수 있다.
- writeLock 이 사용중이면 readLock 은 들어갈 수 없지만, writeLock 이 사용중이지 않다면 readLock 은 여러개가 들어갈 수 있다.
'Atomic' 클래스 라는 것도 있다.
- 이는 CAS 알고리즘을 통해 Atomic 한 연산을 지원하며, Lock 을 사용하지 않고 동시성 제어를 할 수 있다는 특징이 있다.
- Lock 을 사용하지 않아도 되니까 Lock 을 사용했을 때 발생하는 문제들 (e.g 데드락, Context Switching, Race Condition 인한 문제) 에 대해서 걱정하지 않아도 된다.
'Concurrent Programming' 카테고리의 다른 글
자바에서 Multi thread programming 을 조심해서 해야하는 이유 (0) | 2023.11.23 |
---|