10. 멀티 스레드 1

프로세스와 스레드의 개념

  • 실행중인 하나의 프로그램(애플리케이션)을 프로세스라고 한다.

    • 하나의 애플리케이션이 다중 프로세스를 만들 수도 있다.

    • 운영체제는 CPU및 메모리 자원을 프로세스마다 할당해주고 병렬로 실행시킨다.

      • 이를 멀티 프로세싱이라고 한다.

  • 스레드는 프로세스 내부의 코드 실행 흐름이다.

    • 하나의 프로세스가 2개 이상의 작업을 처리하는 데에 필요한 것이 멀티 스레드이다.

      • 2개 이상의 코드 실행 흐름을 이용해 프로세스가 병렬적으로 다양한 작업을 처리하게 만들 수 있다.

    • 스레드(Thread)는 사전적 의미로 한 가닥의 실이란 뜻이다.

멀티 프로세스와 멀티 스레드의 오류 처리

멀티 프로세스

  • 멀티 프로세스는 각 프로세스가 독립적이기 때문에 하나의 프로세스에 오류가 발생해도 다른 프로세스에 영향을 미치지 않는다.

멀티 스레드

  • 하나의 프로세스 내부에 공존하는 것이기 때문에, 하나의 스레드가 잘못되면 프로세스 전체가 종료될 수 있다.

    • 이 때문에 멀티 스레드에서는 예외처리에 만전을 기해야 한다.

멀티 스레드의 활용

  • 대용량 데이터의 분할 병렬 처리

  • 애플리케이션의 UI 네트워크 통신

  • 다수의 클라이언트 요청을 받는 서버

메인 메소드와 스레드

  • 자바의 모든 프로그램은 main() 메소드를 실행하며 시작된다.

    • main() 메소드를 실행시키는 주체는 메인 스레드이다.

  • 싱글 스레드 애플리케이션에서는 메인 스레드가 종료되면 프로세스도 종료된다.

  • 멀티 스레드 애플리케이션에서는 메인 스레드가 종료되어도 실행중인 스레드가 하나라도 있다면, 프로세스는 종료되지 않는다.

작업 스레드 생성 방법

  • Runnable 인터페이스를 상속한 클래스를 만들기

  • Thread 클래스를 상속한 클래스를 만들기

둘 다 결국 run() 메소드를 오버라이드하게 되어있다.

Runnable 인터페이스를 상속하여 작업 스레드 생성하기

Runnable 인터페이스를 상속한 클래스 만들기

public class ImplementRunnable implements Runnable{

    @Override
    public void run() {
        System.out.println("ImplementRunnable.run");
    }
}

어려울 것 없이 run() 메소드만 구현해주면 된다.

Thread의 생성자의 인자로 Runnable 인터페이스 상속 클래스 넣기

public class Main {

    public static void main(String[] args) {
        ImplementRunnable task = new ImplementRunnable();
        Thread thread = new Thread(task);
        thread.start();
    }
}

Runnable 인터페이스를 상속한 클래스는 task(작업)의 역할을 하게되고, 구현한 .run() 메소드는 해당 스레드의 .start() 메소드를 실행하면 수행된다.

익명 객체로 즉시 Thread 만들기

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Main.run");
            }
        });
        thread.start();
    }
}

위와 같이 즉시 Runnable 인터페이스의 익명객체를 만들어 스레드를 만들 수도 있다.

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> System.out.println("Main.run"));
        thread.start();
    }
}

위와 같이 더 간결하게 람다 표현식을 이용할 수도 있다.

비프음 내면서 "삐" 출력하기 예제

BeepTask 클래스

public class BeepTask implements Runnable{
    Toolkit toolkit = Toolkit.getDefaultToolkit();

    @Override
    public void run() {
        for(int i=0; i<5; i++) {
            toolkit.beep();
            try{
                Thread.sleep(500);
            }catch (Exception e) {

            }
        }
    }
}

