Application/JAVA & Kotlin

Java Thead

반응형

프로세스와 스레드


프로세스 운영체제에서 실행 중인 하나의 애플리케이션을 의미한다.

  • 사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 독립적인 메모리를 할당받아 애플리케이션의 코드를 실행한다.

스레드 하나의 실행 흐름으로 프로세스 내의 여러 스레드를 가질 수 있다.


멀티 프로세스 vs 멀티 스레드


멀티 프로세스가 애플리케이션 단위의 멀티 태스킹이라면, 멀티 스레드는 애플리케이션 내부에서의 멀티 태스킹이라고 할 수 있다.

멀티 프로세스와 멀티 스레드

멀티 프로세스들은 운영체제에서 할당받은 자신의 독립적인 메모리를 가지고 있어서, 하나의 프로세스에서 오류가 발생해도 다른 프로세스에 영향을 미치지 않는다.

반면에 멀티 스레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 해당 프로세스 자체가 종료될 수 있어 다른 스레드에게 영향을 미치게 된다.

 

반면 프로세스는 독립적인 메모리 공간을 갖고, PCB를 생성해서 컨텍스트 컨텍스트시 오버헤드가 크다.

반면에 멀티 스레드 메모리 공간 중 code, data, heap 영역은 공유하고, stack 영역만 독립적으로 사용하기 때문에 자원의 효율성이 좋고 컨텍스트 스위칭 시 오버헤드가 적다는 장점이 있다.


자바 메인 스레드 (Main Thread)


모든 자바 애플리케이션은 메인 스레드가 main() 메소드를 실행하면서 시작된다.

public static void main(String[] args {
    ...
}


자바 작업 스레드 (Worker Thread)


메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행할 수 있다. 즉 멀티 스레드를 생성해서 멀티 테스킹을 수행한다.

Thread thread = new Thread(Runnable runnable);
package com.will;

public class Application {

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            System.out.println(Thread.currentThread().getName());
            System.out.println("작업 스레드 실행");
        });
        System.out.println(Thread.currentThread().getName());
        System.out.println("메인 스레드 실행");
        thread.start();
    }

}

// main
// 메인 스레드 실행
// Thread-0
// 작업 스레드 실행


동시성과 병렬성


동시성

  • 멀티 작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행하는 성질

병렬성

  • 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질

 

스레드 우선순위


스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데, 이것을 스레드 스케줄링이라고 한다.

thread.setPriority(우선순위);

Thread.MAX_PRIORITY; // 10
Thread.NORM_PRIORITY; // 5
Thread.MIN_PRIORITY; // 1
package com.will;

public class Application {

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName());
            System.out.println("작업 스레드0 실행");
        });

        Thread thread2 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName());
            System.out.println("작업 스레드1 실행");
        });

        thread1.setPriority(Thread.MAX_PRIORITY);
        thread2.setPriority(Thread.MIN_PRIORITY);

        System.out.println(Thread.currentThread().getName());
        System.out.println("메인 스레드 실행");
        thread2.start();
        thread1.start();
    }

}
  • 우선순위가 높은 스레드가 실행 기회를 더 많이 가지기 때문에 우선순위가 낮은 스레드보다 계산 작업을 빨리 끝낸다. 


동기화 메소드와 동기화 블록


공유 객체를 사용할 때의 주의할 점

싱글 스레드 프로그램에서는 한 개의 스레드가 객체를 독차지해서 사용하면 되지만, 멀티 스레드 프로그램에서는 스레드들이 객체를 공유해서 작업해야 하는 경우가 있다.

이때 스레드 A를 사용하던 객체가 스레드 B에 의해 상태가 변경될 수 있기 때문에 스레드 A가 의도했던 것과는 다른 결과를 산출할 수도 있다


동기화 메소드 및 동기화 블록

스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 해야 한다. 멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역 (criticla section)이라고 한다.

자바는 임계 영역을 지정하기 위해 동기화 메소드 동기화 블록을 제공한다. 스레드가 객체 내부의 동기화 메소드 또는 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역 코드를 실행하지 못하도록 한다.

 

동기화 메소드

package com.will;

public class Calculator {

    private int memory;

    public int getMemory() {
        return this.memory;
    }

    public synchronized void setMemory(int memory) {
        this.memory = memory;
    }

}


