[Java] 쓰레드

seongminn·2022년 7월 1일
0

Java

목록 보기
3/3
post-thumbnail

📌 쓰레드

프로세스란, 실행 중인 프로그램을 의미한다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원, 즉 메모리를 할당받아 프로세스가 된다. 프로세스는 프로그램을 수행하는 데에 필요한 데이터와 메모리 등의 자원과 쓰레드로 구성되어 있는데, 이 자원을 이용하여 실제로 작업을 수행하는 것이 바로 쓰레드이다.

모든 프로세스에는 하나 이상의 쓰레드가 존재하고, 둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스라고 한다. 쓰레드가 작업을 수행하는데 개별적인 메모리를 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정되지만, 한계에 다다를 정도로 많은 양의 쓰레드를 생성하는 일은 거의 없을 것이다.


1. 프로세스와 쓰레드

멀티태스킹과 멀티쓰레딩
멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업하는 것을 말한다. CPU의 코어core가 한번에 하나의 작업만 수행할 수 있기 때문에, 실제로 동시에 처리되는 작업의 수는 코어의 개수와 일치한다. 하지만 처리해야 할 쓰레드의 수는 대부분 코어의 개수보다 많기 때문에, 각 코어는 여러 작업을 아주 짧은 시간동안 번갈아 가며 수행한다. 그렇기 때문에 프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니다.

👍 멀티쓰레딩의 장점

  • CPU 사용률을 향상시킨다.
  • 자원을 보다 효율적으로 사용할 수 있다.
  • 사용자에 대한 응답성이 향상된다.
  • 작업이 분리되어 코드가 간결해진다.

싱글쓰레드로 서버 프로그램을 작성한다면 사용자의 요청이 들어올 때마다 프로세스를 생성해야 한다. 하지만 프로세스를 생성하는 것은 쓰레드를 생성하는 것에 비해 더 많은 시간과 메모리를 필요로 하기 때문에 훨씬 비효율적이다.

👎 멀티쓰레딩의 단점

  • 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업하기 때문에 동기화, 교착상태 등이 발생할 수 있다.

교착상태

데드락deadlock이라고도 하며, 두 쓰레드가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행이 멈춘 상태를 의미한다.

먼길
		-윤석중
아기가 잠드는 걸
보고 가려고
아빠는 머리맡에
앉아 계시고.
아빠가 가시는 걸
보고 자려고
아기는 말똥말똥
잠을 안 자고.

2. 쓰레드의 구현과 실행

쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다. 하지만 Thread 클래스를 상속받게 되면 다른 클래스를 상속받을 수 없기 때문에 일반적으로는 Runnable 인터페이스를 구현한다.

Runnable 인터페이스를 구현한 방법은 재사용성이 높고 코드의 일관성을 유지할 수 있기 때문에 보다 객체지향적인 방법이라 할 수 있다. 인터페이스를 구현하기 위해서 해야 할 일은 추상메서드인 run()의 몸통을 채워주는 것 뿐이다.

class MyThread implements Runnable {
	public void run() {} // 몸통 부분
}

인스턴스 생성 방법

  • Thread 클래스를 상속받은 경우
ThreadEx1_1 t1 = new ThreadEx1_1();
  • Runnable 인터페이스를 구현한 경우
Runnable r = new ThreadEx1_2();
Thread t2 = new Thread(r);

위와 같이 Runnable 인터페이스를 구현한 경우에는, Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성한 뒤, 이를 Thread 클래스의 생성자의 매개변수로 제공해야 한다.

메서드 호출
Thread 클래스를 상속받은 경우에는 직접 호출이 가능하다. 하지만 Runnable 인터페이스를 구현한 경우에는 currentThread()를 호출하여 쓰레드에 대한 참조를 얻어와야만 한다. 그래서 getName()을 호출하기 위해서는 Thread.currentThread().getName();와 같이 해야 한다.

이름 변경
생성자나 메서드를 통해서 지정하거나 변경할 수 있다.

  • Thread(Runnable target, String name)
  • Thread(String name)
  • void setName(String name)

쓰레드의 실행
start()를 호출하여 실행할 수 있다. 정확하게는 start()를 호출하면 실행 대기상태가 되고 자기 차례를 기다린 후, 차례가 오면 실행된다.

