쓰레드(Thread)

2024. 4. 5. 13:12

 

 

쓰레드란 프로그램 실행의 가장 작은 단위이다.

일반적으로 자바 앺을 만들어 실행하면 1개의 메인쓰레드에 의해 프로그램이 실행된다.

 

Thread 클래스

  • 쓰레드 생성을 위해 미리 구현해둔 클래스이다.
  • sleep()
    • 현재 쓰레드 멈추기
    • 자원을 놓아주지는 않고, 제어권을 넘겨주므로 데드락이 발생할 수 있음
  • Interupt()
    • 다른 쓰레드를 깨워서 interruptedException을 발생시킴
    • interupt가 발생한 쓰레드는 예외를 catch하여 다른 작업을 할 수 있음.
  • join()
    • 다른 쓰레드의 작업이 끝날 때까지 기다리게 함
    • 쓰레드의 순서를 제어할 때 사용할 수 있음
  • Thread 클래스로 쓰레드를 구현하려면 이를 상속받는 클래스를 만들고, 내부에서 run 메서드를 구현해야 한다.
  • Thread의 start 메서드를 호출하면 run 메서드가 실행된다.
  • run()이 아니라 start() 메서드를 호출하는 것에 주의해야 한다.
  • 해당 메서드의 실행을 별도의 쓰레드에서 하고 싶은 건데, run()을 호출하는 것은 메인 쓰레드에서 객체의 메서드를 호출하는 것에 불과하다. 별도의 쓰레드로 실행시키려면 JVM의 도움이 필요하다.
  • start() 메서드 진행 과정
    1. 쓰레드가 실행 가능한지 검사함
      • 쓰레드는 NEW ,RUNNALBLE, WAITING, TIMED WAITING, TERMINATED 총 5가지 상태가 있다.
      • start() 가장 처음에는 해당 쓰레드가 실행 가능한 상태인지(0인지) 확인한다.
      • 그리고 만약 쓰레드가 NEW(0) 상태가 아니라면 IllegalThreadStateException 예외를 발생시킨다.
    2. 쓰레드를 쓰레드 그룹에 추가함
      • 쓰레드 그룹에 해당 쓰레드를 추가시킨다. 쓰레드 그룹이란 서로 관련 있는 쓰레드를 하나의 그룹으로 묶어 다루기 위한 장치. 자바에서는 ThreadGroup 클래스를 제공한다. 쓰레드 그룹에 해당 쓰레드를 추가하면 쓰레드 그룹에 실행 준비된 쓰레드가 있음을 알려주고, 관련 작업들이 내부적으로 진행된다.
    3. 쓰레드를 JVM이 실행시킴
      • 그리고 start0() 메서드를 호출하는데, 이것은 native 메서드로 선언되어 있다. JVM에 의해 호출된다. 이것이 내부적으로 run()을 호출한다. 그리고 쓰레드의 상태 역시 Runnable로 바뀐다. 그래서 start는 여러 번 호출하는 것이 불가능하고 한 번만 호출 가능하다.

 

Runnable 인터페이스

  • 1개의 메서드만을 갖는 함수형 인터페이스이다. 람다식으로도 사용 가능하다.
  • 쓰레드를 구현하기 위한 템플릿에 해당한다. 해당 인터페이스의 구현체를 만들고 Thread 객체 생성 시에 넘겨 주면 실행 가능하다. 앞서 Thread 클래스는 반드시 run 메서드를 구현해야 했는데, Thread 클래스가 Runnable를 구현하고 있기 때문이다.

 

Thread VS Runnable

  • Runnable은 익명 객체 및 람다로 사용할 수 있지만, Thread는 별도의 클래스를 만들어야 한다는 점에서 번거롭다. 또한 자바에서 다중 상속이 불가능하므로 Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없어서 좋지 않다. 또한 Thread 클래스를 상속받으면 Thread 클래스에 구현된 코드들에 의해 더 많은 자원(메모리와 시간 등)을 필요로 하므로 Runnable이 주로 사용된다. 
  • 거의 대부분의 경우 Runnable 인터페이스를 사용하면 해결 가능하다.
  •  

  • Thread와 Runnable의 단점 및 한계
    • 지나치게 저수준의 API(쓰레드의 생성)에 의존함.
    • 값의 반환이 불가능
    • 매번 쓰레드 생성과 종료하는 오버헤드가 발생
    • 쓰레드들의 관리가 어려움
    • => Java5에 Executor, ExecutorService 등이 등장함.
  • Callable과 Future 인터페이스에 대한 이해 및 사용
  • Callable 인터페이스 : 제네릭을 사용해 결과를 받을 수 있다.
    • @FunctionalInterface
      public interface Callable<V> {
          V call() throws Exception;
      }
  • Future 인터페이스 :
    • Callable의 구현체인 작업(Task)은 가용 가능한 쓰레드가 없어서 실행 미뤄질 수도 있고, 작업 시간이 오래 걸릴 수도 있다. 그래서 실행 결과를 미래의 어느 시점에 얻을 수도 있는데,
    • 미래에 완료된 Callable의 반환값을 구하기 위해 사용되는 것.
    • 즉, Future는 비동기 작업을 갖고 있어 미래에 실행 결과를 얻도록 도와준다.

 

