[STUDY HALLE] 10주차 과제: 멀티쓰레드 프로그래밍

4 minute read

학습 목표

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

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

Deadline: ~2021/01/23 13:00

프로세스와 쓰레드

프로세스
프로세스는 컴퓨터가 실행중인 프로그램으로, 운영체제로부터 메모리와 cpu자원을 할당받아 사용한다. 프로세스마다 pcb를 가지며, pcb는 프로세스 id, 상태, 프로그램 카운터 등 주요 정보를 담고있다.

쓰레드
쓰레드는 이 프로세스의 실행단위이다. 같은 프로세스에 속한 다른 쓰레드와 자원을 공유할 수 있지만, 각자 쓰레드 스택을 갖는다. 스택을 따로 갖는다는 것의 의미는 독립적인 함수 호출을 통해 독립적인 실행흐름이 가능하다는 것이다.

Thread 클래스와 Runnable 인터페이스

자바에서 쓰레드를 구성하는 방법은 Thread클래스를 사용하거나, Runnable 인터페이스를 사용하면 된다. 사실 Thread 클래스는 Runnable 인터페이스의 구현체이므로 한 가지 방법이라고 봐도 될 것이다. Thread, Runnable 모두 java.lang 패키지 안에 있다.

package week10;

class ThreadByInheritance extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print(0);
        }
    }
}

class ThreadByImplement implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print(1);
        }
    }
}

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

        ThreadByInheritance thread1 = new ThreadByInheritance();
        Thread thread2 = new Thread(new ThreadByImplement());

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

Thread와 Runnable을 이용하여 쓰레드를 생성하는 간단한 예제를 구성하였다. 두 방식 모두 run 함수를 오버라이딩하여 구현해야하는 점은 동일한데, Runnable을 이용한 방식이 인터페이스를 이용하므로 다른 클래스를 상속 받을 수 있도록 유연하게 만들어준다는 점에서 많은 사람들이 선호하는 것으로 보인다.(이곳을 참조)

예제에서 쓰레드를 생성한 뒤 실행시킬 때 오버라이딩한 run()메소드가 아닌 start()라는 메소드를 사용하였다.

public synchronized void start() {
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        group.add(this);
        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
            }
        }
    }

start()메소드의 원형은 위와 같고, 이 메소드를 호출하면 start0()라는 native 메소드를 실행시킨다. 자세한 네이티브 코드는 첨부하지 않겠지만, 네이티브 코드를 요약하자면 start0()는 JVM_StratThread 라는 함수에 매핑되어있다. start()를 실행한 쓰레드가 이미 한번 실행했던 쓰레드라면 예외를 발생시키고, 아니라면 할당할 스택의 크기를 계산하여 새로운 스택과 함께 쓰레드 객체를 새로 생성한다. 만약 스택의 크기가 충분하지 않다면 Out of memory 에러를 출력한다. 그리고 모든 준비가 끝나면 쓰레드를 시작한다.

쓰레드의 상태

상태 의미
NEW 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE 실행 중 또는 실행 가능한 상태
WATING 쓰레드의 작업이 종료되지는 않았지만 실행 가능하지 않은 일시정지 상태
TIMED_WAITING 주어진 시간 동안 기다리는 상태
BLOCKED 동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
TERMINATED 쓰레드의 작업이 종료된 상태

getState() 메소드를 사용하면 쓰레드의 상태를 확인할 수 있다. 그리고 쓰레드의 상태를 제어하기 위한 메소드는 다음과 같다.

  • sleep() : 쓰레드를 WATING 상태로 만든 뒤, 지정된 시간 이후 RUNNABLE 상태로 만든다.
  • join() : 다른 쓰레드의 종료를 기다린다.
  • interrupt() : 실행 중인 쓰레드를 종료시키고, InterruptedException을 발생시킨다.
  • yield() : 주어진 실행시간을 다른 실행 가능한 쓰레드에게 넘겨준다.
ThreadA{
	threadB.start(); // ThreadB 실행 (실행순서 1)
	threadB.join(); 

	... // (실행순서 3)
}
    
ThreadB{
    run(){
      ... (실행순서 2)
	}
}

쓰레드의 우선순위

쓰레드는 우선순위라는 멤버변수를 가지고 있는데, 이 우선순위의 값에 쓰레드를 스케쥴링한다. 우선순위는 1 ~ 10의 값을 가지며, 숫자가 높을수록 우선순위가 높다는 의미이다. 따라서 우선순위는 쓰레드끼리 상대적인 것이고, 쓰레드를 생성한 쓰레드로부터 상속받는다. 예를들어 main쓰레드의 경우 우선순위가 5인데, main쓰레드 내에서 생성하는 쓰레드의 우선순위는 모두 5를 상속받는다. 이것을 변경하려면 setPriority() 메소드를 사용하여 우선순위를 바꾸어줄 수 있다.

Main 쓰레드

