Java/Java-basic

[Java Study 10주차] 멀티쓰레드 프로그래밍

어썸오184 2021. 1. 13. 16:04
728x90
반응형

본 포스팅은 백기선님이 진행하시는 자바 스터디 를 진행하며 혼자 공부하고 이해한 내용을 바탕으로 정리한 글입니다. 오류나 지적 사항이 있다면 댓글로 알려주시면 감사하겠습니다.

Thread 클래스와 Runnable 인터페이스

쓰레드(Thread)란?

우리가 사용하는 프로그램은 하나의 프로세스(process)이다. 프로그램을 실행하면 OS로부터 자원을 할당받아 프로세스가 된다. 블로그에 글을 쓰기 위해 크롬 창을 켜는 순간 하나의 프로세스가 동작하기 시작하는 것이다. 현재 우리가 사용하는 OS들(윈도우, 리눅스, 맥OS 등등..)은 모두 멀티태스킹을 지원한다. 멀티태스킹을 지원한다는 것은 여러 개의 프로세스를 동시에 실행할 수 있다는 것이다. 내가 블로그에 글을 쓰면서, 동시에 유튜브로 음악을 듣고, 인텔리제이를 실행할 수 있는 것은 모두 OS가 멀티태스킹을 지원하기 때문이다.

프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원(resources)과 쓰레드로 구성되어 있다. 프로세스의 자원을 이용해서 실제 작업을 수행하는 것이 바로 쓰레드이다.

하나의 프로세스는 하나 이상의 쓰레드를 가지며, 둘 이상의 쓰레드를 가진 프로세스를 '멀티쓰레드 프로세스(multi-threaded process)'라고 한다. 우리가 카카오톡이나 슬랙을 사용할 때, 상대가 전송한 파일을 다운로드하면서 동시에 채팅을 할 수 있는 것은 해당 프로그램이 멀티쓰레드로 작성되어 있기 때문이다.

맥에서 활성 상태 보기를 사용하면 현재 PC에서 실행 중인 프로세스와 쓰레드의 현황을 볼 수 있다.

멀티쓰레딩을 사용하면, CPU의 사용률이 향상되고, 자원을 보다 효율적으로 사용할 수 있으며, 사용자에 대한 응답성이 향상되고, 작업이 분리되어 코드가 간결해진다. 단 멀티쓰레딩으로 일어날 수 있는 문제들(동기화, 교착상태 등)을 잘 고려하여 신중히 프로그래밍 했을 때, 이런 장점들을 누릴 수 있다.

쓰레드의 구현과 실행

자바에서는 쓰레드를 관리하기 위한 메서드와 변수들을 java.lang.Thread 클래스에서 제공하고 있다.

쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법Runnable 인터페이스를 구현하는 방법 두 가지가 있다. Thread를 상속받으면, 다른 클래스를 상속받을 수 없기 때문에 인터페이스를 구현하는 방법이 일반적이다.

Thread 클래스를 상속받으면 run() 메서드를 오버라이딩해 수행할 작업을 작성할 수 있다.

 

Runnable은 오로지 run() 메서드만 구현되어 있는 함수형 인터페이스이다.

쓰레드를 구현한다는 것은 위 둘 중 어떤 방법이든 하나 선택해서, run 메서드의 몸통을 채우는 것, 즉 실행할 코드를 적는다는 것이다.

아래 예제는 두 가지 방법을 이용해서 쓰레드를 구현하고 동작시키는 예제이다.

public class ThreadDemo {

    public static void main(String[] args) {
        // 상속으로 구현
        ThreadByInheritance thread1 = new ThreadByInheritance();

        //인터페이스로 구현
        Runnable r = new ThreadByImplement();
        Thread thread2 = new Thread(r);    //생성자: Thread(Runnable target)
        // 아래 코드로 축약 가능
        // Thread thread2 = new Thread(new ThreadByImplement());

        thread1.start();
        thread2.start();
    }
}

class ThreadByInheritance extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 500; i++) {
            System.out.print(0);
        }
    }
}

class ThreadByImplement implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 500; i++) {
            System.out.print(1);
        }
    }
}

실행 결과

11111111111111111111100001000001111111111111000010000000000000000000000111100000111
00101111111111111000000000000000000000001111111100000000000000000000000000000011111
1111111111111110000111111111111111...

우선 Thread 클래스를 상속받아 구현하는 경우에는 해당 객체를 생성하고 start()로 실행하면 된다.

Runnable을 구현한 경우에는 Thread 객체의 생성자로 해당 인스턴스를 넘겨주면 된다.

thread1은 0을 500번 출력하고, thread2는 1을 500번 출력하도록 구현했다. 결과를 보면 0과 1이 뒤섞여있는 것을 확인할 수 있다. 각 쓰레드가 번갈아가면서 수행된 것이다.

쓰레드는 OS의 스케쥴링에 따라 작업 시간을 할당받고 다른 쓰레드와 번갈아가면서 작업을 수행한다. 아주 빠른 속도로 작업을 번갈아가면서 수행하기 때문에 마치 동시에 실행되는 것 같은 효과(Concurrent)를 볼 수 있는 것이다. (사실 이게 정확한 표현은 아니다, 서로 다른 자원을 사용하는 작업이라면 동시(Simultaneous)에 수행될 수 있다. 깊게 들어가려면 너무 복잡해지기 때문에 이 정도로 이해했다.)

start()와 run()

