어느 덧 정글에서의 9주차가 지나가고 있습니다.
매일 TIL을 적는 목표가 깨진지 오래 되었네요.
c언어 주차의 malloc-lab 쯤 시작하고 나서는 도저히 글을 쓸 시간을 낼 수 없을 정도..!
그래도 적는게 어디야 ~
정글 끝까지 (Pint OS) - Threads
를 진행하면서 동기화 문제를 마주하게 되었습니다.
동기화 문제의 3가지 해결 방법으로 인터럽트 비활성화, 세마포어, 락 을 사용하는데요.
어떤 상황에서 어떤 해결 방법을 사용해야 하는지 헷갈려서 다시 개념을 확실히 정리할 겸 정리해보려 합니다.
동기화 문제는 멀티프로세스나 멀티쓰레드 환경에서 공유 데이터 자원을 사용할 때, "동시에 접근한 경우 데이터의 일관성과 정확성을 보장하지 않으면 발생하는 문제예요.
멀티 쓰레드 환경에서는 여러 쓰레드가 동시에 수행할 수 있어요.
이 때, 적절한 동기화 없이 접근하면, 이런 문제들이 발생할 수 있습니다.
둘 이상의 스레드가 동시에 공유 자원에 접근하고, 실행 순서에 따라 결과가 달라지는 상황을 Race Condition 이라고 합니다. 예를 들어볼게요.
process
int a = 10;
thread1
a += 10;
printf("a = %d" , a) // 우리는 20을 기대해요.
// a = 400 그러나 400이 출력될 수도 있습니다.
// a = 210 어쩌면 210이 출력될 수도 있어요.
thread2
a *= 20;
우리가 thread1 에서의 printf 결과로 20을 기대하지만
400이나 210이 출력될 수도 있어요. 왜 그럴까요?
thread1
a+= 10; // thread1
context_switching
a*=20; // thread2
context_switching
printf("a =%d" , a) // thread1
우리는 a+=10연산 뒤에 바로 printf 문이 찍히길 기대하지만
실제로 그 사이에 컨텍스트 스위칭이 일어나서 a*=20이 실행되서
그 이후에 printf문이 찍힐 수 있어요.
실행 순서에 따라 연산의 결과가 달라졌습니다.
둘 이상의 쓰레드가 서로 자원을 점유하고 있으면서, 상대방이 가지고 있는 자원을 요청하다가 무한히 대기하는 상황 입니다.
// Thread A
lock_acquire(&lock1);
lock_acquire(&lock2); // 이 시점에서 lock2는 다른 스레드가 잡고 있음
// Thread B
lock_acquire(&lock2);
lock_acquire(&lock1); // lock1은 A가 잡고 있음
이 상황에서 A는 lock2를 기다리고 B는 lock1을 기다립니다.
서로 상대가 락을 풀지 않아서 영원히 멈추는 교착 상태가 발생해요.
낮은 우선순위의 쓰레드가 자원을 점유하고 있어서, 그 자원이 필요한 높은 우선순위 쓰레드가 대기하게 되는 상황이예요.
스케쥴러는 높은 우선순위 쓰레드를 실행해야 하는데, 그걸 실행할 수 없는 역설적인 상황.
A = 10; // 우선순위 낮음 , 락을 잡고있음
B = 50; // 우선순위 중간 , 독립적으로 실행됨
C = 100; // 우선순위 높음 , A가 가진 락을 기다림
c a += 10
이렇게 되면 가장 높은 우선순위를 가진 C는 락을 못 얻고, 낮은 우선순위 A는 계속 뒤로 밀려서 가장 높은 우선순위를 가진 C가 대기하는 우선순위 역전 현상이 발생해요.
이렇게 되면 시스템 응답성도 낮아지고, 심하면 실시간 시스템에서 실패할 가능성도 존재합니다.
이런 경우 락 사용을 최소화하거나, 낮은 우선순위의 쓰레드 A가 높은 우선순위 C에게 우선순위를 상속받아서 빨리 실행되게 합니다.
인터럽트는 CPU가 프로그램을 실행하는 도중에 예기치 않은 상황이 발생했을 때, 현재 실행 중인 작업을 잠시 중단하고 발생한 상황을 먼저 처리한 후, 다시 원래 작업으로 돌아가는 메커니즘 입니다.
/* 타이머 인터럽트 핸들러 */
static void
timer_interrupt (struct intr_frame *args UNUSED) {
ticks++;
thread_tick ();
}
예를 들어 이 타이머 인터럽트는 실제로 10ms 마다 ticks 라는 전역변수를 증가시키고 있어요.
printf("%d", ticks); // 1
printf("%d", ticks); // 1
// 타이머 인터럽트 발생!
printf("%d", ticks); // 2
코드 실행 도중에 인터럽트가 발생해서 먼저 처리하고 왔기 때문에, ticks는 2가 되었습니다.
그냥 단순한 코드에서는 문제가 안 될 수 있지만 아까 확인했던, 경쟁 조건 문제가 발생했기 때문에 인터럽트를 비활성화 해주는 거예요.
enum intr_level old_level = intr_disable(); // 인터럽트를 비활성화 하는 커스텀 함수
printf("%d", ticks); // 1
printf("%d", ticks); // 1
// 타이머 인터럽트 발생! 했지만...무시
printf("%d", ticks); // 1
intr_set_level(old_level); // 인터럽트 되돌리는 커스텀 함수
타이머 인터럽트는 우리가 커스텀한 인터럽트지만, 이외에 정말 많은 인터럽트들이 발생할 수 있어요.
그래서 인터럽트를 비활성화해서 컨텍스츠 스위치나 처리 루틴이 들어오지 못하게 해요.
현재 실행 중인 코드가 강제로 중단되지 않기 때문에 안전하게 공유 자원에 접근할 수 있습니다.
인터럽트 비활성화는 빠르고 구현이 간단하다는 장점이 있지만
인터럽트를 꺼두면 시스템 응답성이 저하되고, 멀티코어 ( 진짜 CPU여러개 ) 에서는 효과가 없고, 심지어 못막는 인터럽트도 있어요 ㅋㅋㅋ.
그리고 인터럽트를 끈 상태에서 실행하는 함수가 오래걸리면 시스템 전체가 멈춘것처럼 보입니다.
굉장히 짧은 시간 동안만 공유 자원 보호가 필요할 때 쓰면 좋아요.
EX) 스레드 리스트 조작, TID 할당 등 커널 내부 자료구조를 수정할 때
tip: 커널 내부 자료구조란?
-> 운영체제가 정상적으로 동작하기 위해서 반드시유지해야 하는 공유 자원 및 상태 정보를 나타내는 자료구조
sleep_list, ready_list 등
얘는 반드시 동기화가 필요
세마포어는 공유된 자원에 접근 가능한 "허가권(permit)" 개수를 정수로 관리하는 구조예요.
세마포어의 내부 값은 value 라는 정수로 되어있는데
이 정수가 0이면 그 자리에서 대기해요.
sema_init(&sema, N); // 동시에 최대 N개의 스레드가 자원에 접근 가능
sema_down(&sema); // 만약 value 0이라면 여기서 대기
// 공유 자원 사용
a += 10;
// 공유 자원 사용 끝
sema_up(&sema); // value 를 다시 더해줌
세마포어는 어떤 공유 자원을 사용하는데 최대 N명 까지의 인원을 제한하는 거예요.
잠깐.. 공유자원을 왜 N명이 같이 써?
지금까지 동기화 문제 해결하려고 공유자원 쓸 때 막아 놓는거 아니야? 같이 쓸 거면 왜 막아 놔
맞습니다. 정말 안전하다면 굳이 세마포어 없이도 됩니다.
그리고 사실, 세마포어는 1보다 큰 값으로 초기화 될 수도 있지만 이러한 세마포어는 거의 사용되지 않아요.
근데 자원이 괜찮은 게 아니라, 동시 접근 개수 자체를 제한하는데도 사용됩니다.
sema_init(&db_conn_pool, 10); // 최대 10개까지만 DB 접속 허용
sema_down(&db_conn_pool); // 접속 시도
connect_to_db();
sema_up(&db_conn_pool); // 접속 종료
DB 자체는 멀티스레드를 지원하니까 동시에 접근해도 괜찮지만,
만약 사용자가 너무 몰려서 DB 서버가 다운되는 상황이라면 세마포어를 사용해서 DB에 접근 가능한 사용자 수를 제한해 주는 거예요.
그럼 세마포어의 value 값을 1로 하면 락처럼 사용할 수 있겠네?
그럴 수도 있지만, 락은 락을 건 쓰레드만 해제할 수 있기 때문에, 자원 보호 관점에서 뮤텍스 락을 사용하는게 더 나아요.
또한 동시 접근 제어뿐 아니라, 시그널링이라는 개념에도 포함됩니다.
시그널링이란 한 스레드가 다른 스레드에게 "너 이제 일 해도 돼!"하고 알려주는 거예요.
struct semaphore sema;
sema_init(&sema, 0); // 시작은 0 → 아무도 못 들어감
void worker_thread(void *aux) {
sema_down(&sema); // 🔴 시그널 받을 때까지 기다림
printf("A가 끝났으니 나 시작함!\n");
}
void main_thread() {
thread_create(..., worker_thread, ...);
do_some_work(); // 🟢 A가 무언가 작업을 함
sema_up(&sema); // 🔔 이제 너 해도 돼!
}
뮤텍스 락은 락을 건 쓰레드에서만 할 수 있기 때문에 할 수 없습니다.
또 생산자 - 소비자 문제라는 대표적인 문제도 세마포어로 해결할 수 있습니다.
#define BUF_SIZE 3
int buffer[BUF_SIZE];
int in = 0, out = 0;
struct semaphore empty, full;
sema_init(&empty, BUF_SIZE); // 빈 칸
sema_init(&full, 0); // 채운 칸
// Producer
void producer() {
int item = make_item();
sema_down(&empty); // 빈 칸 남아야 넣음
buffer[in] = item;
in = (in + 1) % BUF_SIZE;
sema_up(&full); // 채운 칸 증가
}
// Consumer
void consumer() {
sema_down(&full); // 채운 칸 있어야 꺼냄
int item = buffer[out];
out = (out + 1) % BUF_SIZE;
sema_up(&empty); // 빈 칸 증가
}
버퍼에 0,1,2 라는 공간이 있을 때,
꽉 차 있으면 데이터를 넣을 수 없고 비어있으면 데이터를 넣을 수 있잖아요?
락으로는 이런 "수량 기반 제어"를 못하기 때문에 세마포어를 사용해 줄 수도 있습니다.
세마포어는 최대 N 개의 쓰레드가 공유 자원에 동시에 접근할 수 있도록 제한할 수 있을 뿐
N개의 쓰레드가 동시에 접근함으로써 발생하는 동시성 문제는 해결하지 못합니다.
대신, 세마포어와 뮤텍스는 서로 지향하는 바에 차이점이 약간 존재하여
실제로는 둘이 동시에 사용되곤 합니다.
뮤텍스 락은 초기 value가 1인 세마포어랑 똑같습니다.
잠금에서 sema_up()에 해당하는 동작을 release라고 하고, down에 해당하는 동작을 acquire라고 합니다.
세마포어에 비해 한 가지 제약이 있는데, 잠금을 획득학 쓰레드, 즉 잠금의 "소유자"만 잠금을 해제할 수 있다는 것 입니다.
/* Lock. */
struct lock {
struct thread *holder; /* Thread holding lock (for debugging). */
struct semaphore semaphore; /* Binary semaphore controlling access. */
};
위에서 말했던 대로 뮤텍스 락은 공유 자원 보호의 목적에서 많이 사용됩니다.
단 주의해야 할점은
A가 B의 락을 기다리고
B가 A의 락을 기다리면
서로 서로가 가진 자원을 기다리면서, 영원히 아무것도 진행하지 못하게 되는 "데드락"현상이 발생합니다.
pthread_mutex_t lockA, lockB;
void* thread1() {
pthread_mutex_lock(&lockA); // 🔒 lockA 획득
sleep(1); // 잠깐 대기
pthread_mutex_lock(&lockB); // ❌ 여기서 B가 점유 중이면 멈춤
// ...
pthread_mutex_unlock(&lockB);
pthread_mutex_unlock(&lockA);
}
void* thread2() {
pthread_mutex_lock(&lockB); // 🔒 lockB 획득
sleep(1); // 잠깐 대기
pthread_mutex_lock(&lockA); // ❌ 여기서 A가 점유 중이면 멈춤
// ...
pthread_mutex_unlock(&lockA);
pthread_mutex_unlock(&lockB);
}
그래서 늘 같은 순서로 락 획득순서를 통일하거나, 타임아웃을 설정해서 너무 오래기다리면 잠금 요청을 취소하는 방식으로 해결합니다.
마무리
핀토스 넘 어렵네여 다들 하이팅