져니의 개발 정원 가꾸기

Java 멀티 스레딩 본문

개발노트/Spring | Java

Java 멀티 스레딩

전전쪄니 2023. 6. 6. 11:29

멀티쓰레드와 멀티프로세스에 대하여

 

스레드를 생성하는 방법

java에서 멀티스레딩을 구현하는 방법은 Thread 클래스를 이용하는 방법과 Runnable 인터페이스를 이용하는 방법, 크게 두 가지 있다. 이 클래스 및 인터페이스를 상속받는 클래스를 생성했을 때 스레드가 실행되는 것이 아니라, start()객체를 호출할 때 스레드가 실행 되는 것이다.

1. Thread Class

첫 번째 방법으로는 Thread class를 extends하는 방법이 있다. 이렇게 Thread class를 상속받는 클래스는 자체적인 쓰레드가 된다. 방법은 다음과 같다.

  1. Thread 클래스를 exteds하는 클래스를 정의한다..
  2. Thread 클래스의 run() 메소드를 오버라이딩(Overriding)한다.
  3. 스레드를 실행시킬 곳에서 객체 생성 후 start()메소드 호출
// 1. ChatingThread class
public static class ChatingThread extends Thread {

  @Override
  public void run(){
    //스레드가 실행할 코드
  }
}
// 2. Main Class
public static class Main{

  public static void main(String args[]){
    ChatingThread cthread = new ChatingThread();
    cthread.start();  //스레드가 실행시키기
  }
}

+ 이를 익명 객체를 사용해서 만드는 방법도 있다.

// 3. Main Class에서 바로 익명객체를 이용해 작업쓰레드 만들고 실행하기.
public static class Main{

  public static void main(String args[]){
    Thread cthread = new Thread(){
      public void run(){
        //스레드가 실행할 코드
      }
    }
  }
}

2. Runnable Inteface

두 번째 방법으로는 Runnable Interface 를 implements하는 방법이 있다. 아까 Threads 클래스는 직접적인 Thread를 작성했다면, Runnable 인터페이스는 Thread가 사용할 작업을 작성하는 것이다.

  1. Runnable 인터페이스 implements 하는 클래스를 정의한다.
  2. run() 메소드를 오버라이딩(Overriding)한다.
  3. Thread 객체를 만들 때 인자로 Runnable을 implements한 객체를 넘겨주어 thread가 해야할 작업을 명시한다.
  4. 객체 생성 후 start() 메소드 호출
// 1. ChatingThread class
public static class ChatingThread implements Runnable {

  @Override
  public void run(){
    //스레드가 실행할 코드
  }
}
// 2. Main Class
public static class Main{

  public static void main(String args[]){
    Runnable task = new Task();
    Thread cthread = new ChatingThread(task);

    cthread.start();  //스레드가 실행시키기
  }
}

+ 익명 객체로 만들기

// 3. Main Class에서 바로 익명객체를 이용해 작업쓰레드 만들고 실행하기.
public static class Main{

  public static void main(String args[]){
    Thread cthread = new Thread(new Runnable()){
      public void run(){
        //스레드가 실행할 코드
      }
    }
  }
}


주의! start()를 호출하면 해당 스레드에 대한 별도의 실행 stack이 생성되는데, run()메소드를 호출하면 하나의 실행 stack에 넣는 것이기 때문에 병렬처리 되지 않고 순차처리되므로 유의한다

메인 쓰레드, 데몬 쓰레드

메인 쓰레드

main()메소드를 실행하면서 시작된다. 메인 쓰레드가 싱글스레드로 동작할 때 메인 메소드가 끝나는 시점에(return이나 마지막 코드부분) 프로그램이 종료된다. 그러나 병렬적으로 돌아가는 작업 thread가 하나 이상 존재하게 되면, 메인 쓰레드가 끝났을 때도 나머지 작업 쓰레드들이 종료되지 않고 계속 작업을 한다. 그리고 모든 쓰레드들의 작업이 끝났을 떄 프로세스가 끝난다.

즉, 메인 스레드는 작업 쓰레드들의 부모가 아니라 그들과 같은 경쟁 상태에 있는 쓰레드이다.

데몬 스레드