쓰레드를 실행하기 위해서는 start 메서드를 통해 해당 쓰레드를 호출해야 한다. start 메서드는 쓰레드가 작업을 실행할 호출 스택을 만들고 그 안에 run 메서드를 올려주는 역할을 한다.

위 예제에서 start를 호출하지 않고 run을 호출하면, 새로운 호출 스택이 생성되지 않기 때문에, 그냥 한 메서드 안에서 코드를 실행하는 것과 같다.

thread1.run();
thread2.run();

실행결과

00000000000...0001111111111...111

한번 사용한 쓰레드는 재사용할 수 없다. start()를 호출해서 쓰레드를 한 번 실행했다면, 해당 쓰레드를 다시 실행하기 위해서는 쓰레드를 다시 생성해서 start()를 호출해야 한다. 생성은 한번하고 start를 두 번 호출하면 IllegalThreadStateException이 발생한다.

Main 쓰레드

위 예시의 그림을 보면 알겠지만, main 메서드도 하나의 쓰레드이다. 이를 메인 쓰레드(Main Thread)라고 한다. 메인 쓰레드는 프로그램이 시작하면 가장 먼저 실행되는 쓰레드이며, 모든 쓰레드는 메인 쓰레드로부터 생성된다. 다른 쓰레드를 생성해서 실행하지 않으면, 메인 메서드, 즉 메인 쓰레드가 종료되는 순간 프로그램도 종료된다. 하지만 여러 쓰레드를 실행하면, 메인 쓰레드가 종료되어도 다른 쓰레드가 작업을 마칠 때까지 프로그램이 종료되지 않는다. 쓰레드는 '사용자 쓰레드(user thread)'와 '데몬 쓰레드(daemon thread)'로 구분되는데,실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램이 종료된다.

출처: https://www.geeksforgeeks.org/main-thread-java/?ref=lbp

public class ThreadDemo {

    public static void main(String[] args) {
        Thread t1 = Thread.currentThread();
        System.out.println("currentThread = " + t1);

        Thread t2 = new Thread(new ThreadEx_1());
        System.out.println("newThread = " + t2);
    }

}

class ThreadEx_1 implements Runnable {

    @Override
    public void run() {}
}

실행 결과

currentThread = Thread[main,5,main]
newThread = Thread[Thread-0,5,main]

currentThread 메서드는 현재 실행 중인 쓰레드의 참조를 반환하는 스태틱 메서드이다.

Thread 클래스의 toString 메서드는 다음과 같이 구현되어 있다.

    public String toString() {
        ThreadGroup group = getThreadGroup();
        if (group != null) {
            return "Thread[" + getName() + "," + getPriority() + "," +
                           group.getName() + "]";
        } else {
            return "Thread[" + getName() + "," + getPriority() + "," +
                            "" + "]";
        }
    }

대괄호 안에 첫 번째가 쓰레드 이름이고 두 번째는 해당 쓰레드의 우선순위, 세 번째가 쓰레드가 속한 쓰레드 그룹의 이름이다.

메인 메서드에서 현재 쓰레드를 참조하면 main 쓰레드가 반환되는 것을 확인할 수 있다.

쓰레드 그룹

서로 관련된 쓰레드는 쓰레드 그룹으로 묶어서 관리할 수 있다. 쓰레드 그룹은 다른 쓰레드 그룹을 포함시킬 수 있다. 마치 디렉토리 안에 하위 디렉토리를 둘 수 있는 것과 비슷하다. 쓰레드 그룹은 보안상의 이유로 도입된 개념으로, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드는 변경할 수 없다.

모든 쓰레드는 반드시 하나의 쓰레드 그룹에 속하며, 쓰레드 생성 시 쓰레드 그룹을 지정해주지 않으면 자동적으로 main 쓰레드 그룹에 속하게 된다. 왜냐하면 쓰레드는 자신을 생성한 쓰레드(부모 쓰레드)의 그룹과 우선순위를 상속받기 때문이다.

쓰레드를 그룹에 포함시키려면 Thread의 생성자를 이용해야 한다.

Thread(ThreadGroup group, String name)
Thread(ThreadGroup group, Runnable target)
Thread(ThreadGroup group, Runnable target, String name)
Thread(ThreadGroup group, Runnable target, String name, long stackSize)

쓰레드는 쓰레드 그룹으로 관리되며 쓰레드 그룹에 대한 일괄적인 작업 처리가 가능하다는 것 정도만 알고 넘어가면 될듯 하다.

데몬 쓰레드(Daemod Thread)

쓰레드의 종류는 일반 쓰레드와 데몬 쓰레드로 나뉜다. 데몬 쓰레드는 일반 쓰레드의 보조 역할을 수행하는 쓰레드이다. 일반 쓰레드가 종료되면 데몬 쓰레드는 강제적으로 종료된다. 주로 가비지 컬렉터, (워드 등의) 자동저장, 화면 자동갱신 등에 사용된다.

데몬 쓰레드는 일반 쓰레드가 종료되면 같이 종료되기 때문에 일반적으로 무한 루프로 구현한다. 데몬 쓰레드는 일반 쓰레드와 작성방법과 실행 방법이 같다. 단 쓰레드를 생성한 다음 setDaemon(true)를 호출하기만 하면 된다. 또 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.

boolean isDaemon()   // 쓰레드가 데몬 쓰레드인지 아닌지를 반환한다.
void setDaemon(boolean on)  // 쓰레드를 데몬 쓰레드 혹은 사용자 쓰레드로 변경한다.

