티스토리 뷰
스레드란 무엇인가?
스레드란 프로세스의 기본 실행 단위입니다. 현대의 거의 모든 소프트웨어들은 하나의 프로세스에 다중 스레드를 가지고 있습니다. 스레드는 고유한 레지스터, 스택, 프로그램 카운터(PC)를 가지고 있으며, 동일한 프로세스 내에 있는 다른 스레드와 동일한 메모리 공간을 공유합니다.
🤔 프로세스와 스레드의 차이점이 뭘까?
- 프로세스는 운영체제에 의해 자원을 할당받는 것이고, 스레드는 프로세스가 할당 받은 자원을 사용하여 실행하는 기본 실행 단위입니다.
🤔 스택이 각 스레드마다 독립적으로 할당되어 있는 이유가 뭘까?
- 우선 스택은 함수 호출시 함수의 지역변수, 매개변수, 반환 주소값을 가지고 있는 데이터 영역입니다.
- 첫째, 스레드 안전성입니다. 각 스레드는 다른 스레드를 덮어 쓰지 않도록 자체 스택이 필요합니다. 만약 모든 스레드가 동일한 스택을 공유하게 된다면 스레드의 안전성을 보장하기 어렵고 데이터 손상을 방지하기 어렵습니다.
- 둘째, 효율성입니다. 만약 모든 스레드들이 동일한 스택을 공유하게 된다면 잠금이나 동기화 매커니즘이 필요로 하게 됩니다. 하지만 독립적으로 존재하게 된다면 이러한 내용들이 필요없으므로 효율성을 증가시킬 수 있습니다.
- 이렇게 각각의 스레드에 별도의 스택을 할당하게 되면 스레드가 데이터 충돌 및 실행을 방해하지 않고 독립적으로 작동할 수 있으며 효율적인 메모리 관리 및 스레드 안전성이 높아집니다.
🤔 레지스터기 각 스레드마다 독립적으로 할당되어 있는 이유가 뭘까?
- 우선 레지스터란 프로그램 실행 중에 중간 값과 피연산자를 저장하는데 사용되는 CPU 내에 있는 메모리 공간입니다. 스레드는 레지스터 값을 저장하기 위해 자체 개인 작업 공간이 필요하기 때문에 각 스레드에는 고유한 레지스터가 있습니다.
- 첫째, 스레드 안전성입니다. 각 스레드는 다른 스레드에 의해 레지스터 값이 영향을 받지 않게끔 하기 위해 고유한 레지스터가 필요합니다. 만약 여러 스레드가 동일한 레지스터를 공유하게 된다면 데이터가 손실될 우려가 있습니다.
- 둘째, 효율성입니다. 만약 모든 스레드들이 동일한 레지스터를 공유하게 된다면 잠금이나 동기화 매커니즘이 필요로 하게됩니다. 하지만 독립적으로 존재하게 된다면 이러한 내용이 필요없으므로 효율성을 증가시킬 수 있습니다.
🤔 PC가 각 스레드마다 독립적으로 할당되어 있는 이유가 뭘까?
- 우선 프로그램 카운터(PC)란 스레드가 다음에 실행되어야할 명령어를 담고 있는 값입니다.
- 첫째, 스레드 안전성입니다. 각 스레드는 다른 스레드에 의해 PC값이 영향을 받지 않게끔 하기 위해 자체 프로그램 카운터가 필요합니다. 만약 여러 스레드가 프로그램 카운터를 공유하게 된다면 스레드 안전성과 데이터가 손실될 우려가 있습니다.
- 둘째, 제어 흐름을 관리하기 위함입니다. 각 스레드는 올바른 명령어를 실행하기 위해 자체 프로그램 카운터를 관리해야 합니다. 만약 여러 스레드가 동일한 프로그램 카운터를 공유하는 경우 동일한 명령어를 실행함으로 동시 실행에는 적절하지 않습니다.
🤔 스레드의 장점이 뭘까?
- 자원 공유입니다. 프로세스는 공유 메모리와 메시지 전달 기법을 통해 자원을 공유할 수 있습니다. 그러나 스레드는 하나의 프로세스내에 있는 메모리 공간과 파일, 네트워크 등과 같은 자원을 공유할 수 있습니다. 이를 통해 자원을 효과적으로 사용하고 오버헤드를 줄일 수 있습니다.
- 경제성입니다. 보통 프로세스를 생성하기 위해 운영체제로부터 자원을 할당 받는 것은 비용이 많이 듭니다. 반면 스레드는 자신이 속한 프로세스의 자원을 공유하기 때문에 스레드를 생성하고 context-switch를 하는 것이 더욱 더 경제적입니다.
병렬성과 병행성
💡 병행성이란?
- 병행성(Concurrency)은 동시성이라 표현하기도 합니다.
- 스레드들을 동시에 병렬적으로 실행하는 것처럼 보이지만 실은 context-switch를 사용하여 동시에 실행되는것처럼 보이게 합니다.
💡 병렬성이란?
- 병렬성(Parallelism)은 실제로 동시에 실행하는 것을 의미합니다.
- 멀티 코어에서 멀티 스레드를 동작시키는 것입니다.
- 병렬성은 데이터 병렬성과 작업 병렬성으로 구분할 수 있습니다.
데이터 병렬성
- 크기가 N인 배열이 있다 가정합니다. 이 배열의 합을 구할 때, 이 배열을 나누어 서브 데이터를 만든 뒤 서브 데이터를 병렬 처리하여 작업을 빠르게 수행하여 합을 구하는 것입니다.
- 자바 8에서 지원하는 병렬 스트림이 데이터 병렬성을 구현한 것입니다.
- 서브 데이터는 멀티 코어의 수만큼 데이터를 쪼개어 각각의 데이터를 분리된 스레드에서 병렬로 작업한 뒤 합치는 과정을 수행합니다. (Fork Join)
작업 병렬성
- 서로 다른 작업을 병렬적으로 처리하는 것입니다.
- 예를들어 웹 서버는 각각의 브라우저에서 요청한 내용을 개별 스레드에서 병렬적으로 처리합니다.
다중 스레드 모델
스레드를 위한 자원은 사용자 스레드와 커널 스레드가 있습니다. 사용자 스레드는 커널 위에서 지원되며, 커널의 지원없이 관리됩니다. 반면 커널 스레드는 운영체제에 의해 직접 지원되고 관리됩니다. 또한 사용자 스레드와 커널 스레드간에는 여러 연관관계가 존재합니다.
💡 다대일 모델 또는 사용자 수준 스레드
- 다대일(many-to-one)은 스레드를 관리하는 라이브러리로 인해 사용자 단에서 생성 및 관리되는 스레드입니다. 그렇기 때문에 커널이 따로 관리하지 않고, 커널은 스레드에 대해 알지 못합니다.
- 하나의 사용자 스레드가 동기 시스템 콜을 호출할 경우 전체적인 프로세스가 대기하게 됩니다. 또한 한 번에 하나의 스레드만이 커널 스레드에 접근할 수 있으므로 다중 스레드가 다중 코어 시스템에서 병렬로 실행될 수 없습니다.
- 예를들어 입출력 인터럽트가 발생하면 커널은 사용자 모드가 되어서 사용자 수준 스레드의 응답을 기다리게 됩니다. 이때 사용자 수준 스레드로부터 응답이 오면 다시 커널 모드로 변환되어 이어서 커널 스레드가 일 처리를 하게 됩니다.
🤔 커널에는 사용자 모드와 커널 모드가 있는데 무슨 차이점이 있을까?
사용자 모드
- 사용자 모드는 사용자 수준 프로그램과 프로세스가 실행되는 모드입니다. 사용자 모드에서는 하드웨어의 리소스에 대한 접근이 제한되며, 특정 명령을 실행하거나 특정 메모리에 접근할 수 없습니다. 그렇기 때문에 사용자 수준 스레드는 시스템 콜을 호출하여 자신이 하고자 하는 일을 운영체제에 서비스를 요청해야 합니다.
커널 모드
- 커널 모드는 운영체제 커널이 실행되는 모드이며, 최고 수준의 권한과 접근 권한을 가집니다. 커널 모드에서 운영체제는 메모리, CPU 및 I/O 장치를 포함하여 시스템의 하드웨어 자원을 제어합니다.
차이점
- 커널 모드와 사용자 모드의 주요 차이점은 권한입니다. 커널 모드는 최고 수준의 권한을 가지며, 사용자 모드는 제한된 수준의 권한을 가지게 됩니다.
💡 일대일 모델 또는 커널 수준 스레드
- 일대일(one-to-one)은 사용자 스레드를 각각 하나의 커널 스레드로 매칭하는 것입니다.이때 사용자 스레드가 동기 시스템을 콜을 호출하더라도 다른 스레드가 실행될 수 있기 때문에 다대일 모델보다 더 많은 병렬성을 제공할 수 있습니다.
- 일대일 모델의 단점은 사용자 스레드를 만들려면 맞물리는 커널 스레드를 만들어야 하기 때문에 많은 수의 커널 스레드가 시스템 성능에 부담을 줄 수 있습니다.
💡 다대다 모델
- 다대다(many-to-many)는 여러 개의 사용자 수준 스레드를 그보다 작은 수, 혹은 같은 수의 커널 스레드로 멀티 플렉스합니다.
- 커널 스레드의 수는 응용 프로그램이나 특정 기계에 따라 결정되게 됩니다.
- 한 사용자 스레드가 하나의 커널 스레드에만 매핑되도록 하는 것을 허용합니다.
- 다대다 모델 또는 두 수준 모델을 구현하는 많은 시스템은 사용자 수준 스레드와 커널 수준 스레드 사이에 중간 자료구조를 둡니다. 이 자료구조는 경량 프로세스(Light-Weight-Process)라 불립니다.
- 예를들어 입출력이 완료되길 기다리는 동안 커널 수준 스레드가 블락킹되면 LWP도 같이 블락킹됩니다. 그리고 이에 따라 LWP에 소속된 사용자 수준 스레드도 역시 블락킹되게 됩니다.
자바의 스레드
Java에서는 Thread와 Runnable 인터페이스를 사용하여 스레드를 생성할 수 있습니다.
💡 Thread 인터페이스를 사용한 경우
- 아래 예제 코드에서는 Thread의 run 메서드가 아닌 start 메서드를 호출하고 있습니다. 여기서 run 메서드를 호출하는 것은 메인 스레드에서 객체의 메서드를 호출하는 것입니다. 이를 별도의 스레드로 실행시키기 위해서는 JVM의 도움이 필요로 하게 됩니다. start 메서드를 호출하면 내부적으로 run 메서드를 호출하게 됩니다.
public class Main {
public static void main(String[] args) {
Thread thread = new MyThread();
thread.start();
System.out.println("main : "+ Thread.currentThread().getName());
}
public static class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread : " + Thread.currentThread().getName());
}
}
}
// 출력 결과
// MyThread : Thread-0
// main : main
Thread 클래스의 start 메서드는 내부적으로 다음과 같은 과정으로 진행됩니다.
- 스레드가 실행 가능한 상태인지 확인합니다.
- 스레드는 새로운 상태(0) 인지 확인하게 됩니다. 만약 새로운 상태가 아니라면 예외가 발생하게 됩니다.
- 스레드 그룹에 스레드를 추가하게 됩니다.
- 스레드 그룹에 해당 스레드를 추가하게 됩니다. 스레드 그룹이란 서로 연관되어 있는 스레드를 하나의 스레드 그룹으로 묶어 다루기 위함이며 자바에서는 ThreadGroup 클래스를 제공하고 있습니다. 스레드 그룹에 해당 스레드를 추가하면 스레드 그룹에 실행 가능한 스레드가 있음을 알려주고 내부적으로 실행되게 됩니다.
- JVM이 스레드를 실행시킵니다.
- JVM에 의해 네이티브 메서드인 start0 메서드가 호출됩니다. 이것이 내부적으로 run 메서드를 호출합니다. 또한 start 메서드를 여러번 호출하는 것은 불가능하고 1번만 호출할 수 있습니다.
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
private native void start0();
💡 Runnable 인터페이스를 사용한 경우
public class Main {
public static void main(String[] args) {
Runnable runnable = () -> System.out.println("MyThread : " + Thread.currentThread().getName());
Thread thread = new Thread(runnable);
thread.start();
System.out.println("main : "+ Thread.currentThread().getName());
}
}
// 출력 결과
// MyThread : Thread-0
// main : main
암묵적 스레딩
암묵적 스레딩이란 스레드의 생성과 관리 책임을 응용 개발자로부터 컴파일러와 실생시간 라이브러리에게 넘겨주는 것입니다.
💡 스레드 풀이란?
- 스레드 풀은 작업 처리에 사용되는 스레드를 제한된 개수만큼 생성해 놓고, 작업 큐에 들어오는 작업들을 하나의 스레드가 맡아서 처리하는 기법입니다.
🤔 스레드 풀을 사용하는 이유가 뭘까?
- 매 요청마다 스레드를 만들어 작업을 한다면 동시에 실행할 수 있는 최대 스레드 수가 몇개까지 가능한지 정해야 합니다. 또한 스레드를 무한정으로 만들게 된다면 메모리 공간, CPU 시간이 고갈되게 됩니다. 그렇기 때문에 한정적인 자원을 효과적으로 사용하기 위해 스레드 풀이 필요합니다.
- 병렬 작업 처리가 많아지면 스레드의 개수가 증가하게 됩니다. 그에 따라 CPU가 바빠지고 메모리 사용량이 증가하게 됩니다. 이는 성능저하의 문제가 되며, 이를 방지하고자 스레드 풀을 만들게 됩니다.
🤔 자바에서의 스레드 풀은 어떤게 있나?
- 자바에서는 java.util.concurrent 패키지에서 Executors 클래스를 제공하고 있습니다. Executors 클래스의 정적 메서드를 사용하여 ExecutorService 라는 인터페이스의 구현체를 생성할 수 있습니다.
- 해당 구현체에는 newCachedThreadPool, newFixedThreadPool이 있습니다. 이 두 스레드 풀은 내부적으로 ThreadPoolExecutor를 사용하고 있습니다.
🤔 데몬 스레드란 무엇인가?
- 데몬 스레드는 주 스레드의 작업을 돕는 보조 스레드입니다. 스레드 풀에 속한 스레드는 기본적으로 데몬 스레드가 아니기 때문에 main 스레드가 종료되어도 작업을 처리하기 위해 계속 실행 상태로 남아있게 됩니다. 즉 main 메서드의 실행이 끝나도 스레드가 종료되지 않습니다. 하지만 데몬 스레드를 사용하면 main 스레드가 종료되면 데몬 스레드 역시 종료 됩니다. 그 이유는 주 스레드가 종료되면 보조 스레드의 존재가 의미 없어지기 때문입니다. 이를 제외하고는 데몬 스레드와 일반 스레드의 차이점은 크게 없습니다.
🤔 Thread Pool에게 작업 위임
- 보통 execute 메서드와 submit 메서드를 호출할 수 있습니다. 이 두 메서드에는 차이점이 존재합니다.
execute
- 작업의 처리 결과를 반환하지 않습니다.
- 작업 중 예외가 발생하면 해당 스레드는 종료되고, 스레드 풀에서 제거됩니다.
- 예외가 발생한 스레드가 종료되고 스레드 풀에서 제거되어 스레드 풀은 새로운 스레드를 생성하게 됩니다.
submit
- 작업의 처리 결과를 반환합니다.
- 작업 중 예외가 발생하더라도 해당 스레드는 종료되지 않고 다음 작업을 위해 재사용됩니다.
- 스레드의 생성 오버헤드를 줄이기 위해 가급적 submit 메서드를 사용하는게 유리합니다.
💡 execute 메서드를 호출한 결과
- 스레드를 2개를 사용하겠다 선언하였지만 오류가 발생하여 새로운 스레드가 만들어짐을 알 수 있습니다.
private static final ExecutorService executor = Executors.newFixedThreadPool(2);
@Test
void runnable() throws InterruptedException {
int threadCount = 10;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.execute(() -> {
try {
ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
int poolSize = threadPoolExecutor.getPoolSize();
String threadName = Thread.currentThread().getName();
System.out.println("[총 스레드 개수:" + poolSize + "] 작업 스레드 이름: " + threadName);
int value = Integer.parseInt("예외 발생 시킴");
} finally {
latch.countDown();
}
});
}
latch.await();
}
💡 submit을 사용한 결과
- 예외가 발생하더라도 스레드 풀이 새로운 스레드를 만들지 않고 재사용함을 알 수 있습니다.
private static final ExecutorService executor = Executors.newFixedThreadPool(2);
@Test
void runnable() throws InterruptedException {
int threadCount = 10;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
int poolSize = threadPoolExecutor.getPoolSize();
String threadName = Thread.currentThread().getName();
System.out.println("[총 스레드 개수:" + poolSize + "] 작업 스레드 이름: " + threadName);
int value = Integer.parseInt("예외 발생 시킴");
} finally {
latch.countDown();
}
});
}
latch.await();
}
🤔 해당 챕터를 읽으며 생각해볼 수 있는 질문들
- 프로세스와 스레드의 차이점이 무엇인가?
- 스레드마다 스택이 독립적으로 할당되어 있는 이유가 무엇인가?
- 스레드마다 레지스터가 독립적으로 할당되어 있는 이유가 무엇인가?
- 스레드마다 프로그램 카운터(PC)가 독립적으로 할당되어 있는 이유가 무엇인가?
- 스레드의 장점이 무엇인가?
- 커널에는 사용자 모드와 커널모드가 있는데 무슨 차이점이 있는가?
- 자바에서 Thread를 실행할 때 start 메서드와 run 메서드의 차이점이 무엇인가?
- 스레드 풀을 사용하는 이유가 무엇인가?
- 데몬 스레드란 무엇인가?
- 질문에 대한 답변은 깃허브에 있습니다. -> 깃허브로 이동
참고자료
- https://mangkyu.tistory.com/258
- https://helloinyong.tistory.com/293
- https://kldp.org/node/295
- https://coding-start.tistory.com/199
- Total
- Today
- Yesterday
- 트랜잭셔널 아웃박스 패턴 스프링부트
- 레이어드 아키텍처란
- spring boot redisson 분산락 구현
- pipe and filter architecture
- transactional outbox pattern spring boot
- pipeline architecture
- 자바 백엔드 개발자 추천 도서
- 람다 표현식
- service based architecture
- java ThreadLocal
- microkernel architecture
- java userThread와 DaemonThread
- spring boot excel download oom
- spring boot redis 대기열 구현
- polling publisher spring boot
- space based architecture
- @ControllerAdvice
- spring boot redisson destributed lock
- transactional outbox pattern
- redis sorted set
- spring boot poi excel download
- 서비스 기반 아키텍처
- 공간 기반 아키텍처
- 트랜잭셔널 아웃박스 패턴 스프링 부트 예제
- spring boot redisson sorted set
- redis sorted set으로 대기열 구현
- JDK Dynamic Proxy와 CGLIB의 차이
- redis 대기열 구현
- spring boot 엑셀 다운로드
- spring boot excel download paging
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |