본문 바로가기
JAVA

[Java] 멀티쓰레드

by 햄과함께 2021. 1. 23.
320x100

프로세스(Process), 스레드(Thread)

동작중인 프로그램을 프로세스(Process)라 한다.

스레드(Thread)는 프로세스의 작업 단위이다.

하나의 프로세스는 복수개의 스레드를 가질 수 있으며 이 스레드들은 자원을 공유한다.

Thread 클래스

// ThreadImpl.java

public class ThreadImpl extends Thread {

    private static int threadCnt = 1;

    private int cnt;

    public ThreadImpl() {
        super();
        cnt = threadCnt++;
    }

    @Override
    public void run() {
        System.out.println(cnt + " run");

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
        }
        System.out.println(cnt + " end");
    }
}
// ThreadImplTest.java
import org.junit.jupiter.api.Test;

import java.util.ArrayList;

class ThreadImplTest {

    @Test
    public void test() {
        try {
            ArrayList<ThreadImpl> threads = new ArrayList();
            for (int i = 0; i < 10; i++) {
                threads.add(new ThreadImpl());
            }
            for (var thread : threads) {
                thread.start();
            }
            System.out.println("End");
            
            // main 함수에서는 쓰레드가 종료될 때까지 main 함수가 종료되지 않기때문에 sleep할 필요 없다.
            Thread.sleep(1000);
        } catch (Exception e) {
        }
    }
}

테스트코드에서 다른 스레드들이 종료될때까지 테스트 스레드를 종료시키지 않게 하기 위해 sleep을 걸어뒀다.

하지만 이를 만일 main 함수에서 실행한다면 main 함수는 다른 스레드가 종료되기 전까지 종료되지 않으므로 sleep을 할 필요가 없다.

이는 메인 스레드에서 좀 더 상세히 다루겠다.

 

출력

3 run
1 run
2 run
4 run
5 run
6 run
7 run
8 run
9 run
End
10 run
3 end
8 end
5 end
6 end
7 end
2 end
1 end
4 end
9 end
10 end

 

start 한 순서대로 run 함수가 실행되지 않으며 종료또한 먼저 실행된 쓰레드가 먼저 종료됨을 보장하지 않는다.

쓰레드가 run 하기 전에 해당 쓰레드를 호출한 함수가 종료된다.

특정 쓰레드의 동작이 끝난 뒤 이후 사후처리할 로직을 구현하였다면 쓰레드 동작이 끝나기도 전에 사후처리 로직이 실행되어 옳지 못한 처리가 될 것이다.


join()

이를 방지하기 위해서는 Thread 클래스에 구현되어 있는 join 함수를 사용한다.

join 함수에서는 특정시간동안 주기적으로 쓰레드의 상태를 확인하며 활성화 중인 상태이면 wait를 걸어준다.

따라서 해당 쓰레드가 종료될때까지 다음 로직으로 넘어가지 않는다.


// ThreadImplTest.java
class ThreadImplTest {

    @Test
    public void test() {
        try {
            // ...
            for (var thread : threads) {
                thread.start();
                // start 직후 join
                thread.join();
            }
            System.out.println("End");
        } catch (Exception e) {
        }
    }
}

 

각 쓰레드를 start한 직후 join. 

하나의 쓰레드가 실행된 이후 해당 쓰레드가 종료되기 전까지 다른 쓰레드가 실행되지 않으므로 절차적으로 수행된다.

 

// 출력
1 run
1 end
2 run
2 end
3 run
3 end
4 run
4 end
5 run
5 end
6 run
6 end
7 run
7 end
8 run
8 end
9 run
9 end
10 run
10 end
End

import org.junit.jupiter.api.Test;

import java.util.ArrayList;

class ThreadImplTest {

    @Test
    public void test() {
        try {
            // ...
            for (var thread : threads) {
                thread.start();
            }
            for (var thread : threads) {
                thread.join();
            }
            System.out.println("End");
        } catch (Exception e) {
        }
    }
}

모든 쓰레드들을 start한 이후 모든 쓰레드들을 join.

모든 쓰레드들이 실행된 이후 모든 쓰레드들이 종료되기 전까지 break 된다.

병렬적으로 처리된다.

 

// 출력 
5 run
2 run
7 run
4 run
9 run
3 run
8 run
10 run
1 run
6 run
5 end
2 end
10 end
8 end
3 end
4 end
9 end
7 end
6 end
1 end
End

 

Runnable 인터페이스의 구현체이다.

 

Runnable 인터페이스

void run();

 

위 함수 하나만을 가지는 인터페이스이다.

해당 인터페이스를 상속받은 구현체 객체에서 스레드 생성 시, 생성된 스레드에서 run 메서드를 호출한다.

 


Thread의 상태

Thread.State enum

NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
TERMINATED

 

 

enum state description
NEW 객체  생성 스레드 생성. start 전 상태
RUNNABLE 실행 CPU를 점유하고 작업을 수행 중인 상태
BLOCKED 일시정지 사용하고자 하는 자원의 LOCK 해제를 기다리는 상태
WAITING 일시정지 다른 스레드의 액션 실행을 대기.
TIMED_WAITING 일시정지 WAITING + 특정 시간 동안 대기. WAITING 상태에서 해제되는 조건이 아니더라도 특정 시간이 지나면 정지 상태가 해제된다.
TERMINATED 종료 실행 종료

 


Thread의 우선순위

멀티 Thread는 동시성(Concurrency), 병렬성(Parallelism)으로 동작한다.

동시성은 싱글 코어에서 스레드들이 번갈아가면서 실행되서 여러 작업이 동시에 실행되는 것처럼 보이게 한다.

병렬성은 멀티 코어에서 스레드들이 각각 동시에 실행되는걸 의미한다.

 