쓰레드의 상태

멀티쓰레드 프로그래밍을 잘하기 위해서는 정교한 스케줄링을 통해 자원과 시간을 여러 쓰레드가 낭비 없이 잘 사용하도록 해야 한다. 이를 위해서는 쓰레드의 상태와 관련 메서드를 잘 알아야 한다.

먼저 쓰레드에는 5 가지 상태가 있다.

상태 설명
NEW 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE 실행 중 또는 실행 가능한 상태
BLOCKED 동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
WAITING, TIMED_WAITING 쓰레드의 작업이 종료되지는 않았지만 실행 가능하지 않은 일시정지 상태, TIMED_WATING은 일시정지 시간이 지정된 경우를 말함
TERMINATED 쓰레드의 작업이 종료된 상태

JDK 1.5부터 getState() 메서드를 통해 쓰레드의 상태를 확인할 수 있다.

쓰레드의 상태는 메서드를 통해 제어할 수 있다. 일반적으로 start()를 통해 쓰레드를 실행 가능한 상태로 만들면 run 메서드에 의해 코드가 실행되고 모든 작업을 마치면 TERMINATED 상태가 되지만, 메서드를 이용해 쓰레드를 정지시키거나 다시 실행시킬 수 있다.

메서드 설명
static void sleep(long millis)
static void sleep(long millis, int nanos)
지정된 시간(밀리세컨드, or 나노세컨드)동안 쓰레드를 일시정지시키다. 지정한 시간이 지나고 나면, 자동적으로 다시 실행대기 상태가 된다.
void join()
void join(long millis)
void join(long millis, int nanos)
지정된 시간동안 쓰레드가 실행되도록 한다. join()을 호출한 쓰레드는 그동안 일시정지 상태가 된다. 지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.
void interrupt() sleep()이나 join()에 의해 일시정지 상태인 쓰레드를 깨워서 실행대기 상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지 상태를 벗어나게 된다.
void stop() 쓰레드를 즉시 종료시킨다.
void suspend() 쓰레드를 일시정지시킨다. resume()을 호출하면 다시 실행대기 상태가 된다.
void resume() suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기 상태로 만든다.
static void yield() 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기 상태가 된다.

resume(), stop(), suspend()는 쓰레드를 교착상태(dead-lock)로 만들기 쉽기 때문에 deprecated되었다.

프로세스 상태 전이도

I/O Blocking

사용자 입력을 받을 때는 사용자 입력이 들어오기 전까지 해당 쓰레드가 일시정지 상태가 된다. 이를 I/O 블로킹이라고 한다.

한 쓰레드 내에서 사용자 입력을 받는 작업과 이와 관련 없는 작업 두 가지 코드를 작성하면, 사용자 입력을 기다리는 동안 다른 작업 또한 중지되기 때문에 CPU의 사용 효율이 떨어진다.

이 경우 사용자 입력을 받는 쓰레드와, 이와 관련 없는 다른 작업을 하는 쓰레드를 분리해주면 더욱 효율적으로 CPU를 사용할 수 있다.

싱글 쓰레드인 경우

import javax.swing.JOptionPane;

public class ThreadDemo {

    public static void main(String[] args) {
        // 사용자입력
        String input = JOptionPane.showInputDialog("아무 값이나 입력하세요");
        System.out.println("입력 값은 " + input + " 입니다.");

        // 카운트다운
        for (int i = 10; i > 0; i--) {
            System.out.println(i);

            try {
                Thread.sleep(1000);
            } catch (Exception e) { }
        }

    }
}

 

 

실행 결과

입력 값은 asdfqw 입니다.
10
9
8
7
6
5
4
3
2
1

 

멀티 쓰레드인 경우

import javax.swing.JOptionPane;

public class ThreadDemo {

    public static void main(String[] args) {
        Thread t = new Thread(new MyThread());
        t.start();

        String input = JOptionPane.showInputDialog("아무 값이나 입력하세요");
        System.out.println("입력 값은 " + input + " 입니다.");

    }

}

class MyThread implements Runnable {

    @Override
    public void run() {
        for (int i = 10; i > 0; i--) {
            System.out.println(i);

            try {
                Thread.sleep(1000);
            } catch (Exception e) { }
        }
    }
}

 

 

실행 결과

10
9
8
7
6
5
4
3
입력 값은 asdf 입니다.
2
1

카운트 다운을 실행하는 쓰레드를 먼저 실행했기 때문에 메인 쓰레드에서 사용자 입력을 받더라도, 카운트다운 작업이 계속 가능하다.

쓰레드 상태 제어 메서드

sleep(long millis)

sleep 메서드는 지정된 시간 동안 쓰레드를 멈추게 한다. 사용할 때 주의점은 이 메서드가 static 메서드라는 것, 그리고 사용할 때 반드시 try-catch 블록으로 예외처리를 해주어야 한다는 것이다.

public class ThreadDemo {

    public static void main(String[] args) {
        Thread t1 = new Thread(new ThreadEx_1());
        Thread t2 = new Thread(new ThreadEx_2());

        t1.start();
        t2.start();

        try {
            t1.sleep(2000);          // t1을 참조해서 sleep을 호출하고 있음
        } catch (InterruptedException e) { }

        System.out.print("<메인 쓰레드 종료>");
    }

}

class ThreadEx_1 implements Runnable {

