본문 바로가기
Programming/Languages (Java, etc)

[JAVA] Thread 1편 - 멀티스레드에서의 공유자원

by kghworks 2023. 1. 19.

 

 이번 포스팅에서는 JAVA (JVM)이 어떤 식으로 스레드를 구현하도록 설계해 두었는지 알아봅니다. 또한 멀티스레드 환경에서 공유자원으로 연산할 때 발생할 문제점과 해소방안을 확인합니다. 2편에서는 해소방안에 대해 더 깊게 고민해 봅니다.

 

* 스레드의 개념에 대해서는 아래 포스팅에서 간략히 소개했으니 먼저 참고 바랍니다.

2022.02.25 - [Programming/운영체제] - 프로세스 (Process)와 스레드 (Thread)

 

프로세스 (Process)

목표 프로세스의 개념을 이해한다. 스레드의 등장 배경과 그 장점을 파악한다. 스케줄링 단계와 정책을 이해한다. 목차 프로세스 (Process) 스레드 (Thread) 스케줄링(scheduling) 참고 프로세스 (Process)

kghworks.tistory.com

 

목차

  • 스레드 구현 예시
  • 스레드의 상태
  • 공유자원을 사용하는 멀티스레드
  • 스레드 업무의 원자성과 공유자원의 가시성

스레드 구현

 

 java.lang.Thread를 상속받아 스레드를 작성하고, start()로 2개의 스레드를 병렬적으로 작동시켜 보겠습니다. 이 외에도 Ruunable 인터페이스를 구현하여 스레드를 만들 수 있는데, 이는 생략하겠습니다. 포스팅의 목적이 단순한 스레드 구현코드를 소개하는 것이 아니기 때문에 넘어가겠습니다.

 

Thread01 thread01 = new Thread01();
Thread02 thread02 = new Thread02();

/*병렬 동작 테스트*/
thread01.start();
thread02.start();


...

public class Thread01 extends Thread {
    private static final Logger logger = LoggerFactory.getLogger(Thread01.class);

    @Override
    public void run() {
        logger.info("Thread01 run : " + Thread.currentThread().getName());

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        for (int i = 0; i < 5; i++) {
            logger.info("Thread01 run : " + Thread.currentThread().getName() + " idx :" + i);
        }

        logger.info("Thread01 finished : " + Thread.currentThread().getName());
    }
}

...

public class Thread02 extends Thread {
    private static final Logger logger = LoggerFactory.getLogger(Thread02.class);

    @Override
    public void run() {
        logger.info("Thread02 run : " + Thread.currentThread().getName());

        for (int i = 0; i < 5; i++) {
            logger.info("Thread02 run : " + Thread.currentThread().getName() + " idx :" + i);
        }

        logger.info("Thread02 finished : " + Thread.currentThread().getName());
    }
}

 

[10:53:47.561] INFO  [http-nio-8080-exec-2] org.springframework.web.servlet.FrameworkServlet initServletBean - Completed initialization in 1 ms
[10:53:47.594] INFO  [Thread-18] com.kghdev.thread.Thread01 run - Thread01 run : Thread-18
[10:53:47.594] INFO  [Thread-19] com.kghdev.thread.Thread02 run - Thread02 run : Thread-19
[10:53:47.594] INFO  [Thread-19] com.kghdev.thread.Thread02 run - Thread02 run : Thread-19 idx :0
[10:53:47.594] INFO  [Thread-19] com.kghdev.thread.Thread02 run - Thread02 run : Thread-19 idx :1
[10:53:47.594] INFO  [Thread-19] com.kghdev.thread.Thread02 run - Thread02 run : Thread-19 idx :2
[10:53:47.594] INFO  [Thread-19] com.kghdev.thread.Thread02 run - Thread02 run : Thread-19 idx :3
[10:53:47.594] INFO  [Thread-19] com.kghdev.thread.Thread02 run - Thread02 run : Thread-19 idx :4
[10:53:47.594] INFO  [Thread-19] com.kghdev.thread.Thread02 run - Thread02 finished : Thread-19
[10:53:52.607] INFO  [Thread-18] com.kghdev.thread.Thread01 run - Thread01 run : Thread-18 idx :0
[10:53:52.607] INFO  [Thread-18] com.kghdev.thread.Thread01 run - Thread01 run : Thread-18 idx :1
[10:53:52.607] INFO  [Thread-18] com.kghdev.thread.Thread01 run - Thread01 run : Thread-18 idx :2
[10:53:52.607] INFO  [Thread-18] com.kghdev.thread.Thread01 run - Thread01 run : Thread-18 idx :3
[10:53:52.608] INFO  [Thread-18] com.kghdev.thread.Thread01 run - Thread01 run : Thread-18 idx :4
[10:53:52.608] INFO  [Thread-18] com.kghdev.thread.Thread01 run - Thread01 finished : Thread-18

 

  1. 메인 스레드 http-nio-8080-exec-2에서 동작 중 Thread-18 생성 후 sleep
  2. Thread-19 생성 후 동작, 종료
  3. Thread-18 깨어나서 동작, 종료

 메인스레드 위에 성공적으로 2개의 스레드를 만들고 병렬적으로 수행한 것을 확인할 수 있습니다. 


