Skip to content

Latest commit

 

History

History
489 lines (230 loc) · 18.8 KB

10주차.md

File metadata and controls

489 lines (230 loc) · 18.8 KB

10주차

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

Thread는 사전적 의미로 한 가닥의 실이라는 뜻인데, 한 가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어 놓았다고 해서 유래된 이름이다.

하나의 스레드는 하나의 코드 실행 흐름이기 때문에 한 프로세스 내에 스레드가 두 개라면 두 개의 코드 실행 흐름이 생긴다는 의미이다.

모든 자바 애플리케이션은 메인 스레드(main Thread) 가 main() 메소드를 실행하면서 시작된다.

위의 그림은 싱글 스레드 애플리케이션과 멀티 스레드 애플리케이션의 도식이다.

1. Thread 클래스와 Runnable 인터페이스

멀티 쓰레드로 실행하는 애플리케이션을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 쓰레드를 생성해야 한다.

어떤 자바 애플리케이션이건 메인 쓰레드는 반드시 존재하기 때문에 메인 작업 이외에 추가적인 병렬 작업의 수만큼 스레드를 생성하면 된다.

자바에서는 스레드도 객체로 생성되기 때문에 클래스가 필요하다. java.lang.Thread 클래스를 직접 객체화 해서 생성해도 되지만, Thread를 상속해서 하위 클래스를 만들어 생성할 수도 있다.

  • Thread 클래스로 부터 직접 생성

java.lang.Thread 클래스로부터 작업 스레드 객체를 직접 생성하려면 다음과 같이 Runnable을 매개 값으로 갖는 생성자를 호출해야 한다.

Thread thread = new Thread(Runnable target);

Runnable은 작업 스레드가 실행할 수 있는 코드를 가지고 있는 객체라고 해서 붙여진 이름이다.

Runnable은 인터페이스 타입이기 때문에 구현 객체를 만들어 대입해야 한다. Runnable에는 run() 메소드 하나가 정의되어 있는데, 구현 클래스는 run() 을 재정의해서 작업 스레드가 실행할 코드를 작성해야 한다.

class Task implements Runnable {
	public void run() {
		스레드가 실행할 코드;
	}
}

Runnable은 작업 내용을 가지고 있는 객체이지 실제 스레드는 아니다. Runnable 구현 객체를 생성한 후, 이것을 매개값으로 해서 Thread 생성자를 호출하면 비로소 작업 스레드가 생성된다.

Runnable task = new Task();
Thread thread = new Thread(task);

작업 스레드는 생성되는 즉시 실행되는 것이 아니라, start() 메소드를 다음과 같이 호출해야만 비로소 실행된다.

thread.start();

start() 메소드가 호출되면, 작업 스레드는 매개값으로 받은 Runnable 의 run() 메소드를 실행하면서 자신의 작업을 처리한다.

0.5초 주기로 비프음을 발생시키면서 동시에 프린팅하는 작업이 있다고 가정해보자. 비프음 발생과 프린팅은 서로 다른 작업이므로 메인 스레드가 동시에 두 가지 작업을 처리할 수 없다.

따라서 두 작업 중 하나는 메인 스레드가 아닌 다른 스레드에서 실행시켜야한다.

프린팅은 메인 스레드가 담당하고 비프음을 들려주는 것은 작업 스레드가 담당하도록 해보자.

public class BeepTask implements Runnable {
	public void run(){
		Toolkit toolkit = Toolkit.getDefaultTookkit();
		for(int i=0; i<5; i++){
			toolkit.beep();
			try{
				Thread.sleep(500);
			}catch(Exception e){
				
			}
		}
	}
}
public class BeepPrintExample2{
	public static void main(String[] args){
		Runnable beepTask = new BeepTask();
		Thread thread = new Thread(beepTask);
		thread.start();
		
		for(int i=0; i<5; i++){
			System.out.println("띵");
			try{ 
				Thread.sleep(500); 
			}catch(Exception e){
				
			}
		}
	}
}

2. 쓰레드의 상태

