ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [OS] 뮤텍스와 세마포어는 무엇일까?
    CS/OS 2024. 12. 5. 23:43

    이 개념들이 나오게 된 이유는 공유 자원에 대한 경합때문이다.

    여러 스레드가 동작을 할 때 

    한 공유 자원에 대해서 접근하게 된다면

     

    데드락과 성능 저하 그리고 데이터 일관성을 잃을 수 있다.

     

    뮤텍스와 세마포어는

    상호배체를 동기화 기법으로 구현한 것이다.

     

    뮤텍스

     

    뮤텍스는 락이다.

    Mutual Exclusion의 합성어이다.

    여래 개의 프로세스나 스레드가 공유 자원에 동시에 접근하지 못하게 하는 락이다.

     

    한 번에 하나의 스레드만 자원을 사용할 수 있도록 보장한다
    자원을 사용하는 스레드가 잠금(lock)을 획득하고, 사용이 끝나면 잠금을 해제(unlock)한다
    잠금 또는 해제인 이진 상태만을 가진다.

     

    자바 예시 코드를 클로드를 사용해서 작성해보았다.

     

    public class Mutex {
        private static int sharedResource = 0;
        private static Thread mutexOwner = null;
        private static boolean isLocked = false;
        private static boolean shouldContinue = true;
    
        static class CustomMutex {
            public synchronized void lock() {
                Thread currentThread = Thread.currentThread();
                while (isLocked && mutexOwner != currentThread) {
                    try {
                        System.out.println(currentThread.getName() +
                                ": 뮤텍스가 " + mutexOwner.getName() + "에 의해 소유됨. 대기 중");
                        wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                isLocked = true;
                mutexOwner = currentThread;
                System.out.println(currentThread.getName() + ": 뮤텍스 소유권 획득~~!");
            }
    
            public synchronized boolean unlock() {
                Thread currentThread = Thread.currentThread();
    
                if (mutexOwner != currentThread) {
                    System.out.println(currentThread.getName() +
                            ": [실패] 뮤텍스 소유권이 없어 해제할 수 없음! (현재 소유자: " +
                            (mutexOwner != null ? mutexOwner.getName() : "없음") + ")");
                    return false;
                }
    
                isLocked = false;
                mutexOwner = null;
                System.out.println(currentThread.getName() + ": 뮤텍스 소유권 해제");
                notify();
                return true;
            }
    
            public Thread getOwner() {
                return mutexOwner;
            }
        }
    
        static class Worker implements Runnable {
            private final CustomMutex mutex;
            private final boolean tryIllegalUnlock;
    
            Worker(CustomMutex mutex, boolean tryIllegalUnlock) {
                this.mutex = mutex;
                this.tryIllegalUnlock = tryIllegalUnlock;
            }
    
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ": 작업 시작");
    
                if (tryIllegalUnlock) {
                    while (shouldContinue) {
                        if (mutex.getOwner() != Thread.currentThread()) {
                            System.out.println(Thread.currentThread().getName() +
                                    ": 다른 스레드의 락 해제 시도");
                            if (!isLocked) {
                                System.out.println(Thread.currentThread().getName() +": 공유 자원 획득 성공 작업 시작");
                                normalRun();
                                break;
                            }
                        }
                        try {
                            Thread.sleep(1100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }else {
                    normalRun();
                }
    
    
            }
    
            private void normalRun(){
                // 정상적인 작업 수행
                try {
                    mutex.lock();
                    try {
                        System.out.println(Thread.currentThread().getName() +
                                ": 임계 영역 진입, 공유 자원 접근 시작");
    
                        Thread.sleep(500);
                        sharedResource++;
                        System.out.println(Thread.currentThread().getName() +
                                ": sharedResource = " + sharedResource);
    
                    } finally {
                        mutex.unlock();
                    }
    
                    if (sharedResource >= 3) {  // 종료시킬 부분
                        shouldContinue = false;
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public static void main(String[] args) {
            CustomMutex mutex = new CustomMutex();
    
            Thread thread1 = new Thread(new Worker(mutex, false), "스레드-1");
            Thread thread2 = new Thread(new Worker(mutex, false), "스레드-2");
            Thread thread3 = new Thread(new Worker(mutex, true), "스레드-3");
    
            thread1.start();
            thread2.start();
            thread3.start();
        }
    }

     

    이와 같이 뮤텍스는 lock 과 unlock 두 상태만을 가진다.

     

    위의 출력 결과는 아래와 같다.

     

    스레드-2: 작업 시작
    스레드-3: 작업 시작
    스레드-2: 뮤텍스 소유권 획득~~!
    스레드-3: 다른 스레드의 락 해제 시도
    스레드-2: 임계 영역 진입, 공유 자원 접근 시작
    스레드-1: 작업 시작
    스레드-1: 뮤텍스가 스레드-2에 의해 소유됨. 대기 중
    스레드-2: sharedResource = 1
    스레드-2: 뮤텍스 소유권 해제
    스레드-1: 뮤텍스 소유권 획득~~!
    스레드-1: 임계 영역 진입, 공유 자원 접근 시작
    스레드-1: sharedResource = 2
    스레드-1: 뮤텍스 소유권 해제
    스레드-3: 다른 스레드의 락 해제 시도
    스레드-3: 공유 자원 획득 성공 작업 시작
    스레드-3: 뮤텍스 소유권 획득~~!
    스레드-3: 임계 영역 진입, 공유 자원 접근 시작
    스레드-3: sharedResource = 3
    스레드-3: 뮤텍스 소유권 해제

     

    보이다싶이 락이 걸리면

     

    다른 스레드는 공유 자원에 접근하지 못한다.

     

    락이 해제되고 나면 그 때 얻어서 사용하게 된다.

     

    세마포어

    세마포어는 여러 스레드 나 프로세스가 공유 자원에 접근하는 것을 제한하기 위한 정수이다.

     

    세마포어는 바이너리 와 카운팅이 있다.

     

    근데 바이너리는 뮤텍스랑 다를게 뭔가? 해서 보면


    바이너리 세마포어는 허가 개수가 1이고
    0과 1의 두 가지 값만 가질 수 있는데 소유권 개념이 없다고 함.
    소유권이 없다는 것은

    세마포어는 Signaling 메커니즘으로 Lock을 걸지 않은 스레드도 Signal을 보내 Lock을 해제할 수 있다는 것.

     

     

     

    대기하게 되는 상황에서도

     

    강성(FIFO) 과 약성이 있다.

     

    강성 세마포어 (Strong Semaphore)


    FIFO(First In First Out) 방식으로 동작
    가장 먼저 대기한 스레드가 가장 먼저 자원을 획득
    Java에서는 fair 파라미터로 구현한다고 함.


    약성 세마포어 (Weak Semaphore)


    대기 중인 스레드들의 순서가 보장되지 않음
    성능상 이점이 있지만 특정 스레드가 계속 자원을 못 받을 수 있음

     

     

    import java.util.concurrent.Semaphore;
    
    public class SemaphoreClass {
        private static final int MAX_CONCURRENT_THREADS = 3; // 그니까 바이너리는 이게 1인 것
        private static final Semaphore semaphore = new Semaphore(MAX_CONCURRENT_THREADS); //  Semaphore(MAX_CONCURRENT_THREADS, true); 면 강성임 
    
        static class ResourceUser implements Runnable {
            private final String name;
    
            ResourceUser(String name) {
                this.name = name;
            }
    
            @Override
            public void run() {
                while (true) {
                    try {
                        System.out.println(name + " 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: " +
                                semaphore.availablePermits() + ")");
    
                        if (semaphore.tryAcquire(1)) {
                            try {
                            // semaphore.release(); 소유권이 없기에 release 도 가능하다.
                                System.out.println(name + " 이(가) 세마포어 획득 성공! 리소스 사용 시작");
                                Thread.sleep(2000);
                            } finally {
                                semaphore.release();
                                System.out.println(name + " 이(가) 리소스 사용 완료! 세마포어 반환");
                            }
                            break;
                        } else {
                            System.out.println(name + " 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중");
                            Thread.sleep(500);
                        }
    
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        break;
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            System.out.println("총 허용 가능한 동시 스레드 수: " + MAX_CONCURRENT_THREADS);
            for (int i = 1; i <= 5; i++) {
                new Thread(new ResourceUser("스레드-" + i)).start();
            }
        }
    }

     

    위의 결과는 아래와 같다.

    총 허용 가능한 동시 스레드 수: 3
    스레드-2 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 3)
    스레드-2 이(가) 세마포어 획득 성공! 리소스 사용 시작
    스레드-1 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 3)
    스레드-1 이(가) 세마포어 획득 성공! 리소스 사용 시작
    스레드-3 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 2)
    스레드-3 이(가) 세마포어 획득 성공! 리소스 사용 시작
    스레드-4 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-4 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-5 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-5 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-4 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-4 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-5 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-5 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-4 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-4 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-5 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-5 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-4 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-4 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-5 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 0)
    스레드-5 이(가) 세마포어 획득 실패~ 다른 스레드가 모든 리소스를 사용 중
    스레드-2 이(가) 리소스 사용 완료! 세마포어 반환
    스레드-1 이(가) 리소스 사용 완료! 세마포어 반환
    스레드-3 이(가) 리소스 사용 완료! 세마포어 반환
    스레드-4 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 3)
    스레드-4 이(가) 세마포어 획득 성공! 리소스 사용 시작
    스레드-5 이(가) 세마포어 획득 시도 중임돠~~ (현재 사용 가능: 2)
    스레드-5 이(가) 세마포어 획득 성공! 리소스 사용 시작
    스레드-4 이(가) 리소스 사용 완료! 세마포어 반환
    스레드-5 이(가) 리소스 사용 완료! 세마포어 반환

     

     

     

    뮤텍스는 구현체를 만들어봤으니 세마포어는 자바에서 어떻게 구현되어 있을까?

     

    AbstractQueuedSynchronizer 를 확장한 Sync 클래스를 쓰고있었다.

     

    안에 NonfairSync 와 FairSync 가 있다.

     

    들어가기 전에 CAS 라는 것을 알아야 한다.

     

    CAS(Compare-And-Set)


    Lock-Free 알고리즘의 전형적인 패턴이라고 한다.

    int available = 5;  // 현재 값
    int acquires = 2;   // 획득하려는 값
    int remaining = available - acquires;  // 3
    
    // CAS 연산
    if (compareAndSetState(5, 3)) {  // 현재값이 5면 3으로 변경
        // 성공
    } else {
        // 실패 - 다른 스레드가 이미 값을 변경했음
    }

    현재 값을 읽어서 현재 값이 예상값(expected)과 일치하는지 확인하고
    일치하면 새로운 값(newValue)으로 업데이트하고 true 반환한다.
    불일치하면 아무것도 하지 않고 false 반환한다.

     

    이제 내부 구현을 보자.

    /**
     * NonFair version
     */
    static final class NonfairSync extends Sync {
        private static final long serialVersionUID = -2694183684443567898L;
    
        NonfairSync(int permits) {
            super(permits);
        }
    
        protected int tryAcquireShared(int acquires) {
            return nonfairTryAcquireShared(acquires);
        }
    }
    
    /**
     * Fair version
     */
    static final class FairSync extends Sync {
        private static final long serialVersionUID = 2014338818796000944L;
    
        FairSync(int permits) {
            super(permits);
        }
    
        protected int tryAcquireShared(int acquires) {
            for (;;) {
                if (hasQueuedPredecessors())  // 1
                    return -1; // 2
                int available = getState();
                int remaining = available - acquires;
                if (remaining < 0 ||
                    compareAndSetState(available, remaining))
                    return remaining;
            }
        }
    }

     

    for(;;) 이 형태는 처음 봤는데

    클로드한테 물어보니

     

    CAS 작업이 성공할 때까지 계속 시도하는 무한 루프라고 한다.

     

    tryAcquireShared 를 보자면

     

    1. 먼저 대기 중인 스레드가 있는지 확인한다. (hasQueuedPredecessors)
    2. 있다면 즉시 실패 반환(-1)
    없다면 permits이라는 허가권(처음 세마포어 설명할 때 나왔던 정수인 것) 획득 시도한다.
    CAS를 사용하여 원자적으로 상태 업데이트

    3. 음수인 경우 획득 실패, 0은 획득 성공 남은거 없음 양수는 획득 성공 남은 수 있음

     

    NonfairSync 가 리턴하고 있는 nonfairTryAcquireShared 는 

    final int nonfairTryAcquireShared(int acquires) {
        for (;;) {
            int available = getState(); // 1 
            int remaining = available - acquires;
            if (remaining < 0 ||
                compareAndSetState(available, remaining)) // 2
                return remaining; // 3
        }
    }

     

    이렇다.

     

    각 코드들로 설명하자면

     

    1. 현재 세마포어가 가진 permits 수를 조회

    2. CAS 연산으로 원자적으로 상태 업데이트 시도
    성공하면 remaining 반환
    실패하면 루프 계속

    3. 결과는 음수면 획득 실패 0 이상이면 획득 성공인 것

     

    결국

    코드에서 보다싶이 공정은 대기 중인 큐 를 확인하니 큐의 자료구조 특성상 FIFO 이다.

    따라서 

     

    공정 모드: 먼저 요청한 스레드가 먼저 허가권을 획득 (FIFO)
    비공정 모드: 허가권 획득 순서가 보장되지 않음 (성능상 이점)

     

    라는 성질을 확인할 수 있었다.

     

     

Designed by Tistory.