개바리여리 2025. 4. 13. 18:47

다수의 스레드가 동시에 공유자원에 접근했을때는 문제가 생길 수도 있고 안 생길 수도 있다.

 

두 스레드가 동시에 공유자원을 읽기만 하는 경우에는 문제가 없다.

하지만 한 스레드는 쓰기작업을 하고 한 스레드는 읽을 경우 데이터에 대한 정확성이 떨어 질 수 있다.

그래도 데이터의 훼손은 없지만

 

두 스레드가 동시에 공유 데이터를 쓰기 작업할 경우 훼손이 발생 할 수 있다.

 

 

<예시>

ex: 철수와 영희는 같은 은행 계좌를 함께 사용 중

둘 다 스마트폰 뱅킹 앱으로 동시에 입금/출금 작업을 시도함

 

현재 잔액: 100,000원

 

철수와 영희가 동시에 출금 시도

  • 철수: 80,000원 출금
  • 영희: 50,000원 출금

철수 앱 동작

  1. 잔액을 읽음 → 100,000원
  2. 출금 가능하다고 판단
  3. 출금 실행 (잔액 - 80,000 → 20,000원)

영희 앱 동작 (거의 동시에 실행)

  1. 잔액을 읽음 → 100,000원 (아직 철수가 반영되기 전이라서)
  2. 출금 가능하다고 판단
  3. 출금 실행 (잔액 - 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감소