스레드 객체를 생성하고, start() 메소드를 호출하면 곧바로 스레드가 실행되는 것처럼 보이지만 사실은 실행 대기 상태가 된다.

실행 대기 상태란 아직 스케줄링이 되지 않아서 실행을 기다리고 있는 상태를 말한다.

실행 대기 상태에 있는 스레드 중에서 스레드 스케줄링으로 선택된 스레드가 비로서 CPU를 점유하고 run() 메소드를 실행한다.

이때를 실행(Running) 상태라고 한다.

실행 상태의 스레드는 run() 메소드를 모두 실행하기 전에 스레드 스케줄링에 의해 다시 실행 대기 상태로 돌아갈 수 있다. 그리고 실행 대기 상태에 있는 다른 스레드가 선택되어 실행 상태가 된다.

실행 상태에서 run() 메소드가 종료되면, 더 이상 실행할 코드가 없기 때문에 스레드의 실행은 멈추게 된다. 이 상태를 종료 상태라고 한다.

경우에 따라서 스레드는 실행 상태에서 실행 대기 상태로 가지 않을 수도 있다. 실행 상태에서 일시 정지 상태로 가기도 하는데, 일시 정지 상태는 스레드가 실행할 수 없는 상태이다.

일시 정지 상태는 1. WAITING, 2. TIMED_WAITING, 3. BLOCKED가 있다.

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

3. 쓰레드의 우선순위

멀티 스레드는 동시성 또는 병렬성으로 실행된다.

동시성은 멀티 작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행하는 성질을 말한다.

병렬성은 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질을 말한다.

스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데, 이것을 스레드 스케줄링이라고 한다.

스레드 스케줄링에 의해 스레드들은 아주 짧은 시간에 번갈아가면서 그들의 run() 메소드를 조금씩 실행한다.

자바의 스레드 스케줄링은 우선순위 방식과 순환 할당 방식을 사용한다.

  • 우선순위 방식

우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것을 말한다.

  • 순환 할당 방식

순환 할당 방식은 시간 할당량(Time Slice)을 정해서 하나의 스레드를 정해진 시간만큼 실행하고 다시 다른 스레드를 실행하는 방식을 말한다.

스레드 우선순위 방식은 스레드 객체에 우선 순위 번호를 부여할 수 있기 때문에 개발자가 코드로 제어할 수 있다. 하지만 순환 할당 방식은 자바 가상 기계에 의해서 정해지기 때문에 코드로 제어할 수 없다.

우선순위 방식에서 우선순위는 1에서 10까지 부여되는데, 1이 가장 우선순위가 낮고, 10이 가장 높다.

우선순위를 부여하지 않으면 모든 스레드들은 기본적으로 5의 우선순위를 할당받는다.

만약 우선순위를 변경하고 싶다면 Thread 클래스가 제공하는 setPriority() 메소드를 이용하면 된다.

thread.setPriority(우선순위);

순위가 높은 스레드가 실행 기회를 더 많이 가지기 때문에 우선순위가 낮은 스레드보다 계산 작업을 빨리 끝낸다. 쿼드 코어일 경우에는 4개의 스레드가 병렬성으로 실행될 수 있기 때문에 4개 이하의 스레드를 실행할 경우에는 우선순위 방식이 크게 영향을 미치지 못한다.

최소한 5개 이상의 스레드가 실행되어야 우선순위의 영향을 받는다.

4. Main 스레드

메인 스레드는 main() 메소드를 실행하며 시작된다.

즉 메인 스레드는 main 메소드의 코드 흐름이다. 메인 스레드가 없다면 멀티 스레드가 나올 수 없다.

public static void main(String[] args){ //메인스레드 시작
	...
	...
	...
}//메인스레드 끝

5. 동기화

자바에서 동기화란 여러 개의 스레드가 한 개의 자원을 사용하고자 할 때 해당 스레드만 제외하고 나머지는 접근을 못하도록 막는 것이다.

싱글스레드 프로세스의 경우 프로세스 내에서 단 하나의 스레드만 작업하기 때문에 문제가 없지만 멀티스레드 프로세스의 경우 여러 스레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다.

