멀티 스레드

고재석·2021년 5월 26일
0

Java는 기본!

목록 보기
7/12
post-custom-banner

프로세스와 스레드

자바에서 사용하는 멀티 스레드를 공부하기 이전에 스레드와 프로세스가 무엇인지 한 번 짚고 넘어가자.

프로세스는 운영체제 관점에서 실행 중인 프로그램이라고 볼 수 있다. 이 하나하나의 프로그램들은 CPU가 실행한다. 즉 프로세스는 CPU를 할당받아서 동작하는 작업의 단위이다. 따라서 CPU를 하나의 프로세스에 할당해서 작업을 수행한다.

그렇다면 스레드는 무엇일까? 스레드는 직역하면 이라는 뜻으로 하나의 실행 흐름이라고 볼 수 있다. 하나의 프로세스는 최소 한 개 이상의 스레드를 가지며, 이 실행 흐름을 따라 프로세스가 동작한다. 하나의 프로세스에 두 개 이상의 스레드가 있다면, 이것이 멀티 스레드 프로세스이다.

위의 그림을 보면 각 스레드들은 코드, 데이터, 파일과 같은 메모리 영역을 공유한다. 따라서 한 프로세스 내의 다른 메소드를 여러 스레드에서 호출하거나 스레드간 통신이 가능하다. 하지만 스택 영역은 독립적으로 할당하는 것을 볼 수 있는데 이 스택은 각 스레드 영역에서 선언하는 변수, 돌아갈 주소 등을 독립적으로 관리해야하기 때문이다.

그렇다면 자바에서 스레드는 어떤 개념이라고 할 수 있을까? JVM이 하나의 프로세스이고, main 스레드를 포함한 여러 실행 흐름이 스레드라고 볼 수 있다. 멀티 스레드 기능을 활용하지 않고 개발한다면 프로세스는 당연히 main 스레드만으로 동작하는 단일 스레드 프로세스로 동작하겠지만 멀티 스레드 기능을 지원하기 때문에 이를 활용할 수도 있다.


스레드 실행

우선 스레드를 하나 만들어서 실행해보자.

스레드는 Thread라는 클래스로 생성할 수 있다. Thread 클래스는 Runnable 인터페이스를 구현하고 있는데, Runnable 인터페이스는 run()이라는 추상 메소드만을 가지고 있는 함수형 인터페이스이다.

주로 사용하는 Thread의 생성자는 Thread(Runnable target)이다. 아래의 코드와 같이 스레드 인스턴스를 생성하고 start() 메소드를 입력하면 run() 메소드의 내용을 수행하며 스레드가 시작된다.


Thread thread = new Thread(()->{
    for(int i = 0; i < 10; i++) {
	System.out.println(i);
    }
});
thread.start();

그럼 두 개 이상의 스레드 인스턴스를 생성해서 실행하면 어떨까? 아래의 코드를 참고해보자.

public static void main(String[] args) {

    // main thread
    System.out.println(Thread.currentThread().getName() + " Start ==================");


    // beep thread
    Thread beepThread = new Thread(()->{
        Toolkit toolkit = Toolkit.getDefaultToolkit();

        for(int i = 0; i < 5; i++){
            toolkit.beep();
            try {Thread.sleep(500); } catch (Exception e) {e.printStackTrace();}
        }
    });
    beepThread.setName("beepThread");
    System.out.println(beepThread.getName() + " Start ==================");
    beepThread.start();


    // print thread
    Thread printThread = new Thread() {
        @Override
        public void run() {
            for (int i = 0; i < 5; i++){
                System.out.println("땡");
                try {Thread.sleep(500); } catch (Exception e) {e.printStackTrace();}
            }
        }
    };
    System.out.println(printThread.getName() + " Start ==================");
    printThread.start();
}

우선 Thread의 전역 메소드인 currentThread()를 사용해서 현재 스레드의 이름을 get 했다. 이 때는 main이라고 출력이 되었을 것이다.

