멀티 스레드 프로그래밍

Tina Jeong·2021년 2월 24일
0

Re-자바

목록 보기
14/16

프로그램의 흐름, 스레드

'강은 바다로 통한다'는 말이 있다. 샘에서 시간된 물이 흐름이 시냇물이되고, 강이 되고, 바다로 흘러가는 자연의 규칙을 의미하는 말이다. 컴퓨터 프로그램도 프로그래머의 의도에 따른 흐름이 존재한다. 이를 스레드라고 한다.

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int a = Integer.parseInt(br.readLine());
        for(int i=0;i<a;i++) {
            System.out.println(i);
        }
    }

위 프로그램은 키보드 입력으로 숫자를 받아 해당 숫자 a에 이를때까지 숫자를 출력한다. 이와 같이 평소에 간단하게 작성하는 프로그램들은 대부분 싱글 스레드이다. 즉, 프로그램의 흐름이 하나만 있다. 프로그램의 독립적 흐름이 여러개 존재하면 이를 멀티 스레드라고 한다. 멀티 스레드는 프로세스 내부에서 여러 개의 스레드가 존재할 수 있으며, 각각의 stack을 가지기 때문에 동시에 프로그램의 독립적 진행이 가능한 것을 의미한다.

동시에라는 단어를 사용했지만 실제로 '동시에' 돌아가는 것은 아니며, 아주 짧은 시간 단위로 실행되는 스레드가 달라지기switching 때문에 사람이 보기에 동시에 실행되는 것처럼 보일 뿐이다.

Concurrency vs Parallelism

특정 시간 지점에서는 독립된 하나의 스레드만 진행되지만, 짧은 시간 단위로 스레드 스위칭이 이루어지는 프로그램의 특성을 동시성Concurrency이라고 한다. 반면, 실제 물리적으로 별개의 코어에서 테스크 각각이 진행되는 것을 병렬성Parallelism이라고 한다. 그러므로 이번 포스팅은 동시성에 대한 이야기이다.


(t1,t2는 테스크)

메인 스레드

메인 스레드는 어플리케이션 스레드라고도 불리며, 일반적인 싱글 스레드 프로그램은 메인 스레드 하나만 존재한다. 자바 프로그램이 실행되면, 자바 플랫폼 내부적으로는 다음과 같은 일이 일어난다.

  1. JVM이 프로그램을 실행할 준비를 한다.
  2. 메인 함수가 있는 클래스가 클래스로딩이 완료되면 메인 스레드가 실행된다.
  3. 메인 스레드의 자바 interpreter가 메인함수가 있는 클래스::main()의 바이트 코드를 읽는다.

위의 과정을 재고하면, 자바 플랫폼은 스레드별로 interpreter가 있으며, JVM은 메인 스레드를 최우선적으로 실행하도록 구성되어 있다는 사실을 알 수 있다. 스레드 자세한 내용은 바로 아래에 언급하겠지만, 스레드는 일반적으로 start() 메소드를 호출해서 스레드를 시작시켜야 하는데 메인 함수는 그런 조작이 필요없는 이유이기도 하다.

Thread t = new Thread(() -> {System.out.println("Hello Thread");});
t.start();

java.lang.Thread

자바는 다중 스레드 프로그래밍을 지원한다. java.lang 패키지에 속해있으므로 import할 필요가 없다는 뜻이다. 다중 스레드는 Thread 클래스를 이용해 구현할 수 있다. 또는 run()메소드를 가진 Runnable 인터페이스를 상속받아 구현한다. Thread 클래스는 이미 Runnalbe의 자식 클래스이다.

스레드 상태

