Inor

[JAVA] Thread(Thread Class 사용법) 본문

Computer Engineering/Java

[JAVA] Thread(Thread Class 사용법)

Inor 2017. 7. 19. 16:40

Thread(스레드)


 프로세스란 메모리에 올라와 cpu를 할당 받으면서 운영체제에의해서 실행되고 있는 프로그램이라는 뜻 입니다. 이런 프로세스를 구성하고 있는 단위를 스레드라고 칭합니다. JAVA에서는 기본적으로 하나의 메인스레드를 제공하고 필요에 따라서 여러개의 스레드를 만들어서 사용할 수 있습니다. 그렇다면 여러개의 스레드를 만들어서 사용하는 이유가 궁금할 수 있습니다. 그 이유는 간단하게 말하면 멀티태스킹을 가능하게해서 프로세스의 효율을 높이기 위한 것 입니다. 하지만 프로세스의 성능을 높이기 위해서 너무 많은 스레드를 생성 하게되면 Context Switch 때문에 오히려 효율이 떨어지게 됩니다.

 만약에 1인 쉐프로 운영이 되는 레스토랑이 있다고 생각해 봅시다. 쉐프는 주문, 요리, 설거지를 해야 합니다. 만약에 이 레스토랑이 멀티 스레드 환경이 아니라면 이 쉐프는 주문 -> 요리 -> 설거지의 순서로 일을 해야 하고 요리를 하는 중간에 설거지를 하거나 다른 손님의 주문을 받을 수 없습니다. 이런 상황은 매우 비효율 적인 상황 입니다. 멀티스레드 환경은 이런 상황을 더 효율적으로 처리 할 수 있도록 바꿔줄 수 있는 개념 입니다. 쉐프는 이런 상황이 효율적이지 못하다는 것을 인지하고 식기 세척기와 주문을 받을 수 있는 기계를 주방과 홀에 설치를 했습니다. 이제 요리를 하면서 주문을 받을 수 있고 주문을 받는 동시에 설거지를 할 수 있게 됐습니다. 이런 환경을 멀티 스레드 환경이라고 하고 여기서 요리, 주문, 설거지는 각각 하나의 스레드 라고 이해 할 수 있습니다. 

 하지만 이렇게 병렬적으로 일을 처리하면 한 가지 문제가 발생 합니다. 바로 공유 자원에대한 문제 입니다. 예를 들어서 스테이크가 다 구워져서 이제 그릇에 담아야 합니다. 하지만 음식을 담아야할 그릇이 아직도 식기 세척기에서 돌아가고 있는 중이고 손님은 음식이 빨리 달라고 재촉하는 상황 입니다. 쉐프는 결국 젖은 그릇에 음식을 담아서 손님에게 전달 했고 결과는.... 그릇이라는 공유 자원에 스레드들이 동시에 접근이 가능 했고 원하지 않는 결과가 발생 했습니다. 이런 상황을 공유 자원에대한 문제 라고 합니다. 

 사실 스레드를 몇 줄로 설명하는건 말이 안됩니다. 지금은 자바에서 스레드가 어떻게 사용 되는지에대한 주제이기 때문에 예를 들어서 설명하는 방식으로 간단하게 알아 보았습니다. 추후에 운영체제를 다루는 섹션에서 자세히 알아보도록 하겠습니다.




Thread Class


 아래의 코드는 주유소에서 자동차에 기름을 주유하는 상황을 표현한 것 입니다. 실제 세상과 비교해보면 약간 어이없는 코드이긴 하지만 공유 자원의 문제와 스레드가 어떻게 작동 하는지 보여주기 좋은 예제일 것 같아서 만들어봤습니다. 주유소에서 모든 자동차에 주유를 하면 마지막에 주유소에는 얼만큼의 연료가 남아 있는지 보여주는 코드 입니다.


import java.util.ArrayList;
import java.util.Random;

class GasStation{
	private int gas;
	
	public GasStation(int gas){
		this.gas = gas;
	}
	
	//가스를 자동차에 주유할 때마다 1씩 줄여줬습니다.
	public int fillGas(){
		if(gas > 0){
			gas--;
			return 1;
		}else{
			System.out.println("가스가 없습니다");
			return 0;
		}
	}
	
	//남아있는 가스의 양을 보여줍니다.
	public void showGas(){
		System.out.println(gas);
	}
}

