10주차 항해일지 - Multi Thread Programming

김종하·2021년 1월 21일
0

Live Study - WHITESHIP

목록 보기
11/14
post-thumbnail

학습사항

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

Thread 클래스와 Runnable 인터페이스

Thread

싱글쓰레드 프로그램과 멀티쓰레드 프로그램

쓰레드는 프로세스 내에서 실행되는 흐름의 단위를 말한다.
다음 코드를 살펴보도록 하자.

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

        System.out.println(" 에인 스레드 실행흐름 ");

        for(int i = 0 ; i < 3; i++){
            System.out.println("Hello Thread");
            switch (i){
                case 1 :
                    System.out.println("Second loop");
                    break;
            }
        }

        int a = 1;

        if( a != 1 ){
            System.out.println("Skip");
        } else {
            System.out.println("Do");
        }
    }
}
---------------------- console
메인 스레드 실행흐름 
Hello Thread
Hello Thread
Second loop
Hello Thread
Do     

기본적으로 CPU 는 코드의 명령어를 위에서 아래로 한줄 한줄 실행하게 된다.
앞서서 배웠던 반복문과 switch-case, if 문을 사용해 진행순서를 바꿀 순 있다. 하지만 코드의 진행은 하나의 흐름 아래에서 진행되게 된다. 그리고 이렇게 하나의 흐름으로만 이루어진 프로그램을 싱글쓰레드 프로그램( SingleThread Program ) 이라한다.

하지만, 하나의 흐름으로만으로는 만들 수 없는 것들이 있다. 동시작업이 필요한 것들이 바로 그런 부류의 프로그램인데 예를들어 실시간 채팅을 생각해면, 최소한 상대방이 치는 채팅을 계속 트래킹하며 내 화면에 보여야함과 동시에 내가 채팅을 칠 수 있어야한다. 이러한 동작을 싱글쓰레드 프로그램으로는 구현할 수 없다.

  • 상대방의 채팅을 지속적으로 트래킹하며, 채팅을 치면 화면에 띄어 줄 흐름.
  • 내가 친 채팅을 상대방에게 보낼 흐름.

최소한 다음과 같은 두 개의 흐름이 필요할 것이다.
그리고 이렇게 두 개 이상의 흐름으로 이루어진 프로그램을 멀티쓰레드 프로그램( MultiThread Program ) 이라한다.

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

멀티쓰레드와 멀티프로세스라는 개념은 모두 동시작업 ( MultiTasking ) 을 위해 생겨난 개념이다.

멜론으로 노래를 들으며 크롬 웹브라우져를 실행해 웹서핑을 하면서, 카카오톡 채팅을 하는 것은 멀티프로세싱이라고 할 수 있다.
멜론, 크롬, 카카오톡 이라는 프로세스가 메인메모리에 별도의 공간으로 적재되며
CPU 는 세가지 프로세스를 빠른 속도로 번갈아 수행하며 동시에 동작하는 것 처럼 보이게 하는 것이다.
그리고 각각의 프로그램 안에서 필요에 따라 여러 쓰레드가 동작하고 있을 것이다.
앞서 말했듯 카카오톡이란 프로세스 안에는 상대방이 치는 채팅을 처리하는 쓰레드와 내가 치는 채팅을 처리하는 쓰레드가 있을 것이다.

<<참고>> 서블릿과 CGI

자바는 쓰레드라는 개념을 제공한다. 쓰레드 라는 개념을 잘 활용한 대표적인 API로 서블릿이 있다. 웹 서버는 동시에 들어오는 요청들을 처리하기 위해, 요청마다 요청을 수행할 흐름이 필요하다. 서블릿이 등장하기 이전의 CGI 스팩은 이를 위해 요청이 들어올 때 마다 프로세스를 만들어 처리하도록 하였다. 하지만 서블릿은 쓰레드를 활용해 요청마다 쓰레드를 생성해 요청을 처리함으로써 자원을 훨씬 효율적으로 사용할 수 있게 되었다.

Thread 만들기