BeepPrintTask 클래스

public class BeepPrintTask implements Runnable{
    @Override
    public void run() {
        for(int i=0; i<5; i++) {
            System.out.println("삐");
            try{
                Thread.sleep(500);
            }catch (Exception e) {

            }
        }
    }
}

BeepExample 클래스 (main)

public class BeepExample {
    public static void main(String[] args) {
        BeepTask beepTask = new BeepTask();
        BeepPrintTask beepPrintTask = new BeepPrintTask();

        Thread beepThread = new Thread(beepTask);
        Thread beepPrintThread = new Thread(beepPrintTask);

        beepThread.start();
        beepPrintThread.start();
    }
}

실행하면 "삐" 가 출력되는 동시에 비프음도 난다.

Thread 하위 클래스로 스레드 생성하기

  • Runnable 인터페이스를 상속하여 .run() 메소드를 구현하지 않고, Thread 클래스를 상속하여 .run() 메소드를 오버라이드하여도 된다.

public class ExtendThread extends Thread {
    @Override
    public void run() {
        System.out.println("ExtendThread.run");
    }
}
public class Main {
    public static void main(String[] args) {
        Thread thread = new ExtendThread();
        thread.start();
    }
}
  • 위와 같이 Thread 클래스를 상속하고 .run() 메소드를 오버라이드하여도 잘 동작한다.

익명 자식 객체를 이용하기

public static void main(String[] args) {
    Thread thread = new Thread() {
        @Override
        public void run() {
            System.out.println("Main.run");
        }
    };
    thread.start();
}

위처럼 익명 자식 객체를 이용해도 무방하다.

람다식 이용하여 익명 자식 객체 작성하기

public static void main(String[] args) {
    Thread thread = new Thread(() -> System.out.println("Main.run"));
    thread.start();
}

람다식을 이용하면 더 간결하다.

스레드의 이름

  • 스레드는 이름을 가진다.

  • 이름은 특별히 쓸모있진 않지만, 디버그할 때 어떤 스레드가 어떤 작업을 하는지 알기 좋다.

  • 스레드는 자동으로 Thread-n 이라는 이름으로 명명된다.

    • n은 스레드의 번호이다.

    • main 스레드의 이름은 main이다.

  • 스레드의 이름을 변경하고 싶다면, 스레드 객체의 .setName() 메소드를 이용하면 된다.

  • 스레드의 이름을 알고 싶다면, 스레드 객체의 .getName() 메소드를 이용하면 된다.

  • 현재 수행되고 있는 스레드가 궁금하다면, Thread.currentThread() 메소드로 현재 실행되고 있는 스레드의 참조를 얻을 수 있다.

스레드 이름 예제

public class ThreadA extends Thread{
    public ThreadA() {
        this.setName("ThreadA");
    }

    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
            System.out.println(this.getName() + "가 출력한 내용");
        }
    }
}

public class ThreadB extends Thread{

    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
            System.out.println(this.getName() + "가 출력한 내용");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread();
        System.out.println("프로그램 시작 스레드 이름: " + mainThread.getName());

        Thread threadA = new ThreadA();
        System.out.println("작업 스레드 이름: " + threadA.getName());
        threadA.start();

        Thread threadB = new ThreadB();
        System.out.println("작업 스레드 이름: " + threadB.getName());
        threadB.start();
    }
}

스레드의 우선순위

멀티 스레드의 동시성과 병렬성 용어 정리

동시성 (Concurrency)

하나의 코어에서 멀티스레드가 번갈아가며 실행되는 성질

싱클 코어 CPU를 이용한 동시성은 사용자 입장에서 병렬성처럼 보이기 쉽지만, 엄연히 동시성이다.

병렬성 (Parallelism)

멀티 코어에서 개별 스레드를 동시에 실행하는 성질