class Car extends Thread{
	private int id;
	private GasStation curGasStation;
	private int gas;
	
	public Car(int id){
		this.id = id;
		gas = 0;
	}
	
	//Thread를 상속 받으면서 오버라이딩한 메서드 입니다.
	@Override
	public void run(){
		System.out.println("Car ID : " + id + " 주유중");
		Random r = new Random(System.currentTimeMillis());
		try {
			long time = r.nextInt(6000);
			Thread.sleep(time);
			//자동차에 연료를 채웠습니다.
			gas += curGasStation.fillGas();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println("Car ID : " + id + " 주유 완료");
	}
	
	//최근(current) 할당 받은 주유소 객체(gasStation)를 참조 합니다.
	public void setGasStation(GasStation gasStation){
		curGasStation = gasStation;
	}
}

public class ThreadTest {
	public static void main(String[] args){
		//Car 객체들을 저장할 자료구조를 생성 했습니다.
		ArrayList<Thread> cars = new ArrayList<Thread>();
		//Car 객체에 주유할 GasStation 객체를 만들었습니다.
		GasStation inorGasStation = new GasStation(20);
		//새로운 Car 객체를 만들고 GasStation 객체를 참조하도록 설정 합니다.
		for(int i=0;i<10;i++){
			Car tempCar = new Car(i);
			tempCar.setGasStation(inorGasStation);
			//스레드를 실행 시킵니다.
			tempCar.start();
			cars.add(tempCar);
		}
		inorGasStation.showGas();
	}	
}


 주유소 클래스(GasStation)과 자동차 클래스(Car)가 존재 합니다. Car는 Thread 클래스를 상속 받고 있으며 스레드로서 동작 할 것 입니다. 스레드를 실행(tempCar.start()) 시키면 Car 클래스가 Thread 클래스를 상속 받으면서 오버라이딩한 run 메서드(public void run())가 실행 됩니다. 자바 프로그램을 실행 시키면 main 메서드가 실행되는 것과 비슷하게 스레드를 실행 시키면 run 메서드가 실행 됩니다. 아래는 해당 코드의 결과 입니다.



 결과를 보면 뭔가 이상한 것을 볼 수 있습니다. 일단 스레드는 실행 순서와 상관 없이 제각각 실행되는 것을 확인할 수 있습니다. 끝나는 순서 또한 먼저 실행 됐다고해서 먼저 끝나는 것이 아니라는 것을 확인할 수 있습니다. 그리고 메인 스레드가 끝난 시점 이후에도 개발자가 작성한 스레드는 동작 중이라는 것을 알 수 있습니다. 그렇기 때문에 주유가 완료 된 후에 연료가 얼마나 남아있는지 알아볼 수 없게 됐습니다. 메인 스레드가 다른 모든 스레드가 끝날때까지 기다리도록 하는 것은 쓰레드 조인(Thread Join)을 통해서 해결 할 수 있습니다. 쓰레드 조인을 통해서 위의 코드에서 메인 메서드를 수정해 보도록 하겠습니다.




Join