스레드의 상태

스레드의 6가지 상태

 

 

  1. NEW : new 연산자로 스레드의 객체가 생성된 상태. 스레드가 start() 되지 않은 상태.
  2. RUNNABLE : start()를 통해 JVM (CPU)으로부터 사용이 가능한 상태. 동시성 구현에 따라 다른 스레드들과 실행과 대기를 번갈아가고 있음.
  3. TERMINATED : run() 내용이 모두 완료되어 스레드가 종료됨.
  4. TIME_WAITING : n초 동안 스레드가 일시중지된 상태. 일시정지 시간이 지나거나 interrupt()를 통해 RUNNALBE로 돌아감.
  5. BLOCKED : 선행된 스레드를 기다리는 상태.
  6. WAITING : 정지된 상태. (일시정지 X)

 실제 Thread에 구현되어 있는 Sate (상태)는 열거형으로 위 6가지를 정의해두고 있습니다. 스레드의 상태는 getState()를 통해 얻을 수 있습니다.

 

java.lang.Thread의 getState()와 열거형 State

 

 6가지의 상태로 변하게 할 수 있는 Thread에 정의된 메서드들이 있습니다. Thread 클래스에 가보면 주석에 상세히 설명해두고 있기 때문에 하나씩 확인해 보시면 좋겠습니다. 여기선 간략히만 소개하겠습니다.

 

start()

 JVM이 스레드를 시작할 수 있도록 실행합니다. JVM은 start()가 호출되면  run()을 통해 스레드를 실행시킵니다. 스레드가 NEW상태가 아니라면 IllegalThreadStateException 을 발생시킵니다. 

 

* start()와 run()의 차이점

 run()을 직접 호출하여 스레드를 실행하면 싱글 스레드로 실행됩니다. run()은 스레드를 단순 실행하는 것이고, start()는 스레드를 스레드 그룹 (java.lang.ThreadGrooup, 여러 개의 스레드가 그룹을 형성)에 add()하여 동작합니다.

 따라서 run()을 직접 호출하면 새로운 스레드와 스택을 만들지 않고 현재 메인 스레드에 run()을 스택에 쌓아서 실행합니다. 프로그램 단에선 싱글스레드로 실행되는 것입니다.

 

interrupt()

해당 스레드를 방해합니다. 인터럽트한 스레드 B가 일시 중지 (sleep())상태였다면 B스레드는 InterruptedException이 발동됩니다. sleep()이 CheckedException (InterruptedException)을 throws 하는 이유입니다.

 

wait(), notify(), notifyAll()

 wait() 은 동기화 블록 (synchronized) 내에서 스레드를 WAITING 상태로 만듭니다. 매개값으로 전달한 시간이 지나면 자동으로 RUNNABLE(실행 대기)로 돌려보냅니다. 정해진 값이 없다면, notify(), notifyAll()을 통해서만 RUNNABLE상태가 될 수 있습니다.

 

 notify()notifyAll()은 동기화 블록 (synchronized) 내에서 wait()으로 인해 WAITING 상태인 스레드를 RUNNABLE (실행 대기)로 만듭니다.

 