그리고 0.5초 단위로 출력음이 들리는 스레드와, "땡"이라는 글자가 출력되는 스레드를 동시에 실행시켜서 소리가 들리며 콘솔에 출력되게끔 구현했다.


동기화

위의 설명에서 스레드는 메모리를 공유하는 것이라고 말했었다. 그렇다면 하나의 변수나 객체에 두 개 이상의 스레드가 작업을 수행하면 어떻게 될까? 당연히 무결성이 깨질 것이다.

예를 들어 A 스레드가 object라고 하는 객체에 접근해서 데이터를 10으로 바꾸고 출력한다고 하자. 그리고 B 스레드가 object 객체의 데이터를 20으로 바꾸고 출력한다. 이 두 스레드가 동시에 object 객체에 접근해서 A가 10으로 바꾸고 출력하기 직전 B가 20으로 바꾼다면? A는 본인의 작업을 성실히 수행했지만 출력값은 20으로 받을 것이다.

이와 같이 공유 객체를 사용하는 경우에는 동기화가 필수적이다. 방법은 간단하다. 공유 객체에 접근하는 method에 synchronized라고 명시해주거나 따로 블록을 만들면 된다. 아래의 코드를 참고해보자.

public static void main(String[] args) {
    Calculator calculator = new Calculator();

    Thread user1 = new Thread(()->{
        calculator.setMemory(100);
    });
    user1.setName("User1");
    user1.start();


    Thread user2 = new Thread(()->{
        calculator.setMemory(50);
    });
    user2.setName("User2");
    user2.start();
}


public static class Calculator {
    private int memory;

