[CS] Thread Safety와 동기화(synchronization) | lock, synchronized, deadlock, ThreadLocal

dyomi·2024년 7월 4일

들어가기전...

Process 와 Thread 란?

프로세스는 실행중인 하나의 애플리케이션을 말하고,
쓰레드는 한 프로세스 내에서 동작되는 여러 실행 흐름을 말한다.

Thread Safety

Thread Safety란, 여러 쓰레드가 동시에 실행되더라도 프로그램이 예상대로 동작하는 특성을 말한다.

동기화

동기화란, 여러 쓰레드가 접근 할 수 있는 공유자원에 대한 접근을 제어해서 데이터의 무결성을 유지하는 방법을 말한다.

동기화에는 원자성가시성이라는 특징이 있는데, 원자성은 하나의 소스 코드가 한 번만 실행된다는 것을 보장하는 것이고, 가시성은 한 쓰레드에서 변경한 공유자원을 다른 쓰레드가 확인할 수 있어야 한다는 것을 의미한다.

자바에서 대표적인 동기화 기법으로는 synchronized 키워드와 volatile 키워드, Atomic 클래스가 존재한다.

synchronized 키워드

synchronized 키워드는 메서드나 블록단위로 사용이 가능하며, 해당 키워드가 붙은 코드는 한번에 한 쓰레드만 수행하도록 보장하는 역할을 한다.

동작 방식은 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 락을 얻어서 작업을 수행하다가 메서드가 종료되면 락을 반환하는 구조이다.

이러한 방식은 한번에 하나의 쓰레드만 접근이 가능하기 때문에 데이터의 무결성을 지킬 수 있다.

하지만 너무 남용하게 될 경우, 락이 걸리는 쓰레드가 많아지고 메서드 혹은 로직에 대한 병목현상이 발생하기 쉬워 성능상 이슈가 발생할 수 있다.

또한 두개 이상의 쓰레드가 상대방의 락을 기다리면서 무한 대기 상태에 빠지는 데드락(deadlock) 상황이 발생할 수도 있다.

그렇다면, 아래 코드에서 methodA와 methodB를 두 개의 쓰레드가 동시에 접근한다고 했을 때 어떤 시점에서 데드락이 발생할까?

public methodA(){
   synchronized(lockA){
     synchronized(lockB){
     }
   }
}

public methodB(){
   synchronized(lockB){
     synchronized(lockA){
     }
   }
}

methodB에서 lockB가 먼저 잠기게 되고, 이때 methodA에서 lockA를 잠그게 되었을 때, methodB는 lockA의 해제를 기다리게 되고, methodA에서는 lockB의 해제를 기다리게 된다.

이렇게 서로의 락 해제를 기다리며 무한 대기하는 데들락 상황이 발생하게 된다.

그렇다면 이 코드를 어떻게 고치야 데드락을 방지할 수 있을까?

답은 생각보다 간단했다.

락을 잡고 있는 객체의 순서를 항상 같은 순서로 잡고, 반대 순서로 풀어주면 아무리 코드가 많아져도 데드락은 발생하지 않는다.

public methodA(){
   synchronized(lockA){
     synchronized(lockB){
     }
   }
}

public methodB(){
   synchronized(lockA){
     synchronized(lockB){
     }
   }
}

volatile

volatile 키워드는 변수 앞에 붙이게 되면서, 이 변수 값을 사용할때 cpu 코어마다 있는 캐시가 아닌 메모리에서 직접 값을 읽어 올 수 있도록 한다.

원자성과는 거리가 있지만, 가시성 역할을 하며 lock-free 하기때문에 성능면에서도 좋다.

Atomic

Atomic 클래스는 java.util.concurrent.atomic 패키지에 있으며, lock-free 하면서도 스레드 세이프한 기능을 지원하는 클래스이다.

동작원리를 보면 인자로 기존값과 변경할 값을 전달하고, 기존값이 현재 메모리가 가진 값과 같다면 변경할 값을 반영한다. 아니라면 반영하지 않고 false를 리턴하는 방식이다.

이 경우 공유 변수에 대한 계산을 마치고 반영을 하려고 할때, 다른 스레드가 공유 변수를 변경한 경우 다시 계산을 할 수 있도록 해준다.

