Java/live-study

[Java]백기선 라이브 스터디 10주차 과제: 멀티쓰레드 프로그래밍

jay Joon 2021. 1. 20. 07:34

목표

자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.

학습할 것 (필수)

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

Thread 와 Process의 차이점 

 

프로세스와 쓰레드의 차이점은 무엇일까?

 

프로세스(process) :  실행 중인 프로그램 즉 OS 내에서 돌고 있는 프로그램을 의미한다.

 

쓰레드(Thread) : 프로세스 내에서 실제 작업을 수행

 

즉 프로세스는 하나 이상의 쓰레드를 가지고 있다고도 말할 수 있다.

 

그렇다면 왜 프로세스를 여러 개 즉 멀티 프로세스 프로그래밍이 아니라 멀티 쓰레드 프로그래밍을 할까?

 

그 이유는 한 프로세스 내의 자원은 다른 프로세스에서 접근할 수 없다. 또한 Context Switching 과정에 있어

많은 비용이 소모되기 때문에 멀티 프로세스 프로그래밍은 비용이 많이 든다고 생각할 수 있다.

 

process는 독립된  메모리의 영역을 할당받음으로 / 한 process 내의 자원을 다른 process 에서 참조할 수 없음

따라서 멀티쓰레드 프로그래밍을 할 시 Process 내에 있는 자원을 공유 할 수 있음으로

Context Switching 비용을 조금 더 절감할 수 있다.

 

하지만 멀티쓰레드 프로그래밍은 아까도 말했지만 Process 내 자원을 공유 함으로

자원의 동기화(Synchronization) 문제가 일어날 수 있음으로 프로그래밍 시 주의를 요한다.

 


Thread 클래스와 Runnable 인터페이스

 

쓰레드를 구현하는데 java에서는

  • Thread 클래스 를 상속
  • Runnable 인터페이스를 구현

위와 같은  2가지 방법이 있다.

 

Runnable

Runnable 인터페이스

Runnable 인터페이스를 구현 시에는 Run 메서드 안에 쓰레드가 할 코드를 작성해주면 된다.

 

public class MyThreadByRunnable implements Runnable{
    @Override
    public void run() {
        for (int i = 0; i <10; i++) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

 

 

Thread

 

Thread 클래스

 

Thread 클래스를 상속받을 시에는 Thread 클래스 내 Run 메서드를 재정의(Override) 하면 된다.

public class MyThreadByThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i <10; i++) {
            System.out.println(getName());
        }
    }
}

 

이 코드를 실행할 때도 차이점이 존재한다.

 

Thread를 상속받은 MyThreadByThread  클래스는 객체를 생성 후 start() 메소드를 통해 시작하면 된다.

public class MainSolution {
    public static void main(String[] args) {
        MyThreadByThread thread =new MyThreadByThread();
        thread.start();
    }
}

 

하지만

 

Runnable을 구현하고 있는 MyThreadByRunnable은  Thread 생성자에 파라미터로 넘기어 Thread 타입으로

생성 후 start() 메소드를 통해 시작하면 된다.

 

public class MainSolution {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new MyThreadByRunnable());
        thread1.start();
    }
}

 

여기서 start() 메서드는 새로운 call stack을 만드는 메서드이다. 흐름은 다음과 같다.

출처: 자바의 정석(남궁성)

 

 

따라서 두 코드를 같이 실행하면 쓰레드의 시작 순서는 상관없이 뒤죽박죽 섞여서 나올 것이다.

 

쓰레드의 실행 순서는 OS의 스케쥴러를 통해 결정되기 때문에 그렇다. 

 


Main 쓰레드

자바 애플리케이션의 시작점이라고 말할 수 있다.

 

Main 스레드는 프로그램이 시작될 때 실행을 시작하는 이고 자식 쓰레드 들은 Main으로부터 생성된다.

 

또한 Main 스레드가 종료되어도 자식 스레드가 종료되지 않으면 프로그램이 종료되지 않는다.

 

 public static void main(String[] args) {
      //code 
    }

 

 

데몬 쓰레드(daemon thread)

 

데몬 쓰레드는 일반 쓰레드를 보조하는 역할로써 일반 쓰레드와 동작 방식은 동일하지만

 

일반 쓰레드와의 가장 큰 차이점은

 

일반쓰레드가 종료된다면 데몬쓰레드의 작업이 남아있어도 강제 종료된다는 것이다.

 

코드를 보면 다음과 같다.

 