지금까지 멀티쓰레드에 대해 대략적으로 알아봤으니, 직접 사용해보도록 하자.
자바에서 쓰레드를 구현하는 방법은 Thread 클래스 를 상속하여 사용하거나 Runnable 인터페이스 를 구현해 사용할 수 있다.

Thread 클래스

Thread 클래스를 상속하는 클래스를 생성하여 쓰레드를 구현할 수 있다.

1초마다 1씩 증가하는 정수를 출력하는 쓰레드를 구현해보도록 하겠다.

public class NumberThread extends Thread{
    @Override
    public void run() {
        for (int i = 0 ; i < 10; i++){
            System.out.println(Thread.currentThread().getName() + "  :  " +  i);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

쓰레드가 수행할 흐름을 java.lang.Thread 의 run() 메소드 안에 구현해두면 된다.

Runnable 인터페이스

Runnable 인터페이스를 구현하여 쓰레드를 만들 수도 있다.

1초마다 알파벳을 오름차순으로 출력하는 쓰레드를 구현해보도록 하겠다.

public class AlphabetThread implements Runnable {
    @Override
    public void run() {
        for(char i = 'A'; i <= 'Z'; i++){
            System.out.println(Thread.currentThread().getName() + " : " + i);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

참고로 java.lang.Thread 는 Runnable을 인터페이스를 구현한 객체이다

두 쓰레드를 사용하여 숫자와 알파벳을 동시에 찍는 프로그램을 만들어보도록 하겠다

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

        System.out.println(Thread.currentThread().getName() + " start ");

        Thread digitThread = new NumberThread();
        Thread alphaThread = new Thread(new AlphabetThread());

        digitThread.start();
        alphaThread.start();


        System.out.println(Thread.currentThread().getName() + " end ");
    }
}
-------------console
ain start 
main end 
Thread-1 : A
Thread-0  :  0
Thread-0  :  1
Thread-1 : B
Thread-1 : C
Thread-0  :  2
.
.
.

여기서 사용된 중요한 두가지 메소드는 쓰레드의 동작을 구현한 run() 과 쓰레드를 시작시킨 start() 메소드이다.

오라클 문서에서 두 메소드를 찾아보면 다음과 같이 설명되어있다.

void start()
Causes this thread to begin execution; the Java Virtual Machine calls the run method of this thread.

void run()
If this thread was constructed using a separate Runnable run object, then that Runnable object's run method is called; otherwise, this method does nothing and returns.

이를 바탕으로 위에서 작성한 코드를 생각해보면
digitThread.start();
alphaThread.start();
숫자를 찍어주는 digitThread 와 알파벳을 찍어주는 alphaThread 를 실행시켰다.
쓰레드가 동작되기 시작하면 JVM 은 해당 쓰레드의 run() 메소드를 호출해 run()내에 구현되어있는 작업을 수행하게 되는 것이다.

만약, start()를 하지않고 run()을 하면 단순히 run() 메소드를 호출하는 꼴이 되어 동시작업이 되지않고 순차적으로 작업을 처리하게된다.
start()를 함으로써 쓰레드를 생성하고. 각 쓰레드는 각각의 메소드 호출스택을 가지게됨으로 작업을 병렬적으로 처리할 수 있게 되는 것이다.

쓰레드의 상태(State)

쓰레드는 6가지의 상태로 구분할 수 있다.
java.lang.Thread 코드를 보면 실제로 enum 으로 쓰레드가 가질 수 있는 상태들을 구분해 놓은 모습을 확인할 수 있다.



(Thread State Diagram 사진출처 Naver D2)

  • NEW
    쓰레드 인스턴스가 생성되었지만 아직 start 되지 않은 상태이다.
public static void main(String[] args) {
        Thread numberThread = new NumberThread();
        System.out.println(numberThread.getState()); // -> NEW
    }
  • RUNNABLE
    쓰레드를 start 하면 쓰레드는 Runnable 상태가 된다. 이 상태에서는 JVM 이 쓰레드를 동작하게 할 수 있다. 하지만 Runnable 상태라고 해서 쓰레드가 동작중이라는 뜻은 아니며 단어그대로 동작가능한 상태라는 뜻이다.
    CPU 는 한번에 하나의 인스터럭션만을 수행함으로, 10개의 쓰레드가 Runnable 상태라도 하나의 쓰레드만 동작중이고 나머지는 Runnable 상태이지만 실행대기큐 안에서 실행을 기다리는 상태이다.
numberThread.start();
System.out.println(numberThread.getState()); // -> RUNNABLE
  • BLOCKED
    Monitor lock을 획득하기 위해 다른 스레드가 락을 해제하기를 기다리는 상태이다.
    동시에 여러 쓰레드가 한 자원에 접근하면, 문제가 일어날 수 있는 경우가 존재한다. 이 경우에 synchronized 키워드를 활용하여 Critical Section(임계영역) 을 만들어 해당 영역은 한번에 하나의 쓰레드만 접근 가능하도록 만드는데
    이 때, Monitor lock 을 가지고있는 쓰레드만 해당 영역에 접근이 가능하다.

  • WAITING
    말 그대로 대기상태에 들어간 쓰레드를 의미한다. Object.wait(), Thread.join(), LockSupport.park() 메소드를 사용하여 쓰레드를 WAITING 상태로 만들 수 있으며 WAITING 상태에 들어간 쓰레드는 다른 쓰레드가 특정 동작을 하기를 기다리게 됩니다. 다른 쓰레드에서 Object.notify(), Object.notifyAll() 를 호출하면 대기상태가 풀리게 된다. join()의 경우 특정쓰레드가 terminate 되어야 대기상태가 풀리게 된다.

public class JoinTester {
    public static void main(String[] args) throws InterruptedException {
        Thread movie = new Thread(new MovieClient());
        System.out.println("영화 상영 버튼 누르기");
        movie.start();
        movie.join(); // main Thread 는 movie Thread 가 종료될 때가지 WAITING
        System.out.println("영화 종료 버튼 누르기");
    }
}

class MovieClient implements Runnable{
    @Override
    public void run() {
        watchingMovie();
    }

    public void watchingMovie(){
        System.out.println("영화시청 시작");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("영화시청 종료");
    }
}
------------------console
영화 상영 버튼 누르기
영화시청 시작
영화시청 종료
영화 종료 버튼 누르기
  • TIMED_WAITING
    WAITING 과 유사하지만, 정해진 시간동안 대기상태로 들어가는 것을 의미한다.
    Thread.sleep(),Object.wait(long timeoutMillis), Object.join(long millis), LockSupport.parkNanos(Object blocker, long nanos), LockSupport.parkUntil(Object blocker, long deadline) 메소드를 활용해 쓰레드를 TIMED_WAITING 상태로 만들 수 있다.

  • TERMINATED
    쓰레드가 동작을 완료한 상태이다.

쓰레드의 우선순위

다중작업을 동시에 처리하기 위해서 멀티쓰레드 방식을 활용한다고 하였다.
이 때, 동시작업은 두 가지 형태로 이루어질 수 있다.
싱글코어에 멀티 스레드를 번갈아가며 실행시키는 방법과
멀티코어(여러개의 CPU) 에 개별 스레드를 동시에 실행시키는 방법이 있을 수 있다.
전자를 두고 동시성 ( Concurrency ), 후자를 병렬성( Parallelism )이라고 한다.

병렬적으로 처리하는 작업은 그렇다치더라도, 동시성을 사용하려면 코어가 어떤 작업을 더 우선시 할지에 대해 생각해볼 필요가 있다.

쓰레드 우선순위

쓰레드에 우선순위를 설정해, 우선순위가 높은 쓰레드의 작업을 더 많이 처리하도록 한다.
Thread.setPriority() 를 통해 설정할 수 있으며,
가장 높은 우선순위인 10 부터 가장 낮은 우선순위인 1까지 설정할 수 있다.

public class PriorityThreadTester {

    private static void task(){
        for (int i = 0; i < 5; i++) {

            System.out.println(Thread.currentThread().getName() + " 우선순위  : " + Thread.currentThread().getPriority());
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
           task();
        });

        Thread thread2 = new Thread(()->{
           task();
        });
        
        thread1.setPriority(Thread.MAX_PRIORITY); // 우선순위를 높게 설정
        thread2.setPriority(Thread.MIN_PRIORITY); // 우선순위를 낮게 설정 
        thread2.start();
        thread1.start();
    }
}
---------------console
첫번째 수행결과 
Thread-0 우선순위  : 10
Thread-0 우선순위  : 10
Thread-0 우선순위  : 10
Thread-0 우선순위  : 10
Thread-0 우선순위  : 10
Thread-1 우선순위  : 1
Thread-1 우선순위  : 1
Thread-1 우선순위  : 1
Thread-1 우선순위  : 1
Thread-1 우선순위  : 1
두번째 수행결과
Thread-0 우선순위  : 10
Thread-1 우선순위  : 1
Thread-0 우선순위  : 10
Thread-1 우선순위  : 1
Thread-0 우선순위  : 10
Thread-1 우선순위  : 1
Thread-0 우선순위  : 10
Thread-1 우선순위  : 1
Thread-1 우선순위  : 1
Thread-0 우선순위  : 10

하지만, 우선순위를 설정해두어도 반드시 높은 우선순위의 쓰레드작업이 먼저 실행되는 것을 보장하진 않는다. 위 코드를 돌리다 보면 어떤 경우에는 우선순위를 낮게 정한 스레드의 작업이 먼저 다 수행되는 경우도 있었다. 생각해보면 우선순위대로만 작업이 수행되면 동시작업이 불가능하니 당연한 것이기도 하다.
기본적으로 쓰레드 스케쥴링은 OS 에서 이루어지기 때문에, 우선순위를 이렇게 정하는 것은 특정 경합 상황에서 실행큐에 먼저 넣어주기를 기대하는 정도의 수준이라고 보여진다.

Main 쓰레드

모든 작업에는 시작점이 있을 것이다. Java 프로그램을 작성하면
메인메소드를 실행시키면 프로세스가 시작되는 것을 알 수 있다. 바로 이때 생성되는 최초의 쓰레드가 Main 쓰레드이다. 다른 모든 쓰레드들은 이 Main 쓰레드의 작업안에서 생겨난다. 물론, 별도의 호출스택을 가지므로 생성된 이후에는 Main 쓰레드와 동등한 레벨의 쓰레드가 되고, 메인 쓰레드가 종료된다고 해서 다른 쓰레드들 역시 종료되는 것은 아니다. 하지만, 특정 쓰레드에서 생성된 쓰레드의 라이프 사이클을 특정 쓰레드 안으로 만들 수도 있는데 데몬 쓰레드를 활용하면 그러한 작업이 가능하다.

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

        Thread demonThread = new Thread(() -> {
            for(int i = 0 ; i < 5; i++){
                System.out.println("데몬 쓰레드 ");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        demonThread.setDaemon(true);
        demonThread.start();

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("메인쓰레드 종료");
        
    }
}
----------- console 
데몬 쓰레드 
메인쓰레드 종료

위코드에서 setDaemon을 false 로 한다면 "데몬 쓰레드" 는 5번이 찍힐 것이다. 하지만 해당 쓰레드를 데몬 쓰레드로 설정했기 때문에 메인이 작업을 마치게 되면 데몬 쓰레드는 작업을 다 하지 않았어도 자동으로 종료되게 된다.

동기화

앞서서, 쓰레드의 6가지 상태에 대해 알아보면서 잠시 나왔던 Synchronized 키워드가 있었다. 해당 키워드가 바로 동기화를 위해 사용한 키워드인데,
특정 자원에 동시 다발적으로 여러 쓰레드가 접근하여 자원을 사용하는 경우 간섭으로인해 원치 않는 상황이 발생할 수 있다. 이를 막기 위해 필요에 동기화 작업은 반드시 해주어야 한다.

public class OarContainer {
    private int numberOfOar = 1;

    public  int getOar() {
        if (numberOfOar >= 0 ){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            numberOfOar -= 1;
        }
        return numberOfOar+1;
    }

    public void returnOar(){
        numberOfOar += 1;
    }
}


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

        OarContainer oarContainer = new OarContainer();

        for(int i = 0 ; i < 10; i ++ ){
            Thread sailor = new Thread(new Runnable() {
                @Override
                public void run() {
                    if(oarContainer.getOar() == 1){
                        System.out.println("노로 노를 젓는다.");
                        oarContainer.returnOar();
                    } else {
                        System.out.println("노도 없는데 손으로 노를 젓는다.. ㅠㅠ");
                    }
                }
            });
            sailor.start();
        }
    }
}
--------- console
노도 없는데 손으로 노를 젓는다.. ㅠㅠ
노도 없는데 손으로 노를 젓는다.. ㅠㅠ
노도 없는데 손으로 노를 젓는다.. ㅠㅠ
노로 노를 젓는다.
노로 노를 젓는다.
노도 없는데 손으로 노를 젓는다.. ㅠㅠ
노도 없는데 손으로 노를 젓는다.. ㅠㅠ
노도 없는데 손으로 노를 젓는다.. ㅠㅠ
노로 노를 젓는다.
노도 없는데 손으로 노를 젓는다.. ㅠㅠ

다음의 코드는 하나의 노를 선원들이 번갈아 가지며 노를 젓기를 기대하는 코드를 작성하였다. 하지만 동기화 작업을 하지 않으면 노가 없음에도 손으로 노짓을 하는 불상사가 발생하게 된다..
이러한 불상사를 막기 위해 동기화 작업을 해보도록 하자

public class OarContainer {
    private int numberOfOar = 1;

    public synchronized int getOar() { // 동기화 처리
        if (numberOfOar >= 0 ){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            numberOfOar -= 1;
        }
        return numberOfOar+1;
    }

    public void returnOar(){
        numberOfOar += 1;
    }
}

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

        OarContainer oarContainer = new OarContainer();

        for(int i = 0 ; i < 10; i ++ ){
            Thread sailor = new Thread(new Runnable() {
                @Override
                public void run() {
                    if(oarContainer.getOar() == 1){
                        System.out.println("노로 노를 젓는다.");
                        oarContainer.returnOar();
                    } else {
                        System.out.println("노도 없는데 손으로 노를 젓는다.. ㅠㅠ");
                    }
                }
            });
            sailor.start();
        }
    }
}
--------- console
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.
노로 노를 젓는다.