스레드의 상태와 라이프사이클을 요약하면 위의 그림과 같다.

  • NEW
    모든 스레드는 NEW 상태에서 시작하는데, 스레드는 생성되었지만 start() 함수가 아직 호출되지 않은 상태이다. 즉, 아직 해당 스레드의 작업이 시작되지 않은 상태이다.

  • RUNNABLE
    start() 함수가 호출되면 RUNNABLE 상태로 진입하는데, OS에게 해당 스레드의 관리 소관이 넘어가고, OS의 스레드가 생성된다.

  • RUNNING
    스레드별 RUNNING 타임 유닛을 지정하는 OS 스케줄러가 해당 스레드의 순서가 되면 해당 스레드는 RUNNING 상태가 되어 스레드가 진행된다. 코드 상에서는 run() 메소드의 내부로직이 실행된다. 즉, run() 메소드에 스레드 작업내용을 구현하면 된다. 위 코드에서는 람다식이 작업내용에 해당한다. 자세한 내용은 아래에서 정리한다.

  • WAITING

  1. Object.wait()
    해당 스레드가 깨워질 때까지 기다리는 메소드이다.
  2. Thread.join(ms)
    해당 스레드가 종료되기를 기다리는 메소드이다. join()을 호출하고 난 다음 코드에서는 반드시 해당 스레드가 종료되어 있다는 보장이 있기 때문에, join()은 꼭 써주는 것이 좋다.
  • TIMED_WAITING
  1. Thread.sleep(ms)
    함수에 ms 단위의 숫자를 기입해 스레드가 해당 시간동안 진행을 중단하도록 한다. sleep()하는 동안 lock 점유를 잃어버리지 않는다.
  2. Object.wait(ms)
    함수에 ms 단위의 숫자를 기입해 스레드가 해당 시간동안 기다리고, 대기 시간이 지나면 RUNNABLE 상태로 전환된다.
  3. Thread.join(ms)
    ms의 시간 동안 해당 스레드가 죽기를 기다린다. 그러나, 정확히 ms의 기입된 수치만큼 기다리진 않으며, 해당 시간이 지나도 스레드가 살아있을 수도 있다. 제어권이 os에게 있기 때문이다.
  • BLOCKED
    스레드가 동기화된 코드 블록이 존재하는 경우, monitor lock을 기다리는 상태이다.

  • TERMINATED
    스레드의 작업이 완료되었거나 비정상적인 상황 등으로 다른 스레드가 해당 스레드를 강제 종료한 상태이다.

📌 JDK에 RUNNING이라는 스테이트는 없다. RUNNABLE 상태가 돌 준비가 되었거나 돌고 있는 상태 둘다를 의미하는데, 보다 명확한 상태의 구분을 위해 RUNNING 상태를 기입하였다.

Thread의 메소드

deprecated된 메소드나 Effective Java에서 알맞게 사용하는 것이 거의 불가능하다고 언급된 Thread.stop()은 정리하지 않았다.

  • long getId()
    스레드에게 부여되는 long 타입의 유니크한 아이디이다. 주의할 점은, 실행되는 동안 unique하므로 해당 스레드가 종료되고 난 후에는 다른 스레드가 재활용할 수 있다.

  • State getState()
    위에서 언급한 NEW,RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED 상태 중 하나를 리턴해준다. State 타입으로 반환해준다.

  • boolean isAlive()
    해당 스레드의 종료 여부를 알 수 있는 메소드이다.

  • void start()
    스레드를 실행하는 메소드이다.

  • void join()
    해당 스레드가 종료되기를 기다리는 메소드이다.

  • void setDeamon(boolean true)
    데몬 스레드는 데몬 스레드가 아닌 유저 스레드의 작업을 서포트하기 위해 사용된다. 스레드를 생성하고 setDeamon(true)로 데몬 스레드로 설정하고, isDaemon()으로 데몬 스레드 여부를 확인할 수 있다. 데몬 스레드는 유저 스레드에 종속적인 스레드이기 때문에 유저 스레드가 종료되면 데몬 스레드도 종료된다. 문서 자동 저장, 자동 새로고침 등의 활용이 있을 수 있다.

➕ 데몬 스레드는 무한 루프를 돌면서 대기하다가 조건이 만족되면 작업을 수행하도록 하는 방식으로 구현한다. 코드는 다음과 같은 형태가 된다.