참고로, 하나의 쓰레드는 단 한번만 실행이 가능하기 때문에 같은 작업을 한번 더 수행해야 한다면 새로운 쓰레드를 생성한 뒤 start()를 호출해야만 한다.


3. start()와 run()

main 메서드에서 run()을 호출하는 것은 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐이다.

반면 start()는 새로운 쓰레드가 작업을 실행하는 데에 필요한 호출스택을 생성한 뒤 run()을 호출하여, 생성된 호출스택에 run()이 첫번째로 올라가게 한다.

스케줄러는 실행대기 중에 있는 쓰레드의 우선순위를 고려하여 실행순서와 실행시간을 결정하고 작업을 수행한다. 작업을 마친 쓰레드, 즉 run()의 수행이 종료된 쓰레드는 호출스택이 모두 비워지게 되면서 결국 사라진다.

만일 한 쓰레드에서 예외가 발생해 종료되어도 다른 쓰레드의 실행에는 영향을 미치지 않는다. 그렇기 때문에 예외가 발생하지 않은 쓰레드의 호출스택은 비워지고 사라진다.


4. 싱글쓰레드와 멀티쓰레드

두 개의 작업을 싱글쓰레드와 멀티쓰레드로 수행했을 때, 싱글쓰레드로 수행한 경우에 더 적은 소요시간을 갖는다. 이는 작업 전환을 할 때, 정보를 저장하고 읽어 오는 시간을 필요로 하기 때문이다.

하지만, 위의 경우는 두 쓰레드가 서로 같은 자원을 사용하는 경우이고, 그렇지 않은 경우에는 멀티쓰레드 프로세스가 더 효율적이다.

예를 들어 사용자로부터 데이터를 입력받는 작업을 할 때, 싱글쓰레드의 경우 사용자의 입력을 기다리는 동안 아무 작업도 하지 않고 기다리기만 한다. 멀티쓰레드로 처리한다면 사용자의 입력을 기다리는 동안 다른 쓰레드가 작동하기 때문에 훨씬 효율적으로 CPU를 사용할 수 있다.


5. 쓰레드의 우선순위

쓰레드는 우선순위라는 속성을 갖고 있는데, 이 우선순위 값에 따라 쓰레드가 얻는 실행시간이 달라진다. 그래서 쓰레드가 수행하는 작업의 중요도에 따라 우선순위를 다르게 하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 변경
int getPriority() // 쓰레드의 우선순위 반환

이 때, 우선순위의 범위는 1 ~ 10이고, 숫자가 높을수록 우선순위가 높다. 또한, 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다.


6. 쓰레드 그룹

쓰레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것으로 폴더의 개념과 비슷하다. 여러 쓰레드를 그룹으로 묶어서 관리할 수 있고, 쓰레드 그룹 내에 다른 쓰레드 그룹을 포함시킬 수도 있다.

이는 보안상의 이유로 도입된 개념인데, 자신이 속한 쓰레드 그룹이나 하위 그룹은 변경할 수 있지만 다른 그룹의 쓰레드를 변경할 순 없다.

쓰레드 그룹에 포함시키기 위해서는 Thread의 생성자를 이용한다.

Thread(ThreadGroup group, String name)
Thread(ThreadGroup group, Runnable target)
Thread(ThreadGroup group, Runnable target, String name)
Thread(ThreadGroup group, Runnable target, String name, long stackSize)

모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 한다. 하지만 쓰레드 그룹을 지정하지 않은 경우에는 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다.


7. 데몬 쓰레드

데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조 쓰레드이다. 대표적으로 가비지 컬렉터, 워드 프로세서의 자동저장, 화면 자동갱신 등이 있다.

쓰레드를 생성한 뒤 setDaemon(true)를 호출하여 데몬 쓰레드임을 선언해주면 된다. 이렇게 생성된 데몬 쓰레드는 무한 루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기한다. 이후, 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 종료된다.


8. 쓰레드의 실행제어

효율적인 멀티쓰레드 프로그램을 만들기 위해서는 정교한 스케줄링을 통해 프로세스에 주어진 자원과 시간을 여러 쓰레드가 낭비없이 사용하도록 프로그래밍 해야 한다. 그러기 위해서 쓰레드의 상태와, 관련 메서드를 잘 알아야 한다.

