Reporting Date: May. 02, 2025
스레드 문제에 대해 다루고자 한다.
목차01 스레드 문제
02 fork()와 exec()
03 신호 처리
04 스레드 취소
05 스레드 풀
06 스레드별 데이터
07 스케줄러 활성화 기법
01 스레드 문제
Threading Issues
스레드를 OS 또는 사용자 수준에서 효과적으로
관리 및 실행하기 위해 다음과 같은 주요 이슈들이 존재한다.
① fork() 및 exec() 시스템 호출
다중 스레드 환경에서 fork()를 호출하면
부모 프로세스의 메모리를 복제하지만, 스레드까지 복제하지는 않음.
따라서 fork() 후 어떤 스레드만 남을지 주의해야 한다.
exec()는 새로운 프로그램으로 현재 프로세스를 대체하므로,
기존 스레드는 모두 종료된다.
② 스레드 취소
실행 중인 스레드를 중단시킬 필요가 있을 경우 사용된다.
비동기식 취소 (Asynchronous Cancellation):
대상 스레드를 즉시 종료시킴. 예측하기 어려운 상태 유발 가능하다.
지연 취소 (Deferred Cancellation):
스레드가 취소 가능한 지점에 도달할 때까지
기다린 후 종료되므로 안전성이 더 높다.
③ 신호 처리
시그널은 비동기적 이벤트 처리 방식으로,
다중 스레드 환경에서 어떤 스레드가 시그널을
받아야 할지 명확히 정의해야 한다.
일반적으로 시그널 처리기를 등록한 스레드
또는 특정 조건을 만족하는 스레드가 처리하게 된다.
④ 스레드 풀
반복적으로 생성 및 종료되는 스레드의 오버헤드를
줄이기 위해 일정 수의 스레드를 미리 생성해 재사용한다.
이는 자원 효율성과 성능 향상에 기여한다.
⑤ 스레드별 데이터
여러 스레드가 동시에 실행되더라도
각 스레드가 자신만의 독립적인 데이터를 갖고
접근할 수 있도록 하는 메커니즘이다.
전역 데이터를 스레드 간 충돌 없이 사용할 수 있다.
⑥ 스케줄러 활성화 기법
사용자 수준 스레드와 커널 수준 스레드 간의
효율적인 연동을 위해 도입된 메커니즘이다.
커널은 사용자 수준 스레드 라이브러리에 이벤트(예: 블로킹, 종료 등)를 통보하고,
라이브러리는 적절한 대응을 통해 스레드를 관리한다.
02 fork()와 exec()
프로세스 생성의 4단계
fork()와 exec()는 새로운 프로세스를 생성하거나,
기존 프로세스의 주소 공간을 새로운 프로그램으로
대체하기 위해 사용하는 핵심적인 시스템 호출이다.
fork()는 현재 프로세스의 복사본을 생성하여 자식 프로세스를 만들며,
exec()는 해당 프로세스의 메모리 공간을 새로운 실행 파일로 덮어써서
다른 프로그램을 실행하게 한다.
추가적으로, 일반적으로 UNIX 계열 OS에서의
프로세스 생성 4단계는 다음과 같이 요약된다:
- 프로세스 복제:
fork()를 호출하여 부모 프로세스의 복사본인 자식 프로세스를 생성한다. - 새 프로그램 로딩:
자식 프로세스 내에서 exec()를 호출하여 다른 프로그램을 메모리에 적재한다. - 프로세스 실행:
새로 적재된 프로그램의 코드가 실행된다. - 프로세스 종료:
실행이 완료되면 exit() 호출을 통해 프로세스가 종료되며,
부모는 wait()를 통해 종료 상태를 수집한다.
fork() 호출 시 스레드 복제 여부
멀티스레드 프로세스에서 특정 스레드가
fork()를 호출하는 경우, 다음과 같은 문제가 발생한다:
- 새로 생성되는 자식 프로세스에 모든 스레드를 복제해야 하는가?
- 아니면, fork()를 호출한 스레드만 포함한 단일 스레드 프로세스로 생성되어야 하는가?
단일 스레드인 경우 문제는 단순하지만,
다중 스레드일 경우 다음 중 하나를 선택해야 한다:
- 전체 스레드 복사
- 호출한 스레드만 복사
유닉스 계열 시스템의 지원
일부 UNIX 및 POSIX 호환 OS에서는
두 가지 버전의 fork()를 모두 제공한다:
- 전체 스레드를 복사하는 fork()
- 호출한 스레드만 복사하는 fork()
이 중 어떤 방식이 선택될지는 응용 프로그램의 목적과
구현 방식에 따라 결정되며, 프로그래머의 설계 판단이 중요하다.
exec() 호출 시의 처리
현재 실행 중인 프로세스를
지정된 프로그램으로 완전히 대체한다.
이 과정에서 모든 기존 스레드는 제거되며,
새로 시작된 프로그램은 단일 스레드 상태에서 실행을 시작한다.
따라서 fork() 이후에 exec()를 바로 호출하는 구조라면,
어떤 fork() 버전을 사용했는지는 큰 영향을 미치지 않으며,
일반적으로 단일 스레드 복사 fork()가 자주 사용된다.
03 신호 처리
Signal Handling
UNIX 시스템에서 신호는 프로세스에게
특정 사건이 발생했음을 비동기적으로 알리기 위해 사용된다.
인터럽트 vs 신호
인터럽트(Interrupt)는 하드웨어
또는 커널 수준에서 발생하며, OS 커널이 직접 처리한다.
이때는 인터럽트 서비스 루틴(Interrupt Service Routine, ISR)
또는 인터럽트 핸들러(Interrupt Handler)가 동작한다.
반면, 신호는 사용자 공간의 프로세스가 직접 처리해야 한다.
신호의 전달 과정
- 신호 발생: 신호는 특정 사건(예: 잘못된 연산, 외부 요청 등)이 발생할 때 생성된다.
- 신호 전달: 생성된 신호는 관련 프로세스 또는 스레드에 전달된다.
- 신호 처리: 전달된 신호는 반드시 처리되어야 한다.
신호는 그 발생 원인에 따라 다음과 같이 두 가지로 분류된다.
1 . 동기식 신호
Synchronous Signal
프로그램이 실행 중에 0으로 나누기,
유효하지 않은 메모리 접근 등 잘못된 연산을 수행할 경우 발생한다.
신호를 유발한 프로세스 자신에게 전달되며,
발생 시점과 명확히 연관된 명령어가 존재한다.
2 . 비동기식 신호
Asynchronous Signal
사용자가 키보드에서 Ctrl+C 입력, 타이머 만료 등
외부의 사건에 의해 발생합니다.
보통 신호를 유발한 프로세스와는 다른 프로세스에 전달되며,
명확한 발생 시점을 특정하기 어렵다.
신호 처리 방법
모든 신호는 다음 중 하나의 신호 처리기(Handler)에 의해 처리된다.
기본 신호 처리기, Default Signal Handler
시스템이 기본적으로 정의한 처리 방식이며, 대부분의 신호는 이를 사용한다.
사용자 정의 신호 처리기, User-defined Handler
프로그래머가 signal() 또는 sigaction() 함수를 이용해 지정한 함수로,
특정 동작을 사용자 수준에서 처리할 수 있다.
다중 스레드 환경에서의 신호 처리
멀티스레드 프로그램에서 신호 처리 방식은 더 복잡해진다.
신호는 다음과 같은 방식 중 하나로 스레드에 전달될 수 있다:
- 특정 스레드에만 전달
- 프로세스 내 모든 스레드에 전달
- 선택된 일부 스레드에만 전달
- 특정 스레드가 모든 신호를 처리하도록 지정
(pthread_sigmask 등을 사용)
신호의 전달 대상은 신호의 유형과 스레드의 설정에 따라 결정된다.
POSIX에서는 이를 보다 정밀하게 제어할 수 있는 API를 제공한다.
04 스레드 취소
Thread Cancellation
실행 중인 스레드를 정상적인 종료 없이 강제로 중단시키는 작업이다.
취소 대상이 되는 스레드를 "목적 스레드(Target Thread)"라고 부른다.
스레드를 취소하는 방식은 두 가지로 구분된다.
1 . 비동기식 취소
Asynchronous Cancellation
한 스레드가 즉시 목적 스레드를 강제로 종료시키는 방식이다.
간단하지만, 공유 자원 정리나 상태 복구 없이 종료되므로
데이터 일관성 문제나 리소스 누수가 발생할 수 있다.
이 방식은 예외적인 상황에서만 제한적으로 사용된다.
2 . 지연 취소
Deferred Cancellation
목적 스레드가 정해진 지점에서 주기적으로
취소 요청 여부를 확인 및 스스로 종료를 수행한다.
스레드에게 정리(clean-up) 작업을 수행할 기회를
제공하므로 안정적인 종료가 가능하다.
대부분의 POSIX 스레드 환경에서는 이 방식을 기본으로 채택한다.
※ 스레드에 취소 요청을 보내는 즉시 종료되는 것이 아닌,
스레드의 상태나 구현 방식에 따라 실제 종료 시점이 달라진다.
Mode (모드) | State (상태) | Type (유형) | 설명 (Description) |
Off (꺼짐) | Disabled (비활성) | Deferred (지연형) | 스레드 취소 기능이 꺼져 있어, 취소 요청이 무시된다. |
Deferred (지연형) | Enabled (활성) | Deferred (지연형) | 스레드가 주기적으로 취소 요청을 확인하고, 정리 후 종료되므로 안정적이다. |
Asynchronous (비동기형) | Enabled (활성) | Asynchronous (비동기형) | 스레드가 즉시 강제 종료된다. 정리 없이 종료되므로 위험할 수 있다. |
Java에서 지연 취소 방식으로 스레드를 중단하는 방법이다:
// 스레드에게 인터럽트(interrupt)를 요청
Thread thrd = new Thread(new InterruptibleThread());
thrd.start();
thrd.interrupt();
interrupt() 메서드는 스레드를 즉시 종료시키는 것이 아닌,
해당 스레드가 실행 중에 인터럽트 여부를 주기적으로
확인하도록 구성되어 있어야 한다.
InterruptibleThread 클래스의 run() 메서드 내에서는 보통
Thread.currentThread().isInterrupted()를 반복적으로 검사하여,
중단 요청이 들어왔는지를 확인하고 스레드를 스스로 종료하게 만든다.
단, InterruptibleThread 클래스가 실제로 인터럽트 상태를
체크하도록 구현되어 있어야 효과가 있다.
다음은 Java에서 지연 취소 방식으로
인터럽트 상태를 확인하며 스레드를 종료하는 예시이다:
class InterruptibleThread implements Runnable {
// 이 스레드는 인터럽트가 걸리지 않는 한 계속 실행됩니다.
public void run() {
while (true) {
// 일부 작업 수행
// ...
if (Thread.currentThread().isInterrupted()) {
System.out.println("I'm interrupted!");
break;
}
}
// 종료 전 정리 작업 수행
}
}
run() 메서드 내에서 무한 루프를 돌며 작업을 수행하다가,
isInterrupted() 메서드를 이용해 인터럽트 요청이 들어왔는지를 주기적으로 확인한다.
인터럽트가 감지되면 "I'm interrupted!" 메시지를 출력하고 루프를 종료한 뒤,
필요한 정리 작업(clean-up)을 수행하고 정상적으로 종료된다.
즉, 스레드가 외부에서 interrupt() 호출을 통해 종료 요청을 받고,
그 상태를 직접 확인해 질서 있게 종료되는 구조이다.
05 스레드 풀
Thread Pools
웹서버와 같이 다중 클라이언트 요청을 처리해야 하는 환경에서는,
요청이 들어올 때마다 새로운 스레드를 생성하는 방식은 성능에 큰 부담을 줄 수 있다.
스레드 생성과 제거는 시간과 시스템 자원을 많이 소모하므로,
매 요청마다 새 스레드를 생성 시 동시 실행 가능한 스레드 수의 한계를
초과하거나 시스템 자원이 고갈될 위험이 있다.
이러한 문제를 해결하기 위해,
앱이 시작될 때 미리 일정 수의 스레드를 생성해 풀에 보관하고,
이후 요청이 들어오면 풀에서 스레드를 꺼내
작업을 처리한 후 다시 반환하는 방식으로 운영된다.
장점
① 새 스레드를 생성하는 비용 없이, 미리 생성된 스레드를 재사용하므로
응답 속도가 빨라지고 처리 효율이 높아진다.
② 스레드 개수를 제한할 수 있어,
시스템 자원의 과도한 사용을 방지하고 안정적인 운영이 가능하다.
③ 작업 실행을 위한 메커니즘과 실제 작업 처리를 분리할 수 있어,
정기적 실행, 지연 실행 등 다양한 실행 전략을 구현할 수 있다.
Java의 스레드 풀 종류
java.util.concurrent.Executors
Java에서는 Executors 클래스를 통해
다음과 같은 기본적인 스레드 풀 구현을 제공한다.
① 단일 스레드 실행기, Single Thread Executor
크기 1의 스레드 풀로, 한 번에 하나의 작업만 순차적으로 처리한다.
이는 작업이 순서대로 실행되어야 할 때 유용하다.
② 고정 크기 스레드 풀, Fixed Thread Pool
고정된 크기의 스레드 풀을 생성하는 것으로,
일정 수의 스레드만 유지하며, 새 작업이 들어오면 대기 큐에 저장한다.
과도한 스레드 생성 방지에 효과적이지만
사용하지 않는 스레드도 계속 유지되므로 자원 낭비 우려 있다.
③ 케시 스레드 풀, Cached Thread Pool
필요한 만큼 동적으로 스레드를 생성하고 재사용한다.
짧고 많은 요청을 빠르게 처리할 때 유리하며,
유휴 상태가 오래되면 스레드는 제거된다.
스레드 수에 제한이 없으므로,
자원이 부족한 환경에서는 주의가 필요하다.
Java에서 스레드 풀이 실행할 작업은
Runnable 인터페이스를 구현하여 정의할 수 있다.
예를 들어, 다음과 같이 Task 클래스를 구현할 수 있다:
public class Task implements Runnable {
public void run() {
System.out.println("I am working on a task.");
}
}
이 클래스는 스레드 풀이 실행할 수 있는 작업 단위를 의미하며,
run() 메서드 안에 실제 수행할 작업 내용을 작성한다.
스레드 풀은 이 Task 인스턴스를 받아 하나의 스레드에서 실행하게 된다.
Java에서 캐시된 스레드 풀을 생성하고 작업을 실행하는 예제이다:
import java.util.concurrent.*;
public class TPExample {
public static void main(String[] args) {
int numTasks = Integer.parseInt(args[0].trim());
// 캐시된 스레드 풀 생성
ExecutorService pool = Executors.newCachedThreadPool();
// 각 작업을 스레드 풀을 통해 실행
for (int i = 0; i < numTasks; i++)
pool.execute(new Task());
// 모든 작업이 완료된 후 스레드 풀 종료
pool.shutdown();
}
}
이 예제는 Executors.newCachedThreadPool()을 사용하여
동적으로 크기가 변하는 캐시된 스레드 풀을 생성한다.
이 풀은 필요한 만큼 스레드를 생성하고,
사용하지 않는 스레드는 일정 시간 후 제거되어 자원을 효율적으로 관리한다.
06 스레드별 데이터
Thread Specific Data
TLS: Thread-Local Storage
각 스레드가 자신만 접근할 수 있는 고유한 데이터를
따로 보유해야 할 필요가 있을 때 사용되는 메커니즘이다.
예를 들어, 트랜잭션 처리 시스템에서는 각 스레드가
고유한 식별자나 상태 정보를 유지해야 하므로, 스레드별 데이터가 필수적이다.
Win32 API나 POSIX Threads(Pthreads)와 같은
대부분의 스레드 라이브러리에서 스레드별 데이터를 지원하며,
Java 또한 ThreadLocal 클래스를 통해 동일한 기능을 제공한다.
전역 변수를 사용할 경우, 해당 데이터는
데이터 영역에 저장되고 모든 스레드가 공유하게 된다.
이로 인해 동시에 접근하면 데이터가 덮어쓰여져
의도하지 않은 오류가 발생할 수 있다.
대표적인 예로 errno 변수를 들 수 있으며, 이를 방지하기 위해
각 스레드가 독립적으로 자신의 errno 값을 유지하도록 TLS를 사용하는 것이다.
Java에서 스레드 로컬 저장소는 각 스레드가 독립적으로
데이터를 저장하고 접근할 수 있도록 해주는 기능이다.
아래 예제는 ThreadLocal을 이용해 스레드마다 독립적인 errorCode를 저장하는 방법을 보여준다.
public class Service {
private static ThreadLocal<Exception> errorCode = new ThreadLocal<>();
public static void transaction() {
try {
// 예외가 발생할 수 있는 일부 작업 수행
} catch (Exception e) {
// 현재 스레드에 대한 예외 정보를 저장
errorCode.set(e);
}
}
// 현재 스레드에 저장된 예외 정보 반환
public static Exception getErrorCode() {
return errorCode.get();
}
}
이 코드에서 ThreadLocal<Exception> 타입의 errorCode는
각 스레드가 자신만의 예외 정보를 저장하도록 도와준다.
이렇게 하면 멀티스레드 환경에서도 서로 간섭 없이
스레드별 상태 정보를 안전하게 관리할 수 있다.
07 스케줄러 활성화 기법
Scheduler Activations
스레드는 크게 사용자 수준 스레드와 커널 수준 스레드로 나뉘며,
각각은 사용자 스레드 라이브러리와 운영체제 커널에 의해 관리된다.
이 둘은 서로 분리되어 있어 일반적으로 직접적인 상호작용이나 정보 공유 없이 동작한다.
그러나 M : M 모델(다수 사용자 스레드 ↔ 다수 커널 스레드)
또는 Two - level 모델에서는 사용자와 커널 스레드 사이에
경량 프로세스(LWP)라는 중간 구조가 존재한다.
LWP는 커널이 인식하고 스케줄링할 수 있는 단위로,
사용자 스레드를 실제 CPU 자원과 연결해주는 가상 처리기(Virtual Processor) 역할을 한다.
응용 프로그램은 LWP를 통해 사용자 스레드를 스케줄링하며,
커널은 특정 이벤트가 발생했을 때 사용자 스레드 라이브러리에
업콜(Upcall) 방식으로 해당 사실을 알려준다.
이러한 구조를 통해 커널과 사용자 수준 스레드 라이브러리 간의 효율적인 협력이 가능해진다.
이때 사용자 스레드 라이브러리는 LWP를 가상의 처리기처럼 간주하고,
내부적으로 사용자 스레드를 LWP에 할당한다.
커널은 사용자 프로그램에 가상 처리기 집합(LWP)을 제공하고,
특정 이벤트 발생 시 Upcall을 통해 사용자 라이브러리에 이를 알린다.
사용자 라이브러리에는 업콜 처리기(Upcall Handler)가 내장되어 있어,
커널의 알림을 받아 적절히 대응한다.
커널은 스레드의 생성, 종료, 차단, 해제 등 상태 변화를 사용자 수준 런타임에 통보하며,
이 메커니즘을 통해 사용자 라이브러리는 가용한 LWP를 활용하여 사용자 스레드를
효율적으로 스케줄할 수 있으며, 동시에 커널과의 동기화도 유지된다.
결론적으로, 이 구조는 고급 시스템에서 성능과 유연성을
동시에 추구하기 위한 메커니즘으로 설계되었으며,
사용자 스레드의 효율적인 관리를 위해
커널과 사용자 수준 스케줄러 간의 협력을 도입한 대표적인 예입니다.
단점
커널과 사용자 라이브러리 코드 간의 밀접한 협력을 전제로 하며,
같은 주소 공간을 공유하므로, 높은 수준의 신뢰성과 보안성이 요구된다.
사용자 라이브러리의 버그나 악의적인 코드로 인해
전체 시스템에 영향을 줄 수 있는 위험이 있으며,
이 구조는 예외 처리나 비정상 상황에 대한 내성이 약하다.
즉, 강건성이 부족하다.
사용자 스레드는 커널에 의해 선점될 수 있으며,
이에 따른 동기화 문제나 성능 저하가 발생할 수 있다.
LWP의 시간 흐름 요약
시간의 축을 따라 경량 프로세스(LWP)와
사용자 스레드, 커널 스레드 간의 상호작용을 설명한 개념도이다.
타임 1 (Time 1)
상단에는 사용자 스레드,
하단에는 커널 스레드가 존재하며, 이 사이에 LWP가 배치된다.
새로운 사용자 스레드가 생성되면, 커널은 이 요청을 처리하기 위해
새로운 LWP를 생성하고, 이를 사용자 스레드 라이브러리에 Upcall을 통해 통지한다.
라이브러리는 해당 가상 프로세서(LWP)를 사용자 스레드(t1)에 매핑하여 스케줄링한다.
총 4개의 사용자 스레드(t1 ~ t4)가 있으며, 이 시점에서는 t1이 CPU 자원을 할당받아 실행된다.
이러한 사용자-커널 협력 기반의 실행 흐름을 Scheduler Activations이라 한다.
타임 2 (Time 2)
듀얼 코어 시스템에서는 동시에 2개의 LWP만이 활성화될 수 있다.
이 시점에서 t1이 I/O 작업으로 인해 블로킹(blocking)되었고,
커널은 해당 LWP를 회수한 뒤, 스레드 라이브러리에 업콜을 통해 이 상태를 전달한다.
이로 인해 유휴 LWP가 발생하였고, 대기 중이던 t3가 이 자원을 할당받아 실행된다.
타임 3 (Time 3)
t1의 I/O 작업이 완료되어 다시 실행 가능 상태가 되었고,
t2는 선점되어 대기 상태로 전환되었다.
이 시점에서 스레드 리스트에는 t1, t2, t4가 대기 중이다.
이와 함께, 커널은 필요한 경우 새로운 LWP를 생성해 사용자에게 업콜을 통해 통지한다.
타임 4 (Time 4)
현재 시점에서 실행 중인 스레드는 t1과 t3이다.
이는 듀얼 코어 시스템에서 두 개의 가상 프로세서(LWP)가
병렬로 사용자 스레드를 실행 중임을 의미한다.
'2025 - 1학기 > 플랫폼OS' 카테고리의 다른 글
CPU 스케줄링 (2) | 2025.05.12 |
---|---|
스레드 구조 및 구현 방식 (0) | 2025.05.12 |
스레드 (7) | 2025.05.12 |
다중 코어 프로그래밍 (0) | 2025.05.12 |
다중 스레드 프로그래밍 (7) | 2025.04.19 |