public class 유저스레드 extends Thread {
...
Thread t = new Thread();
t.setDeamon(true);
t.start();
...
while(true) {
	if(조건) { // 작업 수행 }
}
...
@Override 
public void run() {
...
}

스레드 우선순위

스레드 우선순위 값은 1~10 사이의 숫자이며, 우선순위가 높을 수록 숫자가 커진다. 스레드 내부적으로는 3개의 상수가 존재한다. MIN_PRIORITY는 1이고, default 우선순위인 NORM_PRIORITY는 5, MAX_PRIORITY는 10이다. void setPriority(int newPriority)를 통해 스레드별로 우선순위를 부여할 수 있다. 스레드가 RUNNABLE 상태이기 전에, 즉 start() 메소드를 호출하기 전에 우선순위를 부여해야 한다.

동기화 synchronization

멀티 스레드 환경에서 각 스레드는 독립된 진행과 결과값을 가진다고 언급하였다. 그러나, 여러 스레드가 하나의 데이터를 공유하는 경우, 즉 스레드 간 shared data가 존재할 경우에 동기화 문제가 중요해진다.

동기화 이슈

아주 현실적인 예를 들어 보자. 승객 두명이 동일한 자리 번호 3번을 예매하는 상황을 가정하였다. 승객A가 3번을 예매했다가 취소하면, 승객B가 취소된 자리를 예매한다.

위 내용을 코드로 구현해보자. 좌석을 다음과 같이 구현하였다. Seat 클래스는 예매 여부와 좌석 숫자를 멤버 변수로 가지면, cancel()reserve() 메소드에서 shared data 부분에 접근한다. 예약되지 않은 좌석이라면 reserve()에서는 해당 좌석 번호가 예매되었다는 메시지를, cancel()에서는 좌석을 취소할 수 없다는 메시지를 출력한다. 이미 예약이 완료된 좌석이라면 reserve()에서는 해당 좌석 번호는 이미 예매되었다는 메시지를, cancel()에서는 좌석 예매 취소했다는 메시지를 출력한다.

public class Seat{
    boolean isBooked = false;
    int seatNum =-1;

    public Seat(int seatNum) {
        this.seatNum = seatNum;
    }

    public Seat(boolean isBooked, int seatNum) {
        this.isBooked = isBooked;
        this.seatNum = seatNum;
    }

    public void cancel(String name) {
        if(isBooked) {
            this.isBooked = false;
            System.out.println("\tcanceled by "+name);
        }
        else {
            System.out.println("\tunreserved seat by"+name);
        }
    }
    public void reserve(String name) {
        if(!isBooked) {
            this.isBooked = true;
            System.out.println("\treserved by "+name);
        }
        else {
            System.out.println("\talready booked by "+name);
        }
    }
}

이 때, 어떤 특정 좌석을 승객이 예매 중이라면 다른 승객은 해당 기차표를 예매할 수 없어야 한다. 즉, 기차표가 shared data가 되고, 해당 데이터를 수정하는 동안에는 다른 승객이 해당 데이터를 수정할 수 없어야 한다.

public class PassengerA extends Thread{
    Seat seat;
    String name;
    PassengerA(Seat seat) {
        this.seat= seat;
    }

    PassengerA(Seat seat, String name) {
        this.seat= seat;
        this.name= name;
    }

    @Override
    public void run() {
        seat.reserve(this.getClass().getSimpleName());
        seat.cancel(this.getClass().getSimpleName());
    }
}
public class PassengerB extends Thread {
    Seat seat;
    String name;

    PassengerB(Seat seat) {
        this.seat = seat;
    }

    PassengerB(Seat seat, String name) {
        this.seat = seat;
        this.name = name;
    }

    @Override
    public void run() {
        seat.reserve(this.getClass().getSimpleName());
    }
}

그 다음 메인 함수에서 스레드를 시작시킨다.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Seat seat_3 = new Seat(3);
        Seat seat_3 = new Seat(3);

        PassengerA passengerA = new PassengerA(seat_3, "passengerA");
        PassengerB passengerB = new PassengerB(seat_3, "passengerB");
        passengerA.start();
        passengerB.start();

    }
}

그렇다면, 우리가 의도하는 결과는 다음과 같다. A가 예매했다가 취소하고, B가 그 자리를 받아 예매하는 흐름이다.

reserved by PassengerA
canceled by PassengerA
reserved by PassengerB

그러나, 프로그램을 실행하면 다음과 같은 결과가 나온다. 아직 A는 예매하지도 않았는데 예매가 되어버렸고, 이미 예매가 된 그 자리를 B가 예매를 한데다, 예매하지도 않은 A가 예매를 취소하는 혼란스러운 상황이 된다. 심지어 실행할 때마다 결과가 다르게 나온다.

already booked by PassengerA
reserved by PassengerB
canceled by PassengerA

임계 구역 critical section

위와 같은 문제를 critical section problem이라고 한다. 멀티 스레드 환경에서 shared data에 접근하는 코드의 부분을 critical section이라고 한다. 위의 예시에서는 다음의 부분이 -임계구역을 줄여서-C.S가 된다.