만일 스레드A가 작업하던 도중에 다른 스레드B에게 제어권이 넘어갔을 때 스레드A가 작업하던 공유데이터를 쓰레드B가 임의로 변경하였다면, 다시 스레드A가 제어권을 받아서 나머지 작업을 마쳤을 때 원래 의도했던 것과는 다른 결과를 얻을 수 있다.

이러한 일이 발생하는 것을 방지하기 위해서 한 스레드가 특정 작업을 끝마치기 전까지 다른 스레드에 의해 방해받지 않도록 하는 것이 필요하다.

그래서 도입된 개념이 바로 임계영역(critical section) 과 잠금(lock) 이다.

공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 스레드만 이 영역 내의 코드를 수행할 수 있게 한다. 그리고 해당 스레드가 반납된 lock을 획득하여 임계영역의 코드를 수행할 수 있게 된다.

이처럼 한 스레드가 진행 중인 작업을 다른 스레드가 간섭하지 못하도록 막는 것을 스레드의 동기화 라고 한다.

  • synchronized를 이용한 동기화

이 키워드는 임계 영역을 설정하는데 사용된다.

1. 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum(){
	// ...
}

2. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수){
	// ...
}

첫 번째 방법은 메서드 앞에 synchronized를 붙이는 것인데, synchronized를 붙이면 메서드 전체가 임계 영역으로 설정된다. 스레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다.

두 번째 방법은 메서드 내의 코드 일부를 블럭{} 으로 감싸고 블럭 앞에 'synchronized(참조변수)'를 붙이는 것인데, 이때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 한다.

이블럭을 synchronized블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서 부터 스레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납한다.

첫 번째와 두 번째 방법 모두 lock의 획득과 반납이 모두 자동적으로 이루어진다.

임계 영역은 멀티스레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락을 거는 것보다 synchronized블럭으로 임계 영역을 최소화해서 보다 효율적인 프로그램이 되도록 노력해야 한다.

class ThreadEx21 {
	public static void main(String[] arges){
		Runnable r = new RunnableEx21();
		new Thread(r).start();
		new Thread(r).start();
	}
}

class Account{
	private int balance = 1000;
	
	public int getBalace(){
		return balance;
	}
	
	public void withdraw(int money){
		if(balance >= money){
			try{ Thread.sleep(1000);} catch(InterruptedException e){}
			balance -= money;
		}
	}//withdraw
}

class RunnableEx21 implements Runnable{
	Account acc = new Account();
	
	public void run(){
		while(acc.getBalance() > 0){
			//100, 200, 300 중 한 값을 임으로 선택해서 출금
			int money = (int)(Math.random() * 3 + 1) * 100;
			acc.withdraw(money);
			System.out.println("balance : " + acc.getBalance());
		}
	}//run()
}

은행계좌(account)에서 잔고(balance)를 확인하고 임의의 금액을 출금(withdraw)하는 예제이다.

아래의 코드를 보면 잔고가 출금하려는 금액보다 큰 경우에만 출금하도록 되어 있는 것을 확인 할 수 있다.

	public void withdraw(int money){
		if(balance >= money){
			try{ Thread.sleep(1000);} catch(InterruptedException e){}
			balance -= money;
		}
	}//withdraw

그러나 실행결과를 보면 잔고가 음수인 것을 볼 수 있다.

그 이유는 한 스레드가 if문의 조건식을 통과하고 출금하기 바로 직전에 다른 스레드가 끼어들어서 출금을 먼저 했기 때문이다.

예를 들어 한 스레드가 if문의 조건식을 계산했을 때는 잔고가 200이고 출금하려는 금액이 100이라서 조건식이 true가 되어 출금을 수행 하려는 순간 다른 스레드에게 제어권이 넘어가서 다른 스레드가 200을 출금하여 잔고가 0이 되었다. 다시 이전 스레드로 제어권이 넘어오면 if문 다음부터 수행하게 되므로 잔고가 0인 상태에서 100을 출금하여 잔고가 결국 -100이 된다. 그래서 잔고를 확인하는 if문과 출금하는 문장은 하나의 임계 영역으로 묶여져야 한다.