    public synchronized void setMemory(int memory) {
        this.memory = memory;
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {}
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

연산을 수행하는 객체인 Calculator 객체의 setMemory 메소드에 synchronized라고 명시해준 것을 볼 수 있다. 이 경우 user1 스레드가 먼저 작업을 수행하고 모든 작업이 끝날 때 까지 user2 스레드는 기다리게 된다. 따라서 정상적으로 출력이 끝난 후에 user2의 작업이 실행된다.


스레드 상태

스레드는 실행되는 상태만 존재하는 것이 아니다. 다른 스레드가 작업 중이면 기다리기도 해야하고 임의로 스레드를 종료할 수도 있다. 멀티 스레드를 활용한 프로그래밍을 위해서는 스레드의 라이프사이클을 이해하고 각 상태에 대한 지식이 있어야 한다. 아래의 그림을 참고하여 스레드의 다양한 상태를 알아보자.

  1. NEW : 객체 생성 상태, 스레드 객체가 생성되고 아직 start() 되지 않은 상태
  2. Runnable : 실행 상태로 언제든지 갈 수 있는 상태
  3. Blocked : 사용하고자 하는 객체의 락이 풀릴 때까지 기다리는 상태
  4. Waiting : 다른 스레드가 통지할 때까지 기다리는 상태
  5. Timed_Waiting : 주어진 시간동안 기다리는 상태
  6. Terminated : 실행이 끝난 상태

위와 같이 Thread 클래스의 열거 상수로 각 상태가 명시되어 있다. 그렇다면 각 상태에서 스레드를 어떤 식으로 제어하면 될까?

sleep()

스레드의 정적 메소드인 sleep() 메소드로 스레드를 주어진 시간동안 일시정지 할 수 있다.

Thread.sleep(100);과 같은 코드로 실행 상태에서 timed_waiting 상태로 전이시킨다.

yield()

스레드의 정적 메소드인 yield() 메소드로 다른 스레드에게 실행을 양보할 수 있다.
Thread.yield();로 runnable 상태의 다른 스레드가 running 되게 한다. 보통 무의미한 작업을 반복하며 기다리는 경우 다른 스레드에게 실행을 넘겨 효율적으로 작업 시간을 분배하기 위해 사용한다.

join()

스레드의 클래스의 join() 메소드로 다른 스레드의 종료를 기다릴 수 있다. 다른 스레드의 작업이 선행되어야 하는 경우 otherThread.join();로 다른 스레드가 terminated될 때까지 현재 스레드는 기다린다.

wait(), notify(), notifyAll()

두 개 이상의 스레드를 번갈아가며 실행하는 경우, 자신을 일시 정지시키는 wait() 메소드와 waiting 중인 스레드 하나를 runnable로 만드는 notify()나 waiting 중인 모든 스레드를 runnable로 만드는 notifyAll() 메소드를 사용할 수 있다.

interrupt()

스레드를 종료하기 위해서 interrupt() 메소드를 사용하면 된다. 스레드가 일시 정시 상태일 때 interrupt() 메소드를 사용하면 InterruptedException이 발생해서 예외 처리 블록으로 이동하게 된다. 하지만 스레드가 일시 정지 상태가 아니라면 추후에 일시 정지 상태가 되는 경우에 예외가 발생한다. 따라서 실행하다가 스레드가 바로 종료되버리면 interrupt() 메소드는 의미가 없다.

스레드 풀

여러 개의 작업이 주어질 때 모든 작업에 스레드가 생기면 스레드 생성 및 스케줄링으로 CPU에 오버헤드가 발생하고 성능이 저하될 수 있다. 이러한 경우에 사용하면 좋은 것이 스레드 풀이다.

스레드 풀은 스레드의 갯수를 제한해두고 그 이상의 스레드가 생성되지 않도록 한다. 따라서 작업 요청이 폭증하더라도 급격한 성능 저하를 방지할 수 있다.

자바에서는 ExecutorService 인터페이스와 Executors 클래스를 제공함으로써 스레드풀을 생성할 수 있도록 한다. ExecutorService 구현 객체는 Executors 클래스의 메소드로 생성할 수 있다.

        ExecutorService executorService = Executors.newFixedThreadPool(
                Runtime.getRuntime().availableProcessors()
        );
        
        executorService.shutdown();

newFixedThreadPool() 메소드는 최대 스레스 수를 파라미터로 받아 스레드 풀을 생성한다. 그리고 shutdown() 메소드로 남아있는 작업을 마무리하고 스레드 풀을 종료한다. shutdownNow() 메소드는 지금 당장 강제 종료하는 메소드이다.

작업 처리

각 스레드가 처리할 작업은 Runnable / Callable 인터페이스를 구현해서 만들어준다. 리턴 값이 없는 경우에는 Runnable, 있는 경우는 Callable 인터페이스를 사용한다. Callable 타입의 제네릭은 리턴 값의 타입으로 명시해주면 된다.

이렇게 생성한 작업을 스레드 풀에 넣어주는 메소드가 두 가지 있다. execute(), submit()이다. 이 두 메소드의 차이점은 execute()는 리턴 값이 없는 메소드이고, submit()은 Future 타입의 리턴 값을 반환하는 메소드이다.

그리고 작업 처리 도중 예외가 발생하는 경우 execute() 타입은 스레드가 종료되고 스레드가 스레드 풀에서 삭제된다. 그리고 다른 작업 처리를 위해 스레드가 생성된다. 하지만 submit() 메소드는 스레드가 종료되지 않고 다른 작업을 위해 재사용된다. 따라서 submit()을 사용하는 것이 스레드 생성 오버헤드를 줄이는데에 효과적이다.

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

    for(int i = 0; i < 10; i++){
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executorService;

                int poolSize = threadPoolExecutor.getPoolSize();
                String threadName = Thread.currentThread().getName();
                System.out.println("[총 스레드 개수 : " + poolSize + " ] 작업 스레드 이름 : " + threadName);

                int value = Integer.parseInt("삼");
            }
        };

        Future future = executorService.submit(runnable);
        Thread.sleep(10);
    }
    executorService.shutdown();
}

스레드가 종료되지 않고 같은 스레드를 반복해서 사용하는 것을 확인할 수 있다.

profile
명확하게 말하고, 꼼꼼하게 개발하자
post-custom-banner

0개의 댓글