안드로이드 시스템은 UI가 오랫동안 정지 상태에서 벗어나지 못하면 앱을 강제로 종료시키는 ANR(Android not responding) 정책을 가지고 있다. 네트워크 통신, 로컬 DB 사용, 파싱 등 비용이 큰 작업이 지속적으로 이루어지더라도 사용자에게 앱이 부드럽게 동작하는 경험을 제공하려면 쓰레드와 동기/비동기에 대한 이해가 꼭 필요하다.

 

동일한 자원을 공유하는 여러 개의 쓰레드가 번갈아가며 동작할 경우, 어떤 Task가 먼저 실행될지 알 수 없기 때문에 개발자가 의도하지 않는 동작이 발생할 수 있다. 따라서 Thread, Runnable 객체를 마구잡이로 생성하며 멀티쓰레드 프로그래밍을 아무 생각없이(?) 하다보면 필연적으로 동시성 문제를 겪게 된다.

 

앞으로 멀티쓰레드 처리에 대해 공부한 내용을 지속적으로 업로드 하려고 하는데, 오늘은 그 시작으로 자바의 Concurrency API에 대해 정리해보려고 한다. 제공하는 API를 모두 다루기엔 양이 많아서 여러 편에 걸쳐서 작성하려 한다.

 


java.util.concurrent

java.util.concurrent는 Java 5에서 추가된 패키지로, 동기화가 필요한 상황에서 사용할 수 있는 다양한 유틸리티 클래스들을 제공한다. 패키지에서 제공하는 주요 기능을 요약하면 아래와 같다.

  • Locks : 상호 배제를 사용할 수 있는 클래스를 제공한다.
  • Atomic : 동기화가 되어있는 변수를 제공한다.
  • Executors : 쓰레드 풀 생성, 쓰레드 생명주기 관리, Task 등록과 실행 등을 간편하게 처리할 수 있다.
  • Queue : thread-safe한 FIFO 큐를 제공한다.
  • Synchronizers : 특수한 목적의 동기화를 처리하는 5개의 클래스를 제공한다. (Semaphroe, CountDownLatch, CyclicBarrier, Phaser, Exchanger)

java.util.concurrent.locks

locks 패키지엔 상호 배제를 위한 Lock API가 정의되어 있다. java의 synchronized 블록을 사용했을 때와 동일한 메커니즘으로 동작한다. 내부적으로 synchronized를 사용해 구현되었으며, synchronized를 더욱 유연하고 정교하게 처리하기 위해 사용하는 것이지 대체하는 목적을 가지진 않았다.

 

Interface

  • Lock : 공유 자원에 한번에 한 쓰레드만 read, write를 수행 가능하도록 한다.
  • ReadWriteLock : Lock에서 한단계 발전된 메커니즘을 제공하는 인터페이스다. 공유 자원에 여러개의 쓰레드가 read를 수행할 수 있지만, write는 한번에 한 쓰레드만 수행 가능하다.
  • Condition : Object 클래스의 monitor method인 wait, nofity, notifyAll 메서드를 대체한다. wait → await, notify → signal, notifyAll → signalAll로 생각하면 된다.

Interface의 구현체

  • ReentrantLock : Lock의 구현체. 임계영역의 시작 지점과 종료 지점을 직접 명시할 수 있게 해준다.
  • ReentrantReadWriteLock : ReadWriteLock의 구현체.

주요 메서드

  • lock() : Lock 인스턴스에 잠금을 걸어둔다. Lock 인스턴스가 이미 잠겨있는 상태라면, 잠금을 걸어둔 쓰레드가 unlock()을 호출할 때까지 실행이 비활성화된다.
  • lockInterruptibly() : 현재 쓰레드가 intterupted 상태가 아닐 때 Lock 인스턴스에 잠금을 건다. 현재 쓰레드가 intterupted 상태면 InterruptedException를 발생시킨다.
  • tryLock() : 즉시 Lock 인스턴스에 잠금을 시도하고 성공 여부를 boolean 타입으로 반환한다.
    • tryLock(long timeout, TimeUnit timeUnit) : tryLock()과 동일하지만, 잠금이 실패했을 때 바로 false를 반환하지 않고 인자로 주어진 시간동안 기다린다.
  • unlock() : Lock 인스턴스의 잠금을 해제한다.
  • newCondition() : 현재 Lock 인스턴스와 연결된 Condition 객체를 반환한다.


Lock 적용 예제

여러 쓰레드가 동일한 자원을 공유할 때 벌어지는 일을 확인하기 위한 간단한 예제를 만들어보자.

SharedData는 모든 쓰레드가 공유할 데이터를 정의한 클래스다.

여러개의 쓰레드가 하나의 SharedData 인스턴스를 공유하며 increase() 메서드를 마구마구 호출할 예정이다.

public class SharedData {
    private int value;

    public void increase() {
        value += 1;
    }

    public void print() {
        System.out.println(value);
    }
}

main 함수에서 10개의 TestRunnable 객체를 생성해 쓰레드별로 각각 increase()를 100번씩 호출하는 코드를 작성했다.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static void main(String[] args) {
        final SharedData mySharedData = new SharedData(); // shared resource

        for (int i = 0; i < 10; i++) {
            new Thread(new TestRunnable(mySharedData)).start();
        }
    }
}

class TestRunnable implements Runnable {
    private final SharedData mySharedData;

    public TestRunnable(SharedData mySharedData) {
        this.mySharedData = mySharedData;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            mySharedData.increase();
        }

