멀티 Thread 환경에서 Safe하다?

LSM ·2022년 1월 22일
0

1. 작성 배경

책이나 대학 수업 ppt or pdf 를 보다 보면, 가끔 Thread Safe하다 라는 말을 볼 수 있을 것이다. 매번 음~ 그렇구나 하고 넘어갔던 주제를 한번 정리 하려 한다 ㅎㅎ!

먼저 스레드가 무엇인지 모르는 분들을 위해 스레드에 대한 설명을 간단하게 하겠다

Thread는 Process의 하위 개념으로써 프로세스에 할당된 자원을 공유한다. Program을 스레드로 분리하면 자연스럽게 병렬성을 이용할 수 있기 때문에 복잡한 애플리케이션의 성능을 향상시킬 수 있다. 하지만, Thread 구조를 정확하게 이해하지 못하고 사용한다면 병렬성 효율성의 문제 및 데이터 공유성 문제 등 다양한 문제가 발생할 수 있다.

그렇다면 Multi Thread는 어떤 방법으로 다루어야 하는가??

아래의 모든 예시는 관련 게시물중 잘 정리된 것들의 내용을 기반으로 이해하고, 코드를 사용하여 설명한 것이다!!


2. Lock

저번 학기에서 DB 수업시간에서 배운 Lock 개념이다.
DB에서 lock은 데이터에 대해서 동시에 접근하는 경우 데이터의 일관성과 무결성을 유지하기 위한 즉, 트랜잭션 처리의 순차성을 보장하기 위한 동시성 제어 방법이다.

Thread 관련해서 사용되는 Lock 역시 위의 개념과 유사하다.

여러 Thread의 변수나 메서드에 대한 접근의 일관성을 위해 존재한다.

코드를 통해 그예시를 알아보겠다.

1) synchronized

@ThreadSafe
public class Sequence {
    @GuardedBy("this") private int nextValue;

    public synchronized int getNext() {
        return nextValue++;
    }
}
  • 자바에서 가장 쉽고 간편하게 스레드 안전성을 보장하는 방법인데 바로 synchronized 키워드를 사용하는 방법이다.

위 코드는 Sequence 인스턴스의 getNext() 메서드의 동시성을 보장한다. 즉 getNext()에 접근할 수 있는 스레드를 단 한개만 보장한다는 뜻이다.
따라서 수많은 스레드들이 동시에 getNext()에 접근한다고 하더라도 정확한 nextValue 값을 보장받을 수 있다.

'synchronized' 키워드는 꼭 메서드에만 사용할 수 있는 것이 아니라, 특정 코드 블록에서 사용하는 방법도 있다.
더 자세한 코드는 공식문서를 확인해 보길 바란다!!


3. 자료구조 사용하기

불가피하게 synchronized를 사용해야한다면 어쩔수 없지만 선택이 가능하다면 자바에서 제공하는 자료구조를 사용하는게 더 좋은 방법이라고 한다.

java.concurrent 패키지 하위에 존재하는 자료구조들인데 대표적으로 Hashtable, ConcurrentHashMap, AtomicInteger, BlockingQueue 등등이 있다.

'synchronized'을 사용하는 방법의 가장 큰 단점은 성능이다.

자바의 Hashtable의 주요 메서드들을 확인해보겠다.


    public synchronized V get(Object key) {
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                return (V)e.value;
            }
        }
        return null;
    }
    
    ...
    
    
    public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }

        // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }
    
    ...
    

위 코드에서 볼 수 있듯이,

get(), put() 등등 주요 메서드들이 전부 synchronized 키워드에 감싸진 상태로 동시성을 보장하고 있다.
즉, Hashtable의 동기화된 인스턴스 메서드를 호출할 수 있는 스레드는 반드시 1개라는 뜻이다.

동시에 호출이 불가능하다!!

자바는 Hashtable 의 성능의 증진을 위해 ConcurrentHashMap을 도입했다고 한다. ConcurrentHashMap은 조금 더 세밀하게 동시성을 보장하기 때문에 더 좋은 성능을 보장한다.

출처 : http://asjava.com/core-java/thread-safe-hash-map-in-java-and-their-performance-benchmark/

위 테이블에서 보면, thread의 개수 증가에 따라 동시성 제어의 성능 차이를 볼 수 있다. 확실히 ConcurrentHashMap이 가장 좋은 성능을 낼 수 있음을 알 수 있다.

그렇다면 ConcurrentHashMap의 주요 코드를 살펴보자

  public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }
    
    ...
    
        
    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

위에서 보았던 기존 HashMap의 차이점으로는 동시성제어를 위한 synchronized 를 조금 더 섬세하게 사용했다는 점으로 들 수 있겠다. 이는 함수 접근에 대한 동시성에 조금 더 자유롭기에 조금 더 좋은 성능을 낼 수 있는 것 같다.

lock level이 높을 수록, 일관성 무결성에는 더 확실하지만 동시성 측면에서는 좋지 못하다.


4. 스택 한정 프로그래밍

스레드 안전성을 보장하는 또 다른 방법 중 하나는 stack에 한정되도록 프로그래밍하는 방법이다.

스레드는 위에서 설명한것과 같이 프로세스에 할당된 자원을 공유하지만 각 스레드는 각기 별도의 스택과 지역변수를 갖도록 되어 있다.