스레드의 개수가 코어 수보다 많을 경우, 어떤 스레드를 먼저 실행해야 할지 결정해야 하며 이를 스케줄링이라 한다.

스레드 스케줄링은 우선순위 방식과 라운드로빈 방식을 주로 사용한다.

 

우선순위 방식에서는 쓰레드에 우선순위를 직접 지정해줘서 우선순위가 높은 쓰레드 먼저 실행되게 하는 방식이다.

 

Thread.setPriority(priority); 

 

위 함수를 사용하면 해당 쓰레드의 우선순위를 설정할 수 있다.

 

라운드로빈(RR; Round Robin) 방식은 순서대로 돌아가면서 특정 시간단위만큼 스레드들을 실행시킨다.

 


메인 스레드 (Main Thread)

java 어플리케이션은 메인 스레드가 실행되면서 시작된다.

만일 메인스레드가 종료되더라도 실행 중인 스레드가 있다면 메인스레드는 종료되지 않는다.

 

데몬 스레드 (Daemon Thread)

데몬 스레드는 메인스레드가 종료되는 경우 데몬 스레드의 작업이 끝나지 않았음에도 강제로 종료된다.

 

Thread.setDaemon(true);

 

데몬 쓰레드는 위 함수로 설정할 수 있다.

 


동기화

 

스레드들은 자원을 공유하기 때문에 멀티 환경에서는 생각대로 동작하지 않는 경우가 있다.

동기화 처리가 되지 않는 경우 예로 많이 드는, 정상처리가 되지 않으면 등꼴이 오싹한, 통장 출금을 예로 들어보자.

 

// Account.java
public class Account {

    private int balance = 0;

    Account(int money) {
        this.balance = money;
    }

    void withdraw(int money) {
        if (money > balance) {
            System.out.println("impossible");
            return;
        }

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

        balance -= money;
        System.out.println(balance);
    }
}

 

계좌 클래스를 위와 같이 구현해보자.

money 만큼의 돈을 출금한 후 남는 잔고를 반환하는 함수 withdraw가 있다.

이 함수는 통장잔고에서 출금하고자 하는 비용만큼이 있는지 체크한 뒤 500 milliseconds 만큼 대기를 타고.

실제 돈이 빠져나간다.

만약 500 milliseconds 만큼 대기하고 있을 때 다른 스레드에서 해당 함수에 접근하는 경우 아직 실제로 돈이 빠져나가지 않았으므로 유효성체크가 정상작동하지 않을 것이다. 

 

// HelloJava.java

import java.util.ArrayList;

public class HelloJava {

    public static void main(String[] args) {
        Account account = new Account(1000);
        try {
            ArrayList<ThreadImpl> threads = new ArrayList();
            for (int i = 0; i < 15; i++) {
                threads.add(new ThreadImpl() {

                    @Override
                    public void run() {
                    	account.withdraw(100);
                    }
                });
            }
            for (var thread : threads) {
                thread.start();
            }
        } catch (Exception e) {
        }
    }
}

 

실제로 돌려보자. 

시작 잔고는 1000원이고 총 15개의 쓰레드에서 100원씩 출금한다.

// 출력
900
0
100
500
200
600
800
700
-300
300
400
-400
-500
-200
-100

실행결과는 위와 같다.

잔고를 출력했는데 음수가 나온다.

유효성체크가 정상작동 했다면 출금불가능한 경우 "impossible" 이 출력되었을 것이다.

 

// Account.java
public class Account {

    // ...
    synchronized void withdraw(int money) {
        // ...
    }
}

 

 synchronizred 키워드 를 함수 앞에 추가해서 해당 함수는 동기화 블럭임을 명시한다.

이제 해당 함수에서는 여러 스레드가 접근하지 못한다.

 

// 출력
900
800
700
600
500
400
300
200
100
0
impossible
impossible
impossible
impossible
impossible

 

정상작동을 확인할 수 있다.


데드락 (Deadlock)

 

데드락은 2개 이상의 프로세스가 서로 필요한 공유자원을 점유하고 있어 해당 자원을 할당받기 전까지 대기한 상태를 의미하며 이 경우, 무한대기 상태에 이른다.

 

발생조건

1. 상호 배제 (Mutual exclusion) : 자원을 동시에 다른 프로세스가 사용할 수 없다. 

2. 점유 대기 (Hold and wait) : 자원을 선점한 후 다른 자원을 대기.

3. 선점 불가 (No preemption) : 다른 프로세스의 자원을 가져올 수 없다. 반환되기 전까지 기다릴 수 밖에 없다.

4. 환형 대기 (circular wait) : 자원의 대기 상태가 circular 상태가 된다.

 

위 조건들을 모두 만족하는 경우 데드락 상태가 된다.

반대로 말하면 4조건 중 하나를 방지하면 된다.

 

1. 상호배제 : 여러 개의 프로세스에서 자원을 사용할 수 있게 한다. 이 경우 위에서 언급한 동기화 문제가 발생하지 않게 고안되어야 한다.

2. 점유대기 : 필요한 자원 중 대기해야 하는 자원이 있다면 다른 자원들도 점유하지 않는다.

3. 선점불가 : 자원에 우선순위를 매긴다.

4. 환형대기 : 자원에 우선순위를 매긴다.

 


참고

 

wikidocs.net/230

d2.naver.com/helloworld/10963

 

320x100

'JAVA' 카테고리의 다른 글

[Java] Annotation  (0) 2021.02.05
[java] Enum  (0) 2021.01.30
[Java] 패키지  (0) 2020.12.27
[Java] 데이터, 변수, 배열  (0) 2020.12.27
[Java] JVM  (0) 2020.12.27

댓글