스레드 동기화

  • synchronized 키워드를 사용하여 메서드나 블록을 동기화 할 수 있다.

스프링에서의 비동기 처리

  • @Async 어노테이션을 사용하여 비동기 처리를 쉽게 구현할 수 있다. 이를 위해서는 스프링의 설정 클래스에 @EnableAsync 어노테이션을 추가하여 비동기 지원을 활성화해야 한다.
  • @Async 어노테이션을 사용하면 스프링이 자동으로 메서드를 별도의 스레드에서 실행한다.
  • @Async의 원리
    • 기본적으로 AOP에 의해 프록시 패턴 기반으로 동작한다.
      1. @Async 어노테이션이 붙은 메서드가 호출되면, 스프링은 해당 호출을 가로채서 비동기 실행을 처리하기 위한 프록시 객체를 생성한다.
      2. 해당 메서드는 TaskExecutor에 의해 스레드풀에 작업으로 등록한다.
      3. 해당 메서드는 호출자와 별도의 스레드에서 작업이 진행되며, 호출자 메서드는 블러킹되지 않고 즉시 리턴된다.
    • 조건 두 가지
      1. public 메서드에서만 적용 가능하다.
      2. self-invocation은 불가하다. (같은 클래스의 메서드를 호출할 수 없다.)

 