public class MainSolution {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        MyThreadByThread thread =new MyThreadByThread();
        thread.setDaemon(true);
        thread.start();
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

쓰레드 우선순위

 

쓰레드 우선순위 란?

 

특정 쓰레드를 조금 더 가중치를 주는 것 임으로

 

절대로 가중치가 높은 쓰레드의 작업이 먼저 실행이 완료된다고 생각하면 안된다.

 

단지 가중치를 줌으로써 확률상 먼저 작업이 끝날 수 있다고 생각하는것이 좋다.

 

 

java 에서 쓰레드 우선순위를 주는 방법은

 

setPriority(int newPriority)  통해 설정 할 수 있으며

 

여기서 newPriority 의 최대 값은 10  최소 값은 1 이다.

 

우선순위를 설정해주지 않으면 중간 값인 5 가 자동으로 셋팅 된다.

 


쓰레드 상태

docs.oracle.com/javase/8/docs/api/java/lang/Thread.State.html 

쓰레드의 상태의는 다음과 같이 정의 할 수 있다.

상태 열거 상수  설명 
객체 생성 NEW  스레드 객체가 생성. 아직 start() 메소드가 호출되지 않은 상태 
실행 대기  RUNNABLE 실행 상태로 언제든지 갈 수 있는 상태 
 
일시 정지 
 
WAITING  다른 스레드가 통지할 때까지 기다리는 상태
TIMED_WAITING  주어진 시간 동안 기다리는 상태 
BLOCKED   사용하고자 하는 객체의 락이 풀릴 때까지 기다리는 상태
종료  TERMINATED   실행을 마친 상태

 

위의 표중 일시 정지 의 경우를 살펴보자.

 

일시 정지 상태란 Thread 가 다음 작업을 기다리거나 어떠한 행위를 기달리고 있는 경우 라고 생각하면된다.

 

즉 코드로 보자면 다음과 같다.

public class MainSolution {
    public static void main(String[] args) {
        System.out.println("main 쓰레드 시작");

        MyThreadByThread myThreadByThread =new MyThreadByThread();
        Thread myThreadByRunnable = new Thread(new MyThreadByRunnable());
        
        myThreadByThread.start();
        myThreadByRunnable.start();
        
        String s = new Scanner(System.in).next(); // BLOCKED 상태 입력값을 대기하는중(I/O BLOCKING)
        System.out.println("입력값은 "+s+"입니다");
        
        try {
            myThreadByThread.join();   //WAITING  상태  Main 쓰레드가 myThreadByThread 작업이 종료될때까지 기달림
            myThreadByRunnable.join(); //WAITING  상태  Main 쓰레드가 myThreadByRunnable 작업이 종료될때까지 기달림
        } catch (InterruptedException e) {
            e.printStackTrace();
        }


        System.out.println("Main 쓰레드 :한숨 자고 종료하자..");
        try {
            Thread.sleep(1000); //TIMED_WAITING 상태 주어진 1초를 기달렸다가 종료됨
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main 쓰레드 종료");
    }
}

 

자 다음과 위 코드 처럼 쓰레드의 상태를 조작할 수 있는 method들이 존재한다. 

docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/Thread.html

method 설명
static void yield() 현재 스레드가 프로세서의 현재 사용을 양보 할 의사가 있다는 스케줄러에 대한 힌트입니다.
static void sleep​(long millis) 현재 실행중인 스레드가 지정된 밀리 초 동안 일시적으로 실행 중지 되도록합니다.
void join​(long millis) 이 스레드가 작업이 완료 될 때 까지 기다립니다
void interrupt() 이 스레드의 interrupted  상태를 true 로 변경합니다.

 

 


동기화(synchronization)

글 시작 부분에 이런 말을 했었다.

 

멀티쓰레드 프로그래밍을 할 시 Process 내에 있는 자원을 공유 할 수 있음으로 ...

 

 

따라서 한 쓰레드가 진행중인 작업을 다른 쓰레드 쪽에서 간섭하는 일이 발생 할 수 있다.(not thread-safe)

 

이러한 문제점을 해결 하기위해 동기화(synchroniztion)을 진행하면 된다.

 

동기화를 한다는 뜻은 단 하나의 쓰레드만 영역 내의 코드 수행할 수 있게 한다는 뜻이다.

 

 

동기화를 하는 방법은 2가지가 존재한다.

 

1. synchronized 키워드 사용

 

2. java.lang.Object 클래스의  wait(), notify(), notifyAll() 메소드 이용

 

 

1. synchronized 키워드 사용

 

동기화 블록(임의의 코드블록을 임계영역으로 지정)

synchronized(객체){
            //code
        }

해당 블록내의(임계영역) 객체에는 한 쓰레드에서만 접근이 가능함. 

 

 

동기화 메서드(메소드 전체를 임계영역으로 지정)

public static synchronized void method(){
   		//code
    }

해당 메서드에 쓰레드가 배정되면 작업이 다 완료 될때까지 다른 쓰레드에서는 접근이 불가능함

 

 

synchronized 사용 예제

 

Main

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

        SyncCount count = new SyncCount(); // 공유할 데이터 생성

        SyncExThread jaeJoon = new SyncExThread("JaeJoon", count); //jaejoon 쓰레드생성
        SyncExThread kim = new SyncExThread("KIM", count); //KIM 쓰레드 생성

        jaeJoon.start();
        kim.start();

    }
}

 