동시성에서의 스케쥴링

  • 스레드의 개수가 CPU 코어의 수보다 많다면 동시성 성질을 갖게 된다.

    • CPU 코어의 수가 더 많으면 병렬성 성질을 갖게 되어 말 그대로 병렬적으로 처리되고 스케쥴링도 필요없다.

  • 동시성 성질이 있을 때, CPU가 스레드를 어떤 방식으로 처리할지 정하는 방법이 스케쥴링이다.

우선순위(Priority)

  • 우선순위가 높은 스레드 먼저 처리한다.

  • 개발자가 코드로 스레드에 우선순위를 부여할 수 있다.

    • 우선순위는 1~10의 수치를 정해줄 수 있다.

    • .setPriority() 메소드를 이용하면 된다.

가독성을 위해 Thread.MAX_PRIORITY = 10, Thread.NORM_PRIORITY = 5, Thread.MIN_PRIORITY = 1 등의 상수도 정의되어 있다.

순환 할당(Round-Robin)

  • 시간 할당량(Time Slice)를 정해서 각 스레드를 정해진 시간만큼 실행한 뒤에 다음 스레드를 실행한다.

  • JVM에 의해 실행되기 때문에 코드로 제어할 수 없다.

동시성 우선순위 코드 예제

public class CalcThread extends Thread{
    public CalcThread(String name) {
        setName(name);
    }

    @Override
    public void run() {
        for (int i = 0; i < 200000000; i++) {

        }
        System.out.println(getName());
    }
}

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new CalcThread("thread" + i);
            if(i != 9) {
                thread.setPriority(Thread.MIN_PRIORITY);
            } else {
                thread.setPriority(Thread.MAX_PRIORITY);
            }
            thread.start();
        }
    }
}

우선순위가 가장 높은 thread9가 먼저 출력된 것을 볼 수 있다.

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

  • 한 객체를 여러 스레드가 공유해서 작업하는 경우 주의해야 한다.

    • thread-A가 사용하는 객체의 내용을 thread-B가 변경할 수 있어서 원치 않은 결과를 초래할 수 있기 때문이다.

한 저장소를 같이 쓰는 스레드의 잘못된 예제

public class Store {
    private int value;

    public void setValueAndPrint(int value) {
        this.value = value;
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + ": " + this.value);
    }
}

public class User extends Thread{
    Store userStore;
    int valueToStore;

    public User(Store userStore, String userName, int valueToStore) {
        this.setName(userName);
        this.userStore = userStore;
        this.valueToStore = valueToStore;
    }

    @Override
    public void run() {
        userStore.setValue(valueToStore);
    }
}

public class Main {
    public static void main(String[] args) {
        Store sharedStore = new Store();
        Thread user1Thread = new User(sharedStore, "user1", 50);
        user1Thread.start();

        Thread user2Thread = new User(sharedStore, "user2", 100);
        user2Thread.start();
    }
}
  • 위는 한 저장소를 같이 쓸 때 날 수 있는 일에 대한 예제이다.

  • "user1"50을 저장소에 저장하고 출력하고 싶다.

  • "user2"100을 저장소에 저장하고 출력하고 싶다.

  • 단, 값을 저장하고 출력하는데 2초라는 딜레이가 있다.

위에서 일어난 일은 "user1"이 값을 저장하고 출력을 기다리는 사이 2초라는 시간에 "user2"가 값을 수정해서 둘 다 100을 출력하고 말았다. 이를 해결하기 위해서는 "user1"이 해당 객체를 쓰고있을 때는 해당 객체를 잠궈놓거나 하는 등의 처리가 필요하다.

임계영역과 동기화 메소드, 동기화 블록을 이용하여 예제 올바르게 변경해보기

  • 임계영역(critical section)이란 단 하나의 스레드만 실행할 수 있는 코드 영역을 말한다.

  • 자바는 임계영역을 만들 수 있는 동기화(synchronized) 메소드 혹은 동기화 블록을 제공한다.

    • 스레드가 객체 내부의 동기화 메소드 혹은 블록에 들어가면 다른 스레드는 임계 영역 코드에 접근할 수 없다.

    • 자바는 synchronized 키워드를 제공하고 이 키워드는 인스턴스 메소드, 정적 메소드 등 어디든 붙일 수 있다.

  • 임계 영역 외부의 코드는 여전히 여러 스레드가 동시에 실행할 수 있다.