동기화 블록

package com.will;

public class Calculator {

    private int memory;

    public int getMemory() {
        return this.memory;
    }

    public void setMemory(int memory) {
        synchronized (this) {
            this.memory = memory;
        }
    }

}


스레드 상태


스레드 상태

스레드 객체를 생성하고, start() 메소드를 호출하면 곧바로 스레드가 실행되는 것 처럼 보이지만, 사실은 실행 대기 상태(Runnable)가 된다.

실행 대기 상태 아직 스케줄링이 되지 않아서 실행을 기다리고 있는 상태를 말한다.

실행 대기에 있는 스레드 중에서 스레드 스케줄링으로 선택된 스레드가 CPU 프로세서를 점유하고, run() 메소드를 실행한다. 이때를 실행 (Running) 상태라고 한다.

실행 상태에서 run() 메소드가 종료되면, 더 이상 실행할 코드가 없기 때문에 스레드의 실행은 멈추게 된다. 이 상태를 종료 상태(Terminated)라고 한다.

또한 I/O 작업 등을 위해 일시정지 상태가 있는데, 스레드가 다시 실행 상태로 가기 위해서는 일시 정지 상태에서 실행 대기 상태로 가야 한다.


스레드 풀


병렬 작업 처리가 많아지면 스레드 개수가 증가되고 그에 따른 스레드 생성과 스케줄링으로 인해 CPU가 바빠져 메모리 사용량이 늘어나게 되고 애플리케이션의 성능이 저하된다. (스레드의 생성과 소멸은 비용이 비싼 작업이다)

 

스레드 풀은 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 큐에 들어오는 작업들을 하나씩 스레드가 맡아 처리한다. 작업 처리가 끝난 스레드는 다시 작업 큐에서 새로운 작업을 가져와 처리한다. 그렇기 때문에 작업 처리 요청이 폭증되어도 스레드의 전체 개수가 늘어나지 않으므로 애플리케이션의 성능이 급격히 저하되지 않는다.


스레드풀 생성


ExecutorService 구현 객체는 Executors 클래스의 다음 메소드를 사용해서 생성할 수 있다.

  • 초기 스레드 수는 ExecutorService 객체가 생성될 때 기본적으로 생성되는 스레드 수
  • 코어 스레드 수는 스레드 수가 증가된 후 사용하지 않는 스레드를 스레드 풀에서 제거할 때 최소한 유지해야 할 스레드 수


newCachedTheadPool()


  • newCachedTheadPool
    • 초기 스레드 수: 0
    • 코어 스레드 수: 0
    • 최대 스레드 수: Integer.MAX_VALUE
  • newCachedTheadPool()로 생성된 스레드 풀의 특징은 초기 스레드 개수와 코어 스레드 개수는 0개이고,

스레드 개수보다 작업 개수가 많으면 새 스레드를 생성시켜 작업을 처리한다.

(이론상 Integer.MAX_VALUE 만큼의 스레드가 추가되지만, 운영체제의 성능과 상황에 따라 달라진다.)

  • 1개 이상의 스레드가 추가되었을 경우 60초 동안 추가된 스레드가 아무 작업을 하지 않으면 추가된 스레드를 종료하고 풀에서 제거한다.


newFixedThreadPool(int nThreads)


  • newFixedThreadPool(int nThreads)
    • 초기 스레드 수: 0
    • 코어 스레드 수: nThreads
    • 최대 스레드 수: nThreads
  • 초기 스레드 개수는 0개이고, 코어 스레드 수는 nThreads이다.
  • 스레드 개수보다 작업 개수가 많으면 새 스레드를 생성시키고 작업을 처리한다. 최대 스레드 수는 nThreads이다.
  • 스레드가 작업을 처리하지 않고 놀고 있더라도 스레드 개수가 줄지 않는다.
package com.will;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

public class ExecuteExample {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        for (int i = 0; i < 10; i++) {
            Runnable runnable = () -> {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executorService;
                int poolSize = threadPoolExecutor.getPoolSize();
                System.out.printf("총 스레드 개수: %s 작업 스레드 이름: %s\n", poolSize, Thread.currentThread().getName());
            };

            executorService.execute(runnable);
        }

        executorService.shutdown();
    }

}

 

작업 생성


하나의 작업은 Runnable, Callable 구현 클래스로 표현한다.