주 스레드의 작업을 돕는 어시스턴트 역할의 스레드이다. 스레드를 돕는 역할을 하기 때문에 주 스레드의 작업이 종료되면 할 일이 없어진다(?). 그래서 주 스레드가 종료되면 데몬 스레드도 같이 자동 종료된다. 이런 돕는 역할을 빼면 일반 작업 스레드와 별다른 차이가 없다.

예) 가비지 컬렉터(GC)

...
  thread.setDaemon(true);
  thread.start();

Interrupt vs yield

yield는 wait상태로 되는 것이 아니다. waiting pool로 가지 않고 바로 runnable로 상태로 간다. 많이 사용되진 않는다.

Thread Synchronization

멀티 스레드로 프로그램이 돌아갈 경우, 스레드들이 같은 객체를 공유하여 작업하는 경우가 많다.

커플 통장의 예를 들어보자

통장에는 10만원이 들어있고 둘이 다른 ATM기에서 각각 10만원씩 출력하려고 한다고 해보자.

남자 쪽에서 찰나의 시간으로 10만원을 뽑았고, 여자 쪽에서 10만원을 뽑으려고 하는 순간 아직 남자쪽의 인출 결과가 반영되지 않고 처리 중인 상태이다. 이 순간 여자가 사용하는 ATM에서도 아직 10만원이 있기 때문에(반영되기 전이니까) 인출 시도를 하게 될 것이고, 양쪽이 모두 처리되어 잔고가 0이 아닌 -10만원이 되는 불상사가 생길 수 있다.

이처럼 스레드들도 공유하는 객체를 동기화 하지 않으면 원치 않은 결과를 받을 수 있다. 이에 한 스레드가 객체를 사용할 경우 스레드의 작업이 끝날 때 까지 lock을 걸어 다른 스레드들이 값을 변경할 수 없도록 한다. 그리고 이렇게 lock이 걸린 부분을 임계영역 (critical section이라고 한다.)

Synchronization method

method 선언시 synchronized 키워드를 붙인다. 이 메소드를 실행하는 순간 공유하는 객체에 lock이 걸려 메소드를 종료할 때까지 lock걸리고 종료시 잠금이 풀린다.

...
  public synchronized void method(){
    //임계 영역
  }

그런데 메소드가 길어지면 임계영역이 길어질 수 있기 때문에 꼭 필요한 부분만 임계영역으로 만들고 싶다면 임계 블록을 만든다.

Synchronization block

객체에 lock이 블록구간의 코드에서만 걸린다. 함수 내부에서 일부분의 코드만 이처럼 동기화블록으로 처리가 되었다면, 이부분을 제외한 나머지 코드 부분에서는 여러 스레드가 동시에 실행할 수 있게 된다.

...
  synchronized(shared instance){ //공유객체가 자신이면 this
    //임계 영역
  }

주의!lock걸리는 것은 코드나 메소드가 아니라 객체이다!

wait(), notify(), notifyAll()

데드락 방지하는 상호보완적 프로그래밍을 지원한다. 두 개의 스레드를 교대로 번갈아서 실행해야할 때 즉, 스레드 간에 협력을 지원하는 메서드이다.

스레드 풀 (Thread Pool)

병렬 작업 처리가 많아지면 생성되는 스레드의 수 역시 증가한다. 과도한 스레드 생성과 스케줄링으로 인해 CPU가 바빠지고 메모리 사용량이 늘어나면, 애플리케이션의 성능이 저하된다. 스레드풀을 사용하면 갑작스런 병렬 작업 폭증으로 인한 스레드 폭증을 막을 수 있다.

