[Java/Kotlin] Concurrency API - Locks

Jay·2021년 3월 17일
0

Java&Kotlin

목록 보기
26/30
post-thumbnail

java.util.concurrent

  • Java 5에서 추가된 패키지
  • 동기화가 필요한 상황에서 사용할 수 있는 다양한 유틸리티 클래스를 제공
    • Locks : 상호 배제를 사용할 수 있는 클래스 제공
    • Atomic : 동기화가 되어있는 변수 제공
    • Executors : 스레드 풀 생성, 스레드 생명주기 관리, task 등록과 실행을 간편하게 처리
    • Queue : thread-safe한 FIFO 큐 제공
    • Synchronizers : 특수 목적의 동기화를 처리하는 5개의 클래스 제공 (Semaphore, CoundDownLatch, 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, notify, notifyAll메소드를 대체한다. wait -> await, notify -> signal, notifyAll -> signalAll

Interface 구현체

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

주요 메소드

  • lock() : Lock 인스턴스에 잠금을 걸어둔다. Lock 인스턴스가 이미 잠겨있는 상태라면, 잠금을 걸어둔 스레드가 unlock()을 호출할 때까지 실행이 비활성화 된다.
  • lockInterruptibly() : 현재 스레드가 interrupted 상태가 아닐 때 Lock 인스턴스에 잠금을 건다. 현재 스레드가 interrupted 상태면 interruptedException을 발생시킨다.
  • tryLock() : 즉시 Lock 인스턴스에 잠금을 시도하고 성공 여부를 boolean 타입으로 반환한다.
  • unlock() : Lock 인스턴스의 잠금을 해제한다.
  • newCondition() : 현재 Lock 인스턴스와 연결된 Condition객체를 반환한다.

Sample

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();
    }
}

TestData 객체를 공유하는 10개의 스레드가 run()블록에 정의된 작업을 시분할 방식으로 번갈아가면서 실행.
결과가 매번 달라지고 보장받지 못한다. 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();
        }
    }
}

위와 같이 run()메소드에 lock을 걸고 finally에서 해제해준다.
ReentrantLock 객체를 이용해서 Runnable 을 넘겨준다.

위의 예제의 경우, 충분히 synchronized로 처리 가능하지만 굳이 lock으로 처리를 하였다.
그리고 앞서 말했듯 lock은 synchronized를 보다 더 우아하게 쓴다고 하였따.

Synchronized와 Lock의 차이는 무엇일까 ?

둘을 구분 짓는 키워드는 fairness(공정성) 이라고 한다.

공정성?

모든 스레드가 자신의 작업을 수행할 기회를 공평하게 갖는 것
공정한 방법에선 큐 안에서 스레드들이 무조건 순서를 지켜가며 lock을 확보한다.
불공정한 방법에선 특정 스레드에 lock이 필요한 순간 release가 발생하면 대기열을 건너뛰는 새치기가 발생한다.

다른 스레드들에게 우선순위가 밀려 자원을 계속해서 할당받지 못하는 스레드가 존재하는 상황을 기아상태(starvation) 이라고 한다. 이를 해결하기 위해선 공정성이 필요하다.
synchronized는 공정성을 제공하지 않는다.
반면, 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은 lock()-unlock() 메소드를 호출함으로써 어떤 스레드가 먼저 락을 획득할 지 순서를 지정할 수 있다. 이를 명시적인 lock이라고 한다.

  • Lock은 인스턴스에 1개 이상의 Condition을 지정할 수 있다. lockInterruptibly(), tryLock() 같은 편리한 제어 메서드를 사용할 수 있고, lock 획득을 기다리고 있는 스레드의 목록을 간편하게 확인 할 수 있다.

  • synchronized는 간결한 코드로 임계 영역을 지정할 수 있다. 그리고 개발자의 실수로 lock을 해제하지 않아 문제가 생길 가능성이 없다. Lock을 사용할 경우, synchronized와 다르게 import 구문과 try-finally구문이 추가됨으로 코드가 더 늘어난다는 단점이 있다.

Reference

profile
developer

0개의 댓글