[스레드의 동기화]
다수의 스레드가 동시에 공유자원에 접근했을때는 문제가 생길 수도 있고 안 생길 수도 있다.
두 스레드가 동시에 공유자원을 읽기만 하는 경우에는 문제가 없다.
하지만 한 스레드는 쓰기작업을 하고 한 스레드는 읽을 경우 데이터에 대한 정확성이 떨어 질 수 있다.
그래도 데이터의 훼손은 없지만
두 스레드가 동시에 공유 데이터를 쓰기 작업할 경우 훼손이 발생 할 수 있다.
<예시>
ex: 철수와 영희는 같은 은행 계좌를 함께 사용 중
둘 다 스마트폰 뱅킹 앱으로 동시에 입금/출금 작업을 시도함
현재 잔액: 100,000원
철수와 영희가 동시에 출금 시도
- 철수: 80,000원 출금
- 영희: 50,000원 출금
철수 앱 동작
- 잔액을 읽음 → 100,000원
- 출금 가능하다고 판단
- 출금 실행 (잔액 - 80,000 → 20,000원)
영희 앱 동작 (거의 동시에 실행)
- 잔액을 읽음 → 100,000원 (아직 철수가 반영되기 전이라서)
- 출금 가능하다고 판단
- 출금 실행 (잔액 - 50,000 → 50,000원)
결과: 잔액이 100,000 - 80,000 - 50,000 = -30,000원...?
- 두 번 출금됐는데, 은행 시스템은 둘 다 출금 성공으로 처리함
- → 데이터가 꼬였고, 실제로는 누군가가 없는 돈을 쓴 셈이 됨
스레드 동기화(Thread synchronization)
공유자원에 대해서 다수의 스레드가 동시에 접근할 때 공유자원이 훼손되지 않게 순서를 맞추는 것
or 스레드의 작업이 완료될때 까지 다른 스레드는 접근하지 못하도록 제어하는 것
스레드 동기화와 관련된 중요 개념 2가지가 있다.
1. 임계구역 : 공유 데이터에 접근하는 코드 영역
2. 상호배제 : 임계구역을 오직 한 스레드만 접근 할 수 있도록 하는 기술
상호 배제를 구현하는 방법으로는 하드웨어적인 방법과 소프트웨어적인 방법이 있다.
1. 하드웨어에 의한 구현
임계구역 코드에서 인터럽트 서비스를 금지하는 명령을 실행한다.
인터럽트 신호가 발생해도 무시한다.
문제점 :
모든 인터럽트가 무시되는 문제가 발생한다.
한 CPU의 인터럽트 금지로 다른 CPU에게 인터럽트를 금지 불가능 (멀티 코어 CPU, 다중 CPU에서는 활용불가)
2. 단순한 LOCK변수를 활용한 구현
- 철수와 영희가 프린터 한 대를 같이 사용
- lock이라는 boolean 변수를 사용해서 한 번에 한 명만 사용 가능하게 만듦
- 누군가 프린터를 사용 중이면 다른 사람은 기다림
public class SimpleLockExample {
static boolean lock = false; // 공유 락 변수
static Object lockObj = new Object(); // 동기화를 위한 객체
static class PrinterUser extends Thread {
String name;
public PrinterUser(String name) {
this.name = name;
}
@Override
public void run() {
while (true) {
synchronized (lockObj) {
if (!lock) {
// lock이 열려 있으면 프린터 사용
lock = true;
System.out.println(name + "가 프린터 사용 시작");
// 프린터 사용 시간 시뮬레이션
try {
Thread.sleep(1000); // 1초 동안 사용
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + "가 프린터 사용 종료");
lock = false;
break; // 작업 끝났으면 while 탈출
} else {
System.out.println(name + "는 대기 중...");
}
}
// 잠깐 쉬고 다시 lock 확인
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
Thread chulsu = new PrinterUser("철수");
Thread younghee = new PrinterUser("영희");
chulsu.start();
younghee.start();
}
}
문제점 :
- 이런 단순 LOCK은 바쁜 대기(busy waiting)로 이어질 수 있다
- 예: 계속 lock을 확인하면서 기다림 → CPU 낭비
- 또는 기아(starvation) 발생 가능 (계속 누가 먼저 lock 잡으면 다른 스레드 못씀)
멀티스레드 동기화
✔ 뮤텍스 기법 (sleep-waiting lock)
한 번에 단 하나의 쓰레드만 임계영역(Critical Section)에 접근할 수 있도록 막는 동기화 기법
잠김/열림 중 한 상태를 가지는 락 변수를 이용한다.
한 스레드만 임계구역에 진입시킨다. 다른 스레드는 큐에 대기한다.
| 목적 | 공유 자원의 동시 접근 방지 |
| 원리 | 하나의 쓰레드가 lock → 다른 쓰레드는 대기 |
| 사용 위치 | 임계 영역(Critical Section) |
| 효과 | Race Condition 방지, 동기화 보장 |
| 주의 | Deadlock(교착 상태) 주의 필요 |
주의할 점
lock 후 unlock을 안하면 -> 다른스레드는 데드락이 걸린다.
lock 순서가 꼬인다면 --> 교착상태가 발생한다.
임계구역의 실행 시간이 짧은 경우 비효율적이다. --> 락이 잠겨있으면 대기 큐에서 대기, 락이 풀리면 다시 문맥교환이 되어
실행하면서 오버헤드가 많아진다.
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 뮤텍스 잠금
try {
count++;
} finally {
lock.unlock(); // 반드시 풀어줘야 함
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
✔ 스핀락 기법 (busy-waiting)
락을 얻을 때까지 무한 루프를 돌면서 기다리는 동기화 기법
락이 해제될 때까지 CPU를 점유한 채 계속 확인
busy-waiting기법을 사용한다. --> 스레드가 큐에서 대기하지 않고 락이 열릴 때까지 계속 락 변수를 검사
| 핵심 개념 | 락이 해제될 때까지 루프 돌면서 기다림 |
| CPU 자원 | 기다리는 동안 CPU 계속 점유 |
| 속도 | 락이 짧게 유지되는 상황에선 빠름 |
| 단점 | 락이 오래 걸리면 CPU 낭비 심함 |
import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
//true --> 락걸림
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
//락이 열려있으면 내가 차지하겠다는 의미
while (!lock.compareAndSet(false, true)) {
// Busy-wait (Spin)
Thread.yield(); // 또는 그냥 continue
}
}
public void unlock() {
lock.set(false);
}
}
public class Main {
static int count = 0;
static SpinLock spinLock = new SpinLock();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for(int i=0; i < 1000; i++){
spinLock.lock();
count++;
spinLock.unlock();
}
};
Thread t1 = new Thread(task);
Threda t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 카운트 값: " + count);
}
}
락 점유 시간이 매우짧고, 컨텍스트 스위칭 비용이 더 큰 경우에 사용하면 유리하다.
단일코어 환경에서는 CPU를 계속 점유하고 있어 비효울적이다.
✔ 세마포 기법 (semaphore)
n개의 공유 자원을 다수의 스레드가 공유하여 사용하도록 돕는 자원관리 기법
임계영역에 동시에 들어올 수 있는 "허용 수(count)"를 제한
| 목적 | 제한된 자원을 동시에 접근하는 스레드 수 제한 |
| 내부 상태 | 정수형 카운터 (예: 3이면 3개까지 허용) |
| 역할 | 뮤텍스는 1명만, 세마포어는 N명까지 |
| acquire() | 들어가기 전에 하나 차감 (P 연산) |
| release() | 나오면서 하나 추가 (V 연산) |
import java.util.concurrent.Semaphore;
public class MyApp {
static Semaphore sem = new Semaphore(3); // 최대 3개 동시 접근 허용
public static void main(String[] args) {
Runnable task = () -> {
try {
sem.acquire(); // 세마포어 획득 (열쇠 가져감)
System.out.println(Thread.currentThread().getName() + " 입장");
// 임계영역 (공유 자원 접근)
Thread.sleep(2000); // 2초 작업
System.out.println(Thread.currentThread().getName() + " 퇴장");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
sem.release(); // 세마포어 해제 (열쇠 반납)
}
};
for (int i = 0; i < 5; i++) {
new Thread(task).start();
}
}
}
보통 아래와 같은 경우에 사용한다.
- 동시접속제한: 웹서버에서 100명 이상으로 동시에 접속하지 못하도록
- 리소스 풀 관리: DB커넥션 풀, 쓰레드 풀 등 제한된 리소를 관리할때
- 생산자-소비자 문제 : 버퍼 크기를 세마포어로 제어한다.
동기화에서 이슈 : 우선순위 역전 ( Priority Inversion )
낮은 우선순위의 쓰레드가 공유 자원을 점유하고 있어서,
더 높은 우선순위의 쓰레드가 기다리게 되는 현상
문제가 되는 이유
- 실시간 시스템에서는 우선순위가 높은 작업은 반드시 빨리 끝나야 한다.
- 그런데 낮은 우선순위 쓰레드가 락을 잡고 있는 상태에서 높은 우선순위가 기다리는 상황이 발생한다.
- 황당하게도 중간 우선순위 쓰레드들이 CPU를 점유하면서 높은 우선순위 쓰레드가 무한 대기할 수 있음
쓰레드우선순위동작
| T1 | 낮음 | 공유 자원 락을 먼저 획득 |
| T2 | 중간 | 자원 안 씀, CPU만 계속 점유 |
| T3 | 높음 | T1이 점유 중인 자원을 필요로 함 |
➡️ T3은 T1이 락을 풀 때까지 기다려야 함
➡️ 근데 T1은 우선순위 낮아서 실행될 기회도 안 옴 (T2가 계속 실행)
➡️ 결과적으로 T3이 멈춰버리는 우선순위 역전 현상 발생
해결방법
✔ 1. 우선순위 상속 (Priority Inheritance)
락을 점유하고 있는 낮은 우선순위 쓰레드(T1)에게
잠시 동안 높은 우선순위 쓰레드(T3)의 우선순위를 상속시켜줌
* T1이 빨리 실행되고, 락을 해제하면 T3는 곧바로 실행이 가능하다.
✔ 2. 우선순위 ceiling
공유 자원을 사용하는 쓰레드들의 최고 우선순위를 계산해서
락을 얻는 순간 그 우선순위로 강제로 설정하는 방식
생산자- 소비자 문제
공유버퍼를 사이에 두고 공유버퍼에 데이터를 공급하는 생산자와 데이터를 읽고 소비하는 소비자
사이에서 공유버퍼를 문제 없이 사용하도록 생산자와 소비자를 동기화시키는 문제
문제의 핵심: 동기화
- 버퍼가 꽉 차면 생산자는 기다려야 함
- 버퍼가 비어 있으면 소비자는 기다려야 함
개념 : 스트리밍 서버 역할
| 생산자 | 비디오/오디오 인코더, 카메라 입력, 실시간 데이터 수신기 |
| 버퍼 | 프레임 큐, 오디오 버퍼, 전송 대기 리스트 |
| 소비자 | 클라이언트로 전송하는 송출기 (HTTP/WebRTC/MPEG-DASH 등) |
시나리오
- 생산자(카메라, 인코더)가 영상 프레임 or 음성 패킷을 계속 생성
- 생성된 데이터를 버퍼에 저장
- 소비자(네트워크 송신 스레드)가 버퍼에서 꺼내 클라이언트에 전송
- 생산 속도가 소비 속도보다 빠르면 → 버퍼가 overflow
- 소비 속도가 생산 속도보다 빠르면 → underflow → 끊김 발생
- 동기화 안 하면 race condition 발생 → 데이터 깨짐, 중복, 손실
| 문제 | 설명 |
| Lag/지연 | 버퍼가 너무 크면 → 출력 지연 |
| 끊김 | 버퍼가 너무 작고 소비 속도가 빠르면 underflow 발생 |
| 동기화 안됨 | 생산자-소비자가 동시 접근 시 race condition 발생 가능 |
세마포 R(읽기 가능한 버퍼 갯수)을 활용하여 문제 해결
<버퍼가 비어있을때>
* 버퍼가 비어 있는 상태에서 소비자가 엑세스 하려할때
소비자 : 버퍼에서 읽기 전 P연산을 실행
P연산 : 버퍼가 빈경우(R=0) 소비자를 대기하도록 설정한다.
* 빈 버퍼에 생산자가 쓰기 작업을 할때
버퍼에 데이터를 기록하고 V연산 실행
V 연산 : 세마포 변수 R을 1증가 대기 중인 소비자들 깨운다.
소비자 : 깨어나면 P연산을 마치고 공유버퍼에서 읽는다. 연산을 마치면 세마포 R을 1감소
<버퍼가 꽉차있을때>
세마포 W(쓰기 가능한 버퍼) 변수 활용 : 버퍼가 차 있을 때 처리하는 P/V 연산으로 해결
* 공유 버퍼가 찬 상태에서 생산자가 쓰기 작업을 할 때
생산자 : 버퍼에 쓰기 전 P연산 실행
P연산 : 버퍼가 꽉 찬 상태면(w=0) 생산자가 wait을 통해 대기하도록 설정
* 버퍼에 데이터가 있는 경우 소비자가 read 할때
소비자 : 버퍼에서 데이터를 읽은 후 V연산 실행
V 연산 : 세마포 변수 W를 1증가시키고, 대기 중인 생산자를 작동
생산자 : 깨어나면서 P 연산을 마치고 공유버퍼에 쓰기작업, P연산에서 세마포 W를 1감소