따라서 모든 스레드는 각자 고유한 스택영역과 해당 스택 영역 내에 독립적으로 사용가능한 지역변수를 가진다는 특성을 잘 이해하면 동시성을 보장하도록 할 수 있다.

/**
 * Animals
 * <p/>
 * Thread confinement of local primitive and reference variables
 *
 * @author Brian Goetz and Tim Peierls
 */
public class Animals {
    Ark ark;
    Species species;
    Gender gender;

    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;

        // animals confined to method, don't let them escape!
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a))
                candidate = a;
            else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }
}

위 예제에서 animals 라는 SortedSet 인스턴스는 지역변수로 선언되었다.

넘어오는 candidates 파라미터를 복사(addAll) 한 뒤에 추가적인 작업을 실행하는데 지역변수, 즉 stack 내부에서만 사용했기 때문에 동시성을 보장할 수 있다.

위에서 설명한것처럼 각 스레드는 각기 별도의 스택과 지역변수를 갖도록 되어 있기 때문에 이런 방법으로도 동시성을 보장하도록 할 수 있다고 한다!


5. Thread Local

위에서 지역변수를 사용해서 동시성을 보장하는 방법은 간결하고 이해하기 쉽지만 저 메서드 스택을 벗어나는 순간 animals라는 변수의 참조가 없어지기 때문에 다른곳에서 animals를 사용할 수 없다는 단점이 있다.

위와 같은 단점을 해결하기 위해서 자바는 ThreadLocal이라는 클래스를 제공하고 있다.

ThreadLocal을 이용하면 쓰레드 영역에 변수를 설정할 수 있기 때문에, 특정 쓰레드가 실행하는 모든 코드에서 그 쓰레드에 설정된 변수 값을 사용할 수 있게 된다.

조금 더 자세하게 알아보겠다.

Java 에서 사용하는 Thread Local 이란 간단히 설명해서 쓰레드 지역 변수이다.

즉, Thread Local 로 정의된 객체는 같은 Thread 라는 Scope 내에서 공유되어 사용될 수 있는 값으로 다른 쓰레드에서 공유변수를 접근할 시 발생할 수 있는 동시성 문제의 예방을 위해 만들어졌다.

한 쓰레드 내에서만 사용되는 변수라더라도 전역변수처럼 State 값을 부여해서 사용하게 되므로 가능한 가공이 없는 참조용 객체의 경우가 사용되며, 지나친 사용은 재사용성을 떨어트리고 부작용을 발생시킬 수 있다.

아래의 그림은 Thread local을 사용한 예시를 보여준다.

출처 : https://javacan.tistory.com/entry/ThreadLocalUsage

  • ThreadLocal의 활용

ThreadLocal은 한 쓰레드에서 실행되는 코드가 동일한 객체를 사용할 수 있도록 해 주기 때문에 쓰레드와 관련된 코드에서 파라미터를 사용하지 않고 객체를 전파하기 위한 용도로 주로 사용된다.

대표적인 사용 예로 SpringSecurity에서 제공하는 SecurityContextHolder가 바로 ThreadLocal 적절한 예가 된다.

SecurityContextHolder에 저장된 Authentication 객체는 Filter영역에서 처리가 되어지지만 Controller 영역에서도 파라미터 없이 전달이 가능하다.


6. 불변 객체 사용 (final)

스레드 안전성을 보장하는 마지막 방법으로 불변객체를 사용하는 방법이다.

불변객체란 객체가 선언된 이후로는 변경할 수 없는 객체임을 뜻하는데 자바에서는 대표적으로 String이 있다.

그렇다면 String은 기본적으로 스레드에 안전할까? YES!

String은 스레드에 안전하다.

적절한 'final' 키워드도 별다른 동기화작업이 없이도 동시성환경에서 자유롭게 사용할 수 있다.

불변객체와 비슷한관점으로 초기화된 이후에 변경될 수 없기 때문에 여러 스레드가 동시에 접근해도 동일한 값을 보장받을 수 있기 때문이다.


7. 정리

병렬 프로그래밍의 핵심인 스레드 환경에서 어떻게 코드를 설계할 것 인가에 대한 고민은 솔직히 아직 한 적이 없다. 스레드 라고는 학교 다니며 운영체제 시간에 그리고 자바 시간에 배운 개념적인 부분과 약간의 실습(채팅 구현)이 거의 전부여서 매번 헷갈리고 어려웠던 개념인데 이렇게 잘 정리된 글을 읽으며 정리해보니 머리속에 더 잘 남을 듯 하다!

추가적으로 우리가 자주 사용하던 자료구조가 멀티 스레드 환경에서 세이프 하게 설계된 자료구조인 것은 처음 알았다..^^ 정말 안다고 생각하면 2배로 공부할게 생기는 느낌이다~~

기회가 된다면 실제 프로젝트에서도 오늘 정리한 내용을 회상하는 날이 빨리 왔으면 좋겠다!ㅎㅎ


출처

감사합니다! 덕분에 잘 정리 되었습니다 ㅎㅎ

https://jins-dev.tistory.com/entry/Java-의-Thread-Local-이란 [Jins' Dev Inside]

https://sup2is.github.io/2021/05/03/thread-safe-in-java.html

https://javacan.tistory.com/entry/ThreadLocalUsage

profile
개발 및 취준 일지

0개의 댓글