        mySharedData.print();
    }
}

실행 결과는 아래와 같다.

101
300
401
494
594
200
694
894
794
994

TestData 객체를 공유하는 10개의 쓰레드가 run() 블록에 정의된 작업을 시분할 방식으로 번갈아가며 실행하고 있다. 이로인해 실행 결과는 매번 조금씩 달라져 동일한 결과가 보장되지 않는다. 만약 개발자가 value가 순차적으로 100씩 증가하는 상황을 의도했다면 이는 잘못된 동작에 해당된다.

 

이제 Lock 인스턴스를 사용해 이러한 동시성 문제를 해결할 수 있다.

쓰레드들이 공유할 Lock 인스턴스를 만들고, 동기화가 필요한 실행문의 앞 뒤로 lock(), unlock()을 호출하면 된다.

이때 lock()을 걸어놨다면 unlock()도 빼먹지 말고 반드시 호출해줘야 한다. 임계 영역 블록의 실행이 끝나더라도 unlock()이 호출되기 전까지는 쓰레드의 잠금 상태가 영원히 유지되기 때문이다. 어떤 예외가 발생하더라도 반드시 unlock()이 호출되도록 try-catch-finally 형태를 사용하는 것이 권장된다.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static void main(String[] args) {
        final SharedData mySharedData = new SharedData(); // shared resource
        final Lock lock = new ReentrantLock(); // lock instance

        for (int i = 0; i < 10; i++) {
            new Thread(new TestRunnable(mySharedData, lock)).start();
        }
    }
}

class TestRunnable implements Runnable {
    private final SharedData mySharedData;
    private final Lock lock;

    public TestRunnable(SharedData mySharedData, Lock lock) {
        this.mySharedData = mySharedData;
        this.lock = lock;
    }

    @Override
    public void run() {
        lock.lock();
        try {
            for (int i = 0; i < 100; i++) {
                mySharedData.increase();
            }

            mySharedData.print();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

실행해보면 값이 순차적으로 100씩 증가하는 것을 알 수 있다.

// 실행 결과
100
200
300
400
500
600
700
800
900
1000

 

synchronized vs Lock

앞에서 ReentrantLock는 기존의 synchronized보다 확장된 기능을 가지고 있다는 이야기를 했지만, 사실 앞선 예제는 synchronized 키워드로 충분히 대체가 가능하다. 그렇다면 synchronized와 Lock는 어떤 차이가 있는 것일까?

 

이 둘을 구분짓는 키워드는 fairness(공정성)이다. 여기서 공정성은 모든 쓰레드가 자신의 작업을 수행할 기회를 공평하게 갖는 것을 의미한다. 공정한 방법에선 큐 안에서 쓰레드들이 무조건 순서를 지켜가며 lock을 확보한다. 불공정한 방법에선 만약 특정 쓰레드에 lock이 필요한 순간 release가 발생하면 대기열을 건너뛰는 새치기 같은 일이 벌어지게 된다.

 

다른 쓰레드들에게 우선순위가 밀려 자원을 계속해서 할당받지 못하는 쓰레드가 존재하는 상황을 starvation(기아 상태)라 부른다. 이러한 기아 상태를 해결하기 위해 공정성이 필요하다.

synchronized는 공정성을 지원하지 않는다. 반면 ReentrantLock은 생성자의 인자를 통해서 공정/불공정 설정을 할 수 있다. ReentrantLock의 생성자는 아래와 같이 정의되어 있다.

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

 

공정한 lock을 사용할 경우 경쟁이 발생했을 때 가장 오랫동안 기다린 쓰레드에게 lock을 제공한다. 락을 요청하는 시간 간격이 긴 경우가 아니라면, 쓰레드를 공정하게 관리하는 것보다 불공정하게 관리할 때 성능이 더 우수하다. 그래서 일반적으로는 불공적 방식이 사용되는 듯 하다.

 

그 외의 차이점을 간단히 정리해 보았다.

  • synchronized는 블록 구조를 사용하기 때문에 하나의 메서드 안에서 임계 영역의 시작과 끝이 있어야 한다. Lock은 lock(), unlock()으로 시작과 끝을 명시하기 때문에 임계 영역을 여러 메서드에 나눠서 작성할 수 있다.
  • synchronized는 동기화가 필요한 블럭을 synchronized { }로 감싸 lock을 건다. 여러 쓰레드가 경쟁 상태에 있을 때 어떤 쓰레드가 진입권을 획득할지 순서를 보장하지 않는다. 이를 암시적인(implicit) lock 이라고 칭한다.
  • Lock은 lock()-unlock() 메서드를 호출함으로써 어떤 쓰레드가 먼저 락을 획득하게 될지 순서를 지정할 수 있다. 이를 명시적인(explicit) lock이라고 칭한다.
  • Lock은 인스턴스에 한개 이상의 Condition을 지정할 수 있다. lockInterruptibly(), tryLock() 같은 편리한 제어 메서드를 사용할 수 있고, lock 획득을 기다리고 있는 쓰레드의 목록을 간편하게 확인할 수 있다.
  • synchronized는 간결한 코드로 임계 영역을 지정할 수 있다. 그리고 개발자의 실수로 lock을 해제하지 않아 문제가 생길 가능성이 없다. Lock을 사용할 경우 synchronized를 사용할 때와 달리 import 구문과 try-finally 문이 추가됨으로써 코드가 덜 간결해진다는 단점이 있다.

 


참고 자료