스레드 동기화는 왜 필요한 것일까?
간단히 말하면 다수의 작업이 공유 데이터에 접근하면 공유 데이터가 훼손될 수 있기 때문이다.
그리고 여기서 다수의 작업이란 프로세스의 작업을 수행하는 스레드라고 할 수 있다.
따라서 공유 데이터에 대한 스레드들의 동시 접근을 해결하는 것이 스레드 동기화의 목표라고 할 수 있다.
스레드 동기화를 위한 가장 간단한 해결 방법은 한 스레드가 공유 데이터 사용에 사용을 마칠 때 까지 다른 스레드가 공유 데이터에 접근하지 못하도록 막는 것이다.
공유데이터 훼손 문제
그런데 사실 이렇게 말로만 공유데이터가 훼손된다고 하면 뭐가 어떻게 훼손된다는건지 직접적으로 이해하기 쉽지 않다.
여기 sum = sum + 10이라는 연산을 수행하는 코드가 있다.
이를 기계 명령으로 보면
mov ax, sum ; -> sum 변수 값을 읽어 ax 레지스터에 저장한다
add ax, 10; -> ax 레지스터 값을 10 증가한다
mov sum, ax; -> ax 레지스터 값을 sum 변수에 저장한다 .
이렇다.
sum이 50인 상태에서
이 연산을 T1, T2 스레드가 각각 실행하면, 우린 당연히 sum에 70이라는 값이 저장되어있길 기대할 것이다.
그런데 연산 중간에 인터럽트 등의 문제가 발생해 약간의 지연이 발생한다면 10이 더해진 갱신된 sum에 스레드들이 접근하는 것이 아니라 50이 저장되어 있는 sum 변수에 접근해 기계명령을 수행하게 될 수 있다.
그러니까 T1,T2 스레드가 차례대로 각각의 연산이 끝나고 sum 변수에 접근하지 한 것이 아니라, 50에 접근해서 연산이 두번 실행되었는데도 불구하고 sum에 60이라는 값이 저장될 수 있다는 것이다.
이런 문제가 발생하게 된 문제는 sum = sum + 10이라는 한줄 짜리의 코드가 사실 하나의 CPU 명령이 아니기 때문이다.
그렇기 때문에 코드 실행이 끝날 때 까지 다른 스레드가 이 코드에 접근하는 것을 막지 못한다.
이런 문제를 막기 위해서 먼저 공유 데이터(여기서는 sum 변수)에 접근한 스레드가 그 공유 데이터에 대한 배타적인 사용 권한을 갖게하고, 이 것이 스레드 동기화인 것이다.
이렇게 베타적인 사용 권한을 갖게 하는 공유 데이터의 구역을 임계 구역이라고 하며, 스레드들이 공유 데이터을 배타적, 독립적으로 사용하는 것을 상호배제라고 한다.
상호배제
상호배제는 멀티 스레드 관점에서 한 스레드가 임계 구역 전체를 배타적으로 실행하도록 보장해주는 것이다.

