읽는데 약 8분
멀티쓰레드 프로그래밍
Monitor lock and synchronized
세마포어를 활용하여 critical section을 해결하려고 하면 프로그래머가 신경써야할 부분이 많아 코드 작성이 굉장히 까다롭습니다. 이러한 문제점을 해결하기 위해서 JAVA에서는 monitor lock을 사용합니다.
Monitor은 mutual exclusion을 보장하면서 조건에 따라 쓰레드가 wait 상태로 전환가능한 역할을 제공합니다. 이러한 특징 때문에 멀티쓰레드 환경에서 특정 코드영역이 한번에 한 쓰레드만 실행되어야할 때 사용합니다.
mutual exclusion을 제공하기 위해서 내부적으로 mutex를 사용하며 조건에 따라 wait & signal을 위해 condition variable을 사용합니다.
mutex를 사용하는 부분부터 살펴보겠습니다.
빨간색 사각형이 mutex를 활용해 lock을 건 모습입니다. 이 때문에 노란색 부분이 critical section이 되었습니다. 현재 critical section을 Thread1
이 실행중인데 Thread2
가 접근한다면 어떻게 될까요?
mutex에 의해 lock이 걸려있으므로 임계영역에 진입하지 못합니다. 이 쓰레드는 mutex에 의해 관리되는 베타동기큐에서 대기하게 됩니다.
즉, 베타동기는 하나의 쓰레드만 공유자원에 접근하게 해주며 공유자원을 사용하는 쓰레드가 존재하면 베타동기큐에서 대기하게 됩니다.
자바에서는 베타동기를 선언하는 키워드로 synchronized
라는 키워드를 제공합니다. 연산결과가 메모리에 써질때까지 다른 쓰레드는 임계영역에 접근할 수 없습니다.
쓰레드1이 임계영역을 수행하다가 어떠한 이유로 인해 당장은 실행할 수 없는 상태가 되었다고 가정하겠습니다. 이 경우 현재 사용하고 있는 mutex를 반납한 후 잠시 기다려야합니다. 이 때문에 wait()
를 호출하면서 mutex를 반납하기 위해 m을 파라미터로 사용하게 됩니다.
condition variable은 자체적은 waiting queue를 가지고 있으므로 wait()
를 호출하면 CV 내 큐에 쓰레드를 저장하게 됩니다. 이 공간을 조건동기큐라고 합니다.
mutex를 반납하였으므로 베타동기큐에 있는 쓰레드가 mutex를 획득하여
임계영역에 들어올 수 있습니다. 이 쓰레드가 실행중 notify()
나 notifyAll()
을 만나게 되면 조건동기큐에 있는 쓰레드가 깨어나게 됩니다.
그렇다면 다시 임계영역이 비어있을 때 mutex를 획득하여 다시 코드를 수행할 수 있게됩니다.
자바에서는 조건동기를 관리하기 위해 wait()
,nofify()
, notifyAll()
을 제공합니다. Lock을 가진 쓰레드가 다른 목적을 위해 잠시 대기해야할 때는 wait()
를 호출하고 현재 조건동기큐에서 대기하고 있는 임의의 쓰레드를 깨우기 위해서 notify()
, 모든 쓰레드를 깨우기 위해 notifyAll()
을 사용합니다.
Atomoic
synchronized는 blocking을 통해 멀티쓰레드 환경에서 동기화를 제공합니다. 하지만 여러 쓰레드가 lock을 획득하려는 상황에서 하나의 쓰레드가 lock을 얻었다면 다른 쓰레드는 blocked 됩니다. 프로세스를 중지하고 재개하는 것은 굉장히 큰 비용이 발생하므로 성능의 문제로 직결됩니다.
이러한 문제점을 해결하기 위해서 자바에서는 Atomic을 제공합니다. 동시성을 보장하기 위해서는 원자성과 가시성을 제공해야하는데 CAS 알고리즘을 통해 원자성, volatile을 통해 가시성을 제공합니다. volatile에 대한 설명은 여기서 확인가능합니다.
CAS(Compare And Swap)
메모리에 쓰여있는 값과 쓰레드가 가장 최근에 읽어온 값을 비교해서 같다면 새로운 값으로 업데이트하는 연산입니다. counter++
와 같은 연산은 3개의 atomoic operation으로 이루어졌지만 Compare and Swap은 single atomic operation입니다. 아래 예시를 통해 살펴보겠습니다. 이 예시에서 사용하는 변수는 3가지가 있습니다.
- 메모리에 존재하는 값(V)
- 쓰레드가 마지막으로 읽어온 예전 값(A)
- 메모리에 쓰여질 새로운 값(B)
- thread1과 thread2가 V=10에서 값을 읽어와 각자 1씩 증가시키는 연산을 수행
- thread1이 먼저 수행, V= 10, A=10, B=11 이므로 V=11 업데이트 성공
- thread2가 다음으로 연산 수행, V=11, A=10, B=11 이므로 V와 A가 달라 업데이트 실패
- thread2가 다시 연산 수행, V=11, A=11, B=12 이므로 업데이트 성공, V=12
요약하자면 멀티쓰레드 환경에서 CAS를 이용해 변수를 업데이트하는 경우, 하나의 쓰레드가 업데이트에 성공하면 다른 쓰레드들은 실패합니다. 하지만 쓰레드를 중지하는 비용을 지불하지 않아도 되므로 금방 재시도해 부담이 적습니다.
Process vs Thread
프로그램은 어떤 목적을 달성하기 위해 컴퓨터의 동작을 하나로 모아 놓은 것을 의미하고 프로세스는 메모리에서 현재 실행되고 있는 프로그램을 의미합니다.
반면, 쓰레드는 하나의 프로세스 내에서 실행되는 흐름의 단위라고 할 수 있습니다. 하나의 프로세스는 하나 이상의 쓰레드를 가지며 각 쓰레드들은 프로세스내 자원을 공유합니다.
Thread Class
자바에서는 쓰레드를 제어하기 위해 클래스를 제공하며 여러 메서드를 사용할 수 있습니다. 아래는 static method 입니다.
.currentThread()
: 현재 실행중인 쓰레드를 가리키는 레퍼런스를 리턴합니다..sleep()
: 현재 실행중인 쓰레드를 특정 밀리세컨만큼 중지시킵니다..yield()
: 현재 실행중인 쓰레드가 CPU를 양보하여 대기중인 쓰레드에게 양보합니다.
다음은 instance method입니다. 해당 메서드에 대해 본격적으로 살펴보기 전 .start()
와 .run()
의 차이점에 대해 알아봅시다. 둘 다 쓰레드를 실행하지만 차이가 있습니다. 이를 이해하기 위해 status에 대해 알아야합니다.
Thread Status
생명주기 안에서 쓰레드는 6가지 상태를 가질 수 있습니다.
1. NEW
쓰레드가 생성되고 아직 실행이 되지 않은 상태입니다. .start()
메서드를 사용하기 전까지 이 상태에 머무르게 됩니다.
2. Runnable
새로운 쓰레드를 생성하고 .start()
메서드를 호출한 상태입니다. 이 때는 아래 두가지 중 하나에 속하게 됩니다.
- Running
- Ready to run
3. Blocked
하나의 쓰레드가 다른 쓰레드에 의해 잠긴 코드 섹션에 접근하기 위해 monitor lock을 기다리는 상태를 의미합니다.
💡 monitor lock이란?
자바의 모든 객체는 lock을 갖고 있습니다. 모든 객체가 가지고 있으므로 고유 락(intrinsic lock)이라고도 하며 모니터처럼 작동한다고 하여 모니터 락(monitor lock) 혹은 모니터(monitor)라고 합니다. 자바의synchronized
블록은 고유 락을 활용해서 동시성 문제를 해결합니다.
4. Waiting
특정 액션을 수행하기 위해 다른 쓰레드를 기다리는 상태입니다. JavaDocs에 따르면 아래 세개 메서드중 하나를 호출하여 진입할 수 있습니다.
object.wait()
thread.join()
LockSupport.park()
5. Time Waiting
정해진 시간동안 특정 액션을 수행하기 위해 다른 쓰레드를 기다리는 상태입니다. 아래 다섯가지 메서드를 호출하여서 해당 상태에 진입할 수 있습니다.
thread.sleep(long millis)
wait(int timeout)
orwait(int timeout, int nanos)
thread.join(long millis)
LockSupport.parkNanos
LockSupport.parkUntil
6. Terminated
죽은 쓰레드를 의미합니다. 수행을 종료하거나 비정상적으로 종료할 경우 해당 상태를 갖게 됩니다.
이제 Thread
class의 .start()
메서드를 살펴보겠습니다.
9번째줄에서 threadStatus가 0인 경우는 막 쓰레드를 생성한 NEW
를 의미합니다. .start()
를 호출하면 NEW
상태에서 RUNNABLE
상태로 변경해주는데 이는 실행될 수 있는 대기큐에 들어간 것을 의미합니다.
큐에 들어가기 위해서 필요한 것은 해당 쓰레드에 대한 메타데이터입니다. 어느 그룹에 속해있는지, 우선 순위는 어떻게 되는지에 대한 정보들이죠. 이러한 작업은 15번째 줄에서 이루어집니다.
큐에 올바르게 들어갔다면 start0()
native method를 이용해 최종적으로 .run()
메서드를 실행합니다.
요약하자면 .start()
는 생성한 쓰레드의 메타정보를 넘긴 후 override 된 run()
을 호출합니다.
getPriority() & setPriority()
자바의 thread priority는 1과 10 사이의 정수값을 가집니다. 숫자가 클수록 우선순위가 높은데 이 값을 통해 Thread Scheduler은 어떤 쓰레드가 실행될지 결정하게 됩니다.
하지만 항상 priority가 높은 쓰레드를 실행한 후 priority가 낮은 쓰레드를 실행하게 된다면 starvation이 발생하므로 각 쓰레드들이 CPU를 공정하지 못하게 사용하는 상황이 생깁니다. 즉, priority가 높다고 해서 우선적으로 실행되는 것은 맞으나 항상 적용이 되진 않습니다.
join()
.join()
메서드는 쓰레드가 종료될 때까지 WAITING 상태로 대기를 하고 끝나면 RUNNABLE 상태로 돌아갑니다.
package org.example;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread mainThread = Thread.currentThread();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("thread start");
System.out.println("2 ."+mainThread.getState());
System.out.println("thread end");
}
});
System.out.println("1. "+mainThread.getState());
thread.start();
thread.join();
System.out.println("3. "+mainThread.getState());
}
}
class DemoBlockedRunnable implements Runnable {
@Override
public void run() {
System.out.println("another thread start");
System.out.println("another thread end");
}
}
result>
1. RUNNABLE
thread start
2 .WAITING
thread end
3. RUNNABLE
Runnable interface
실행할 코드를 쓰레드로 실행하기 위해 사용하는 인터페이스입니다. 쓰레드를 실행하기 위해서는 Thread
class를 상속하거나 Runnable
interface를 구현해야합니다. 두 방법중 어떤 것을 사용하는 것이 적절할까요?
목적에 따라 다르지만 일반적으로 .run()
메서드만 오버라이딩할 경우 Runnable
interface를 구현하는 것이 더 적절합니다.
Thread vs Runnable
- Runnable의 구현체를 생성하고 Thread Class의 생성자로 전달하는 것은 inheritance 대신 composition을 활용하므로 더욱 flexible합니다.
- Thread class를 상속할 경우, 동시에 다른 클래스를 상속하지 못합니다.
- JAVA 8부터 single abstract method는 functional interface로 간주되어 lambda expression을 사용할수 있습니다. 즉 Runnable을 lambda expression으로 대체가능합니다.
요약하자면 Thread
class를 상속하여 기본동작을 수정하거나 개선할 것이 아니라면 Runnable
interface를 사용하는 것이 더 적절합니다.
User Thread and Daemon Thread
자바에는 user thread와 daemon thread가 있습니다. User Thread는 high-priority를 가지며 JVM은 모든 user thread에 대한 실행을 마친 뒤 종료합니다.
daemon thread는 user thread에 대한 보조적인 역할을 하는 쓰레드입니다. user thread 종료시 daemon thread도 강제적으로 같이 종료됩니다. 예를 들어 구글 Docs에는 자동 저장기능이 있습니다. 이를 위해 별도 쓰레드를 실행하며 구글 Docs 종료시 해당 기능 또한 사라져야합니다.
이처럼 daemon thread는 background support task에 적절하며 자바에서는 garbage collection을 수행할 때 daemon thread를 사용합니다.
새로운 쓰레드 생성시 쓰레드의 status는 생성한 쓰레드의 status에 영향을 받습니다. Main Thread에서 생성한 쓰레드의 daemon 여부는 false이므로 .setDaemon()
을 호출해서 상태를 변경해야합니다.
NewThread daemonThread = new NewThread();
daemonThread.setDaemon(true);
daemonThread.start();
아래는 Main thread에서 daemon thread를 호출 할 때 종료여부를 확인할 수 있는 예시입니다.
public class Main {
public static void main(String[] args){
Thread daemon = new DaemonThread();
daemon.setDaemon(true);
daemon.start();
}
}
class DaemonThread extends Thread{
@Override
public void run() {
for(int i=0;i<10;++i){
System.out.println(i);
}
}
}
result>
0
1
2
3
9까지 하지못하고 못하고 Main Thread 종료시 같이 종료됨을 확인할 수 있습니다. 실행할때마다 결과값이 바뀝니다.
DeadLock
쓰레드들이 일을 하지 않고 서로가 가지고 있는 자원에 대해 요구하는 상태를 뜻합니다.
Deadlock을 발생시키려면 아래 4가지 조건을 전부 만족해야합니다.
Condition
Mutual Exclusion(상호배제)
공유자원에 접근할 때 한 시점에 하나의 쓰레드만 접근이 가능해야합니다.
Hold-and-Wait(점유 대기)
부분 할당, 다른 종류의 자원을 부가적으로 요구하면서 이미 어떤 자원을 점유하고 있는 상태입니다.
No Preemption(비선점)
쓰레드가 점유하고 있는 자원은 중간에 제거할 수 없음을 의미합니다.
Circular wait(순환대기)
점유 대기 시, 그 구조가 순환적으로 이루어져야합니다.
위 네가지를 만족해야만 Deadlock이 발생하며 이를 예방하기 위해 위 네가지 조건 중 한가지라도 만족하지 못하면 됩니다.
Prevention
Circur Wait 예방하기
Lock을 획득할 때 모든 쓰레드에 동일 규칙을 적용하는 것입니다. L1,과 L2가 있는 경우 모든 쓰레드가 L1을 획득한 후 L2를 획득하면 순환 대기를 방지할 수 있습니다. 대표적인 방법으로 메모리 주소를 기준으로 순서를 부여합니다.
Hold-and-Wait 예방하기
사용하는 lock을 한번에 획득하도록 보장합니다. L1과 L2에 대해 atomcity를 부여함으로써 L1, L2를 동시에 얻거나 L1, L2를 동시에 얻지 못함을 보장합니다.
No Preemption 예방하기
하나의 쓰레드가 lock을 획득하고 다른 lock을 요구할 때 현재 보유하고 있는 lock을 계속 쥐고있는 대신 상황에 따라 lock을 포기하도록 하면 됩니다.
하지만 이는 lock을 얻고 포기하는 과정을 반복하는 livelock이 발생할 수 있다는 문제가 있습니다.
Mutual Exclusion 예방하기
공유 자원에 대해 여러 쓰레드가 접근하도록 합니다. 자바에서 AtomicVariable을 사용하는 것과 유사합니다. 공유 자원에 대해 원자적으로 실행되는 하드웨어 명령어(Compare And Swap)를 사용하는 것입니다. 상호 배제를 고려하지 않아도 되지만 제한적인 작업만 가능합니다.
ThreadLocal
쓰레드 영역에 변수를 저장합니다. 특정 쓰레드가 실행하는 모든 코드공간에서 쓰레드 영역에 접근하여 변수를 설정하거나 가져올 수 있습니다. 이때는 .get()
, .set()
메서드를 사용합니다.
ThreadLocal<Integer> threadLocalValue = new ThreadLocal<>();
threadLocalValue.set(1);
Integer result = threadLocalValue.get();
System.out.println(result);
result>
1
내부적으로 현재 쓰레드의 객체를 key로 사용하여 map에 데이터를 저장합니다.
활용
ThreadLocal은 한 쓰레드에서 실행되는 코드가 파라미터를 사용하지 않고 동일한 객체를 전파하기 위한 목적으로 사용됩니다.
- 사용자 인증정보 전파
- Spring Security에서는 Thread Local을 이용해 사용자 인증정보를 전파한다.
- 트랜잭션 컨텍스트 전파
- 트랜잭션 매니저는 트랜잭션 컨텍스트를 전파하기 위해 ThreadLocal을 사용한다.
ThreadLocal 사용시 주의사항
Thread Pool환경에서 ThreadLocal을 사용하는 경우 종료 시 해당 데이터를 삭제해야합니다. 아래 시나리오를 살펴보겠습니다.
Thread Pool 이란?
하나의 프로그램에서 단순히 여러 쓰레드를 생성해서 처리하는 것은 문제가 발생할 수 있습니다. Thread 생성비용이 크기 때문에 요청에 대한 응답이 길어질 뿐만 아니라 처리할 수 있는 요청의 크기를 넘게 되면 Thread가 무제한적으로 생겨 메모리 문제 및 CPU 오버헤드가 발생합니다. 이러한 문제점을 해결하기 위해 Thread Pool이 등장했습니다. 쓰레드 작업이 끝나게 되면 쓰레드를 종료하는 대신 쓰레드 풀에 해당 쓰레드를 저장하고 최대로 저장할 수 있는 쓰레드를 제한하는 역할을 합니다.
- 어플리케이션이 Pool에서 쓰레드를 빌립니다.
- 해당 ThreadLocal에 값을 저장합니다.
- 작업이 끝난 후 쓰레드를 반환합니다.
- 다른 요청으로 인해 어플리케이션이 Pool에서 쓰레드를 빌립니다.
- 이전 요청에 대한 ThreadLocal 값이 남아 있어 올바른 동작을 하지 않습니다.
ThreadLocal에 남아 있는 값을 제거하기 위해서 .remove()
를 사용하면 됩니다.
threadLocal.remove();
스터디에서 추가로 알게 된 내용
.join()
에서 파라미터로 시간을 넘기면TIME_WAITING
상태로 진입한다..stop()
는 모든 코드를 실행하지 않고 중간에 정지될수 있어 위험하다..interrupt()
권장.start()
의 호출 순서와 상관없이 쓰레드가 실행되고 종료된다. OS 스케쥴링에 의해 실행순서가 결정되기 때문이다.
JAVA의 thread는 OS thread와 ONE-TO-ONE mapping을 이룹니다. 각 thread마다 고유한 자원을 가지므로 생성할 수 있는 쓰레드가 한정됩니다.
Reference
- https://www.baeldung.com/java-threadlocal
- https://jenkov.com/tutorials/java-concurrency/threadlocal.html
- http://happinessoncode.com/2017/10/04/java-intrinsic-lock/
- https://www.baeldung.com/java-thread-lifecycle
- https://www.baeldung.com/java-thread-priority
- https://www.youtube.com/watch?v=Dms1oBmRAlo
- https://m.blog.naver.com/gngh0101/221174237333
- https://howtodoinjava.com/java/multi-threading/compare-and-swap-cas-algorithm/
- https://www.baeldung.com/java-runnable-vs-extending-thread#introduction
- https://docs.oracle.com/javase/10/docs/api/java/lang/Runnable.html