sleep(long millis)
지정된 시간동안 쓰레드를 멈추게 한다. sleep()에 의해 멈춘 쓰레드는 지정한 시간이 지나거나, interrupt()가 호출되면 잠에서 깨어나 실행대기 상태가 된다.

이 때, sleep()은 현재 실행 중인 쓰레드에 작동하기 때문에, 참조변수를 이용해서 호출하기 보다는 Thread.sleep(2000); 등과 같은 방법으로 호출해야 한다.

interrupt()와 interrupted()
interrupt()는 진행중인 쓰레드의 작업을 멈추라고 요청한다. 이 때, 멈추라고 하는 것일 뿐 강제적으로 종료시키는 것은 아니다.

그리고 interrupted()는 쓰레드에 대해 interrupt()가 호출되었는지 true 혹은 false를 반환하여 알려준다.

sleep()에 의해 일시정지된 쓰레드에 interrupt()를 호출하면 InterruptedExceoption이 발생하면서 쓰레드의 interrupted 상태는 false로 자동 초기화 된다. 이 때는 catch 블럭에 interrupt()를 추가로 넣어줘서 interrupted 상태를 true로 바꿔줘야 한다.

suspend(), resume(), stop()
suspend()sleep()처럼 쓰레드를 멈추게 한다. 이로 인해 멈춘 쓰레드는 resume()를 호출하여 다시 실행대기 상태가 된다. 그리고 stop()은 쓰레드를 즉시 종료한다.

이들은 쓰레드의 실행을 제어하는 가장 쉬운 방법이지만, 교착상태를 일으키기 쉽게 작성되어 있으므로 사용하지 않을 것을 권장한다.

yield()
yield()는 자신에게 주어진 실행시간을 다음 차례의 쓰레드에 양보한다. yield()interrupt()를 적절히 활용하면 프로그램의 응답성을 높이고 보다 효율적인 실행을 할 수 있도록 한다.

join()
쓰레드 자신이 하던 작업을 멈추고, 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 한다. 작업 도중 다른 쓰레드의 작업이 먼저 수행되어야 할 필요가 있을 때 사용한다.

sleep()과 마찬가지로 interrupt()에 의해 대기상태에서 벗어날 수 있으며, 호출되는 부분을 try-catch문으로 감싸야 한다. 다만, join()은 현재 실행 중인 쓰레드가 아닌 특정 쓰레드에 대해 동작한다는 점이 다르기 때문에 static 메서드가 아니다.

9. 쓰레드의 동기화

멀티쓰레드 프로세스에서는 여러 쓰레드가 같은 프로세스의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다. 결국 작업을 마쳤을 때 원래 의도했던 것과는 다른 결과를 얻을 수도 있다. 이러한 일이 발생하는 것을 막기 위해 도입된 개념이 임계 영역잠금이다.

임계 영역은 공유 데이터를 사용하는 코드 영역을 의미하고, 잠금은 해당 임계 영역 내의 코드 수행을 허락하는 열쇠와 같은 것이다. 그래서 공유 데이터가 갖고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다. 그리고 모든 코드를 실행하고 나면 lock을 반납하여 다른 쓰레드가 다시 lock을 획득할 수 있도록 한다.

이처럼 한 쓰레드가 작업을 진행하고 있을 때, 다른 쓰레드가 해당 작업에 간섭하지 못하도록 막는 것을 바로 쓰레드의 동기화라고 한다.


9-1. synchronized를 이용한 동기화

synchronized 키워드는 임계 영역을 설정하는 데에 사용된다. 메서드 앞에 붙었을 경우에는, 해당 메서드 전체가 임계 영역으로 지정된다.

두번째로, 메서드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 synchronized (참조변수)를 붙이는 방법이 있다. 이 때, 해당 블럭을 synchronized 블럭이라고 하는데, 이 블럭 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻는다. 그리고 참조변수는 lock을 걸고자 하는 객체를 참조해야 한다.

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


9-2. wait()와 notify()

synchronized를 사용했을 때, 특정 쓰레드가 객체의 lock을 가진 채로 오랜 시간을 보내는 경우가 발생할 수 있다. 이 경우, 다른 쓰레드는 객체의 lock을 기다리느라 다른 작업을 원활히 수행할 수 없다.