스레드가 임계 구역에 진입할 때, 임계 구역 진입 코드를 만나게 된다. 이때 임계구역에 실행중인 스레드가 있는지 검사하고 만약 있다면 임계구역에서 스레드가 나올 때 까지 대기한다.
스레드가 임계 구역에서 나올 때는 임계 구역 진출 코드를 만나는데 여기서 대기 중인 스레드가 있다면 그 스레드에게 임계 구역이 비었음을 알리는 등 조치를 취한다.
상호배제를 어떻게 보장할 수 있을까?
먼저, 인터럽트 서비스 금지(Interrupt Flag)에 대해 알아보자.
인터럽트 서비스 금지란 임계 구역에서 스레드가 실행되고 있다면 인터럽트 서비스를 실행하지 않고, 임계 구역을 나가서 인터럽트 서비스를 실행하는 것이다. 이 이유는 임계 구역 안에 스레드가 오래 남아 있을수록 다른 스레드가 더 오래 대기하기 때문에 임계 구역에서 인터럽트 서비스를 실행하지 않게 해 임계구역을 더 빨리 나가게 하기 위함이다.
그런데 인터럽트 서비스 금지 방법은
임계 구역동안 모든 인터럽트가 무시되고, 멀티 코어 환경에서 다른 코어의 인터럽트 서비스까지 금지시키지는 못하기 때문에 활용성이 낮다.
원자명령
그래서 원자 명령이라는 것이 사용된다.
원자 명령이란 말 그대로 하나의 CPU명령을 의미하는데 이번에도 예시를 통해 이를 이해해보겠다.
임계 구역에 진입하기 전 lock 변수(0or1)을 두고, 이 값이 0일 경우에만 임계 구역에 접근하도록 하는 코드를 작성했다고 해보자 .
--임계구역 진입 코드 --
L1;
mov ax, lock; -> lock변수 값을 ax 레지스 값에 저장한다
mov lock, 1; -> lock 변수에 1을 저장한다
cmp ax, 0; -> ax값(이전 lock 변수값)이 0인지 비교한다
jne L1; -> 같지 않다면 L1으로 점프, 같으면 임계 구역 지입
--임계구역 진입 코드 --
임계 구역
--임계 구역 탈출 코드 --
mov lock, 0; -> lock 변수에 0 저장
--임계 구역 탈출 코드 --
언뜻 봐서는 문제가 없어보이는 명령어들이다.
그런데 만약
mov ax, lock; -> lock변수 값을 ax 레지스 값에 저장한다
mov lock, 1; -> lock 변수에 1을 저장한다
이 사이에 컨텍스트 스위칭이 발생하게 된다면 이 임계 구역이 뚫리게 된다.
이 역시 위의 명령어들이 하나의 CPU 명령이 아니기 때문에 발생하게 된다.
원자 명령을 도입하게 된다면
lock 값을 읽어 들이는 명령과 lock 변수에 1을 저장하는 명령 사이에 컨텍스트 스위칭이 발생하지 않도록 두 명령을 하나의 명령으로 만든다.
이런 원자 명령을 TSL(Test and set Lock) 라고 부른다. 이 중간에는 인터럽트 명령이 발생할 수 없다.
TSL ax, lock;
cmp ax, 0;
jne L1;
이렇게 원자 명령을 이용해 두개의 명령을 하나의 명령으로 바꾸어 컨텍스트 스위칭이나 인터럽트가 발생하지 않고, 안전하게 임계구역을 보호할수 있게 되었다.
멀티 스레드 동기화 방식
멀티 스레드 동기화 방식은 기본적으로 원자 명령을 임계구역 진입시 사용한다.
크게 두 가지 방식이 존재하는데
1. 락 방식
2. wait - signal 방식
이 존재한다.
락 방식은 하나의 락 변수를 두고 락을 잠근 스레드만이 임계구역에 진입하게 하는 동기화 방식이고,
wait-signal 방식은 여러 개의 공유 자원을 여러 스레드가 사용할 수 있도록 관리하는 방식을 의미한다.
멀티 스레드 동기화 기법을 알아보도록 하자
Mutex 기법
- lock 변수를 이용하여 한 스레드만 임계 구역에 진입하도록 하고 다른 스레드는 큐에 대기시킨다.
- 락 변수란 락을 소유하고 푼 스레드만 임계 구역에 접근하고, 나갈때 락을 열거나/푸는 방식이다. 이 과정에서 원자 명령이 사용된다.
- 락이 잠겨 있으면 Blocked 상태가 돼 대기 큐에 들어간다. 그리고 스레드가 임계 구역에 나갈 때 락을 열고 큐에서 대기 중인 스레드를 임계구역으로 들여보낸다.
- mutex 기법은 임계 구역의 실행 시간이 짧을 경우 비효율적이다. 컨텍스트 스위칭이 너무 자주 발생하기 때문이다.
스핀락 기법
- 스레드가 임계구역에 진입하지 못할 때 대기하는 대기큐가 존재하지 않는다. 대신에 락이 잠겨 있다면 락 검사를 무한히 반복한다.
- mutex 기법의 락 변수가 스핀락인 것이다, 여기서 원자 명령이 사용된다.
- 이는 단일 CPU에서 매우 비효율적인데 T2라는 스레드가 임계구역 밖에서 무한히 대기하는 타임슬라이스 까지 임계 구역 안의 스레드 T1이 작업을 하지 못해 매우 작업시간이 매우 오래걸리기 때문이다. 하지만 멀티코어에서는 효율적이다.
- 임계 구역 코드가 짧을 때 효과적인데, 컨텍스트 스위칭이 적게 발생하기 때문이다.
- 기아 스레드가 발생할 수 있다.
세마포어 기법
- wait-signal 방식이다. Counter 변수와 P/V 연산이 사용된다
- 자원의 개수 n개를 알고, 스레드의 요청을 받아 사용을 허락한다, 자원이 모자라면 스레드는 대기큐에서 잠을 자며 대기한다.
- 자원이 n개일 때, 가용가능한 자원의 개수를 나타내는 Counter 변수를 n으로 초기화시켜둔 뒤 자원이 할당될 때마다 P연산을 통해 counter 변수의 값을 1 감소 시킨다. 그리고 자원 사용을 완료하면 V 연산을 통해 counter 변수 값을 증가시킨다. 이렇게 되면 counter 변수를 기준으로 대기큐에 스레드가 대기하게 된다.
- 수면 세마포어 기법은 대기큐에서 스레드를 대기시키며 임계 구역 바깥의 스레드를 관리하고, 바쁜 대기 세마포어 기법은 대기큐를 없애고 스핀락처럼 무한히 대기시킨다.
그런데 스레드 동기화에는 우선순위 역전 문제가 발생할 수 있다.
스레드 동기화로 인해 우선순위가 더 낮은 스레드가 먼저 실행되는 경우를 말하는데, 이럴 때는 자원을 소유하고 있는 스레드의 우선순위를 일시적으로 올리거나, 우선순위를 상속시켜 우선순위를 유지시킬 수 있다. 하지만 두 방법 모두 구현이 어렵고 오버헤드가 존재한다.
생산자 소비자 문제
전형적인 동기화 문제를 보여주는 것이 생산자 소비자 문제이다.
자원을 생산하는 생산자 스레드와 자원을 소비하는 소비자 스레드가 존재한다고 해보자.
공유 버퍼에 데이터를 공급하는 생산자들과 공유 버퍼에 데이터를 읽고 소비하는 소비자들이 공유버퍼를 문제없이 사용하도록 생산자와 소비자 스레드를 동기화시키는 것이 생산자 소비자 문제의 목적이라고 할 수 있다.