스레드 풀에서 작업이 처리되는 절차

  1. 초기화: 일정한 수의 스레드로 구성된 스레드 풀이 생성된다. 이러한 스레드들은 일반적으로 스레드 풀 실행기(thread pool executor)에 저장한다.
  2. 작업 요청: 클라이언트는 스레드 풀에 실행을 위해 작업을 요청한다. 작업은 일반적으로 Runnable 또는 Callable 객체로 표현된다.
  3. 작업 대기열(작업큐  적재): 스레드가 풀에서 사용 가능한 경우 작업은 해당 스레드에 할당되어 실행된다. 그렇지 않으면 모든 스레드가 바쁜 경우 작업은 작업 대기열에 배치된다.
  4. 스레드 실행: 스레드 풀 내의 각 스레드가 할당된 작업을 실행한다. 스레드가 작업을 완료하면 작업 대기열에서 다음 사용 가능한 작업을 가져온다.
  5. 작업 완료: 작업이 완료되면 선택적으로 결과를 반환할 수 있다. 작업이 Callable인 경우 값(value)을 반환할 수 있다. Runnable인 경우 결과를 반환하지 않는다.
  6. 스레드 재사용: 작업이 완료되면 스레드는 다른 작업을 실행할 수 있도록 사용 가능 상태가 된다. 이렇게 하면 각 개별 작업마다 스레드를 생성하고 파괴하는 오버헤드를 피할 수 있다.
  7. 스레드 풀 종료: 스레드 풀이 더 이상 필요하지 않은 경우 종료할 수 있다. 일반적으로 새로운 작업의 수락을 중지하고, 현재 실행 중인 모든 작업이 완료될 때까지 대기한 다음 풀 내의 스레드를 종료하는 과정을 포함한다.

스레드 풀 초기화

  • ExecutorService인터페이스와 Executors 클래스 (java.util.concurrent 패키지)
  • 생성 
    • ExecutorService 구현체 생성 by Executors의 정적 메서드
    • (or) new ThreadPoolExecutor 객체 직접 생성하여 ExecutorService에 할당
ExecutorService executorService = Executors.newCachedThreadPool();
메서드명(매개 변수) 초기 스레드 수  코어 스레드 수 최대 스레드 수
newCachedThreadPool() 0 0 Integer.MAX_VALUE
newFixedThreadPool(int nThreads) 0 nThreads nThreads

 

Runnable, Callable

스레드 풀은 요청받은 작업을 작업큐에 넣어놓고, 각 스레드가 큐에서 작업을 가져와 처리한다. 하나의 작업은 Runnable 혹은 Callable로 표현되며, 이 둘의 차이점은 작업 처리 완료 후 리턴값이 존재하는가이다. 작업을 정의하기 위해서는 두 인터페이스를 구현해야한다.

// Runnable 구현 클래스
Runnable task = new Runnable() {
	@Override
    public void run() {
    	// 스레드가 처리할 내용
    }
}

// Callable 구현 클래스
Callable<T> task = new Callable<T>() {
	@Override
    public T call() throws Exception {
    	// 스레드가 처리할 내용
        return T;
    }
}

작업 처리는 ExecutorService의 작업 큐에 Runnable 혹은 Callable 객체를 넣는 행위를 말한다. ExecutorSerivce에 정의된 작업 처리 요청 메서드는 다음과 같다.

리턴 타입  메서드명(매개변수) 설명
void execute(Runnable command) - Runnable을 작업 큐에 저장
- 작업 처리 결과를 받지 못함
- 작업 처리 도중 예외가 발생하면 스레드가 종료되고 스레드풀에서 해당 스레드가 제거됨.
Future<?>
Future<V>
Future<V>
submit(Runnable task)
submit(Runnable task, V result)
submit(Callable<V> task)
- Runnable 혹은 Callable을 작업 큐에 저장
- 리턴된 Future로 작업 결과를 얻을 수 있음
- 작업 처리 도중 예외가 발생해도 스레드가 종료되지 않고 다음 작업을 위해 재사용됨

Future (지연 완료 객체  : 블로킹 방식의 작업 완료 통보)

ExecutorService의 submit() 메서드는 인자로 받은 Runnable, Callable 작업을 스레드 풀의 작업 큐에 저장하고 즉시 Future 객체를 리턴한다. Future 객체는 작업 결과를 저장하는 것이 아니라 작업이 완료될 때까지 기다렸다가(블로킹) 최종 결과를 얻는데 사용한다. 즉, Future가 작업 결과를 내부적으로 저장하고 있는 것이 아니라 어딘 가에서 작업결과를 가져오는 것이다.

 

ex) Future의 get()메서드 - 작업이 완료될 때까지 블로킹 되었다가 처리 결과를 V로 반환한다.

cf) 논블로킹 방식의 작업 완료 통보 - 콜백 방식(예. CompletionHandler)

 

 

참고

이것이 자바다 ch12. 스레드