    @Override
    public void run() {
        System.out.print("<t1 시작>");
        for (int i = 0; i < 100; i++) {
            System.out.print("-");
        }
        System.out.print("<t1 종료>");
    }
}

class ThreadEx_2 implements Runnable {

    @Override
    public void run() {
        System.out.print("<t2 시작>");
        for (int i = 0; i < 100; i++) {
            System.out.print("|");
        }
        System.out.print("<t2 종료>");

    }
}

해당 코드를 실행하면, t1 쓰레드가 2초 동안 일시정지가 되니까 t1의 작업이 가장 늦게 끝날까? 위 코드를 실행해보면 메인 쓰레드의 작업이 가장 늦게 종료된다.

실행 결과

<t1 시작>-----------------------------------------------------------------
-------------------<t2 시작>||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||----------------<t1 종료>||||||||||||||||||||||||
|||||<t2 종료><메인 쓰레드 종료>

sleep 메서드는 무조건 해당 메서드를 호출한 쓰레드의 실행을 정지시킨다. 따라서 위의 코드에서 t1.sleep(2000)은 다음과 같이 변경해야 한다.

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

InterruptedException

sleep, join 등으로 정지상태가 된 쓰레드는 interrupt 메서드가 호출되면 InterruptedException이 터지면서 깨어나게 된다. InterruptedException은 쓰레드를 깨우기 위한 수단이 되는 예외이기 때문에 catch 블록에서 따로 예외 처리 작업을 하지 않아도 된다.

interrupt()

interrupt 메서드는 쓰레드를 안전하게 종료시킬 때 사용한다. 쓰레드를 종료시키는 메서드로 stop 메서드가 있지만 불안정성 때문에 deprecated 되었다. 따라서 쓰레드를 실행 도중 종료시키고 싶다면 interrupt 메서드를 사용해야 한다.

void interrupt()                //  쓰레드의 interrupted 상태를 false에서 true로 바꾼다
boolean isInterrupted()         //  쓰레드의 interrupted 상태를 반환한다
static boolean interrupted()    //  쓰레드의 interrupted 상태를 반환하고, false로 초기화한다

Thread 클래스에서는boolean interrupted라는 필드가 있다. 이 변수를 이용해서 쓰레드의 상태를 제어하는데, interrupt 메서드를 사용하면 단지 이 변수의 상태를 바꿀 뿐, 쓰레드의 상태를 TERMINATED로 만드는 것은 아니다.

public class ThreadDemo {

    public static void main(String[] args) {
        Thread t1 = new Thread(new ThreadEx_1());

        t1.start();
        System.out.print("isInterrupted = " + t1.isInterrupted());

        t1.interrupt();
        System.out.print("isInterrupted = " + t1.isInterrupted());
    }

}

class ThreadEx_1 implements Runnable {

    @Override
    public void run() {
        System.out.print("<t1 시작>");
        for (int i = 0; i < 1000; i++) {
            System.out.print("-");
        }
        System.out.print("<t1 종료>");
    }
}

실행 결과

<t1 시작>-------------------------------------------------------------------
---------------------------------------------------------------------------
---------------------------------------------------------------------------
......
---------------------------------------------------------------------------
-------------isInterrupted = false-----------------------------------------
---------------------------------------------------------------------------
---------------------------------------------------------------------------
-isInterrupted = true------------------------------------------------------
---------------------------------------------------------------------------
-----------------------------------------------------------------<t1 종료>

이렇게 interrupted의 상태를 변경할 뿐 쓰레드를 종료시키지는 않는다.

interrupt 메서드를 이용해 쓰레드를 종료시키려면 다음과 같은 방법을 사용한다.

게임이 종료되면 10초 동안 게임을 다시 진행할지 묻고, 사용자의 응답이 입력되면 카운트다운을 종료하는 예제이다.

public class ThreadDemo {

    public static void main(String[] args) {
        ThreadEx_1 t1 = new ThreadEx_1();

        t1.start();

        String input = JOptionPane.showInputDialog("게임을 다시 진행하시겠습니까? [Y/N]");
        System.out.println(input);
        t1.interrupt();
    }

}

class ThreadEx_1 extends Thread {

    @Override
    public void run() {
        int i = 10;

        while (i != 0 && !isInterrupted()) {
            System.out.println(i--);
            for (long x = 0; x < 2500000000L; x++);   // 시간지연용
        }
        System.out.println("카운트다운 종료");

    }
}

 

실행 결과

10
9
8
7
6
5
4
3
y
카운트다운 종료

사용자 입력이 들어오는 순간 interrupt 메서드가 호출되고 ! isInterrupted()가 false가 되면서 쓰레드가 종료된다.

위 예제에서는 의미없는 반복문을 통해서 시간을 지연시켰는데, 정확히 1초 간격으로 카운트다운하려면 위 while문을 이렇게 변경해야 한다.

while (i != 0 && !isInterrupted()) {
    try {
        System.out.println(i--);
        Thread.sleep(1000);
    } catch (InterruptedException e) {}
}

하지만 이렇게 변경하면 사용자 입력이 들어오더라도 카운트다운이 멈추지 않는다.

왜냐하면 sleep 메서드로 인해서 쓰레드가 잠들어있는 동안 interrupt가 호출되면 InterruptedException이 발생하면서 interrupted 변수의 상태가 false로 다시 초기화되기 때문이다. 따라서 다음과 같이 interrupt()를 한번 더 호출해 interrupted 변수를 다시 true로 바꾸는 작업을 해줘야한다.