ThreadLocal

ThreadLocal은 이라는 자바 클래스이며, 공유 자원 문제와 별개의 개념으로 오직 한 쓰레드에 의해서 읽고 쓰여질 수 있는 변수를 말한다.

한 쓰레드에서 동일한 객체를 사용하기 때문에 관련된 코드에서 파라미터를 사용하지 않고 객체를 가져다 쓸 때 사용되며, 예를 들면 스프링 시큐리티에서 사용자마다 다른 인증정보를 사용할때 사용된다.



🌟 추가 질문

📍 동시성 문제가 발생할 떄 가장 대표적인 상황을 말하자면,

공유되는 자원과 그 공유되는 자원을 기존에 있는 값을 기준으로 무언가 변경을 해야 할 때, 읽고 쓰기가 동시에 일어날때 문제가 생긴다.

예를 들면, 클래스가 하나 있고 거기에 멤버 변수로 카운터라는게 있다.
카운터는 0으로 지정돼서 increase라는 메서드를 통해 카운터를 늘려준다.
이런 상황일때 synchronized 키워드가 붙어 있지 않다면, 동기화가 되지 않는다.

이유는 무엇일까?

public class Counter {
    private int counter = 0;

    public void increase() {
        counter++;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increase();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increase();
            }
        });

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  1. t1 스레드가 counter 변수를 읽는다. (현재 값이 0이라고 가정)
  2. t1 스레드가 counter 변수를 1 증가시키려는 순간, t2 스레드가 끼어들어 counter 변수를 읽는다. (여전히 값은 0)
  3. t1 스레드가 counter 변수를 1 증가시킨다. (값은 1이 됨)
  4. t2 스레드가 counter 변수를 1 증가시킨다. (값은 1 + 1 = 2가 되어야 하지만 실제로는 1 + 1 = 1이 됨)

결과적으로, 두 스레드가 동시에 counter 변수를 수정하려고 할 때 예상치 못한 결과가 발생할 수 있으며, 최종적으로 counter 값은 예상보다 적을 수 있다.

그렇다면 예시를 바꿔서, 어떤 클래스에 타임 스탬프라는 변수가 존재하고, now라는 메서드를 통해서 현재 시간을 저장하는 역할을 수행할때, synchronized 키워드 없이 여러 쓰레드가 접근할 경우 동시성 문제가 발생 했다는 걸 인지할 수 있을까?

아마 어려울 것이다.

이유는 now 메서드가 단순히 현재 시간을 timestamp 변수에 저장하기 때문에, 이전 예시와 달리 기존 값에 무언가를 더하는 오퍼레이션이 없기 때문이다.


📍 메서드에 synchronized 키워드를 붙이는 것과, 블록에 synchronized 키워드를 붙이는 것은 어떤 차이가 있을까?

우선 synchronized 키워드가 메서드에 붙으면, 인스턴트 메서드인 경우 'this'라는 객체에 락이 걸리고, 정적 메서드의 경우 클래스 객체에 락이 걸린다.
그렇기 때문에 동시에 여러 쓰레드가 동일한 인스턴스의 메서드를 호출할 수 없게된다.

예를 들어, 한 클래스에 A와 B 메서드가 존재하고 두 메서드 모두 synchronized 키워드가 붙어 있다면, 두 개의 쓰레드가 각각의 메서드를 접근한다고 했을 때, 하나의 쓰레드만 접근이 가능하고, 하나는 기다리게 된다.

이때 만약 A와 B메서드를 블록 단위로 synchronized 키워드를 붙이고 락을 각각의 객체를 생성해서 걸어준다면, 기다리는 현상 없이 각 메서드에 진입할 수가 있다.

이 외에도 메서드에 synchronized를 붙이면 메서드 전체에 락이 걸리기 때문에, 메서드내의 특정 부분만 동기화를 적용해야 한다면, 블록 단위로 synchronized 키워드를 사용하는 것이 효율적일 수 있다.



참고자료

스레드를 많이 쓸수록 항상 성능이 좋아질까?
ThreadLocal 이란 ?
[Java] 동기화 - synchronized와 volatile, Atomic Class에 대하여.

profile
기록하는 습관

0개의 댓글