스레드를 많이 쓸수록 항상 성능이 좋아지나?

  • 항상은 아니다.
    1. 임계 영역에 대한 동기화 비용
      • 멀티 스레드는 자원을 공유하기 때문에 프로세스 생성에 비해 적은 메모리와 자원을 소모하고 컨텍스트 스위칭도 멀티 프로세스에 비해 빠르다. 하지만 여러 개의 스레드가 임계 영역(Critical Section)의 공유 자원에 접근할 수 있기 때문에, 데이터의 일관성과 정확성을 유지하기 위해 동기화 기법을 사용하여야 한다.
      • 임계 영역 : 공유 자원을 접근하는 코드 영역. 대표적으로 전역 변수나 heap 메모리 영역.
      • 동기화 기법은 대표적으로 뮤텍스, 세마포어 같은 잠금 기법이 있다. 하지만 이러한 동기화 기법은 스레드 간의 경쟁과 대기 상황을 발생시키므로, 오히려 성능에 부정적인 영향을 미칠 수 있다. 락 획득 및 해제 작업은 추가적인 시간이 소요되며, 나머지 스레드의 실행을 중지하거나, 대기하게 만들어 프로그램의 성능이 저하될 수 있다.
      • CPU 캐시와 메모리 사이의 캐시 데이터 일관성 문제 -> CPU 캐시에서 데이터를 불러오는 비용 발생
      • 즉, 많은 양의 공유 데이터를 사용하는 경우, 동기화 및 캐시 일관성 작업으로 인해 병목이 일어나 성능이 떨어진다.
      • 하지만 그렇다고  싱글 스레드가 더 좋다는 말은 아니다. 실제로 대부분의 프로그램에서는 많은 양의 데이터와 복잡한 로직을 처리해야 하고 요즘 CPU는 기본적으로 다중 코어를 탑재하므로, 왠만한 상황에서는 멀티스레드가 빠르다. 하지만 성능 상승 곡선이 싱글 스레드보다 무조건적으로 가파르지 않다.
    2. 컨텍스트 스위칭 오버헤드
      • 여러 개의 프로세스나 스레드가 있을 때, CPU가 현재 프로세스나 스레드의 상태를 저장하고 다른 프로세스나 스레드로 전환될 때 발생하는 비용을 의미한다. 스위칭하는 과정에서 CPU 시간과 자원을 소모한다.
      • 스레드가 많을 수록 스위칭 횟수도 많아지고 덩달아 오버헤드도 많아져 성능이 저하될 수 있다.
      •  

  • 즉, 스레드가 많을 수록 작업을 분리해서 동시에 처리하니까 항상 빠르다는 거짓임. 고정 관념 깨야함.
  • 이용률 한산할 때, 나머지 잔여 스레드들이 CPU, 메모리, 네트워크 등의 자원을 불필요하게 점유해서 성능 저하나 오류의 원인이 될 수 있게 된다. -> 시스템 자원 낭비.
  • 그런데 놀고 있음에도 CPU는 다른 스레드에게 CPU 시간을 양도하도록 설계되어 있기 때문에 노는 스레드와 다른 스레드 간에 컨텍스트 스위칭이 계속 발생하여 CPU의 효율성을 떨어뜨린다. 즉, 스레드가 작업을 수행하지 않더라도 존재 자체만으로 여전히 리소스를 소비하고 오버헤드를 생성하기 때문에 잔여 스레드의 문제는 결코 가볍지 않다.이러한 문제를 해결하기 위해서는, 노는 스레드의 개수를 최소화하고, 스레드풀과 같은 매커니즘 사용하여 스레드의 개수를 관리하여 리소스 낭비를 최소화하는 것이 중요하다.
  •  

  • 어플리케이션 성격에 따른 제약
    • CPU 바운드 
      • CPU 연산 능력에 의존하는 작업을 말한다.
      • 데이터 마이닝, 영상 처리 작업, 이미지 프로세싱, 암호화폐 마이닝 등
      • 적절한 스레드 수는 코어 수 + 1 (컨텍스트 스위칭 오버헤드 비용 때문에)
    • I/O 바운드
      • I/O 장치의 응답 속도에 의존하는 작업을 말한다.
      • 파일 입출력, 네트워크 통신, 데베 접근 등
      • CPU 코어 수보다 2배 3배 그 이상으로 스레드 수를 늘려주는 것이 코어들을 더 효율적으로 쓸 수 있다. 그러나 잔여 스레드의 컨텍스트 오버헤드와 동기화 등의 문제점이 동반될 수 있기 때문에, I/O 바운드 어플리케이션에 멀티 스레드 모델 대신 비동기 I/O 처리에 특화된 이벤트 기반 프로그래밍 모델을 접목하기도 한다. 대표적으로 Node.js
      • Node.js와 같은 싱글 스레드 + 이벤트 기반 프로그래밍 모델에서는 이벤트 루프를 통해 이벤트를 감지하고, 이벤트 핸들러를 호출하여 해당 이벤트를 처리하는 식이다. 만약 입출력 작업을 수행해야 하는 경우, 이벤트 핸들러는 비동기 I/O를 사용하여 입출력 작업을 수행하고, 입출력 작업이 완료될 때까지 이벤트 루프를 블로킹하지 않게 된다. 그래서 작업 처리 도중 다른 작업을 처리할 수 있어서, 멀티 스레드 모델에 비해 더 적은 리소스를 사용하고 높은 처리량을 보여줄 수 있다.
      • 물론 비동기 I/O 처리 주체는 Node.js에 내장된 라이브러리의 스레드 풀에서 가져온 멀티 스레드로 처리하는 것이다. 웹 브라우저도 멀티 스레드 프로그램이며 이를 이용해 비동기 작업을 수행한다.
      • Node.js의 메인 스레드는 싱글 스레드이기 때무에 멀티 스레드 모델에서 발생할 수 있는 문제들을 걱정할 필요가 없이 CPU 바운드 혹은 I/O 바운드 작업이 발생하면 그때만 멀티스레드를 가져와 사용해, 멀티스레드 모델 변형 버전인 싱글스레드 + 이벤트 기반 프로그래밍 모델 + 비동기 I/O 모델이라고 한다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

newCachedThreadPool vs newFixedThreadPool

 

 

 

 

 

 

'Java' 카테고리의 다른 글

직렬화 역직렬화  (0) 2024.04.10
Garbage Collection  (1) 2024.04.10
OOP(Object Oriented Programming)  (0) 2024.04.05
예외 처리 (Java)  (0) 2024.04.05
상속  (0) 2024.03.13

BELATED ARTICLES

more