while (i != 0 && !isInterrupted()) {
    try {
        System.out.println(i--);
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        interrupt();
    }
}

suspend(), resume(), stop()

stop은 쓰레드를 완전히 종료시키는 메서드이다. suspend는 쓰레드를 일시정지 상태로 만들고 resume은 정지상태의 쓰레드를 다시 실행 대기 상태로 만드는 메서드이다. 이 세 개의 메서드는 교착상태(deadlock)를 일으킬 가능성이 있어서 deprecated되었다. 따라서 사용하지 않는 것이 권장된다.

다만 이런 형태로 구현해서 사용할 수는 있다.

class ThreadEx_1 extends Thread {

    @Override
    public void run() {
        int i = 10;

        while (i != 0 && !isInterrupted()) {
            try {
                System.out.println(i--);
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                interrupt();
            }
        }
        System.out.println("카운트다운 종료");

    }
}

class ThreadEx_2 implements Runnable {

    boolean suspended = false;
    boolean stopped = false;

    @Override
    public void run() {
        while (!stopped) {
            if (!suspended) {
                //수행할 코드
            }
        }
    }

    public void suspend() {
        suspended = true;
    }

    public void resume() {
        suspended = false;
    }

    public void stop() {
        stopped = true;
    }
}

join()

join 메서드는 일정 시간 동안 특정 쓰레드가 작업하는 것을 기다리게 만드는 메서드이다. sleep과 마찬가지로 try-catch 블록으로 예외처리를 해야 한다.

void join()                          // 작업이 모두 끝날 때까지
void join(long millis)               // 밀리세컨드동안
void join(long millis, int nanos)    // 밀리세컨드 + 나노세컨드동안
public class ThreadDemo {

    public static void main(String[] args) {
        Thread t1 = new Thread(new ThreadEx_1());
        Thread t2 = new Thread(new ThreadEx_2());

        long startTime = System.currentTimeMillis();

        t1.start();
        t2.start();

        try {
            t1.join();    // t1의 작업이 끝날 때까지 기다린다.
            t2.join();    // t2의 작업이 끝날 때까지 기다린다.

        } catch (InterruptedException e) {}

        System.out.println("소요시간 : " + (System.currentTimeMillis() - startTime));
        // 메인쓰레드가 종료된다.
    }

}

class ThreadEx_1 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 500; i++) {
            System.out.print("-");
        }

    }
}

class ThreadEx_2 implements Runnable {

    boolean suspended = false;
    boolean stopped = false;

    @Override
    public void run() {
        for (int i = 0; i < 500; i++) {
            System.out.print("|");
        }
    }
}

yield()

yield 메서드는 자신에게 할당된 시간이 남았더라도 다음 쓰레드에게 작업을 넘기도록 하는 메서드이다. static 메서드이므로 자기 자신에게만 사용할 수 있다. 쓰레드가 busy-waiting 상태(작업할 내용이 없는데 작업 시간이 할당되어 쓰레드가 돌아가는 상태) 일 때 yield를 호출하도록 설계하면 프로그램의 응답성과 효율을 높일 수 있다.

쓰레드의 우선순위

쓰레드는 우선순위(priority)라는 멤버 변수를 갖고 있다. 각 쓰레드별로 우선순위를 다르게 설정해줌으로써 어떤 쓰레드에 더 많은 작업 시간을 부여할 것인가를 설정해줄 수 있다. 우선순위는 1 ~ 10 사이의 값을 지정해줄 수 있으며 기본값으로 5가 설정되어 있다.

쓰레드의 우선순위 지정하기

Thread 클래스에서 우선순위와 관련된 메서드와 상수는 다음과 같다.

public class Thread implements Runnable {

    void setPriority(int newPriority)  // 쓰레드의 우선순위를 지정한 값으로 변경한다.

    int getPriority()                  // 쓰레드의 우선순위를 반환한다.


    public static final int MIN_PRIORITY = 1;   // 최소 우선순위

    public static final int NORM_PRIORITY = 5;  // 보통 우선순위

    public static final int MAX_PRIORITY = 10;  // 최대 우선순위

}

setPriority 메서드는 쓰레드를 실행하기 전에만 호출할 수 있다. 쓰레드의 우선 순위를 높이면 더 많은 실행 시간과 실행 기회를 부여받을 수 있다. 그런데 주의할 점은 이것이 반드시 보장되는 것이 아니라는 것이다. 쓰레드의 작업 할당은 OS의 스케쥴링 정책과 JVM의 구현에 따라 다르기 때문에 코드에서 우선순위를 지정하는 것은 단지 희망사항을 전달하는 것일 뿐, 실제 작업은 내가 설정한 우선 순위와 다르게 진행될 수 있다.

동기화(Synchronization)

synchronized

멀티 쓰레드 프로세스에서는 여러 프로세스가 메모리를 공유하기 때문에, 한 쓰레드가 작업하던 부분을 다른 쓰레드가 간섭하는 문제가 생길 수 있다. 어떤 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 하는 작업을 동기화라고 한다.

동기화를 하려면 다른 쓰레드가 간섭해서는 안 되는 부분을 임계 영역(critical section)으로 설정해 주어야 한다. 임계 영역 설정은 synchronized 키워드를 사용한다.

// 메서드 전체를 임계영역으로 설정
public synchronized void method1 () {
    ......
}

// 특정한 영역을 임계영역으로 설정
synchronized(객체의 참조변수) {
    ......
}

