간단하게 자바에서 동시성 제어를 하는 방법으로는 '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 인한 문제) 에 대해서 걱정하지 않아도 된다.

+ Recent posts