Runnable task = new Runnable() {
    @Override
    public void run() {
        // 스레드가 처리할 작업 내용
    }
}

Callable<T> task = new Callable<T> {
    @Overrice
    public T call() throws Exception {
        // 스레드가 처리할 작업 내용.
        return T;    
    }    
}

Runnable vs Callable

  • Runnable의 run() 메소드는 리턴 값이 없고, Callable의 call() 메소드는 리턴값이 있다.



작업 처리 요청


execute()

  • Runnable을 작업 큐에 저장
  • 작업 처리 결과를 받지 못함.

submit()

  • Runnable 또는 Callable을 작업 큐에 저장 (Runnable의 경우 결괏값이 없음에도 불구하고 Future 객체를 반환하는데, 이것은 스레드가 작업 처리를 정상적으로 완료했는지, 아니면 작업 처리 도중에 예외가 발생했는지를 확인하기 위해서이다)
  • 리턴된 Future을 통해 작업 처리 결과를 얻을 수 있다.


첫번째 차이점은 execute()는 작업 처리 결과를 받지 못하고, submit()은 작업 처리 결과를 받을 수 있도록 Future를 리턴하는 차이가 있다.

또 다른 차이점은 execute()는 작업 처리 도중 예외가 발생하면 스레드가 종료되고 해당 스레드는 스레드풀에서 제거된다. (스레드풀은 다른 작업 처리를 위해 새로운 스레드를 생성한다) 
반면 submit()은 작업 처리 도중 예외가 발생하더라도 스레드는 종료되지 않고 다음 작업을 위해 재사용된다. 
=> 이러한 이유로 가급적이면 스레드의 생성 오버헤드를 줄이기 위해 submit()을 사용하는 것이 좋다.


블로킹 방식의 작업 완료 통보


  • ExecutorService의 submit() 메소드는 매개변수로 준 Runnable, Callable 작업을 스레드 풀의 작업 큐에 저장하고 즉시 Future 객체를 리턴한다.
  • Future 객체는 작업 결과가 아니라 작업이 완료될 때 까지 기다렸다가(블로킹) 최종 결과를 얻는 데 사용된다.
  • Future의 get() 메소드를 호출하면 스레드가 작업을 완료할 때까지 블로킹되었다가 작업을 완료하면 처리 결과를 리턴한다.
  • 주의할 점은 작업을 처리하는 스레드가 작업을 완료하기 전까지는 get() 메소드가 블로킹되므로 다른 코드를 실행할 수 없다.


리턴 값이 없는 작업 완료 통보


package com.will;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class NoResultExample {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

        System.out.println("작업 처리 요청");
        Runnable runnable = () -> {
            int sum = 0;
            for (int i = 1; i <= 10; i++) {
                sum += i;
            }
            System.out.printf("처리 결과: %s\n", sum);
        };

        Future<?> future = executorService.submit(runnable);

        try {
            future.get();
            System.out.println("작업 처리 완료");
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }

        executorService.shutdown();
    }

}

// 작업 처리 요청
// 처리 결과: 55
// 작업 처리 완료


리턴값이 있는 작업 완료 통보


package com.will;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ResultExample {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

        System.out.println("작업 처리 요청");
        Callable<Integer> task = () -> {
            int sum = 0;
            for (int i = 1; i <= 10; i++) {
                sum += i;
            }
            return sum;
        };

        Future<Integer> future = executorService.submit(task);

        try {
            int sum = future.get();
            System.out.println(sum);
        } catch (Exception e) {
            throw new IllegalArgumentException(String.format("에러 발생 %s", e.getMessage()));
        }

        executorService.shutdown();
    }

}

// 작업 처리 요청
// 55


콜백 방식의 작업 완료 통보


콜백이란 애플리케이션이 스레드에게 작업을 완료하면 특정 메소드를 자동 실행하는 기법.

블로킹 vs 콜백방식

 

블로킹 방식 작업 처리를 요청한 후 작업이 완료될 때까지 블로킹되지만

콜백 방식 작업 처리를 요청한 후 결과를 기다릴 필요 없이 다른 기능을 수행할 수 있다.

  • 작업 처리가 완료되면 자동적으로 콜백 메소드가 실행되어 결과를 알 수 있기 때문이다.
반응형