먼저 메서드의 반환 타입 앞에 synchronized 키워드를 붙여서 메서드 전체를 임계 영역으로 설정할 수 있다. 쓰레드는 synchronized 키워드가 붙은 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock(자물쇠)을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다.

두 번째로 메서드 내의 코드 일부를 블록으로 감싸고 블록 앞에 synchronized(참조 변수)를 붙이는 방법이 있다. 이때 참조 변수는 락을 걸고자 하는 객체를 참조하는 것이어야 한다. 이 영역으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고 블록을 벗어나면 lock을 반납한다.

lock

lock은 일종의 자물쇠 개념이다. 모든 객체는 lock을 하나씩 가지고 있다. 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다. 한 객체의 lock은 하나밖에 없기 때문에 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다.

임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 lock을 거는 것보다 synchronized 블록으로 임계 영역을 최소화하는 것이 좋다.

동기화하지 않아서 문제가 발생하는 경우

public class ThreadDemo {

    public static void main(String[] args) {
        Runnable r = new ThreadEx_1();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000; //잔고

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
        // 잔고가 출금액보다 클때만 출금을 실시하므로 잔고가 음수가 되는 일은 없어야함
        if (balance >= money) {
            try {
                // 문제 상황을 만들기 위해 고의로 쓰레드를 일시정지
                Thread.sleep(1000);
            } catch (InterruptedException e) {}

            balance -= money;
        }
    }
}

class ThreadEx_1 implements Runnable {

    Account account = new Account();

    @Override
    public void run() {
        while (account.getBalance() > 0) {
            // 100, 200, 300 중 임의의 값을 선택해서 출금
            int money = (int) (Math.random() * 3 + 1) * 100;
            account.withdraw(money);
            System.out.println("balance: " + account.getBalance());
        }
    }
}

실행 결과

balance: 800
balance: 800
balance: 700
balance: 700
balance: 500
balance: 600
balance: -100
balance: -100

분명 잔고는 음수가 되지 않도록 설계했는데 음수가 나왔다. 왜냐하면 쓰레드 하나가 if문을 통과하면서 balance를 검사하고 순서를 넘겼는데, 그 사이에 다른 쓰레드가 출금을 실시해서 실제 balance가 if문을 통과할 때 검사했던 balance보다 적어지게 된다. 하지만 이미 if문을 통과했기 때문에 출금은 이루어지게 되고 음수가 나오는 것이다. 이 문제를 해결하려면 출금하는 로직에 동기화를 해서, 한 쓰레드가 출금 로직을 실행하고 있으면 다른 쓰레드가 출금 블록에 들어오지 못하도록 막아줘야 한다.

public class ThreadDemo {

    public static void main(String[] args) {
        Runnable r = new ThreadEx_1();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000; //잔고

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
        synchronized (this) {
            if (balance >= money) {
                try {
                    // 문제 상황을 만들기 위해 고의로 쓰레드를 일시정지
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                }

                balance -= money;
            }
        }
    }
}

class ThreadEx_1 implements Runnable {

    Account account = new Account();

    @Override
    public void run() {
        while (account.getBalance() > 0) {
            // 100, 200, 300 중 임의의 값을 선택해서 출금
            int money = (int) (Math.random() * 3 + 1) * 100;
            account.withdraw(money);
            System.out.println("balance: " + account.getBalance());
        }
    }
}

위 코드는 실행해도 음수가 나오지 않는다.

DeadLock(교착상태)

데드락은 한 자원을 여러 시스템이 사용하려고 할 때 발생할 수 있다.

Process1과 Process2가 모두 자원 A, B가 필요한 상황이라고 가정하자. Process1은 A에 먼저 접근하고 Process2는 B에 먼저 접근했다. Process1과 Process2는 각각 A와 B의 lock을 가지고 있는 상태이다. 이제 Process1은 B에 접근하기 위해 B의 락이 풀리기를 대기한다. 동시에 Process2는 A에 접근하기 위해 A의 락이 풀리기를 대기한다. 서로 원하는 리소스가 상대방에게 할당되어 있기 때문에 두 프로세스는 무한히 대기 상태에 있게 되는데, 이를 데드락이라고 한다.

데드락은 한 시스템 내에서 다음의 네 가지 조건이 동시에 성립할 때 발생한다. 아래 네 가지 조건 중 하나라도 성립하지 않도록 만든다면 교착 상태를 해결할 수 있다.

  1. 상호 배제(Mutual exclusion)
    • 자원은 한 번에 한 프로세스만이 사용할 수 있어야 한다.
  2. 점유 대기(Hold and wait)
    • 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용하고 있는 자원을 추가로 점유하기 위해 대기하는 프로세스가 있어야 한다.
  3. 비선점(No preemption)
    • 다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼앗을 수 없어야 한다.
  4. 순환 대기(Circular wait)
    • 프로세스의 집합{P0, P1, ,…Pn}에서P0는P1이 점유한 자원을 대기하고P1은P2가 점유한 자원을 대기하고P2…Pn-1은Pn이 점유한 자원을 대기하며Pn은P0가 점유한 자원을 요구해야 한다.