public void cancel(String name) {
//---------------C.S----------------
        if(isBooked) {	
            this.isBooked = false;
            System.out.println("\tcanceled by "+name);
        }
        else {
            System.out.println("\tunreserved seat by "+name);
        }
//---------------C.S----------------
}
public void reserve(String name) {
 //---------------C.S----------------
        if(!isBooked) {      
            this.isBooked = true;      
            System.out.println("\treserved by "+name);
        }
        else {
            System.out.println("\talready booked by "+name);
        }
//---------------C.S----------------
}

상호 배타 mutual exclusion

C.S를 접근하면서 발생하는 문제를 해결하기 위해서는 어떻게 해야 할까? 가장 대표적인 해결 방법이 mutual exclusion을 보장하는 방법이다. 즉, 하나의 스레드가 공유 자원에 접근하는 중일 때는 다른 스레드들이 접근하지 못하도록 만든다.

join

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Seat seat_3 = new Seat(3);

        PassengerA passengerA = new PassengerA(seat_3, "passengerA");
        PassengerB passengerB = new PassengerB(seat_3, "passengerA");
        passengerA.start();
        passengerA.join();
        passengerB.start();
        passengerB.join();
    }
}

synchronized

C.S에 해당하는 부분을 synchronized 키워드로 묶어주면, 자바의 lock을 이용해 작업이 진행될 동안 데이터 점유권을 해당 스레드에게만 주고, 작업이 완료되면 풀어주는 처리가 자동으로 된다.

    public synchronized void cancel(String name) {
        if(isBooked) {
            this.isBooked = false;
            System.out.println("\tcanceled by "+name);
        }
        else {
            System.out.println("\tunreserved seat by "+name);
        }
    }
    public synchronized void reserve(String name) {
        if(!isBooked) {
            this.isBooked = true;
            System.out.println("\treserved by "+name);
        }
        else {
            System.out.println("\talready booked by "+name);
        }
    }

교착상태 deadlock

교착상태는 말 그대로 이러지도 저러지도 못하는 상태이다. 어떤 경우에 발생할까?

식사하는 철학자 문제


쉽게 이해하기 위해 유명한 비유를 가져왔다. 철학자들이 젓가락하나를 사이에 두고 둥그런 상에 둘러않아 두 가지 행동을 한다. 열심히 사고思考하다가, 배고파지면 오른쪽에 있는 젓가락을 먼저 들고, 왼쪽 젓가락을 들어 식사를 한다. 그런데 만약 모든 철학자들이 배가 고파서 오른쪽 젓가락을 동시에 들었다면 철학자들은 영원히 식사를 할수 없게 된다.starvation

이 상황을 4가지 조건으로 요약할 수 있는데, 이 4가지 조건을 모두 만족해야 교착상태가 발생하게 된다.

데드락 필요조건
1. mutual exclusion : 스레드끼리 자원을 공유할 수 없게 함
2. hold and wait : 자원 하나를 hold한채로 다른 자원을 기다림
3. no preemption : 자원을 뺏어오지 않음
4. circular wait : 환형방향으로 자원을 기다리는 형태

필요조건이므로, 교착상태를 깨트리기 위해선 위 4개 중에 하나만 깨면 된다. 상황에 따라 장단점을 잘 따져서 선택한다.

  1. mutual exclusion -> 스레드끼리 자원을 공유할 수 있도록 한다.
  2. hold and wait -> 자원 하나를 hold했는데 또 다른 자원을 가져올 수 없다면 hold한 자원을 일단 놓아준다.
  3. no preemption -> 자원을 뺏어온다.
  4. circular wait -> 순서를 정해 순서대로 자원을 사용하도록 한다.

참고
Java in a Nutsell, 7th edition
Effective Java, 3rd edition
http://www.kocw.net/home/cview.do?lid=3a233aba10131fd5
http://www.kocw.net/home/cview.do?lid=431a50dd2ae2f276
https://www.tutorialspoint.com/operating_system/os_multi_threading.htm
https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/lang/Thread.html
https://www.baeldung.com/java-thread-join
https://stackoverflow.com/questions/28378592/java-thread-state-transition-waiting-to-blocked-or-runnable
https://devbox.tistory.com/entry/Java-%EB%8D%B0%EB%AA%AC%EC%93%B0%EB%A0%88%EB%93%9C

계속해서 문서를 업데이트하고 있습니다. 언제든지 댓글피드백 남겨주세요. 😉

profile
Keep exploring, 계속 탐색하세요.

0개의 댓글