위에서 메인 쓰레드의 우선순위는 5라고 했는데, 메인 쓰레드는 무엇일까? 메인 쓰레드는 프로그램이 시작되고나서 생성되는 최초의 쓰레드이다. 그래서 다른 모든 쓰레드는 메인 쓰레드로부터 파생된 것일 수 밖에 없는데, 프로그램은 메인쓰레드가 종료되더라도 파생된 다른 쓰레드가 종료될 때까지 작동한다. 다만, 메인쓰레드가 종료될 때 함께 종료시키고 싶은 쓰레드는 데몬(Daemon) 쓰레드로 생성하면 된다.

class AutoSaveThread extends Thread{
    public void save(){
        System.out.println("작업 내용을 저장합니다.");
    }

    @Override
    public void run() {
        while(true){
            try{
                Thread.sleep(1000);
            }catch(InterruptedException e){
                break;
            }
            save();
        }
    }
}

public class Week10 {
    public static void main(String[] args) {
        AutoSaveThread autoSaveThread = new AutoSaveThread();
        autoSaveThread.setDaemon(true);
        autoSaveThread.start();

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

        }

        System.out.println("메인 스레드 종료");
    }
}
작업 내용을 저장합니다.
작업 내용을 저장합니다.
메인 스레드 종료

동기화

동기화란 하나의 자원을 특정 순간에 하나의 쓰레드만 점유하도록 제어하여 데이터의 일관성을 유지하는 것이다. OS수업 때 배운 동기화 방식은 다음과 같다.

  • Mutex : 뮤텍스는 동기화를 위해 임계구역(critical section)을 설정한다. 하나의 스레드만이 해당 임계구역에서 실행될 수 있다. 임계구역에 진입할 때 뮤텍스 락을 건다. 이후 진입을 시도하는 스레드들은 대기 상태에 들어가며, 임계구역에서 동작하던 스레드가 모든 할일을 마치고 임계구역을 나오면서 unlock을 한 이후 진입한다.
  • Semaphore : 뮤텍스와 마찬가지로 임계구역을 설정한다. 하지만 공유자원에 접근할 수 있는 스레드의 수를 세마포어라는 변수로 지정해놓고, 임계구역에 스레드가 진입하면 하나 감소시키고, 임계구역에서 탈출하면 하나 증가시킨다. 진입하려는 스레드는 세마포어가 0일경우 1이상이 될 때까지 대기한다.

자바에서는 synchronized 키워드가 있다. 메서드의 반환 타입 앞에 synchronized 키워드를 붙이면 메서드 전체가 임계 영역이 되고, 혹은 메소드 내에서 직접 구역을 설정해서 사용한다.

public static void main(String[] args) {
    ...
    synchronized(객체의 참조변수) {
        ...
    }
}

Dead Lock(교착상태)

데드락

둘 이상의 프로세스(쓰레드)가 서로 자원을 요구하며 무한정 기다리는 상태를 말한다.

  • 상호배제 : 하나의 자원은 하나의 프로세스만 점유 가능하다.
  • 비선점: 점유중인 자원을 뺏을 수 없다.
  • 환형대기: 대기하는 프로세스가 원형(순환)을 이룬다.
  • 점유와 대기: 자원을 점유하고 있으면서 다른 자원을 요구하며 대기하는 상태인 프로세스가 있어야한다.

위의 4가지 조건이 *모두* 만족되면 데드락이 발생한다. 그리고 이러한 데드락에 대응하는 방법도 4가지로 나뉜다. 간혹 교착상태를 무시하는 것을 방법에 포함시키는 글이 있는데, 문제 발생을 무시하는 것을 해결했다고 표현하는 것이 맞나 싶어 제외했다.

  • 교착상태 예방 : 교착 상태 발생 조건 중 하나를 제거한다. 자원의 낭비가 심함
  • 교착상태 회피 : 발생 조건은 두고, 교착 상태가 발생하지 않는 범위 내에서만 자원을 할당한다.
  • 교착상태 탐지와 회복: 시스템을 검사하여 교착 상태를 탐지하면, 프로세스를 중단하거나 자원 선점을 통해 교착상태를 회복한다.

자바에서는 java.util.concurrent 패키지에서 동기화를 위한 클래스를 제공하는데, java.util.concurrent.locks 패키지는 synchronized 동작을 더욱 세세하게 처리하게 해준다. 다음은 synchronized 블락처럼 동작하는 lock방식의 코드이다.

public static void main(String[] args) {
    lock.lock();
    try {
         ...
    } catch (Exception e) { 

    } finally { 
        lock.unlock(); 
    }

}

lock 인터페이스를 구현한 ReetrantLock 클래스에는 교착상태에서 탈출할 수 있도록 lock을 걸 수 있는 메소드를 제공한다.

  • lockInterruptibly() : interrupt() 호출에 의해 InterrupedException이 발생한다.
  • tryLock() : 다른 thread에 의해 Lock이 되어 BLOCKED 되면 바로 false를 반환한다.
  • tryLock(long timeOut, TimeUnit unit) : tryLock()과 비슷한데 바로 lock확보 결과를 리턴하는 것이 아니라 지정된 시간 만큼 기다린다.

참고문헌

https://wisdom-and-record.tistory.com/48 https://sujl95.tistory.com/63 https://cornswrold.tistory.com/195 https://d2.naver.com/helloworld/10963

Leave a comment