ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Thread-Safe: Multi-Thread 환경에서 동시성을 제어하기
    Programming/Java 2021. 10. 17. 00:19

    https://2jinishappy.tistory.com/323

     

    Java에서 Thread를 사용하는 방법

    애플리케이션의 구현이 복잡해지면서 CS 기초 특히 트랜잭션, 쓰레드를 제대로 이해해야 겠다고 느꼈다. 그래서 쓰레드의 정의, 자바에서의 쓰레드 사용법, Thread-Safe 구현 방식, Thread-Safe Collection

    2jinishappy.tistory.com

     

    Thread-Safe

    Java 환경에서는 개발자가 쉽게 다중의 Thread를 생성하고 사용할 수 있다. 여러 쓰레드의 바이트 코드를 동시에 실행시키는 멀티 쓰레딩은 애플리케이션 성능을 향상시킬 수 있지만, Resource를 공유하기 때문에 안전한 접근 방식이 필요하다.
    즉, Multiple Thread를 사용할 때 에는 Thread-Safe하게 사용해야 한다.

    대부분의 경우 멀티 쓰레드 애플리케이션의 오류는 여러 쓰레드 간에 자원을 잘못 공유했을 때 발생한다.

     

    Stateless Implementations

    public class MathUtils {
    
        public static BigInteger factorial(int number) {
            BigInteger f = new BigInteger("1");
            for (int i = 2; i <= number; i++) {
                f = f.multiply(BigInteger.valueOf(i));
            }
            return f;
        }
    }

    factorial() 메서드는 상태를 저장하지 않고, 입력 값이 같을 경우 반드시 동일한 값을 반환한다.

    이 방법은 메서드의 실행이 외부 상태에 의존하지 않으며, 상태 필드를 변화시키지도 않기 때문에 멀티 쓰레드 환경에서도 안전하게 호출할 수 있다.
    즉, 모든 쓰레드가 안전하게 factorial() 메서드를 호출할 수 있고, 서로 간섭하지 않으며 원하는 예상 결과를 얻어낼 수 있다.

    stateless하게 구현하면 쉽게 thread-safe를 달성할 수 있다.

     

    Immutable Implementations

    서로 다른 쓰레드들 간에 상태를 공유해야 한다면, immutable하게 만듦으로써 thread-safe class를 만들 수 있다.

    클래스 인스턴스가 생성되고 나면 내부 상태를 변경할 수 없는 것을 immutable 하다고 한다.

    immutable 클래스를 만드는 방법은 모든 필드를 private, final로 만들고 setter를 제거하면 된다.

    public class MessageService {
    
        private final String message;
    
        public MessageService(String message) {
            this.message = message;
        }
    
        // standard getter
    
    }

    MessageService 클래스는 (하나밖에 없지만) 필드를 모두 private final로 등록하고 setter를 제거했다. 따라서 클래스 인스턴스가 생성될 때 이외에는 필드의 값을 쓸 수 없다.

    '클래스가 immutable 하다 👉 thread-safe 하다'면 이도 성립할까?
    즉, '클래스가 mutable 하다 👉 thread-safe 하지 않다'일까?

    immutable하게 구현하지 않아도 여러 쓰레드가 MessageService 클래스에 대해 read-only 액세스 권한만 있어도 thread-safe 하다고 할 수 있다.

    불변성만이 thread-safety를 달성하는 유일한 방식은 아니라는 뜻이다.

     

    Thread-Local Fields

    OOP에서는 객체가 필드를 이용해서 상태를 표현하고 메서드를 이용해서 행위를 나타내야 한다.

    상태를 유지해야 할 때 필드를 쓰레드 로컬화해서 쓰레드 간 상태를 공유하지 않게 하면 thread-safe 클래스를 만들 수 있다.

    Thread 클래스를 상속받은 클래스에서 필드를 private로 선언하면 해당 필드를 thread-local로 만들 수 있다.

    public class ThreadA extends Thread {
    
        private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
    
        @Override
        public void run() {
            numbers.forEach(System.out::println);
        }
    }

    이 예시에서 클래스 인스턴스를 생성하면 해당 인스턴스만의 고유한 상태를 가질 수 있고, private 제어자를 사용하기 때문에 다른 쓰레드와 공유하지 않는다. 따라서 이 클래스는 thread-safe하다.

    public class StateHolder {
    
        private final String state;
    
        // standard constructors / getter
    }
    
    public class ThreadState {
    
        public static final ThreadLocal<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {
    
            @Override
            protected StateHolder initialValue() {
                return new StateHolder("active");  
            }
        };
    
        public static StateHolder getState() {
            return statePerThread.get();
        }
    }

    private 키워드를 사용하지 않아도 ThreadLocal 인스턴스를 할당해서 쓰레드 로컬 필드를 만들 수 있다.

    이 방법에서 각각의 쓰레드들은 constructor/getter를 통해 쓰레드 로컬 필드에 접근한다. 그리고 고유한 상태를 가질 수 있게 필드를 초기화해 복사한다.

     

    Synchronized Collections

    Collection 프레임워크에 포함된 Synchronized Wrapper Set을 통해 Thread-safe한 Collection을 만들 수 있다.

    Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());
    Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
    Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
    thread1.start();
    thread2.start();

    synchronized된 collection은 동기화를 위해 locking을 사용한다.

    즉, 한 번에 한 쓰레드만 메서드에 접근할 수 있고, 나머지 쓰레드는 첫 쓰레드의 lock이 풀릴 때 까지 blocked된다.

    따라서 synchronize 방식은 수행을 직렬화 할 수 있어서 성능 저하의 원인이 된다.

     

    Concurrent Collections

    위의 synchronized collection를 사용하는 대신 concurrent collection을 사용해도 thread-safe collection을 만들 수 있다.

    Java는 ConcurrentHashMap처럼 여러 concurrent collection을 포함하는 java.util.concurrent 패키지를 제공한다.

    Map<String,String> concurrentMap = new ConcurrentHashMap<>();
    concurrentMap.put("1", "one");
    concurrentMap.put("2", "two");
    concurrentMap.put("3", "three");

    동기화 방식과 달리 동시 방식은 데이터를 세그먼트로 분할하여 thread-safe를 구현한다.

    ConcurrentHashMap에서는 여러 쓰레드가 서로 다른 맵 세그먼트에서 lock을 획득할 수 있기 때문에 동시에 Map에 액세스 하는 것이 가능하다.

    synchronized 방식과 concurrent 방식 모두 Collection을 Thread-safe하게 만드는 것이고, 내부 Elements를 Thread-Safe하게 만드는 것은 아니다 ❗❗

     

    Synchronized Methods

    앞에서 소개한 접근 구현 방식들은 collection, data type에 적용할 수 있는 방법이다. 그보다 더 큰 범주에서 제어하기 위해 synchronized method를 만들어서 thread-safe를 구현할 수 있다.

    synchronized method는 한 번에 하나의 쓰레드만 동기화된 메서드에 접근 할 수 있도록 허용하고, 다른 쓰레드는 해당 메서드의 접근 권한이 없다.

    다른 쓰레드는 첫 번째 쓰레드의 수행이 완료되었거나 메서드가 예외를 throw할 때 까지 접근이 차단된 상태를 유지한다.

     

    public synchronized void incrementCounter() {
        counter += 1;
    }

    synchronized 키워드를 붙이면 동기화 메서드를 만들 수 있다.

    이 예시에서는 한 번에 하나의 쓰레드만이 incrementCounter 메서드에 접근할 수 있으므로 중복 실행은 발생하지 않는다.

    쓰레드가 동기화 메서드를 호출하면 lock을 얻으며, 실행을 완료하면 반납한다. 다른 쓰레드는 다시 lock을 얻어서 액세스 권한을 얻을 수 있다.

     

    Synchronized Statements

    메서드 전체를 synchronized 하는 것은 수행을 불필요하게 직렬화할 수 있으므로 일부만을 thread-safe하게 구현할 수 있다.

     

    public void incrementCounter() {
        // additional unsynced operations
        synchronized(this) {
            counter += 1; 
        }
    }

    synchronized statements를 이용해서 incrementCounter 메서드를 구현하면 내부에서 동기화가 필요한 부분과 필요하지 않은 부분을 분리할 수 있다.

    단, this를 이용해서 lock을 제공하는 객체를 지정해야 한다.

     

    Read/Write Locks

    Thread-safe를 구현하기 위한 강력한 방법 중 하나는 ReadWriteLock을 사용하는 것 이다.

    읽기 전용 작업과 쓰기 전용 작업에 대한 Lock pair를 제공해서, 상황에 따른 접근 권한을 분리할 수 있다.

    리소스에 쓰는 쓰레드가 없으면 여러 쓰레드가 리소스의 읽기 권한을 가질 수 있고, 있으면 다른 쓰레드는 읽기 권한을 가질 수 없다.

     

    public class ReentrantReadWriteLockCounter {
    
        private int counter;
        private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
        private final Lock readLock = rwLock.readLock();
        private final Lock writeLock = rwLock.writeLock();
    
        public void incrementCounter() {
            writeLock.lock();
            try {
                counter += 1;
            } finally {
                writeLock.unlock();
            }
        }
    
        public int getCounter() {
            readLock.lock();
            try {
                return counter;
            } finally {
                readLock.unlock();
            }
        }
    
       // standard constructors
    
    }

     

    정리

    Multi-Thread 환경에서는 자원의 동시 접근으로 인한 문제가 발생할 수 있다.
    Thread-safe를 적용하기 위한 대상은 Field(Collection, Object), Method, Statements가 있다.
    synchronized keyword를 사용하는 것은 강력한 thread-safe를 지원하지만 수행을 직렬화하기 때문에 동시성의 장점이 떨어진다.

Designed by Tistory.