public synchronized void withdraw(int money){
	if(balance >= money){
		try{ Thread.sleep(1000);} catch(InterruptedException e){}
		balance -= money;
	}
}

위와 같이 withdraw 메서드에 synchronized 키워드를 붙이기만 하면 간단히 동기화가 된다.

synchronized로 동기화해서 공유 데이터를 보호하는 것 까지는 좋은데, 특정 스레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다.

이러한 상황을 개선하기 위해 고안된 것이 바로 wait()과 notify()이다. 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출하여 스레드가 락을 반납하고 기다리게 한다. 그러면 다른 스레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다.

나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서 , 작업을 중단했던 스레드가 다시 락을 얻어 작업을 진행할 수 있게 한다.

wait()이 호출되면, 실행 중이던 스레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.

notify()가 호출되면, 해당 객체의 대기실에 있는 모든 스레드에게 통보를하지만, 그래도 lock을 얻을 수 있는 것은 하나의 스레드일 뿐이고 나머지 스레드는 통보를 받긴 했지만, lock을 얻지 못하면 다시 lock을 기다리는 신세가 된다.

Lock과 Condition을 이용한 동기화

동기화할 수 있는 방법은 synchronized블럭 외에도 'java.util.concurrent.locks' 패키지가 제공하는 lock클래스들을 이용하는 방법이 있다.

synchronized 블럭으로 동기화를 하면 자동적으로 lock이 잠기고 풀리기 때문에 편리하다. 심지어 synchronized블럭 내에서 예외가 발생해도 lock은 자동적으로 풀린다.

그러나 때로는 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편하기도 하다. 그럴 때 이 lock클래스를 사용한다. lock클래스의 종류는 3가지가 있다.

  1. ReentrantLock : 재진입이 가능한 lock. 가장 일반적인 배타 lock
  2. ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock
  3. StampedLock : ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가

6. 데드락

교착 상태(영어: deadlock)란 두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태를 가리킨다.

예를 들어 하나의 사다리가 있고, 두 명의 사람이 각각 사다리의 위쪽과 아래쪽에 있다고 가정한다. 이때 아래에 있는 사람은 위로 올라 가려고 하고, 위에 있는 사람은 아래로 내려오려고 한다면, 두 사람은 서로 상대방이 사다리에서 비켜줄 때까지 하염없이 기다리고 있을 것이고 결과적으로 아무도 사다리를 내려오거나 올라가지 못하게 되듯이, 전산학에서 교착 상태란 다중 프로그래밍 환경에서 흔히 발생할 수 있는 문제이다.

이 구조에서 Object A와 Obejct B는 같은 Class의 인스턴스이다. 구현에 따라 인스턴스별로 각기 다른 Lock을 갖게 할 수 있다. test1() test2() method는 synchronized로 선언되어 객체(this)를 Lock 객체로 사용하는 임계영역을 가지고 있다.

Tread 1 가 Object A 의 임계영역에 진입을 한 후 임계영역을 탈출하지 않은 상태에서 Object B의 임계영역에 진입하려 한다. 그런데 이때 이미 Thread 1는 Object B의 임계영역에 진입 한 상태가 되어 Thread 1 은 Object B 의 임계역역 시작 지점에서 BLOCKED 된다. 이 상황에서 Object B의 임계영역에 진입한 Thread 2는 Thread 1이 Lock 을 확보한 Object A의 임계 영역을 진입하려 한다. 이 역시 BLOCKED 된다. 양 Thread 는 영원한 BLOCKED 상태에 들어갔다.

참고 문헌


이것이 자바다

자바의 정석

출처: https://widevery.tistory.com/27

출처: https://sas-study.tistory.com/218

출처: https://gosmcom.tistory.com/19

출처: https://rightnowdo.tistory.com/entry/JAVA-concurrent-programming-교착상태Dead-Lock [지금 당장 해!!!]