임계 영역에 대해 배웠으니, 이제 값을 저장하고 출력하는 메소드 부분을 임계 영역으로 지정하여 처음 의도대로 프로그램을 실행시켜보자.

public class Store {
    private int value;

    public synchronized void setValueAndPrint(int value) {
        this.value = value;
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + ": " + this.value);
    }
}

해당 스레드가 임계 영역을 모두 실행할 때까지 다른 스레드들은 기다리고 접근 가능한 시점에 접근한다.

특정 블록만 임계영역으로 만들어보기

public class Store {
    private int value;

    public void setValueAndPrint(int value) {

        synchronized (this) {
            this.value = value;

            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + ": " + this.value);
        }
    }
}

위와 같이, 핵심 루틴만 임계 영역으로 만들어 놓을 수도 있다.

스레드 상태

실행 대기 상태

  • Thread.start() 메소드를 수행하면, 스레드가 곧바로 실행 상태가 되는 것이 아니라 스레드는 실행 대기 상태가 된다.

실행 상태

  • 실행 대기 상태에서 스케줄링에 의해 선택된 스레드가 CPU를 점유하고 .run() 메소드를 실행한다.

    • 이 때를 실행 상태라고 하지만, Thread.getState()에 의해 조회했을 때는 그냥 RUNNABLE 상태로 나온다.

  • 실행 상태에서 각 스레드는 스케줄링에 따라 자신의 .run() 메소드를 할당 시간 (TimeSlice) 혹은 우선 순위 (Priority) 에 맞게 실행할 것이다.

실행 종료 상태

  • 스레드의 .run() 메소드 실행이 끝나면 실행 종료 상태가 된다.

일시 정지 상태

  • 스레드는 모종의 이유로 일시 정지 상태가 될 수 있다.

  • 일시 정지 상태는 WAITING, TIMED WAITING, BLOCKED 3가지가 있다.

  • 스레드가 다시 실행되려면 일시 정지 상태에서 다시 실행 대기 상태를 만들어야 한다.

스레드의 상태 확인법

  • .getState() 메소드를 이용하여 확인할 수 있다.

스레드의 모든 상태 정리

  • 객체 생성 (NEW): 스레드 객체가 생성되고 아직 .start() 메소드가 호출되지 않은 상태

  • 실행 대기 (RUNNABLE): .start()가 호출된 후, 스케줄링에 의해 선택될 때까지 실행 대기

  • 일시정지 상태 (WAITING, TIMED_WAITING, BLOCKED)

    • WAITING: 다른 스레드가 실행 대기 상태로 가라고 명령할 때까지 기다리는 상태

    • TIMED_WAITING: 주어진 시간만큼 대기하고 있는 상태

    • BLOCKED: 사용하고자 하는 객체의 락이 풀릴 때까지 기다리는 상태 (Critical Section 접근)

  • 종료 (TERMINATED): 실행을 마친 상태