그 외

  • sleep() : 스레드를 특정 시간 동안 일시중지합니다. 시간이 지나면 실행 대기 (RUNNABLE)로 돌아갑니다.
  • join() : 스레드를 기다리게 합니다. 시간 매개값이 정해지면 TIME_WAITING, 없으면 WAITING
  • yield() : 프로세서가 자신 스레드만 단독으로 사용하지 않고, 멀티스레드 환경에서 스레드를 번갈아서 사용가능하도록 허용해 줍니다. (RUNNALBE 안에서 다른 스레드에게 우선순위를 양보)

공유 자원을 사용하는 멀티 스레드

 멀티스레드 환경에서 공유자원이란 2개 이상의 스레드가 동시에 접근이 가능한 단일 자원을 말합니다. JVM 환경에서 여러 스레드로부터 접근하는 공유자원에 대한 무결성을 어떻게 유지할 수 있는지 보겠습니다.

 

* 마치 RDBMS의 데이터에 접근하는 여러 사용자 세션과도 같습니다. RDBMS의 락 알고리즘을 아신다면 JVM 환경에서도 똑같이 발생할 수 있는 문제라는 걸 직감적으로 아실 겁니다. RDBMS는 락 알고리즘을 통해 공유자원인 데이터에 대한 무결성을 유지합니다.

 

 그럼 지금부터 멀티 스레드가 하나의 자원을 공유하여 사용할 때 발생하는 문제점과 해소방안을 살펴보겠습니다.

 

시나리오 1 : 동시접속 주문

 5초 동안 1천 명의 사용자가 (거의) 동시에 접근하여 맥북을 하나씩 사보겠습니다. 최종 팔린 맥북 수는 static 변수로 선언하여 구매할 때마다 실시간으로 1씩 올리겠습니다. 예상대로라면 1천 명이 1개씩 샀으므로 최종 판매량은 1천 개여야 합니다.

 

조건

  • 사용자 : 1천 명
  • 이용시간 : 5초 이내
  • 공유자원 : 총 판매량 cntMacBookSold

 

public class User extends Thread {
    private static final Logger logger = LoggerFactory.getLogger(User.class);

    @Override
    public void run() {

        try {
            Thread.sleep(Integer.parseInt(RandomStringUtils.randomNumeric(2))); // n초간 일시정지
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        OrderService orderService = (OrderService) BeanUtils.getBean("orderService");

        // 맥북 하나 팔림
        orderService.orderMacBook();

    }
}

...

@Service
public class OrderService {

    public void orderMacBook() {
        ThreadController.cntMacBookSold++;
    }
}

...


//팔린 맥북 개수
public static Integer cntMacBookSold = 0;
    
// 사용자 요청
for (int i = 0; i < 1000; i++) {
    User user = new User();
    user.start();
}

Thread.sleep(5000);

logger.info(" SOLD OUT !!! || 최종 맥북 판매량 : " + ThreadController.cntMacBookSold);

 

로그&nbsp; : 주문 결과

 

 실행 결과 총 판매량 882개라는 어이없는 숫자가 나옵니다. 우리는 이 원인을 파악하기에 앞서, 스레드가 멀티코어 환경에서 어떻게 공유자원을 읽고 쓰는지부터 이해해야 합니다.

 

 공유자원으로 선언한 cntMacBookSold​ 값은 Main Memory에 들어있음과 동시에 각 CPU의 CPU cache공간에 할당해 둡니다. 스레드는 본인이 사용 중인 CPU의 캐시 값을 가지고 작업을 합니다. 그러나 캐싱된 값이 mainMemory의 값과 다르다면 문제가 발생합니다. (즉 캐시 된 값이 최신값이 아닐 때) 2명이 동시에 맥북을 하나씩 주문하면 아래 같은 일이 발생합니다.

  1. 각 CPU cache에 cntMacBookSold​가 0
  2. 각 스레드에서 1개씩 주문
  3. 각 CPU cache에 cntMacBookSold​가 1이 됨
  4. Main Memory에 1로 반영
  5. 최종 결과 : 1