공유 객체

public class SyncCount {
    int sum = 0;
    
    
    void add(){ //add 메서드가 불릴때마다 sum 의 값은 +10 씩 됩니다.
        int n = sum;
        Thread.yield(); // 충돌을 유발하기위한 코드 없어도됩니다 
        n +=10; 
        sum =n;
        System.out.println(Thread.currentThread().getName()+":"+sum);
    }
    
    int getSum(){
        return sum;
    }
    
}

 

쓰레드

 

public class SyncExThread extends Thread {
    SyncCount syncCount;

    public SyncExThread(String name, SyncCount syncCount) {
        super(name);
        this.syncCount = syncCount;
    }

    @Override
    public void run() {
        int i= 0;
        while (i<100){
            syncCount.add();
            i++;
        }
    }
}

 

해당 코드를  총 100번 add() 메서드를 각각의 쓰레드에게 실행하라고 했으니

 

현재 총 2개의 쓰레드 이니 100 * 2 = 200   총 200번의 add() 메서드가 실행되야한다.

 

기대값 : 2000

결과값 : 1990(실행 할때마다 정상일 수도 있고 값의 오차가 존재함)

결과값은 1990

이 처럼 한 쓰레드가 계산을 하기전에 다른 쓰레드로 작업 스케쥴이 넘어가서 중복계산이 발생 할 수있다.

thread-safe 하지 않은 코드가 만들어 진것이다.

 

따라서 이 문제를 해결 하기위해서 위에서 배운 동기화 메서드(메소드 전체를 임계영역으로 지정) 사용하면 문제를 해결 할 수 있다.

 

공유객체(메소드 synchronized)

public class SyncCount {
    int sum = 0;
    
    
   synchronized void add(){ //add 메서드가 불릴때마다 sum 의 값은 +10 씩 됩니다.
        int n = sum;
        Thread.yield(); // 충돌을 유발하기위한 코드 없어도됩니다 
        n +=10; 
        sum =n;
        System.out.println(Thread.currentThread().getName()+":"+sum);
    }
    
    int getSum(){
        return sum;
    }
    
}

 

기대값: 2000

결과값: 2000

결과값은 2000

 

2. java.lang.Object 클래스의  wait(), notify(), notifyAll() 메소드 이용 

-> 임계블록 구역에서만 사용가능하니 주의

 

메소드명 설명
wait() 다른 스레드가 이객체의 notify() 를 불러줄 때까지 대기한다.
notify() 이 객체에 대기 중인 스레드를 깨워 RUNNABLE 상태로 변경 , 2개 이상의 스레드가 대기 중이라도 오직 한 개의 스레드만 깨워 RUNNABLE 상태로 변경
notifyAll() 이 객체에 대기 중인 모든 스레드를 깨우고 모두 RUNNABLE 상태로 변경

 

 

위의 synchronized 키워드 만 사용시 lock 을 한 쓰레드에서 차지 하기 때문에 효율이 매우 떨어진다.

 

그러한 문제점을 해결 하기 위해서 위 3가지의 메서드를 이용하여 코딩을 진행하면 된다.

 

wait 은 -> Lock 을 반환하고 waiting 구역에서 대기하게 된다.

 

notify 은 waiting 구역에 잠들어 있는 쓰레드중 무작위( 개발자가 컨트롤 할 수 없음) 를 깨워 쓰레드를 Runnable 상태로 변경한다.

 

norifyAll 은 waiting 구역에 있는 모든 쓰레드를 깨우고 RUNNABLE 상태로 변경 하지만 그렇다고 해서 synchronized 임계영역 안에 모든 쓰레드가 들어가는 것은아니고 어떠한 하나의  쓰레드가 Lock 얻게 된다.

 


데드락(Dead Lock)

 

두 개 이상의 작업이 서로 다른 자원을 점유 하고 있으며 상대방이 가진 자원을 서로 원하는경우

 

따라서 Lock 된 객체에 서로 접근 하지 못하여 프로그램이 무한정 대기하는 상태 말한다.

 

https://www.quest.com/community/blogs/b/database-management/posts/the-anatomy-of-sql-server-deadlocks-and-the-best-ways-to-avoid-them

 

위의 설명처럼 데드락이 발생하는 이유는 4가지의 조건이 동시에 만족해서 발생하는데 그조건은 다음과같다.

 

1. 상호배제(Mutual Exclusion)

    ->  이미 한 쓰레드가 점유하고 있는 자원에 대해서 다른 쓰레드에서 접근 불가능 해야함.

2. 점유와 대기 (Hold and Wait)

    -> 다른프로세스가 자원을 이용하고 있다면 그 프로세스가 끝날때까지 대기해야함

3. 비선점 (Non Preemptive)

    -> 다른 프로세스의 자원을 가져 올 수 없음

4. 환형대기(Circle wait)

    -> 두 개의 프로세스가 서로의 프로세스의 자원을 원하는 경우