다행스럽게도 이제 모든 선원이 노를 가지고 노를 저을 수 있게 되었다.
이처럼 필요에 따라 여러 쓰레드가 하나의 자원 (객체) 에 동시에 접근하는 일을 막아야 할 필요가 있다.

교착상태 (DeadLock)

데드락은, 쓰레드가 자원을 공유하는 환경에서 발생할 수 있는 특별한 상황인데 다음의 이미지를 보면 이해가 쉬울 것이다.

다음의 이미지를 보면 A 는 1을 점유한상태에서 다음 작업을 위해 2를 필요로 하고 B 는 2를 점유한 상태에서 다음 작업을 위해 1을 필요로 하는 상황이다.
이 경우에 두 쓰레드가 양보를 하지 않으면 더 이상 작업의 진행이 불가능한 상태이고 이러한 상태를 바로 'DeadLock - 교착상태' 라고 한다.

이러한 교착상태가 발생하는데 있어서는 4가지 조건이 겹쳐서 발생하게 된다.

  • 상호배제 : 한 자원에 대한 여러 프로세스의 동시 접근이 안된다는 조건
  • 점유와 대기 : 자원을 점유한 상태에서 다른 쓰레드가 사용하고 있는 자원의 반납을 기다리고 있다는 조건
  • 비선점 : 다른 쓰레드의 자원을 강제로 가져올 수 없다는 조건
  • 환형대기 : 각 쓰레드가 순환적으로 다음 쓰레드가 요구하는 자원을 가지고 있다는 조건

스터디 깃헙주소 : https://github.com/whiteship/live-study/issues/10

예제코드 깃헙레포 : https://github.com/JadenKim940105/whiteship-study/tree/master/src/main/java/weeks10

0개의 댓글