 이런 케이스를 대표적으로 READ-MODIFY-WIRTE 패턴이라고 하는데 패턴은 패턴일 뿐 최신화되지 않은 캐시값을 cpu가 병렬적으로 연산함으로써 발생한 문제라는 것을 이해하는 것이 중요합니다.

 

시나리오 2 : 알람

 이번엔 맥북이 100개씩 팔릴 때마다 관리자에게 알림을 보내보겠습니다. 나머지는 위 코드와 모두 동일하고 스레드 업무만 아래와 같이 다르게 작성합니다.

@Override
public void run() {

    try {
        Thread.sleep(Integer.parseInt(RandomStringUtils.randomNumeric(2))); // n초간 일시정지
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    OrderService orderService = (OrderService) BeanUtils.getBean("orderService");

    // 맥북 하나 팔림
    orderService.orderMacBook();

    // 판매량 알림
    orderService.alert();

}

...

@Service
public class OrderService {

    public  void alert() {
        // 100개씩 팔릴 때마다 alert
        if(ThreadController.cntMacBookSold % 100 == 0){
            try {
                Thread.sleep(Integer.parseInt(RandomStringUtils.randomNumeric(2))); // n초간 일시정지
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            logger.info("지금까지 맥북 판매량 : " + ThreadController.cntMacBookSold);

        }
    }

}

...

로그

 100, 200,...로 알려주어야 하는 판매량 숫자가 이상합니다. 이를 CHECK-THEN-ACT 패턴이라고 합니다. 이 로직은 아래와 같이 스레드에서 2가지 일을 하는데,

  1. check : 판매량이 100의 배수인가
  2. then act : 100의 배수이면 관리자에게 알림

근데 1번과 2번 사이에 이미 판매량 (공유자원)이 다른 스레드에 의해서 수정되었기 때문입니다.

 


스레드 업무의 원자성과 공유자원의 가시성

 멀티 스레드 환경에서 스레드의 공유자원에 대한 업무는 원자성을 유지해야 합니다.  RDBMS 트랜잭션처럼 업무가 더 이상 쪼갤 수 없는 하나의 원자 같은 성질을 유지해야 합니다. 스레드가 READ-MODIFY-WIRTE 하는 3단계가 쪼갤 수 없는 하나의 원자처럼 수행되어야 합니다. CHECK 하고 ACT 하는 2단계도 마찬가지입니다.

 

 또한 공유자원에 대한 가시성을 확보해야 합니다. 두 시나리오에서 봤든 각 스레드들은 본인 CPU에 캐시 된 값만 볼뿐 실제 최신값을 알지 못합니다.

 

이제 volatilesynchronized를 사용해 볼 겁니다.

volatile은 해당 변수가 main memory에 직접 저장하고 각 스레드가 메인메모리로 직접 접근하도록 강제합니다. 따라서 해당 변수를 캐시 되지 않고 각 스레드가 항상 최신값만을 바라보도록 하는 겁니다.

 

 synchronized 키워드가 적용된 블록에 대해서는 해당 블록의 자원을 베타동기화하겠다는 뜻입니다. 항상 최대 1개의 스레드만 사용이 가능하게 됩니다. synchronized 키워드가 적용된 메서드는 스레드들이 접근할 때 마치 번호표를 뽑고 스레드가 업무를 마치면 다음 스레드가 해당 메서드를 쓸 수 있도록 합니다. 앞서 본 두 시나리오들을 두 키워드를 사용해 해결해 보겠습니다.

...

@Service
public class OrderService {

    public synchronized void orderMacBook() {
        ThreadController.cntMacBookSold++;
    }
}

...

//팔린 맥북 개수
public volatile static Integer cntMacBookSold = 0;
    
// 사용자 요청
for (int i = 0; i < 1000; i++) {
    User user = new User();
    user.start();
}

Thread.sleep(5000);

logger.info(" SOLD OUT !!! || 최종 맥북 판매량 : " + ThreadController.cntMacBookSold);

 

...

@Service
public class OrderService {

    public synchronized void alert() {
        // 100개씩 팔릴 때마다 alert
        if(ThreadController.cntMacBookSold % 100 == 0){
            try {
                Thread.sleep(Integer.parseInt(RandomStringUtils.randomNumeric(2))); // n초간 일시정지
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            logger.info("지금까지 맥북 판매량 : " + ThreadController.cntMacBookSold);

        }
    }

}

...

public volatile static Integer cntMacBookSold = 0;

 

 volatile 키워드로 공유자원을 메인메모리에만 할당하도록 하고, synchronized 키워드를 사용해 공유자원에 접근하는 메서드는 동시에 최대 1개의 스레드만 존재하도록 했습니다.

 

 

 

 1편에서는 멀티스레드에서 공유자원으로 연산할 때 발생하는 문제와 그 원인을 알아봤습니다. 간단한 키워드로 해결방안도 확인했습니다. 토비의 스프링에서 아래 문단을 옮겨 적으며 마무리합니다. 

 

다중 사용자의 요청을 한꺼번에 처리하는 스레드들이 동시에 싱글톤 오브젝트의 인스턴스 변수를 수정하는 것은 매우 위험하다. 저장할 공간이 하나뿐이니 서로 값을 덮어쓰고 자신이 저장하지 않은 값을 읽어올 수 있기 때문이다. 따라서 싱글톤은 기본적으로 인스턴스 필드의 값을 변경하고 유지하는 상태유지(stateful)방식으로 만들지 않는다. 

...

서버에 배포되고 여러사용자가 동시에 접속하면 데이터가 엉망이 돼버리는 등의 심각한 문제가 발생할 것이다. 물론 읽기 전용의 값이라면 초기화 시점에서 인스턴스 변수에 저장해 두고 공유하는 것은 아무 문제가 없다.

 출처 : 1장 오브젝트와 의존관계 1.6.2 싱글톤과 오브젝트의 상태

 


참고

https://www.youtube.com/watch?v=ktWcieiNzKs 

https://honbabzone.com/java/java-thread/

 

JAVA 쓰레드란(Thread) ? - JAVA에서 멀티쓰레드 사용하기

JAVA에서 Thread사용하는 방법을 배우고 멀티코어 환경에서 멀티 쓰레드를 사용하는 방법을 알아보겠습니다.

honbabzone.com

https://parkcheolu.tistory.com/16

 

자바 volatile 키워드

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.원문 URL : http://tutorials.jenkov.com/java-concurrency/volatile.html 자바 volatile 키워드는 자바 코드의 변수를 '메인 메모리에 저장' 할 것을 명시하

parkcheolu.tistory.com

https://hbase.tistory.com/308

 

[Java] 자바 병렬 프로그래밍 - 멀티 스레드의 장단점

복잡한 프로그램이 제대로 동작하도록 코드를 작성하는 일은 어렵다. 하지만 그 복잡한 프로그램이 빠르면서 제대로 동작하도록 작성하는 것은 더욱 어렵다. 즉, 어떤 작업들을 순차적으로 실

hbase.tistory.com

https://kim-jong-hyun.tistory.com/101

 

[JAVA] - Thread.start()와 Thread.run()의 차이

JAVA로 Thread 관련 프로그래밍을 학습하다보면 start() 메서드와 run() 메서드를 보게되는데 두 메서드를 실행하게되면 Thread의 run() 메서드를 실행하게 된다. 다만 이 두 메서드의 동작방식을 제대로

kim-jong-hyun.tistory.com

https://whitehairhan.tistory.com/249

 

[Java] 쓰레드의 상태

쓰레드는 객체가 생성, 실행, 종료되기까지 다양한 상태를 가진다. 각 쓰레드는 Thread.State 상태로 정의되었다. Thread의 인스턴스 메서드인 getState()로 쓰레드의 상태값을 가져올 수 있다. Thread.State

whitehairhan.tistory.com

https://www.baeldung.com/java-volatile

 

http://www.yes24.com/Product/Goods/7516911

 

토비의 스프링 3.1 세트 - YES24

『토비의 스프링 3.1』은 스프링을 처음 접하거나 스프링을 경험했지만 스프링이 어렵게 느껴지는 개발자부터 스프링을 활용한 아키텍처를 설계하고 프레임워크를 개발하려고 하는 아키텍트에

www.yes24.com

 

댓글