(출처:https://jwprogramming.tistory.com/12[개발자를 꿈꾸는 프로그래머])

wait() & notify()

동기화를 하게 되면 하나의 작업을 하나의 쓰레드밖에 하지 못하기 때문에 작업 효율이 떨어질 수밖에 없다. 이때 동기화의 효율을 높이기 위해서 wait(), notify()를 이용한다.

메서드 설명
void wait()
void wait(long timeout)
void wait(long timeout, int nanos)
객체의 락을 풀고 쓰레드를 해당 객체의 waiting pool에 넣는다.
void notify() waiting pool에서 대기 중인 쓰레드 하나를 깨운다.
void notifyAll() waiting pool에서 대기 중인 모든 쓰레드를 깨운다.

wait과 notify는 Object 클래스에 정의되어 있으며, 동기화 블록 내에서만 사용할 수 있다. 동기화된 임계 코드 영역의 작업을 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출하여 쓰레드가 락을 반납하고 기다리게 한다. 그러면 다른 쓰레드가 락을 얻어서 해당 객체에 대한 작업을 수행할 수 있게 된다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 된다.

class Account {
    private int balance = 1000;

    public synchronized void withdraw(int money) {
        //잔고가 출금액보다 적어 인출을 할 수 없다.
        while (balance < money) {
            try {
                // 해당 객체의 락을 풀고 waiting pool에서 대기
                wait();
            } catch (InterruptedException e) {}
        }
        balance -= money;
    }

    public synchronized void deposit(int money) {
        // 돈을 입금하고 waiting pool의 쓰레드에 통보 
        balance += money;
        notify();
    }
}

만약 잔고가 모자라서 출금을 할 수 없는 경우, 다른 쓰레드가 입금을 할 수 있도록 객체에 대한 락을 풀고 waiting pool에서 기다린다. deposit을 수행하는 쓰레드는 해당 객체의 락을 얻어 잔고를 채우고 waiting pool에서 대기 중인 쓰레드에게 다시 작업을 수행하라고 통보한다. 대기하던 쓰레드는 다시 락을 얻어 인출 로직을 수행한다.

java.util.concurrent.locks

JDK 1.5부터 synchronized 외에 동기화를 구현할 수 있는 방법이 추가되었다. 'java.util.concurrent.locks' 패키지의 Lock 클래스들을 이용하는 방법이다. synchronized로 동기화를 하면 자동으로 락이 걸리고 풀리지만 같은 메서드 내에서만 lock을 걸 수 있다는 불편함이 있다. 그럴 때 lock 클래스를 이용한다. lock 클래스의 종류는 다음 세 가지가 있다.

ReentrantLock                //재진입이 가능한 lock, 가장 일반적인 배타 lock
ReentrantReadWriteLock       //읽기에는 공유적이고, 쓰기에는 배타적인 lock
StampedLock                   //ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가

기본적인 메서드

void lock()            //lock을 잠근다
void unlock()          //lock을 해지한다
boolean isLocked()     //lock이 잠겨있는지 확인한다

ReentrantLock은 가장 일반적인 락이다. '재진입할 수 있는'이라는 말이 붙는 이유는 wait & notify에서 살펴봤듯이 특정 조건에서 락을 풀었다가 나중에 다시 와서 락을 걸 수 있기 때문이다.

ReentrantReadWriteLock읽기를 위한 락(ReadLock)쓰기를 위한 락(WriteLock)을 제공한다(static class로 구현되어있음). ReentrantLock은 무조건 lock이 있어야만 임계 영역의 코드를 수행할 수 있지만, ReentrantReadWriteLock은 읽기 락이 걸려 있으면, 다른 쓰레드가 읽기 락을 중복해서 걸고 읽기를 수행할 수 있다. 그러나 읽기 락이 걸린 상태에서 쓰기 락을 거는 것은 허용되지 않는다. 반대의 경우도 마찬가지이다.

StampedLock은 락을 걸거나 해지할 때 '스탬프(long 타입의 정수 값)'를 사용하며, ReentrantReadWriteLock에 '낙관적 읽기 락(optimistic reading lock)'이 추가된 형태이다. 읽기 락이 걸려있으면 쓰기 락을 얻기 위해서는 읽기 락이 풀릴 때까지 기다려야 하는데 비해 낙관적 읽기 락은 쓰기 락에 의해 바로 풀린다. 코드로 살펴보면 다음과 같다.

StampedLock lock = new StampedLock();
......

int getBalance() {

    long stamp = lock.tryOptimisticRead();    //낙관적 읽기 락을 건다.

    int currentBalance = this.balance;     //공유 데이터인 balance를 읽어온다.

    if (!lock.validate(stamp)) {    //쓰기 락에 의해 낙관적 읽기 락이 풀렸는지 확인
        stamp = lock.readLock();    //락이 풀렸으면, 읽기 락을 얻으려고 기다린다.

        try {
            currentBalance = this.balance;    //공유 데이터를 다시 읽어온다.
        } finally {
            lock.unlockRead(stamp);           //읽기 락을 푼다.
        }
    }
    return currentBalance;    // 낙관적 읽기 락이 풀리지 않았으면 곧바로 읽어온 값을 반환 
}

이렇게 무조건 읽기 락을 거는 게 아니라 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 락을 건다.

추가적으로, 임계 영역 내에서 리턴을 할 경우 unlock이 되지 않기 때문에 lock 클래스를 사용하는 경우 try-finally문을 이용해서 코드 수행이 끝나면 무조건 unlock을 하도록 만들어준다.

Lock 클래스들은 생성자에 boolean을 추가해서 공정(fair) 처리를 해줄 수 있다.

ReentrantLock()
ReentrantLock(boolean fair)

생성자의 매개변수를 true로 주면 lock이 풀렸을 때 가장 오래 기다린 쓰레드가 락을 획득할 수 있게 공정하게 처리한다. 그러나 이 과정에서 어떤 쓰레드가 오래 기다렸는지 확인하는 과정을 거쳐야하므로 성능은 떨어진다.

Condition

synchronized로 동기화를 구현한 후 wait & notify를 사용하면, 해당 객체의 waiting pool에서 대기 중인 임의의 쓰레드 혹은 쓰레드 전체를 깨우는 방법밖에 없다. 그러나 ReentrantLock으로 동기화를 구현하고 Condition을 이용하면 내가 원하는 쓰레드를 깨울 수 있다.

예를 들어, 음식을 만드는 요리사(COOK) 쓰레드와 음식을 소비하는 손님(CUST) 쓰레드가 있고, 두 쓰레드가 음식이 올라가는 테이블(Table)을 공유한다고 할 때, 테이블에 음식을 추가하는 코드를 다음과 같이 구현할 수 있다.

...

public synchronized void add(String dish) {
    while(dishes.size() > MAX_FOOD) { // 테이블에 올라갈 수 있는 최대 음식 수
        try {
            wait();    //COOK 쓰레드를 기다리게 한다.
            Thread.sleep(500);
        } catch (InterruptedException e) {}
    }
    dishes.add(dish);
    notify();     // 기다리고 있는 CUSTOMER를 깨운다.
}

...

public static void main(String[] args) {
    Table table = new Table();

    new Thread(new Cook(table), "COOK1").start();
    new Thread(new Customer(table), "CUST1").start();
    new Thread(new Customer(table), "CUST2").start();
}

테이블 위에 음식이 최대 숫자로 올라가 있으면 더 이상 음식을 올릴 수 없으므로 wait을 호출해 COOK 쓰레드를 기다리게 한다. 또 테이블에 음식이 없으면 손님이 음식을 먹을 수 없으므로 CUST를 기다리게 한 후 음식을 채워 넣으면 CUST를 깨우도록 구현했다.

이렇게 하면 Table 객체의 waiting pool에는 요리사와 손님 모두 대기할 수 있기 때문에 notify 메서드 만으로는 내가 원하는 쓰레드(손님 or 요리사)를 선택해서 깨울 수 없다. 이때 ReentrantLock과 Condition을 이용하면 된다.

private ReentrantLock lock = new ReentrantLock();    //lock 생성
// 생성된 lock으로 condition 생성
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();

...

public void add(String dish) {
    lock.lock();

    try {
        while(dishes,size() > MAX_FOOD) {
            try {
                forCook.await();    // COOK 쓰레드를 기다리게한다.
            } catch (InterruptedException e) {}
        }

        dishes.add(dish);
        forCust.signal();    // 기다리고 있는 CUST를 깨운다.
    } finally {
        lock.unlock();
    }
}

이렇게 lock으로부터 newCondition을 생성하고, wait()과 notify() 대신 await()와 signal()을 이용하면 원하는 쓰레드를 기다리게 하고 깨울 수 있다.

자바 동시성 프로그래밍의 진화

멀티스레딩을 지원하는 API는 시간이 흐름에 따라 지속적으로 발전하였다. 본 포스팅에서 다룬 RunnableThread는 멀티스레딩을 지원하기 위해 가장 처음 만들어진 API이고 사용하기도 불편하기 때문에 지금은 거의 사용하지 않는다. 지금은 자바 5에 추가된 ExecutorServiceCallable 등을 사용한다.

자바가 발전하면서 추가된 동시성 관련 API를 살펴보면 대략 다음과 같다.

  • 처음
    • Runnable, Thread
  • Java 5
    • ExecutorService: 스레드 실행과 태스크 제출을 분리
    • Callable: Runnable의 발전된 형태. 제네릭 지원, 결과 리턴 가능, 예외 던지기 가능
    • Future: 비동기 결과값을 담기위한 객체
  • Java 7
    • java.util.concurrent.RecursiveTask 추가: 포크/조인 구현 지원
  • Java 8
    • CompletableFuture: Future를 조합하는 기능을 추가하면서 동시성 강화
    • Stream: 내부적으로 병렬처리 가능
  • Java 9
    • 리액티브 프로그래밍을 위한 API 지원: 발행-구독 프로토콜(java.util.concurrent.Flow) 등

기본적인 코드 사용은 다음과 같다.

public class Demo {
    public static void main(String[] args) {

        // 스레드 풀을 생성하고 태스크를 제출한다.
        ExecutorService es = Executors.newCachedThreadPool();
        Future<String> futureResult = es.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                //do some long computation
                System.out.println("do some long computation");

                return "result of long computation";
            }
        });

        //do something else
        System.out.println("do something");

        try {
            // 비동기 계산 결과를 가져온다. 결과가 준비되어있지 않으면 예외 발생. 단, 최대 1초 기다린다.
            String result = futureResult.get(1, TimeUnit.SECONDS);
        } catch (ExecutionException e) {
            // 계산 중 예외 발생
        } catch (InterruptedException e) {
            // 현재 스레드에서 인터럽트 발생
        } catch (TimeoutException e) {
            // Future가 완료되기 전에 타임아웃 발생
        }
    }
}

모던 자바 인 액션에 리액티브 프로그래밍에 대한 내용이 자세하게 나와있다. 읽고 추가적으로 포스팅해봐야겠다.

 

참고자료

자바의 정석(남궁성 저)
모던 자바 인 액션

728x90
반응형