이를 개선하기 위해 고안된 것이 바로 wait()notify()다. 임계 영역의 코드를 수행하다가 더 이상 작업을 진행할 상황이 아니면 wait()를 호출하여 lock을 반납하고 다른 쓰레드가 작업을 수행할 수 있도록 한다. 이후, 중단했던 작업을 수행할 준비가 되면 notify()를 호출하여 바로 lock을 얻어 작업을 진행할 수 있도록 한다.

하지만, notify()를 호출하면 대기 중에 있던 모든 쓰레드 중 임의의 쓰레드에만 통보를 한다. 그래서 기아 현상이 발생할 가능성이 있다.

기아 현상과 경쟁 상태

notify()를 호출하면 대기 중에 있던 쓰레드 중에서 얼마나 오래 기다렸는지와는 관계 없이 임의의 쓰레드에 통보를 하여 해당 쓰레드가 작업하도록 한다. 결국 운이 나쁜 경우에는 계속해서 통지를 받지 못하고 기다리는 상황이 발생할 수 있다. 이를 기아 현상이라고 한다.

이 현상을 막기 위해서는 notifyAll()을 사용하여 모든 쓰레드에 통지를 해야 한다. 하지만 이 경우에는 통지할 필요가 없는 쓰레드에까지 통지를 하여, 불필요한 lock 경쟁을 하게 된다. 이처럼 여러 쓰레드가 lock을 얻기 위해 경쟁하는 것을 경쟁 상태라고 하는데, 이를 개선하기 위해서는 쓰레드를 구별하여 통지하는 것이 필요하다.


9-3. Lock과 Condition

synchronized 블럭으로 동기화를 하면 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 존재한다. 이러한 불편함을 개선하기 위해 lock 클래스를 사용한다.

ReentrantLock
ReentrantLock은 재진입이 가능한 lock이다. wati()notify()에서 배운 것처럼, 특정 조건에서 lock을 풀고 다시 lock을 얻고 임계영역으로 들어와 작업을 수행할 수 있기 때문이다.

ReentrantReadWriteLock
ReentrantReadWriteLock은 읽기를 위한 lock과 쓰기를 위한 lock을 별도로 제공한다. 읽기는 내용을 변경하지 않기 때문에 여러 쓰레드가 동시에 읽어도 문제가 되지 않지만, 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않는다. 반대의 경우도 마찬가지이다.

StampedLock
StampedLocklock을 걸거나 해제할 때 스탬프를 사용하며, 읽기와 쓰기를 위한 lock 이외에 낙관적 읽기 lock이 추가된 것이다. 읽기 lock이 걸려 있는 상태에서 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야 한다. 하지만, 낙관적 읽기 lock은 쓰기 lock에 의해 바로 해제된다.

그렇기 때문에 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것이다.

ReentrantLock의 생성자
두 개의 생성자를 갖는다.

  • ReentrantLock()
  • ReentrantLock(boolean fair)

매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 얻을 수 있도록, 즉 공정하게 처리한다. 하지만 대부분의 경우 공정하게 처리하지 않아도 문제가 되지 않는다.

ReentrantLock과 같은 lock 클래스들은 메서드를 호출하여 수동으로 lock을 잠그고 해제해야 한다.

ReentrantLock과 Condition
Conditionwait()notify()가 대기 중인 쓰레드를 구분하여 통지하지 않는다는 단점을 보완한다. Condition은 대기상태에 있는 쓰레드를 구분하기 때문에 원하는 컨디션에 있는 쓰레드에 통지할 수 있다.


9-4. Volatile

코어는 메모리에서 읽어온 값을 캐시에 저장하고, 캐시에서 값을 읽어서 작업한다. 하지만 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아서 출력값이 다른 경우가 발생할 수 있다. 이 때, 변수 앞에 volatile을 붙이면, 코어가 변수값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 이러한 오류를 해결할 수 있다.


9-5. fork & join 프레임워크

하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어주는 프레임워크이다. RecursiveActionRecursiveTask, 두 클래스 중 하나를 상속받아 구현해야 한다.


--

참고 도서

🙇🏻‍♂️ 남궁 성, 자바의 정석

profile
돌멩이도 개발 할 수 있다

0개의 댓글