프로세스와 스레드
프로세스
프로세스는 독립된 실행 환경을 가지고 있고 일반적으로 비공개 기본 런타임 리소스 집합을 가지고 있으며, 특히 각 프로세스에는 자체 메모리 공간이 있다.
프로세스는 종종 프로그램 또는 애플리케이션과 동의어로 간주된다.
그러나 사용자가 하나의 애플리케이션으로 보는 것은 실제로는 협력하는 프로세스의 집합일 수 있다.
대부분의 Java 가상 머신 구현은 단일 프로세스로 실행된다.
스레드
스레드는 경량 프로세스라고도 한다.
프로세스와 스레드 모두 실행 환경을 제공하지만 새 스레드를 만드는 것이 새 프로세스를 만드는 것보다 더 적은 리소스를 필요로 한다.
스레드는 프로세스 내에 존재하며 모든 프로세스에는 적어도 하나가 있다.
스레드는 메모리와 오픈된 파일 등 프로세스의 리소스를 공유한다.
이는 효율적이지만 잠재적으로 문제가 될 수 있다.
멀티스레드 실행은 Java 플랫폼의 필수 기능이다.
모든 애플리케이션에는 적어도 하나의 스레드가 있으며, 메모리 관리 및 신호 처리와 같은 작업을 수행하는 "시스템" 스레드까지 포함하면 여러 개의 스레드가 있다.
하지만 애플리케이션 프로그래머의 입장에서는 메인 스레드라고 하는 하나의 스레드에서 시작한다.
메인 스레드에는 추가 스레드를 생성할 수 있는 기능이 있다.
인터럽트
인터럽트는 스레드가 수행 중인 작업을 중단하고 다른 작업을 수행해야 한다는 표시다.
스레드가 인터럽트에 정확히 어떻게 반응할지는 프로그래머가 결정하지만, 스레드가 종료되는 것은 매우 일반적이다.
스레드는 중단할 스레드에 대해 스레드 객체에서 interrupt 메서드를 호출하여 인터럽트를 보낸다.
인터럽트 메커니즘이 올바르게 작동하려면 인터럽트 된 스레드가 자체 인터럽트를 지원해야 한다.
* interrupt 메서드
interrupt 메서드는 스레드가 가지고 있는 interrupt 변수를 true로 변경하는 메서드다.
이를 이용하면 스레드의 run 메서드를 정상 중단 시킬 수 있다.
public class SimpleThreads {
// Display a message, preceded by
// the name of the current thread
static void threadMessage(String message) {
String threadName =
Thread.currentThread().getName();
System.out.format("%s: %s%n",
threadName,
message);
}
private static class MessageLoop
implements Runnable {
public void run() {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
try {
for (int i = 0;
i < importantInfo.length;
i++) {
// Pause for 4 seconds
Thread.sleep(4000);
// Print a message
threadMessage(importantInfo[i]);
}
} catch (InterruptedException e) {
threadMessage("I wasn't done!");
}
}
}
public static void main(String args[])
throws InterruptedException {
// Delay, in milliseconds before
// we interrupt MessageLoop
// thread (default one hour).
long patience = 1000 * 60 * 60;
// If command line argument
// present, gives patience
// in seconds.
if (args.length > 0) {
try {
patience = Long.parseLong(args[0]) * 1000;
} catch (NumberFormatException e) {
System.err.println("Argument must be an integer.");
System.exit(1);
}
}
threadMessage("Starting MessageLoop thread");
long startTime = System.currentTimeMillis();
Thread t = new Thread(new MessageLoop());
t.start();
threadMessage("Waiting for MessageLoop thread to finish");
// loop until MessageLoop
// thread exits
while (t.isAlive()) {
threadMessage("Still waiting...");
// Wait maximum of 1 second
// for MessageLoop thread
// to finish.
t.join(1000);
if (((System.currentTimeMillis() - startTime) > patience)
&& t.isAlive()) {
threadMessage("Tired of waiting!");
t.interrupt();
// Shouldn't be long now
// -- wait indefinitely
t.join();
}
}
threadMessage("Finally!");
}
}
Java 공식 문서에 나오는 예제를 살펴보며 interrupt에 대해 조금 더 알아보자.
public void run() {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
try {
for (int i = 0; i < importantInfo.length; i++) {
// Pause for 4 seconds
Thread.sleep(4000);
// Print a message
threadMessage(importantInfo[i]);
}
} catch (InterruptedException e) {
threadMessage("I wasn't done!");
}
}
위 코드는 예제의 t 스레드의 run 메서드이다.
run 메서드에서는 Thread.sleep(4000)을 통해 4초간 일시 정지 상태로 들어가는 것을 확인할 수 있다.
t 스레드가 일시 정지 상태에서 main 스레드가 t.interrupt를 통해 t 스레드 종료를 시도한다면 InterruptedException을 발생시켜 해당 스레드를 중단시킨다.
public void run() {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
// Thread.sleep 처럼 일시정지 상태로 들어가는 코드가 존재하지 않아 try-catch 제거
for (int i = 0; i < Integer.MAX_VALUE; i++) {
// 임의로 importantInfo의 0번째 값만 출력
threadMessage(importantInfo[0]);
}
}
}
그렇다면 위의 코드처럼 t 스레드가 일시 정지 상태로 들어가지 않는다면 어떨까?
main 스레드에서 t.interrupt를 통해 t 스레드를 종료하려 해도 t 스레드는 실행 상태이기에 중단되지 않는다.
그렇다면 실행 상태의 스레드를 중단할 수 있는 방법은 없을까?
public void run() {
String importantInfo[] = {
"Mares eat oats",
"Does eat oats",
"Little lambs eat ivy",
"A kid will eat ivy too"
};
// Thread.sleep 처럼 일시정지 상태로 들어가는 코드가 존재하지 않아 try-catch 제거
for (int i = 0; i < Integer.MAX_VALUE; i++) {
// 현재 스레드가 인터럽트 되었는지 확인한다.
if(Thread.currentThread().isInterrupted()) {
threadMessage("I wasn't done!");
return;
}
// 임의로 importantInfo의 0번째 값만 출력
threadMessage(importantInfo[0]);
}
}
}
일시 정지로 들어가는 코드가 존재하지 않더라도 위의 코드처럼 스레드가 인터럽트 되었는지 확인하는 과정을 추가한다면 실행 중인 스레드를 중단할 수 있다.
조인
조인 메서드를 사용하면 한 스레드가 다른 스레드가 완료될 때까지 기다릴 수 있다.
조인 메서드에 파라미터를 설정한다면 해당 시간 동안 스레드가 종료되길 기다리고 설정하지 않는다면 완료될 때까지 기다린다.
동기화
스레드는 주로 필드와 참조 필드가 참조하는 객체에 대한 액세스를 공유하고 있다.
이는 매우 효율적이지만 스레드 간섭과 메모리 일관성 오류라는 두 가지 종류의 오류가 발생할 수 있다.
이러한 오류를 방지하는데 필요한 도구가 동기화이다.
하지만 동기화는 두 개 이상의 스레드가 동일한 리소스에 동시에 접근하려 시도할 때 발생하는 스레드 경합을 유발하여 자바 런타임이 하나 이상의 스레드를 더 느리게 실행하거나 심지어 실행을 중단시킬 수 있다.
기아(Starvation)와 라이브락(Livelock)은 스레드 경합의 한 형태이다.
Intrinsic Lock과 동기화
모든 객체에는 내재적 잠금(Intrinsic Lock)이 있다.
관례에 따라 객체의 필드에 독점적이고 일관되게 액세스 해야 하는 스레드는 필드에 액세스 하기 전에 객체의 내재 잠금을 획득한 다음, 액세스를 완료하면 내재 잠금을 해제한다.
스레드는 잠금을 획득한 시점부터 잠금을 해제할 때까지 내재적 잠금을 소유하고 있다.
한 스레드가 내재적 잠금을 소유하고 있는 한 다른 스레드는 동일한 잠금을 획득할 수 없다.
이때 다른 스레드가 잠금을 시도하면 차단된다.
synchronized 메서드
스레드가 synchronized 메서드를 호출하면 해당 메서드의 객체에 대한 내재 잠금을 자동으로 획득하고 메서드가 반환되면 잠금을 해제한다.
반환이 잡히지 않은 예외로 인해 발생한 경우에도 잠금은 해제된다.
정적 동기화 메서드의 경우 스레드는 클래스와 연관된 클래스 객체에 대한 내재 잠금을 획득한다.
따라서 클래스의 정적 필드에 대한 액세스는 클래스 모든 인스턴스에 대한 잠금과 구별되는 잠금에 의해 제어된다.
synchronized 문
synchronized 문은 내재적 잠금을 제공하는 객체를 지정해야 한다.
동기화를 위해 별도의 객체를 지정하기에 세분화된 동기화를 통해 동시성을 개선하는 데도 유용하다.
public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object();
public void inc1() {
synchronized(lock1) {
c1++;
}
}
public void inc2() {
synchronized(lock2) {
c2++;
}
}
}
예를 들어 위의 MsLuch 클래스는 필드의 모든 업데이터는 동기화되어야 하지만, c1의 업데이트가 c2의 업데이트와 인터리빙 되는 것을 막을 이유가 없으며, 그렇게 하면 불필요한 차단이 발생하여 동시성이 저하된다.
동기화된 메서드를 사용하거나 이와 관련된 잠금을 사용하는 대신 c1과 c2의 잠금을 위한 객체를 생성하여 잠금을 제공할 수 있다.
Lock 객체
Lock 객체는 Explicit Lock이지만 Intrinsic Lock과 매우 유사하게 동작한다.
Explicit Lock 역시 Intrinsic Lock과 마찬가지로 한 번에 하나의 스레드만 Lock 객체를 소유할 수 있고 Condition 객체를 통해 대기/알림 메커니즘도 지원한다.
Java에서 제공하는 Lock 구현체
ReentrantLock (Lock 인터페이스의 구현체)
생성자의 fair 파라미터에 따라 공정한 락(FairSync)과 비공정한 락(NonFairSync)으로 서로 다른 인스턴스를 생성한다.
공정한 락인 경우 lock 메서드를 사용하면 가장 오래 기다린 스레드부터 락을 획득할 수 있다.
하지만 tryLock 메서드를 사용한다면 순서와 상관없이 락 획득이 가능하다면 다른 대기 중인 스레드들을 무시하고 락을 획득할 수 있다.
ReentrantReadWriteLock (ReadWriteLock 인터페이스의 구현체)
생성자의 fair 파라미터에 따라 서로 다른 인스턴스를 생성한다.
읽기 잠금(readLock) - 여러 스레드가 동시에 읽기를 수행할 수 있다. 다수의 읽기 작업이 서로 방해하지 않기 때문에 동시성이 높다.
쓰기잠금(writeLock) - 쓰기 작업을 수행할 때는 하나의 스레드만 접근할 수 있으며, 다른 스레드는 읽기나 쓰기 작업을 할 수 없다. 이는 데이터 일관성을 보장하기 위한 조치이다.
읽기와 쓰기 잠금이 분리되어 있다.
읽기 잠금에서 쓰기 잠금으로 업그레이드는 불가능하지만, 쓰기 잠금에서 읽기 잠금으로 다운그레이드는 가능하다.
Semaphore
세마포어는 특정 리소스에 대한 접근을 제한하는 데 사용되며, 하나 이상의 스레드가 공유 리소스에 접근할 수 있는 동시성 제어를 제공한다.
세마포어의 값은 리소스에 접근할 수 있는 허용 가능한 스레드 수를 나타낸다.
획득(acquire) - 세마포어의 허용된 스레드 수에서 하나를 차감한다. 만약 사용할 수 있는 리소스가 없으면, 스레드는 사용 가능한 리소스가 생길 때까지 대기한다.
릴리즈(release) - 세마포어의 허용된 스레드 수에 하나를 더해 리소스를 반환한다.
세마 포어 역시 생성자의 fair 파라미터에 따라 서로 다른 인스턴스를 생성한다.
이때 공정한 락인 경우 스레드가 FIFO 방식으로 세마포어를 획득할 수 있다.
비공정 락에서는 대기 중인 스레드 중 어떤 것이 먼저 획득할지 알 수 없다.
자원 접근의 동시성 제어가 필요한 상황에서 해당 클래스가 사용되고 네트워크 연결 제한, 데이터베이스 연결 풀 관리 등에서 사용된다고 한다.
실제로 HikariPool의 경우 SuspendResumeLock 클래스를 활용하여 연결 풀을 관리하고 있는데 해당 클래스에서 Semaphore 클래스를 사용하고 있는 것을 확인할 수 있다.
private SuspendResumeLock(final boolean createSemaphore)
{
acquisitionSemaphore = (createSemaphore ? new Semaphore(MAX_PERMITS, true) : null);
}
Lock은 언제 사용하는가?
멀티 스레딩 환경에서 공유변수 영역(Critical Section)에 대해서 상호 배제를 제공하기 위해 사용한다.
공유변수 영역에 대한 상호 배제가 제공되지 않는다면 각 스레드가 공유변수를 사용할 때 그 값이 변경된 값인지 변경되기 전 값인지 보장하지 못한다.
* 공유(변수)에 대한 개인적 경험에 비춘 생각
: 공유는 상태를 수정할 수 있는 권한이 여럿에게 있는 것.
ex)
HikariPool - 여러 스레드에서 DB 접속을 위해 HikariPool에 미리 생성된 커넥션을 사용하며 HikariPool이 가지고 있는 커넥션의 수가 변한다
선착순 티켓팅 - 불특정 다수의 요청에 의해 제한된 티켓의 수가 감소한다.
'자바' 카테고리의 다른 글
세마포어 (0) | 2025.03.06 |
---|---|
Kotlin Data Classes (0) | 2025.03.04 |
Thread Pool 정리 (1) | 2025.01.01 |
스트림 메서드 정리 (0) | 2024.12.19 |
모던 자바 인 액션을 다시 읽으며 든 스트림에 대한 생각 정리 (0) | 2024.12.18 |