자바의 Thread

June Lee·2021년 2월 8일
0

Java

목록 보기
14/23

Thread vs. Process

프로세스는 실행 중인 프로그램을 의미하며, 쓰레드는 해당 프로그램의 자원을 이용하여 실제로 작업을 수행하는 주체이다.

Single Thread vs. Multi Thread

우리가 일반적으로 짜는 프로그램은 대부분 싱글 쓰레드 프로세스이다. 하나의 자원만을 사용하여 프로그램을 짜는 경우, 혹은 싱글 코어 CPU를 사용하는 경우에는 이런 싱글 쓰레딩이 더 유리하기도 하다. 왜냐하면, 여러 개의 쓰레드가 하나의 자원을 공유해서 쓰기 때문에(ex.콘솔) 쓰레드가 여러 개라 하더라도 한 쓰레드가 이를 점유하는 동안에 다른 쓰레드는 이 자원을 사용할 수 없는 blocked 상태가 된다. 게다가 쓰레드 간 전환(Context Switching) 과정은 시간이 소요되기 때문에, 무작정 모든 프로그램을 멀티 쓰레딩으로 짠다고 해서 다 좋은 것은 아니다.

Race Condition
멀티 쓰레드 프로세스에서 쓰레드들이 서로 자원을 차지하기 위해 경합하는 상태를 Race Condition이라고 한다. 이로 인해, 멀티 쓰레드 프로세스에서는 프로그램이 일정한 순서로 일정한 결과를 내도록 통제하는 것이 매우 어렵다.

멀티 쓰레드 프로세스를 사용하면 좋은 경우는 이를 지원할 수 있는 멀티 코어 CPU를 사용하는 경우, 그리고 쓰레드 간 서로 다른 자원을 많이 사용하는 경우이다. 그 예시로, 한 쓰레드가 I/O로 인해 Blocked된 동안 다른 쓰레드는 자신이 해야할 작업을 할 수 있으면 매우 효율적이다.

서버 프로그램이 싱글 쓰레드로 작성되어 있다면, 사용자의 요청마다 새로운 프로세스를 생성해야 한다. 쓰레드 생성에 비해 프로세스 생성에는 많은 cost가 들기 때문에 이는 적절하지 않다. 멀티 쓰레드로 짠다면, 쓰레드에서 사용자의 요청을 일대일로 처리해줄 수 있다.

위의 사진에서 보듯, 멀티 쓰레드라고 해서 완전히 동시에 작업이 일어나는 것은 아니다. 쓰레드들이 CPU를 돌아가면서 쓰는 것이 사용자의 눈에는 동시에 작업이 일어나는 것처럼 보이는 것이다.

멀티 쓰레드 구현 방법

멀티 쓰레드를 구현하는 방법에는 두 가지가 있다.

  1. Thread 상속
  2. Runnable 인터페이스 구현

첫 번째 방법의 경우 Thread를 상속받은 클래스 그 자체가 Thread가 된다고 보면 된다. 하지만 두 번째 방법의 경우 그 자체가 Thread는 아니며, Thread의 메소드 또한 바로 사용할 수 없다.

ThreadName t = new ThreadName(); // 방법 1
Thread t = new Thread(new ClassName()); // 방법 2

얼핏 보기에 더 복잡해 보이는 인터페이스를 구현하는 방식이 존재하는 이유는 자바에서는 다중 상속을 허용하지 않기 때문이다. Thread를 상속받는 클래스가 많이 존재하는데, 이런 클래스들이 모두 다른 클래스를 더 이상 상속받을 수 없다면 문제가 되기 때문에 인터페이스를 상속하는 방식을 많이 활용한다.

쓰레드의 상태

쓰레드는 여러 가지 상태를 가진다. 쓰레드를 구현할 경우 모두 run()이라는 메소드를 overriding하는데,

// Customer는 쓰레드 상속
Customer customer = new Customer();
customer.run(); // x

이렇게 바로 실행하지 않는 이유는, 쓰레드의 경우 작업을 바로 실행주는 것이 목적이 아니라, 작업을 실행할 수 있는 '상태'를 만들어주는 것이 목적이기 때문이다.

customer.start();

따라서 위와 같이 start 메소드를 통해, 쓰레드를 작업을 실행할 수 있는 상태, 즉 Runnable 상태로 만들어준다.
쓰레드의 상태와 전환은 아래와 같다.

Critical Section과 Synchronization

크리티컬 섹션은 쓰레드 간 자원을 공유하는 부분에서 발생한다. 그러나 주의할 점은 그 자원 자체가 크리티컬 섹션이 아니라, 자원을 수정(CRUD)하는 영역이 크리티컬 섹션이라는 점이다.
동기화(Synchronization)는 한 쓰레드가 크리티컬 섹션에서 진행 중인 작업에 다른 쓰레드가 간섭할 수 없도록 막아주는 것이다. 즉 크리티컬 섹션을 구분하고, 여기에 lock을 걸어주는 작업을 동기화라고 한다.

이 방법에는 크게 2가지가 있다.

  1. 메소드에 synchronized 키워드를 붙여서 해당 메소드를 동기화
  2. synchronized 블럭을 통해 lock을 걸 범위 지정(공유 클래스 이름을 인자로 넘겨줘야 함)

1번 방법의 예시는 다음과 같다.

class SharedArea {
	Account account1;
    Account account2;
    synchronized void transfer(int amount) {
    	...
        account1.withdraw(1000);
    	...
    }
}

2번 방법의 예시는 다음과 같다.

class SharedArea { // 공유 클래스
	Account account1;
    Account account2;
}

Class TransferThread extends Thread {
	SharedArea sharedArea;
    ...
    public void run(){
    	synchronized (sharedArea) {
        	...
        	sharedArea.account.withdraw(1000);
        	...
        }
    }
}
class SharedArea {
	Account account1;
    Account account2;
    void transfer(int amount) {
    	sychronized(this) {
        	account1.withdraw(1000);
        }
    }
}

편하게 생각하면, 크게 구분 되는 것은 블럭 단위로 동기화를 해줄지, 아니면 메소드 단위로 동기화를 해줄지이고
어떤 경우든 크리티컬 섹션을 포함하는 것들은 다 공유 영역 클래스에 모여있고, 해당 클래스 내부에서, 혹은 해당 클래스를 거치는 방식으로 크리티컬 섹션인 메소드를 실행해주는 것 같다.

주의!
크리티컬 섹션은 최소화하는 것이 프로그램 성능에 있어 중요하다. 따라서 메서드 전체에 lock을 거는 것보다는 synchronized 블럭으로 크리티컬 섹션을 최소화해주는 편이 좋다.

동기화는 크리티컬 섹션에 접근하는 쓰레드를 하나로 한정 짓는 방법이다. 비유를 하자면, 화장실에 칸이 하나가 있고, 이 칸에 들어갈 수 있는 열쇠를 쓰레드에게 번갈아가며 나눠주는 것이다.

wait()와 notify()

wait()와 notify()는 크리티컬 섹션에 접근하는 쓰레드들이 자원 점유를 위해 기다리고, 자원 사용이 끝났음을 알려주는 과정이다.
wait()와 notify()가 없을 경우, 크리티컬 섹션에의 접근이 서로에게 달려있는, (예를 들어) producer-consumer 구조의 프로그램에서 문제가 발생할 수 있다.

프로듀서가 무언가를 만들어서 리스트에 담고, 컨슈머는 이 리스트에 있는 것을 하나씩 빼와서 사용하는 프로그램에서,
프로듀서는 정해진 갯수만큼 리스트가 찼을 경우 blocked 상태가 된다. (즉 wait()) 또 컨슈머는 리스트가 비었을 경우 blocked 상태가 된다.
이 경우 프로듀서와 컨슈머 쓰레드는 서로가 서로에게 이제 깨어나서 작업을 이어가면 된다는 사실을 알려야하고, 그게 바로 notify()이다.

이를 코드로 짜면 다음과 같다.

List<String> list = new ArrayList<String>();

public synchronized void produce(String producerName, String bread) {
	while (list.size() >= 3) {
		try {
			System.out.println(producerName + " 대기 상태");
			this.wait();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	list.add(bread);
	System.out.println(producerName + ": " + bread + " 생산");
	this.notify();
}

public synchronized void consume(String consumerName) {
	while (list.size() == 0) {
		try {
			System.out.println(consumerName + " 대기 상태");
			this.wait();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	String bread = list.remove(list.size() - 1);
	System.out.println(consumerName + ": " + bread + " 구매");
	this.notify();
}

즉 await은 자신의 차례가 되었지만 원하는 선행 작업이 완료되지 않아 락을 반납하고 다시 기다리는 상태가 되는 것이고, notify는 해당 선행 작업이 끝났음을 알려주는 것이다.
그런데 여기서 주의할 점은 await()을 콜하고 해당 객체의 waiting pool에서 기다리는 쓰레드들 중 먼저 들어온 쓰레드가 먼저 락을 받지 않고, 기다리고 있는 쓰레드들 중 임의의 쓰레드에게 락을 건내준다는 것이다.(notifyAll을 해서 모두 notify를 받더라도 락은 한 쓰레드만 받는다)


Thread 우선 순위 지정

void setPriority(int newPriority)
int getPriority()

Thread Group

서로 관련된 쓰레드를 그룹으로 묶어서 다룰 수 있다. 쓰레드 그룹은 보안상 도입된 개념으로, 자신이 속한 쓰레드 그룹 혹은 하위 쓰레드 그룹만 변경 가능하다.

// 생성자를 이용하여 쓰레드를 쓰레드 그룹에 포함시킴
Thread(ThreadGroup group, String name)
// 예시
new Tread(new Cook(table), "COOK").start()

Demon Thread

데몬 쓰레드가 아닌 일반 쓰레드의 작업을 보조하는 역할을 하는 쓰레드이다.
일반 쓰레드와 같이 생성한 후, setDaemon(true)를 호출하여 데몬 쓰레드로 변환한다. 메인 메서드의 작업을 수행하는 메인 쓰레드에서 쓰레드를 새로 생성하고, setDaemon을 해주면, 이 쓰레드는 메인 쓰레드의 보조 쓰레드가 되어, 메인 쓰레드가 종료될 때 함께 종료된다.

join()

메인 메서드에서

thread.join();

을 해주면, 이 문장에서 메인 쓰레드가 thread의 작업이 끝날 때까지 기다린 후, 이후의 작업들을 수행한다.

profile
📝 dev wiki

0개의 댓글