이전 공유 저장소 소스에서 상태 출력해보기

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Store sharedStore = new Store();
        Thread user1Thread = new User(sharedStore, "user1", 50);
        Thread.State user1ThreadStateBeforeStart = user1Thread.getState();
        System.out.println("user1ThreadStateBeforeStart = " + user1ThreadStateBeforeStart);
        user1Thread.start();
        Thread.State user1ThreadStateAfterStart = user1Thread.getState();
        System.out.println("user1ThreadStateAfterStart = " + user1ThreadStateAfterStart);

        Thread user2Thread = new User(sharedStore, "user2", 100);
        Thread.State user2ThreadStateBeforeStart = user2Thread.getState();
        System.out.println("user2ThreadStateBeforeStart = " + user2ThreadStateBeforeStart);
        user2Thread.start();
        Thread.State user2ThreadStateAfterStart = user2Thread.getState();
        System.out.println("user2ThreadStateAfterStart = " + user2ThreadStateAfterStart);

        Thread.sleep(1000);
        Thread.State user1ThreadStateAfterASecond = user1Thread.getState();
        System.out.println("user1ThreadStateAfterASecond = " + user1ThreadStateAfterASecond);
        Thread.State user2ThreadStateAfterASecond = user2Thread.getState();
        System.out.println("user2ThreadStateAfterASecond = " + user2ThreadStateAfterASecond);

        Thread.sleep(2000);
        Thread.State user1ThreadStateAfterThreeSeconds = user1Thread.getState();
        System.out.println("user1ThreadStateAfterThreeSeconds = " + user1ThreadStateAfterThreeSeconds);
        Thread.State user2ThreadStateAfterThreeSeconds = user2Thread.getState();
        System.out.println("user2ThreadStateAfterThreeSeconds = " + user2ThreadStateAfterThreeSeconds);
    }
}

위와 같이 상태를 출력하는 프린트문을 많이 넣어보았다.

  • new로 새로운 객체 생성 이후 .start() 이전에는 두 스레드 모두 객체 생성(NEW) 상태이다.

  • .start() 이후에는 두 스레드 모두 실행 대기(RUNNABLE) 상태이다.

  • 1초 후에는

    • user1ThreadThread.sleep(2000)에 의해 주어진 시간동안 기다리는 일시정지 상태 (TIME_WAITING)이다.

    • user2Thread는 임계 영역(Critical Section)에 접근할 수 없기 때문에 일시정지 상태 (BLOCKED)이다.

  • 3초 후에는

    • user1Thread.run() 메소드를 끝마쳐 종료 상태 (TERMINATED)이다.

    • user2ThreadThread.sleep(2000)에 의해 주어진 시간동안 기다리는 일시정지 상태 (TIME_WAITING)이다.

TIME_WAITING이 끝난 후에는 RUNNABLE이 되어 나머지 실행을 마치고, TERMINATED가 되었을 것이다.

스레드 상태 출력해보기 예제 2

public class StatePrintThread extends Thread{
    private Thread targetThread;

    public StatePrintThread(Thread targetThread) {
        this.targetThread = targetThread;
    }

    @Override
    public void run() {
        while(true) {
            Thread.State state = targetThread.getState();
            System.out.println("타겟 스레드 상태: " + state);

            if(state == State.NEW) {
                targetThread.start();
            }

            if(state == State.TERMINATED) {
                break;
            }

            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class TargetThread extends Thread {
    @Override
    public void run() {
        for (long i = 0; i < 2000000000; i++) {

        }

        try {
            Thread.sleep(1500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        for (long i = 0; i < 2000000000; i++) {

        }
    }
}

public class Main {
    public static void main(String[] args) {
        StatePrintThread statePrintThread = new StatePrintThread(new TargetThread());
        statePrintThread.start();
    }
}
  • 스레드 안에 스레드가 있어서, 바깥 스레드는 안의 스레드의 상태를 계속 출력해주는 상태이다.

  • 위 코드를 실행해보면, NEW -> RUNNABLE -> TIMED_WAITING -> RUNNABLE -> TERMINATED를 모두 볼 수 있다.

    • NEW 인 상태를 확인하면 targetThread.start()를 실행한다.

    • 실행 중에는 RUNNABLE 상태이다.

    • Thread.sleep() 메소드 부분을 만나게 되면 TIMED_WAITING 상태가 된다.

    • 이후 다시 실행이 되어 RUNNABLE 상태가 된다.

    • 모든 실행이 끝나면 TERMINATED 상태가 된다.

Last updated