 조인은 특정 스레드가 다른 스레드의 동작이 완료되기 까지 기다리도록 해주는 메서드 입니다. 조인이 되는 시점에서 특정 스레드는 다른 스레드들을 기다립니다. 기다리는 시간 또한 정해 줄 수 있습니다.


< pre class="brush:java.">public class ThreadTest { public static void main(String[] args){ //Car 객체들을 저장할 자료구조를 생성 했습니다. ArrayList<Thread> cars = new ArrayList<Thread>(); //Car 객체에 주유할 GasStation 객체를 만들었습니다. GasStation inorGasStation = new GasStation(20); //새로운 Car 객체를 만들고 GasStation 객체를 참조하도록 설정 합니다. for(int i=0;i<10;i++){ Car tempCar = new Car(i); tempCar.setGasStation(inorGasStation); //스레드를 실행 시킵니다. tempCar.start(); cars.add(tempCar); } //Join //tempThread 쓰레드를 조인 합니다. for(int i=0;i<cars.size();i++){ try { Thread tempThread; tempThread = cars.get(i); tempThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } inorGasStation.showGas(); System.out.println("메인 스레드 종료"); } }


  cars라는 ArrayList에 있는 Car 클래스의 객체들을 하나씩 확인 하면서 join 메서드를 실행(tempThread.join()) 했습니다. 아래는 해당 코드의 결과 입니다.



 메인 스레드가 다른 모든 스레드가 종료된 후에 종료 되는 것을 확인 할 수 있습니다. 그러나 여기서 또 하나 문제가 발생 했습니다. 0부터 9까지 10개의 자동차를 주유 했고 한번 주유 할 때마다 1씩 줄어 들도록 만들었는데 결과 값은 10이 아니라 14가 나왔습니다. 이 문제는 스레드가 자원을 공유하기 때문에 발생하는 것으로 위의 예제에서 설명 했던 아직 세척이 안된 그릇을 사용 했던것과 같은 문제 입니다. 이 문제를 해결하기 위해서는 스레드의 동기화에 대해서 학습을 해야 합니다.




동기화(synchronized)


 동기화란 동시에 시스템을 작동 시키기 위해 사건을 일치 시키는 것 입니다.(위키백과) 동기화가 필요한 시점은 스레드의 실행 순서를 정해주고 싶은 경우와 메모리 접근에대한 제한을 두고 싶을 경우에 동기화를 합니다. 저희는 지금 공유 메모리에대한 접근에대해 동기화 하는 방법을 알아 보도록 하겠습니다. 사실 동기화가 스레드에서 매우 중요한 주제 입니다. 그래서 지금은 짧게 언제 동기화가 필요한 것인지와 자바에서 어떻게 동기화를 하는지에대해 간단하게 알아보도록 하겠습니다. 추후에 스레드를 공부 하면서 동기화의 방법들과 왜 필요한지에대해서 더 깊게 알아보도록 하겠습니다.


public synchronized int fillGas(){
		if(gas > 0){
				gas--;
			return 1;
		}else{
			System.out.println("가스가 없습니다");
			return 0;
		}
	}


 위의 코드는 GasStation 클래스의 fillGas 메서드를 수정한 것 입니다. 다른 코드들과 비교를 해보시면 fillGas 메서드 앞에 synchronized 키워드가 추가된 것을 확인할 수 있을 겁니다. 이것은 동기화를 위해서 자바에서 제공하는 키워드 입니다. 수정하기 전의 코드를 실행 시켰을때 저는 모든 스레드가 동작을 완료하면 10개의 연료가 남을 것이라고 예상 했습니다. 그러나 전혀 다른 결과가 발생 했었습니다. 그것은 모든 스레드가 동시다발적으로 GasStation 클래스의 fillGas 메서드를 통해 gas 필드에 접근(run 메서드의 curGasStation.fillGas()) 했기 때문 입니다. 

 synchronized 키워드는 이런 상황을 방지해주는 기능 입니다. 이 키워드가 있는 메서드에는 하나의 스레드만 접근이 가능하고 다른 스레드는 대기 합니다. 저는 여기서 한가지 의문이 발생 했습니다. 그렇다면 대기하고있는 스레드들은 어떤 순서로 synchronized block으로 들어갈 것인지에대해 궁금해졌습니다. 몇 가지 자료를 찾아 봤는데 "자바는 순서에대한 어떠한 보증도 하지 않는다."가 주로 나와있는 대답이었습니다. 대답에대한 자료는 본문 아래에 링크를 걸어 놨습니다. 아래는 결과 화면 입니다.



 이제야 제가 원하는 결과가 정확하게 나왔습니다. 모든 스레드가 종료된 후에 메인 스레드가 종료 됐고 10개의 자동차에 연료를 주입한 후 남은 연료가 10개가 됐습니다. 마지막 코드를 실행 시켰을때 이전에 실행 시켰던 코드들 보다 더 오래 걸리는 것을 체감상 확인할 수 있었습니다. 그 이유는 스레드가 synchronized block을 실행 시키고 있으면 다른 스레드들은 대기하고 있어야 했기 때문 입니다. 사실 synchronized는 동기화를 효과적으로 수행하는 키워드 이지만 성능 문제를 따져봤을 때 효율적이지는 않은 방법 입니다. 그래서 많은 사람들은 "동기화의 문제가 발생하지 않는 경우에만 스레드를 사용해야한다." 라고 하기도 합니다. 다음에는 Runnable interface를 통한 스레드 사용 방법과 synchronized에대해 더 깊이 알아 보도록 하겠습니다.




참고 자료


Comments