이를 해결하기 위해서는
1. 상호배제가 보장되어야 한다. 뮤텍스나 세마포어를 통해 임계 구역에 접근한 스레드의 자원에 대한 독점을 보장해줘야 한다.
2. 버퍼가 비어있을 때의 문제를 해결해야 한다. 이게 문제가 되는 이유는 버퍼가 비어 있을 때 소비자 스레드가 공유 버퍼에 접근할 수 있기 때문이다. 이럴 때는 wait - signal 방식의 세마포어 기법을 사용해 버퍼가 비어 있다면 소비자 스레드가 기다리도록 해야 한다. 변수에 남아 있는 자원의 양을 변수로 할당하고, 소비자 스레드가 자원을 생성했을 때 변수의 값을 늘리고, 소비자가 소비하면 줄이는 식으로 말이다.
3. 버퍼가 꽉 차 있을 때의 문제를 해결해야 한다. 버퍼가 비어있을 때와 비슷하게 버퍼가 꽉차 있을 때 생산자 스레드가 자원을 생성하게 되면 안된다. 마찬가지로 공유버퍼의 자원을 변수로 설정하고 소비자가 자원을 소비했을 때 신호를 줘 변수의 값을 줄이고, 생산자가 다시 생산하면 신호를 줘 변수의 값을 올리는 방식으로 해결할 수 있다.
'운영체제' 카테고리의 다른 글
| [운영체제] 8. 메모리 관리 (1) | 2025.03.31 |
|---|---|
| [운영체제] 7. 교착상태 (0) | 2025.03.18 |
| [운영체제] 5. CPU 스케줄링 (0) | 2025.02.12 |
| [운영체제] 4. 스레드와 멀티 스레딩 (2) | 2025.01.27 |
| [운영체제] 3. 프로세스와 프로세스 관리 (4) | 2025.01.14 |